Skip to content

Commit d7cf187

Browse files
authored
feat: port rule no-import-assign (#536)
1 parent f35630c commit d7cf187

File tree

8 files changed

+2572
-0
lines changed

8 files changed

+2572
-0
lines changed

internal/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ import (
123123
"github.com/web-infra-dev/rslint/internal/rules/no_ex_assign"
124124
"github.com/web-infra-dev/rslint/internal/rules/no_extra_bind"
125125
"github.com/web-infra-dev/rslint/internal/rules/no_global_assign"
126+
"github.com/web-infra-dev/rslint/internal/rules/no_import_assign"
126127
"github.com/web-infra-dev/rslint/internal/rules/no_loss_of_precision"
127128
"github.com/web-infra-dev/rslint/internal/rules/no_sparse_arrays"
128129
"github.com/web-infra-dev/rslint/internal/rules/no_template_curly_in_string"
@@ -474,6 +475,7 @@ func registerAllCoreEslintRules() {
474475
GlobalRuleRegistry.Register("no-ex-assign", no_ex_assign.NoExAssignRule)
475476
GlobalRuleRegistry.Register("no-extra-bind", no_extra_bind.NoExtraBindRule)
476477
GlobalRuleRegistry.Register("no-global-assign", no_global_assign.NoGlobalAssignRule)
478+
GlobalRuleRegistry.Register("no-import-assign", no_import_assign.NoImportAssignRule)
477479
GlobalRuleRegistry.Register("no-loss-of-precision", no_loss_of_precision.NoLossOfPrecisionRule)
478480
GlobalRuleRegistry.Register("no-template-curly-in-string", no_template_curly_in_string.NoTemplateCurlyInString)
479481
GlobalRuleRegistry.Register("no-sparse-arrays", no_sparse_arrays.NoSparseArraysRule)
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
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+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# no-import-assign
2+
3+
## Rule Details
4+
5+
Disallows assigning to imported bindings. Imports are read-only references to values exported from other modules. Assigning to an import binding is always a mistake, as it will either throw a runtime error or silently fail.
6+
7+
For namespace imports (`import * as ns`), writing to any member of the namespace object is also disallowed, since namespace objects are frozen.
8+
9+
Examples of **incorrect** code for this rule:
10+
11+
```javascript
12+
import mod from 'mod';
13+
mod = 0;
14+
15+
import { named } from 'mod';
16+
named = 0;
17+
named++;
18+
19+
import * as ns from 'mod';
20+
ns = 0;
21+
ns.prop = 0;
22+
ns.prop++;
23+
```
24+
25+
Examples of **correct** code for this rule:
26+
27+
```javascript
28+
import mod from 'mod';
29+
mod.prop = 0; // Writing to a property of a default import is fine
30+
31+
import { named } from 'mod';
32+
named.prop = 0; // Writing to a property of a named import is fine
33+
34+
import * as ns from 'mod';
35+
ns.named.prop = 0; // Writing to nested properties is fine
36+
```
37+
38+
## Original Documentation
39+
40+
- [ESLint no-import-assign](https://eslint.org/docs/latest/rules/no-import-assign)

0 commit comments

Comments
 (0)