@fjall/eslint-plugin 7.0.1 → 7.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +3 -1
- package/no-replacement-string-expansion.js +125 -0
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -27,6 +27,7 @@ import requireAbortCompositionOnSdkSend from "./require-abort-composition-on-sdk
|
|
|
27
27
|
import noClassicConnectedAccountAssume from "./no-classic-connected-account-assume.js";
|
|
28
28
|
import noRawDbTransaction from "./no-raw-db-transaction.js";
|
|
29
29
|
import noRawExitCode from "./no-raw-exit-code.js";
|
|
30
|
+
import noReplacementStringExpansion from "./no-replacement-string-expansion.js";
|
|
30
31
|
|
|
31
32
|
export default {
|
|
32
33
|
rules: {
|
|
@@ -60,6 +61,7 @@ export default {
|
|
|
60
61
|
"require-abort-composition-on-sdk-send": requireAbortCompositionOnSdkSend,
|
|
61
62
|
"no-classic-connected-account-assume": noClassicConnectedAccountAssume,
|
|
62
63
|
"no-raw-db-transaction": noRawDbTransaction,
|
|
63
|
-
"no-raw-exit-code": noRawExitCode
|
|
64
|
+
"no-raw-exit-code": noRawExitCode,
|
|
65
|
+
"no-replacement-string-expansion": noReplacementStringExpansion
|
|
64
66
|
}
|
|
65
67
|
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint Rule: no-replacement-string-expansion
|
|
3
|
+
*
|
|
4
|
+
* Flags `.replace(pattern, X)` / `.replaceAll(pattern, X)` where X is a
|
|
5
|
+
* runtime-composed string. The JS engine expands `$` sequences in string
|
|
6
|
+
* replacements — `$&` (whole match), `$'` (suffix), `` $` `` (prefix),
|
|
7
|
+
* `$1`..`$n` (groups), `$<name>` — so any value not visible to the author at
|
|
8
|
+
* the call site silently corrupts output the first time the data contains
|
|
9
|
+
* `$`. The function form performs no expansion and is always safe.
|
|
10
|
+
*
|
|
11
|
+
* Flagged shapes (second argument):
|
|
12
|
+
* content.replace(re, `${comment}\n# $1`) // template WITH expressions
|
|
13
|
+
* content.replace(re, buildReplacement()) // call result — unknown content
|
|
14
|
+
* content.replace(re, options.replacement) // member — unknown content
|
|
15
|
+
* content.replace(re, someParam) // identifier not resolvable to
|
|
16
|
+
* // a same-file static string
|
|
17
|
+
*
|
|
18
|
+
* Allowed shapes:
|
|
19
|
+
* content.replace(re, () => value) // function form — no expansion
|
|
20
|
+
* content.replace(re, (m, g1) => `${g1}x`) // deliberate backreferences
|
|
21
|
+
* content.replace(re, "literal $1") // static literal — `$` visible
|
|
22
|
+
* content.replace(re, `static template`) // no expressions — static
|
|
23
|
+
* const REP = "static"; s.replace(re, REP) // resolves to a const with a
|
|
24
|
+
* // static string init
|
|
25
|
+
*
|
|
26
|
+
* No autofix: wrapping in `() => value` would break call sites that rely on
|
|
27
|
+
* deliberate `$n` backreferences — the conversion to `(m, g1) => ...` is a
|
|
28
|
+
* judgement call.
|
|
29
|
+
*
|
|
30
|
+
* Per .claude/rules/robustness-standards.md § "Replacement strings passed to
|
|
31
|
+
* .replace() must be function-wrapped when runtime-composed".
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
function isStaticString(node) {
|
|
35
|
+
if (!node) return false;
|
|
36
|
+
if (node.type === "Literal" && typeof node.value === "string") return true;
|
|
37
|
+
if (node.type === "TemplateLiteral" && node.expressions.length === 0) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isFunctionNode(node) {
|
|
44
|
+
return (
|
|
45
|
+
node &&
|
|
46
|
+
(node.type === "ArrowFunctionExpression" ||
|
|
47
|
+
node.type === "FunctionExpression")
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Walk the scope chain upward to find the variable binding for `name`. */
|
|
52
|
+
function resolveVariable(scope, name) {
|
|
53
|
+
for (let s = scope; s; s = s.upper) {
|
|
54
|
+
const variable = s.variables.find((v) => v.name === name);
|
|
55
|
+
if (variable) return variable;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Returns true iff the identifier resolves in-file to a binding whose value
|
|
62
|
+
* is statically known to be safe: a `const` initialised with a static string
|
|
63
|
+
* (author-visible content) or a function (function-form replacer), or a
|
|
64
|
+
* function declaration.
|
|
65
|
+
*/
|
|
66
|
+
function identifierResolvesToSafeBinding(context, identifier) {
|
|
67
|
+
const scope = context.sourceCode.getScope(identifier);
|
|
68
|
+
const variable = resolveVariable(scope, identifier.name);
|
|
69
|
+
if (!variable || variable.defs.length !== 1) return false;
|
|
70
|
+
|
|
71
|
+
const def = variable.defs[0];
|
|
72
|
+
if (def.type === "FunctionName") return true;
|
|
73
|
+
if (def.type !== "Variable") return false;
|
|
74
|
+
if (def.parent.kind !== "const") return false;
|
|
75
|
+
|
|
76
|
+
const init = def.node.init;
|
|
77
|
+
return isStaticString(init) || isFunctionNode(init);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const REPLACE_METHODS = new Set(["replace", "replaceAll"]);
|
|
81
|
+
|
|
82
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
83
|
+
export default {
|
|
84
|
+
meta: {
|
|
85
|
+
type: "problem",
|
|
86
|
+
docs: {
|
|
87
|
+
description:
|
|
88
|
+
"Disallow runtime-composed strings as the replacement argument of .replace()/.replaceAll() — `$` sequences expand silently; use the function form.",
|
|
89
|
+
category: "Possible Errors",
|
|
90
|
+
recommended: true
|
|
91
|
+
},
|
|
92
|
+
messages: {
|
|
93
|
+
replacementExpansion:
|
|
94
|
+
"Runtime-composed replacement string — the engine expands `$&`, `$'`, `$`-backtick and `$n` sequences in it, corrupting output when the value contains `$`. Use the function form: `() => value`, or `(match, g1) => ...` if backreferences are intended."
|
|
95
|
+
},
|
|
96
|
+
schema: []
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
create(context) {
|
|
100
|
+
return {
|
|
101
|
+
CallExpression(node) {
|
|
102
|
+
if (node.callee.type !== "MemberExpression") return;
|
|
103
|
+
if (node.callee.computed) return;
|
|
104
|
+
if (node.callee.property.type !== "Identifier") return;
|
|
105
|
+
if (!REPLACE_METHODS.has(node.callee.property.name)) return;
|
|
106
|
+
if (node.arguments.length !== 2) return;
|
|
107
|
+
|
|
108
|
+
const replacement = node.arguments[1];
|
|
109
|
+
if (isFunctionNode(replacement)) return;
|
|
110
|
+
if (isStaticString(replacement)) return;
|
|
111
|
+
if (
|
|
112
|
+
replacement.type === "Identifier" &&
|
|
113
|
+
identifierResolvesToSafeBinding(context, replacement)
|
|
114
|
+
) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
context.report({
|
|
119
|
+
node: replacement,
|
|
120
|
+
messageId: "replacementExpansion"
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
};
|