|
| 1 | +package no_import_assign |
| 2 | + |
| 3 | +import ( |
| 4 | + "github.com/microsoft/typescript-go/shim/ast" |
| 5 | + "github.com/web-infra-dev/rslint/internal/rule" |
| 6 | + "github.com/web-infra-dev/rslint/internal/utils" |
| 7 | +) |
| 8 | + |
| 9 | +// importedBinding holds information about a single imported binding. |
| 10 | +type importedBinding struct { |
| 11 | + name string |
| 12 | + isNamespace bool |
| 13 | + nameNode *ast.Node |
| 14 | + symbol *ast.Symbol |
| 15 | +} |
| 16 | + |
| 17 | +// makeImportedBinding creates an importedBinding, resolving the symbol via the type checker if available. |
| 18 | +func makeImportedBinding(nameNode *ast.Node, isNamespace bool, ctx *rule.RuleContext) importedBinding { |
| 19 | + name := nameNode.Text() |
| 20 | + var sym *ast.Symbol |
| 21 | + if ctx.TypeChecker != nil { |
| 22 | + sym = ctx.TypeChecker.GetSymbolAtLocation(nameNode) |
| 23 | + } |
| 24 | + return importedBinding{ |
| 25 | + name: name, |
| 26 | + isNamespace: isNamespace, |
| 27 | + nameNode: nameNode, |
| 28 | + symbol: sym, |
| 29 | + } |
| 30 | +} |
| 31 | + |
| 32 | +// wellKnownMutationMethods maps global object names to their mutation method names. |
| 33 | +var wellKnownMutationMethods = map[string]map[string]bool{ |
| 34 | + "Object": { |
| 35 | + "assign": true, |
| 36 | + "defineProperty": true, |
| 37 | + "defineProperties": true, |
| 38 | + "freeze": true, |
| 39 | + "setPrototypeOf": true, |
| 40 | + }, |
| 41 | + "Reflect": { |
| 42 | + "defineProperty": true, |
| 43 | + "deleteProperty": true, |
| 44 | + "set": true, |
| 45 | + "setPrototypeOf": true, |
| 46 | + }, |
| 47 | +} |
| 48 | + |
| 49 | +// isArgumentOfWellKnownMutationFunction checks if a given node is the first argument |
| 50 | +// of a well-known mutation function such as Object.assign, Object.defineProperty, |
| 51 | +// Reflect.set, Reflect.setPrototypeOf, etc. |
| 52 | +// It skips the check when Object/Reflect is locally shadowed (e.g. var Object). |
| 53 | +func isArgumentOfWellKnownMutationFunction(node *ast.Node, ctx *rule.RuleContext) bool { |
| 54 | + if node == nil || node.Parent == nil { |
| 55 | + return false |
| 56 | + } |
| 57 | + |
| 58 | + parent := node.Parent |
| 59 | + |
| 60 | + // The parent must be a CallExpression |
| 61 | + if parent.Kind != ast.KindCallExpression { |
| 62 | + return false |
| 63 | + } |
| 64 | + |
| 65 | + callExpr := parent.AsCallExpression() |
| 66 | + if callExpr == nil || callExpr.Arguments == nil || len(callExpr.Arguments.Nodes) == 0 { |
| 67 | + return false |
| 68 | + } |
| 69 | + |
| 70 | + // The node must be the first argument |
| 71 | + if callExpr.Arguments.Nodes[0] != node { |
| 72 | + return false |
| 73 | + } |
| 74 | + |
| 75 | + // The callee must be a PropertyAccessExpression like Object.assign or Reflect.set |
| 76 | + // Unwrap parentheses: (Object?.defineProperty)(ns, ...) → Object?.defineProperty |
| 77 | + callee := callExpr.Expression |
| 78 | + for callee != nil && callee.Kind == ast.KindParenthesizedExpression { |
| 79 | + callee = callee.AsParenthesizedExpression().Expression |
| 80 | + } |
| 81 | + if callee == nil || callee.Kind != ast.KindPropertyAccessExpression { |
| 82 | + return false |
| 83 | + } |
| 84 | + |
| 85 | + propAccess := callee.AsPropertyAccessExpression() |
| 86 | + if propAccess == nil || propAccess.Expression == nil || propAccess.Name() == nil { |
| 87 | + return false |
| 88 | + } |
| 89 | + |
| 90 | + // The object must be a simple identifier (Object or Reflect) |
| 91 | + if propAccess.Expression.Kind != ast.KindIdentifier { |
| 92 | + return false |
| 93 | + } |
| 94 | + |
| 95 | + objectName := propAccess.Expression.Text() |
| 96 | + methodName := propAccess.Name().Text() |
| 97 | + |
| 98 | + methods, ok := wellKnownMutationMethods[objectName] |
| 99 | + if !ok { |
| 100 | + return false |
| 101 | + } |
| 102 | + if !methods[methodName] { |
| 103 | + return false |
| 104 | + } |
| 105 | + |
| 106 | + // If Object/Reflect is locally shadowed, skip the check (same as no-console pattern). |
| 107 | + if ctx.TypeChecker != nil { |
| 108 | + sym := ctx.TypeChecker.GetSymbolAtLocation(propAccess.Expression) |
| 109 | + if sym != nil { |
| 110 | + for _, decl := range sym.Declarations { |
| 111 | + declSF := ast.GetSourceFileOfNode(decl) |
| 112 | + if declSF != nil && declSF == ctx.SourceFile { |
| 113 | + return false |
| 114 | + } |
| 115 | + } |
| 116 | + } |
| 117 | + } |
| 118 | + |
| 119 | + return true |
| 120 | +} |
| 121 | + |
| 122 | +// isMemberExpressionWrite checks if a member expression (PropertyAccess or ElementAccess) |
| 123 | +// is a write target: assignment left side, update expression operand, delete operand, |
| 124 | +// or for-in/of initializer. |
| 125 | +func isMemberExpressionWrite(memberExpr *ast.Node) bool { |
| 126 | + if utils.IsWriteReference(memberExpr) { |
| 127 | + return true |
| 128 | + } |
| 129 | + // IsWriteReference does not handle delete expressions, so check separately. |
| 130 | + if memberExpr.Parent != nil && memberExpr.Parent.Kind == ast.KindDeleteExpression { |
| 131 | + return true |
| 132 | + } |
| 133 | + return false |
| 134 | +} |
| 135 | + |
| 136 | +// isMemberWrite checks if an identifier is the object in a member-write expression. |
| 137 | +// For namespace imports like `import * as ns`, member writes include: |
| 138 | +// - ns.prop = val |
| 139 | +// - ns.prop++ |
| 140 | +// - ns["prop"] = val |
| 141 | +// - delete ns.prop |
| 142 | +// - Object.assign(ns, ...) |
| 143 | +// - Object.defineProperty(ns, ...) |
| 144 | +// - Reflect.set(ns, ...) etc. |
| 145 | +func isMemberWrite(node *ast.Node, ctx *rule.RuleContext) bool { |
| 146 | + if node == nil || node.Parent == nil { |
| 147 | + return false |
| 148 | + } |
| 149 | + |
| 150 | + parent := node.Parent |
| 151 | + |
| 152 | + // Check ns.prop = val, ns.prop++, ns["prop"] = val, delete ns.prop, etc. |
| 153 | + if parent.Kind == ast.KindPropertyAccessExpression { |
| 154 | + propAccess := parent.AsPropertyAccessExpression() |
| 155 | + if propAccess != nil && propAccess.Expression == node { |
| 156 | + return isMemberExpressionWrite(parent) |
| 157 | + } |
| 158 | + } |
| 159 | + |
| 160 | + if parent.Kind == ast.KindElementAccessExpression { |
| 161 | + elemAccess := parent.AsElementAccessExpression() |
| 162 | + if elemAccess != nil && elemAccess.Expression == node { |
| 163 | + return isMemberExpressionWrite(parent) |
| 164 | + } |
| 165 | + } |
| 166 | + |
| 167 | + // Check spread into destructuring assignment target: ({...ns} = obj) |
| 168 | + if parent.Kind == ast.KindSpreadAssignment { |
| 169 | + return utils.IsInDestructuringAssignment(parent) |
| 170 | + } |
| 171 | + |
| 172 | + // Check if the identifier is the first argument of a well-known mutation function |
| 173 | + // e.g., Object.assign(ns, ...), Reflect.set(ns, ...), etc. |
| 174 | + if isArgumentOfWellKnownMutationFunction(node, ctx) { |
| 175 | + return true |
| 176 | + } |
| 177 | + |
| 178 | + // Check for...in/of: for (ns.prop in ...) or for (ns.prop of ...) |
| 179 | + // These are caught by the PropertyAccessExpression + isMemberExpressionWrite path above |
| 180 | + // since IsWriteReference handles for-in/of initializers. |
| 181 | + |
| 182 | + return false |
| 183 | +} |
| 184 | + |
| 185 | +// isImportBindingName checks if the identifier is a declaration name within an import. |
| 186 | +func isImportBindingName(node *ast.Node) bool { |
| 187 | + if node == nil || node.Parent == nil { |
| 188 | + return false |
| 189 | + } |
| 190 | + parent := node.Parent |
| 191 | + switch parent.Kind { |
| 192 | + case ast.KindImportClause, ast.KindNamespaceImport, ast.KindImportSpecifier: |
| 193 | + return true |
| 194 | + } |
| 195 | + return false |
| 196 | +} |
| 197 | + |
| 198 | +// NoImportAssignRule disallows assigning to imported bindings. |
| 199 | +var NoImportAssignRule = rule.Rule{ |
| 200 | + Name: "no-import-assign", |
| 201 | + Run: func(ctx rule.RuleContext, options any) rule.RuleListeners { |
| 202 | + return rule.RuleListeners{ |
| 203 | + ast.KindImportDeclaration: func(node *ast.Node) { |
| 204 | + importDecl := node.AsImportDeclaration() |
| 205 | + if importDecl == nil || importDecl.ImportClause == nil { |
| 206 | + return |
| 207 | + } |
| 208 | + |
| 209 | + importClause := importDecl.ImportClause.AsImportClause() |
| 210 | + if importClause == nil { |
| 211 | + return |
| 212 | + } |
| 213 | + |
| 214 | + var bindings []importedBinding |
| 215 | + |
| 216 | + // Default import: import foo from 'mod' |
| 217 | + if importClause.Name() != nil { |
| 218 | + bindings = append(bindings, makeImportedBinding(importClause.Name(), false, &ctx)) |
| 219 | + } |
| 220 | + |
| 221 | + // Named or namespace bindings |
| 222 | + if importClause.NamedBindings != nil { |
| 223 | + nb := importClause.NamedBindings |
| 224 | + |
| 225 | + switch nb.Kind { |
| 226 | + case ast.KindNamespaceImport: |
| 227 | + nsImport := nb.AsNamespaceImport() |
| 228 | + if nsImport != nil && nsImport.Name() != nil { |
| 229 | + bindings = append(bindings, makeImportedBinding(nsImport.Name(), true, &ctx)) |
| 230 | + } |
| 231 | + case ast.KindNamedImports: |
| 232 | + namedImports := nb.AsNamedImports() |
| 233 | + if namedImports != nil && namedImports.Elements != nil { |
| 234 | + for _, elem := range namedImports.Elements.Nodes { |
| 235 | + importSpec := elem.AsImportSpecifier() |
| 236 | + if importSpec != nil && importSpec.Name() != nil { |
| 237 | + bindings = append(bindings, makeImportedBinding(importSpec.Name(), false, &ctx)) |
| 238 | + } |
| 239 | + } |
| 240 | + } |
| 241 | + } |
| 242 | + } |
| 243 | + |
| 244 | + if len(bindings) == 0 { |
| 245 | + return |
| 246 | + } |
| 247 | + |
| 248 | + // Walk the source file looking for write references to the imported bindings. |
| 249 | + sourceFile := ctx.SourceFile |
| 250 | + var walk func(*ast.Node) |
| 251 | + walk = func(n *ast.Node) { |
| 252 | + if n == nil { |
| 253 | + return |
| 254 | + } |
| 255 | + |
| 256 | + if n.Kind == ast.KindIdentifier { |
| 257 | + for _, binding := range bindings { |
| 258 | + if n.Text() != binding.name { |
| 259 | + continue |
| 260 | + } |
| 261 | + |
| 262 | + // Skip the import declaration names themselves |
| 263 | + if isImportBindingName(n) { |
| 264 | + continue |
| 265 | + } |
| 266 | + |
| 267 | + // Verify symbol identity when type checker is available |
| 268 | + if binding.symbol != nil && ctx.TypeChecker != nil { |
| 269 | + refSym := ctx.TypeChecker.GetSymbolAtLocation(n) |
| 270 | + if refSym != binding.symbol { |
| 271 | + continue |
| 272 | + } |
| 273 | + } |
| 274 | + |
| 275 | + if utils.IsWriteReference(n) { |
| 276 | + ctx.ReportNode(n, rule.RuleMessage{ |
| 277 | + Id: "readonly", |
| 278 | + Description: "'" + binding.name + "' is read-only.", |
| 279 | + }) |
| 280 | + } else if binding.isNamespace && isMemberWrite(n, &ctx) { |
| 281 | + ctx.ReportNode(n, rule.RuleMessage{ |
| 282 | + Id: "readonlyMember", |
| 283 | + Description: "The members of '" + binding.name + "' are read-only.", |
| 284 | + }) |
| 285 | + } |
| 286 | + } |
| 287 | + } |
| 288 | + |
| 289 | + n.ForEachChild(func(child *ast.Node) bool { |
| 290 | + walk(child) |
| 291 | + return false |
| 292 | + }) |
| 293 | + } |
| 294 | + walk(sourceFile.AsNode()) |
| 295 | + }, |
| 296 | + } |
| 297 | + }, |
| 298 | +} |
0 commit comments