@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 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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/eslint-plugin",
3
- "version": "7.0.1",
3
+ "version": "7.2.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fjall-tech/fjall.git",