@vibe-agent-toolkit/utils 0.1.41 → 0.1.42

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.
Files changed (116) hide show
  1. package/README.md +146 -31
  2. package/dist/asset.d.ts +9 -0
  3. package/dist/asset.d.ts.map +1 -0
  4. package/dist/asset.js +9 -0
  5. package/dist/asset.js.map +1 -0
  6. package/dist/crawl.d.ts +19 -0
  7. package/dist/crawl.d.ts.map +1 -0
  8. package/dist/crawl.js +19 -0
  9. package/dist/crawl.js.map +1 -0
  10. package/dist/file-crawler.d.ts +19 -2
  11. package/dist/file-crawler.d.ts.map +1 -1
  12. package/dist/file-crawler.js +19 -1
  13. package/dist/file-crawler.js.map +1 -1
  14. package/dist/fs-utils.d.ts +60 -3
  15. package/dist/fs-utils.d.ts.map +1 -1
  16. package/dist/fs-utils.js +88 -10
  17. package/dist/fs-utils.js.map +1 -1
  18. package/dist/fs.d.ts +7 -6
  19. package/dist/fs.d.ts.map +1 -1
  20. package/dist/fs.js +7 -6
  21. package/dist/fs.js.map +1 -1
  22. package/dist/git-utils.d.ts +2 -0
  23. package/dist/git-utils.d.ts.map +1 -1
  24. package/dist/git-utils.js +16 -0
  25. package/dist/git-utils.js.map +1 -1
  26. package/dist/git.d.ts +23 -0
  27. package/dist/git.d.ts.map +1 -0
  28. package/dist/git.js +23 -0
  29. package/dist/git.js.map +1 -0
  30. package/dist/gitignore-checker.d.ts +0 -9
  31. package/dist/gitignore-checker.d.ts.map +1 -1
  32. package/dist/gitignore-checker.js +0 -12
  33. package/dist/gitignore-checker.js.map +1 -1
  34. package/dist/glob/glob-pattern.js +1 -1
  35. package/dist/glob/glob-pattern.js.map +1 -1
  36. package/dist/glob.d.ts +8 -0
  37. package/dist/glob.d.ts.map +1 -0
  38. package/dist/glob.js +8 -0
  39. package/dist/glob.js.map +1 -0
  40. package/dist/path-core.d.ts +196 -0
  41. package/dist/path-core.d.ts.map +1 -0
  42. package/dist/path-core.js +251 -0
  43. package/dist/path-core.js.map +1 -0
  44. package/dist/path-utils.d.ts +12 -183
  45. package/dist/path-utils.d.ts.map +1 -1
  46. package/dist/path-utils.js +13 -237
  47. package/dist/path-utils.js.map +1 -1
  48. package/dist/path.d.ts +12 -0
  49. package/dist/path.d.ts.map +1 -0
  50. package/dist/path.js +12 -0
  51. package/dist/path.js.map +1 -0
  52. package/dist/process.d.ts +7 -4
  53. package/dist/process.d.ts.map +1 -1
  54. package/dist/process.js +7 -4
  55. package/dist/process.js.map +1 -1
  56. package/dist/project.d.ts +31 -0
  57. package/dist/project.d.ts.map +1 -0
  58. package/dist/project.js +31 -0
  59. package/dist/project.js.map +1 -0
  60. package/dist/safe-exec.d.ts.map +1 -1
  61. package/dist/safe-exec.js +8 -4
  62. package/dist/safe-exec.js.map +1 -1
  63. package/dist/spawn-hardened.d.ts.map +1 -1
  64. package/dist/spawn-hardened.js +4 -17
  65. package/dist/spawn-hardened.js.map +1 -1
  66. package/dist/template-entry.d.ts +10 -0
  67. package/dist/template-entry.d.ts.map +1 -0
  68. package/dist/template-entry.js +10 -0
  69. package/dist/template-entry.js.map +1 -0
  70. package/dist/testing.d.ts +8 -0
  71. package/dist/testing.d.ts.map +1 -0
  72. package/dist/testing.js +8 -0
  73. package/dist/testing.js.map +1 -0
  74. package/dist/windows-shell.d.ts +140 -8
  75. package/dist/windows-shell.d.ts.map +1 -1
  76. package/dist/windows-shell.js +218 -11
  77. package/dist/windows-shell.js.map +1 -1
  78. package/dist/yaml.d.ts +9 -0
  79. package/dist/yaml.d.ts.map +1 -0
  80. package/dist/yaml.js +9 -0
  81. package/dist/yaml.js.map +1 -0
  82. package/dist/zod.d.ts +10 -0
  83. package/dist/zod.d.ts.map +1 -0
  84. package/dist/zod.js +10 -0
  85. package/dist/zod.js.map +1 -0
  86. package/eslint/README.md +217 -0
  87. package/eslint/index.cjs +161 -0
  88. package/eslint/index.d.cts +60 -0
  89. package/eslint/rules/dead-import.cjs +201 -0
  90. package/eslint/rules/eslint-rule-factory.cjs +410 -0
  91. package/eslint/rules/exempt-path-matcher.cjs +265 -0
  92. package/eslint/rules/no-bare-dynamic-import-path.cjs +142 -0
  93. package/eslint/rules/no-child-process-execSync.cjs +23 -0
  94. package/eslint/rules/no-command-direct-factory.cjs +246 -0
  95. package/eslint/rules/no-file-url-string-concat.cjs +77 -0
  96. package/eslint/rules/no-fs-mkdirSync.cjs +23 -0
  97. package/eslint/rules/no-fs-promises-cp.cjs +36 -0
  98. package/eslint/rules/no-fs-realpathSync.cjs +23 -0
  99. package/eslint/rules/no-hardcoded-path-split.cjs +141 -0
  100. package/eslint/rules/no-manual-path-normalize.cjs +161 -0
  101. package/eslint/rules/no-os-tmpdir.cjs +24 -0
  102. package/eslint/rules/no-path-join.cjs +14 -0
  103. package/eslint/rules/no-path-operations-in-comparisons.cjs +148 -0
  104. package/eslint/rules/no-path-relative.cjs +14 -0
  105. package/eslint/rules/no-path-resolve.cjs +14 -0
  106. package/eslint/rules/no-path-sep-in-strings.cjs +130 -0
  107. package/eslint/rules/no-path-startswith.cjs +139 -0
  108. package/eslint/rules/no-test-scoped-functions.cjs +134 -0
  109. package/eslint/rules/no-unix-shell-commands.cjs +152 -0
  110. package/eslint/rules/no-unsafe-root-join.cjs +85 -0
  111. package/eslint/rules/no-url-pathname-for-fs.cjs +107 -0
  112. package/eslint/rules/path-function-rule-factory.cjs +417 -0
  113. package/eslint/rules/prefer-startswith-over-regex.cjs +257 -0
  114. package/eslint/rules/require-justified-skip.cjs +348 -0
  115. package/eslint/rules/safe-import.cjs +164 -0
  116. package/package.json +65 -7
@@ -0,0 +1,410 @@
1
+ /**
2
+ * ESLint Rule Factory - Shared implementation for unsafe operation rules
3
+ *
4
+ * Creates ESLint rules that detect unsafe operations and suggest safe alternatives.
5
+ * Supports auto-fixing with import management.
6
+ *
7
+ * @param {Object} config - Rule configuration
8
+ * @param {string} config.unsafeFn - Name of unsafe function (e.g., 'tmpdir', 'mkdirSync')
9
+ * @param {string} config.unsafeModule - Module containing unsafe function (e.g., 'node:os', 'node:fs')
10
+ * @param {string} config.safeFn - Name of safe replacement function (e.g., 'normalizedTmpdir')
11
+ * @param {string} config.safeModule - Module containing safe function. Pass the
12
+ * NARROW subpath that owns the symbol (`@vibe-agent-toolkit/utils/fs`), never
13
+ * the barrel — see `safe-import.cjs` for why.
14
+ * @param {string} config.message - Error message to display
15
+ * @param {readonly string[]} [config.exemptFiles] - FALLBACK repo-relative paths
16
+ * allowed to call the unsafe function, used only when the consuming config
17
+ * passes no `exemptFiles` option. Ship this empty: an exemption names one file
18
+ * in one repo, so a baked-in default is a hole in every OTHER repo. Consumers
19
+ * declare their own via the rule option (see `exempt-path-matcher.cjs`).
20
+ * @param {boolean} [config.checkMemberExpression] - Check for obj.method() calls (default: false)
21
+ * @returns {Object} ESLint rule definition
22
+ *
23
+ * @example
24
+ * // no-os-tmpdir.cjs
25
+ * const factory = require('./eslint-rule-factory.cjs');
26
+ * module.exports = factory({
27
+ * unsafeFn: 'tmpdir',
28
+ * unsafeModule: 'node:os',
29
+ * safeFn: 'normalizedTmpdir',
30
+ * safeModule: SAFE_FS_MODULE,
31
+ * message: 'Use normalizedTmpdir() for Windows compatibility',
32
+ * });
33
+ *
34
+ * // …and in the consumer's eslint.config.js, naming ITS implementation file:
35
+ * // '@vibe-agent-toolkit/no-os-tmpdir': ['error', { exemptFiles: ['src/paths.ts'] }]
36
+ */
37
+
38
+ const {
39
+ DEAD_UNSAFE_IMPORT,
40
+ DEAD_UNSAFE_IMPORT_MESSAGE,
41
+ reportDeadUnsafeImports,
42
+ } = require('./dead-import.cjs');
43
+ const {
44
+ UNANCHORED_EXEMPT_FILE,
45
+ UNANCHORED_EXEMPT_MESSAGE,
46
+ createConfigurableExemptPathMatcher,
47
+ reportUnanchoredExemptEntries,
48
+ } = require('./exempt-path-matcher.cjs');
49
+ const {
50
+ EXEMPT_AND_SAFE_MODULE_SCHEMA,
51
+ insertAboveWithComments,
52
+ isNameAlreadyBound,
53
+ resolveSafeModule,
54
+ } = require('./safe-import.cjs');
55
+
56
+ /** Does this declaration bring in `name` as a named specifier? */
57
+ function importsName(importNode, name) {
58
+ return importNode.specifiers.some(
59
+ (spec) => spec.type === 'ImportSpecifier' && spec.imported.name === name,
60
+ );
61
+ }
62
+
63
+ /**
64
+ * The local name of `import os from 'node:os'` / `import * as os from 'node:os'`.
65
+ *
66
+ * Without it the member-expression check has no receiver to compare against,
67
+ * and matching on the property name alone turns every `env.tmpdir()` into an
68
+ * `os.tmpdir()` finding.
69
+ */
70
+ function namespaceLocalName(importNode) {
71
+ const spec = importNode.specifiers.find(
72
+ (candidate) =>
73
+ candidate.type === 'ImportDefaultSpecifier' || candidate.type === 'ImportNamespaceSpecifier',
74
+ );
75
+ return spec ? spec.local.name : null;
76
+ }
77
+
78
+ /**
79
+ * The module specifier of `require('x')`, `import('x')` or `await import('x')`.
80
+ *
81
+ * A static `import * as os` is not the only way to end up holding the `node:os`
82
+ * namespace, and the fix does not care which way it happened — the whole callee
83
+ * is replaced by a free function, so `os.tmpdir()` becomes `normalizedTmpdir()`
84
+ * whatever bound `os`.
85
+ *
86
+ * This is NOT a return to rc.1, which matched any receiver at all and so
87
+ * "detected" these shapes only as a side effect of the defect that also produced
88
+ * `os.normalizedTmpdir()`. The receiver check stays; this widens what counts as
89
+ * evidence that the receiver IS the module's namespace, and nothing else.
90
+ *
91
+ * @param {object} [init] - The initialiser of a variable declarator.
92
+ * @returns {string|null} The literal module name, or null.
93
+ */
94
+ function namespaceModuleOf(init) {
95
+ const expr = init?.type === 'AwaitExpression' ? init.argument : init;
96
+ if (!expr) {
97
+ return null;
98
+ }
99
+ const isDynamicImport = expr.type === 'ImportExpression';
100
+ const isRequire =
101
+ expr.type === 'CallExpression' &&
102
+ expr.callee.type === 'Identifier' &&
103
+ expr.callee.name === 'require';
104
+ if (!isDynamicImport && !isRequire) {
105
+ return null;
106
+ }
107
+ // `ImportExpression.source` / the sole `require` argument. A computed
108
+ // specifier names no module we can check, so it binds nothing we may rewrite.
109
+ const source = isDynamicImport ? expr.source : expr.arguments[0];
110
+ return source?.type === 'Literal' && typeof source.value === 'string' ? source.value : null;
111
+ }
112
+
113
+ /**
114
+ * Helper function to filter unsafe import specifiers
115
+ * Extracted to reduce nesting depth for code quality
116
+ */
117
+ function filterUnsafeSpecifiers(importNode, unsafeFn) {
118
+ return importNode.specifiers.filter((s) => s.imported && s.imported.name === unsafeFn);
119
+ }
120
+
121
+ /**
122
+ * Helper function to remove unsafe import specifiers
123
+ * Extracted to reduce nesting depth for code quality
124
+ */
125
+ function removeUnsafeImportSpecifiers(fixer, sourceCode, unsafeSpecs) {
126
+ const fixes = [];
127
+ for (const spec of unsafeSpecs) {
128
+ const comma = sourceCode.getTokenAfter(spec);
129
+ if (comma?.value === ',') {
130
+ fixes.push(fixer.removeRange([spec.range[0], comma.range[1]]));
131
+ } else {
132
+ const commaBefore = sourceCode.getTokenBefore(spec);
133
+ if (commaBefore?.value === ',') {
134
+ fixes.push(fixer.removeRange([commaBefore.range[0], spec.range[1]]));
135
+ } else {
136
+ fixes.push(fixer.remove(spec));
137
+ }
138
+ }
139
+ }
140
+ return fixes;
141
+ }
142
+
143
+ module.exports = function createNoUnsafeRule(config) {
144
+ const {
145
+ unsafeFn,
146
+ unsafeModule,
147
+ safeFn,
148
+ safeModule,
149
+ message,
150
+ exemptFiles = [],
151
+ checkMemberExpression = false,
152
+ } = config;
153
+
154
+ const exemptMatcherFor = createConfigurableExemptPathMatcher(exemptFiles);
155
+
156
+ // Normalize module names (support both 'node:os' and 'os')
157
+ const moduleVariants = [unsafeModule];
158
+ if (unsafeModule.startsWith('node:')) {
159
+ moduleVariants.push(unsafeModule.replace('node:', ''));
160
+ } else {
161
+ moduleVariants.push(`node:${unsafeModule}`);
162
+ }
163
+
164
+ return {
165
+ meta: {
166
+ type: 'problem',
167
+ docs: {
168
+ description: `Enforce use of ${safeFn}() instead of ${unsafeFn}()`,
169
+ category: 'Best Practices',
170
+ recommended: true,
171
+ },
172
+ fixable: 'code',
173
+ schema: [EXEMPT_AND_SAFE_MODULE_SCHEMA],
174
+ messages: {
175
+ noUnsafeOperation: message,
176
+ [DEAD_UNSAFE_IMPORT]: DEAD_UNSAFE_IMPORT_MESSAGE,
177
+ [UNANCHORED_EXEMPT_FILE]: UNANCHORED_EXEMPT_MESSAGE,
178
+ },
179
+ },
180
+
181
+ create(context) {
182
+ const sourceCode = context.getSourceCode();
183
+ // Resolved per invocation, not at factory-construction time: the option is
184
+ // the consuming repo's, and one repo can configure the same rule
185
+ // differently across config blocks.
186
+ const targetModule = resolveSafeModule(context, safeModule);
187
+
188
+ // Only the declared implementation file(s) may call the unsafe function.
189
+ if (exemptMatcherFor(context)(context.getFilename())) {
190
+ // Still surface a malformed exemption list: the file we are standing in
191
+ // may be exempt only BECAUSE the entry is unanchored.
192
+ return {
193
+ Program(node) {
194
+ reportUnanchoredExemptEntries(context, node);
195
+ },
196
+ };
197
+ }
198
+
199
+ let hasUnsafeImport = false;
200
+ // Seeded from SCOPE, not from "did I see an import from safeModule?".
201
+ // A file already importing `safeFn` from the barrel needs the call
202
+ // rewritten but must NOT gain a second binding of the same name — that is
203
+ // a SyntaxError, not a redundant import. See `safe-import.cjs`.
204
+ let hasSafeImport = isNameAlreadyBound(sourceCode, safeFn);
205
+ // The SAME question, answered once and never mutated. `hasSafeImport`
206
+ // flips the moment a fix inserts the import, and the dead-import leg must
207
+ // not be armed by a flag a suppressed report can spend — ESLint runs
208
+ // `fix()` before the `eslint-disable` filter discards the problem.
209
+ const safeBoundInSource = hasSafeImport;
210
+ let unsafeImportNode = null;
211
+ const unsafeImportNodes = [];
212
+ let safeImportNode = null;
213
+ // A SET, because a namespace can be bound by a static import, a
214
+ // `require()`, or a dynamic `import()` — see `namespaceModuleOf`.
215
+ const unsafeNamespaceNames = new Set();
216
+ // Latches the REMOVAL only — never the insert. See `fix()` for why the
217
+ // two shared edits must be treated differently.
218
+ let unsafeImportRemoved = false;
219
+
220
+ return {
221
+ Program(node) {
222
+ reportUnanchoredExemptEntries(context, node);
223
+ },
224
+
225
+ 'Program:exit'() {
226
+ reportDeadUnsafeImports(context, sourceCode, unsafeImportNodes, safeBoundInSource);
227
+ },
228
+
229
+ ImportDeclaration(node) {
230
+ if (moduleVariants.includes(node.source.value)) {
231
+ unsafeImportNode = node;
232
+ unsafeImportNodes.push(node);
233
+ hasUnsafeImport = hasUnsafeImport || importsName(node, unsafeFn);
234
+ const local = namespaceLocalName(node);
235
+ if (local) {
236
+ unsafeNamespaceNames.add(local);
237
+ }
238
+ }
239
+ if (node.source.value === targetModule) {
240
+ safeImportNode = node;
241
+ hasSafeImport = hasSafeImport || importsName(node, safeFn);
242
+ }
243
+ },
244
+
245
+ // `const os = require('node:os')` / `const os = await import('node:os')`.
246
+ //
247
+ // Recorded by NAME, matching how the static-import receiver has always
248
+ // been tracked, so a declaration must precede its use — which is the
249
+ // normal shape and the only one either form appears in. Resolving the
250
+ // receiver through scope instead would also reject a shadowing rebind,
251
+ // but it would change detection parity on a population an adopter has
252
+ // already measured across 4,963 files, so it is not worth trading here.
253
+ //
254
+ // KNOWN RESIDUAL, measured: `dead-import.cjs` only removes an
255
+ // `ImportDeclaration`, so after the rewrite `const os = require('node:os')`
256
+ // and `const os = await import('node:os')` are both left behind as
257
+ // `'os' is assigned a value but never used` (the dynamic form also draws
258
+ // `sonarjs/no-dead-store`). Removing a VariableDeclaration is a wider edit
259
+ // than removing an import (multiple declarators, destructuring, an `await`
260
+ // inside control flow), so it is deliberately not done here. A static
261
+ // `import * as os` — the shape that actually appears at scale — is cleaned
262
+ // up. An adopter confirmed the residual 2-for-2 and measured **zero** files
263
+ // using either dynamic shape across 4,963 tracked sources, so the
264
+ // population this would serve is currently empty.
265
+ //
266
+ // If it is ever extended that far, note what makes the dynamic case
267
+ // different in kind: the leftover `await import('node:os')` STILL RUNS.
268
+ // The module is loaded and the promise awaited, and only the binding is
269
+ // dead — so deleting the statement removes an execution, not just a name.
270
+ // For these builtins that is unobservable, which is precisely why the
271
+ // module list is closed; the same edit against an arbitrary module would
272
+ // not be safe, and no `sideEffects` metadata could tell you so.
273
+ VariableDeclarator(node) {
274
+ if (node.id.type !== 'Identifier') {
275
+ return;
276
+ }
277
+ const source = namespaceModuleOf(node.init);
278
+ if (source !== null && moduleVariants.includes(source)) {
279
+ unsafeNamespaceNames.add(node.id.name);
280
+ }
281
+ },
282
+
283
+ CallExpression(node) {
284
+ let isUnsafeCall = false;
285
+
286
+ // Check for direct function call: unsafeFn()
287
+ if (node.callee.name === unsafeFn) {
288
+ isUnsafeCall = true;
289
+ }
290
+
291
+ // Check for member expression: os.tmpdir()
292
+ //
293
+ // The RECEIVER must be the unsafe module's own namespace binding.
294
+ // Matching on the property name alone made `env.tmpdir()` — any
295
+ // object at all with a same-named method — an `os.tmpdir()` finding.
296
+ // That was survivable while the fixer rewrote only the property
297
+ // (`env.normalizedTmpdir()` fails to compile, so the false positive
298
+ // announced itself); once the whole callee is replaced it becomes
299
+ // `normalizedTmpdir()`, which compiles, type-checks, passes
300
+ // `no-undef`, and silently calls a different function with the
301
+ // receiver discarded. A false positive that produces WORKING code is
302
+ // strictly the more dangerous kind.
303
+ if (
304
+ checkMemberExpression &&
305
+ node.callee.type === 'MemberExpression' &&
306
+ node.callee.object.type === 'Identifier' &&
307
+ unsafeNamespaceNames.has(node.callee.object.name) &&
308
+ node.callee.property.name === unsafeFn
309
+ ) {
310
+ isUnsafeCall = true;
311
+ }
312
+
313
+ if (!isUnsafeCall) {
314
+ return;
315
+ }
316
+
317
+ context.report({
318
+ node,
319
+ messageId: 'noUnsafeOperation',
320
+ data: { safeModule: targetModule },
321
+ fix(fixer) {
322
+ const fixes = [];
323
+
324
+ // Replace the WHOLE callee, member expression or not.
325
+ //
326
+ // Rewriting only the property turned `os.tmpdir()` into
327
+ // `os.normalizedTmpdir()` — a method that does not exist on the
328
+ // `node:os` namespace. The replacement is a free function from
329
+ // OUR package, and the fixer imported it correctly; it just left
330
+ // the call reaching for it through the wrong object. Silent, like
331
+ // the overlap bug below: lint went green (the rule no longer sees
332
+ // `tmpdir`), and it is a dangling MEMBER rather than a dangling
333
+ // identifier, so `no-undef` cannot see it either. `tsc` can.
334
+ fixes.push(fixer.replaceText(node.callee, safeFn));
335
+
336
+ // Add import if needed — on EVERY report, deliberately.
337
+ //
338
+ // The sister factory emits this once per file, because there a
339
+ // report that edits both the import and its own call site spans
340
+ // everything between them, N reports leave N nested ranges, and
341
+ // ESLint keeps one: the defect measured at 146 broken files.
342
+ //
343
+ // That guard does not belong here, and briefly having it was a
344
+ // mistake worth recording. These rules do NOT key detection on
345
+ // the import — `node.callee.name === unsafeFn` is true whether or
346
+ // not the specifier survives — and `hasSafeImport` is reseeded
347
+ // from scope each pass, so pass 2 always finished the job anyway.
348
+ // An adversarial run confirmed the guard changed no output at 4,
349
+ // 40 or 75 call sites. What it DID change was the failure mode:
350
+ // ESLint runs `fix()` for a suppressed problem before the
351
+ // `eslint-disable` filter discards it, so one disable comment on
352
+ // the first call site spent the once-per-file edit and stranded
353
+ // the file with calls the import no longer backs.
354
+ //
355
+ // Every report carrying its own import edit costs a pass and buys
356
+ // a fix that is correct on its own — including when applied alone
357
+ // from an editor's "fix this problem".
358
+ if (!hasSafeImport) {
359
+ if (safeImportNode) {
360
+ // Add to existing safe module import
361
+ const lastSpecifier = safeImportNode.specifiers.at(-1);
362
+ fixes.push(fixer.insertTextAfter(lastSpecifier, `, ${safeFn}`));
363
+ } else {
364
+ // Land next to the imports, never after arbitrary code —
365
+ // `insertTextAfter(body[0])` on a file whose first statement
366
+ // is a `const` welds the declaration onto the end of it.
367
+ const targetNode = unsafeImportNode || sourceCode.ast.body[0];
368
+ const declaration = `import { ${safeFn} } from '${targetModule}';`;
369
+ fixes.push(
370
+ targetNode.type === 'ImportDeclaration'
371
+ ? fixer.insertTextAfter(targetNode, `\n${declaration}`)
372
+ : insertAboveWithComments(fixer, sourceCode, targetNode, `${declaration}\n`),
373
+ );
374
+ }
375
+ }
376
+
377
+ // Remove the unsafe import — LATCHED, unlike the insert above.
378
+ //
379
+ // The asymmetry is the whole design. An insert is safe to repeat
380
+ // (identical text, identical anchor, ESLint drops the duplicate)
381
+ // and repeating it is what keeps each report's fix correct on its
382
+ // own. A REMOVAL is not: if every report removes the specifier,
383
+ // one of those removals lands even when the report that would
384
+ // have rewritten the matching call was suppressed — and the
385
+ // suppressed call is left calling an identifier the import no
386
+ // longer provides. Measured: `tmpdir` undefined, permanently.
387
+ //
388
+ // Latched, the discarded first report simply takes the removal
389
+ // with it, and the worst case is an unused import that
390
+ // `no-unused-vars` will point at. A lint finding, not a crash.
391
+ if (hasUnsafeImport && unsafeImportNode && !unsafeImportRemoved) {
392
+ const unsafeSpecs = filterUnsafeSpecifiers(unsafeImportNode, unsafeFn);
393
+ if (unsafeImportNode.specifiers.length === 1 && unsafeSpecs.length === 1) {
394
+ // Remove entire import
395
+ fixes.push(fixer.remove(unsafeImportNode));
396
+ } else if (unsafeSpecs.length > 0) {
397
+ // Remove just the unsafe specifier
398
+ fixes.push(...removeUnsafeImportSpecifiers(fixer, sourceCode, unsafeSpecs));
399
+ }
400
+ unsafeImportRemoved = true;
401
+ }
402
+
403
+ return fixes;
404
+ },
405
+ });
406
+ },
407
+ };
408
+ },
409
+ };
410
+ };
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Shared exemption matcher for local ESLint rule factories.
3
+ *
4
+ * Rules that ban an unsafe primitive have to exempt the ONE file that implements
5
+ * the safe replacement. Doing that with `filename.includes('path-utils.ts')` is a
6
+ * silent hole: any file anywhere — in this repo or in a consumer repo running
7
+ * these rules — whose path merely CONTAINS that string opts itself out. A private
8
+ * `tools/hooks/path-utils.ts` full of raw `tmpdir()` / `realpathSync()` calls
9
+ * linted clean for exactly this reason.
10
+ *
11
+ * Exemptions are therefore repo-relative paths matched at a path-segment
12
+ * boundary: the linted filename must either BE the exempt path, or END WITH
13
+ * `/` + the exempt path. Matching happens on forward slashes — a rule pack whose
14
+ * whole purpose is enforcing cross-platform path handling must not itself be
15
+ * `\` vs `/` dependent.
16
+ *
17
+ * Three shapes of exemption live here, and every local rule must take one of
18
+ * them rather than reaching for `includes()` again:
19
+ *
20
+ * - `createExemptPathMatcher` — "is this THAT file?" (a named implementation file)
21
+ * - `createExemptDirectoryMatcher` — "is this file INSIDE that directory?" (a package
22
+ * that owns a centralized wrapper)
23
+ * - `isTestFile` — "is this a test file?" (a category, anchored on the basename's
24
+ * extension rather than on a path segment)
25
+ *
26
+ * `eslint-rule-factory.cjs`, `path-function-rule-factory.cjs`,
27
+ * `no-command-direct-factory.cjs` and `no-unix-shell-commands.cjs` all use these.
28
+ * Do not re-implement any of them: the sibling-factory copy is how the bug
29
+ * shipped four times in the first place.
30
+ *
31
+ * A fourth export, `createConfigurableExemptPathMatcher`, wires the file-shaped
32
+ * exemption to the rule's own ESLint option so each CONSUMER declares the paths
33
+ * for its own repo. It also lives here (rather than in each factory) for the same
34
+ * reason: two copies is how the bug spread last time.
35
+ */
36
+
37
+ /** Forward-slash a path and drop any leading `./` or `/` noise used for anchoring. */
38
+ function normalizeForMatch(value) {
39
+ return String(value).replaceAll('\\', '/').replace(/^(?:\.\/)+/, '');
40
+ }
41
+
42
+ /**
43
+ * Build a predicate that reports whether a linted filename is one of `exemptPaths`.
44
+ *
45
+ * @param {readonly string[]} exemptPaths - Repo-relative paths, e.g.
46
+ * `['packages/utils/src/path-utils.ts']`. A bare basename is accepted by the
47
+ * matcher but matches that filename ANYWHERE in the tree (ESLint filenames are
48
+ * absolute, so the `endsWith('/' + target)` leg is what fires) — pass the full
49
+ * repo-relative path. Rules surface that mistake via
50
+ * {@link reportUnanchoredExemptEntries}.
51
+ * @returns {(filename: string) => boolean} Anchored, separator-agnostic predicate.
52
+ */
53
+ function createExemptPathMatcher(exemptPaths) {
54
+ const targets = [...exemptPaths]
55
+ .map((exemptPath) => normalizeForMatch(exemptPath).replace(/^\/+/, ''))
56
+ .filter((exemptPath) => exemptPath.length > 0);
57
+
58
+ return function isExemptPath(filename) {
59
+ if (!filename) {
60
+ return false;
61
+ }
62
+ const normalized = normalizeForMatch(filename);
63
+ return targets.some(
64
+ (target) => normalized === target || normalized.endsWith(`/${target}`),
65
+ );
66
+ };
67
+ }
68
+
69
+ /**
70
+ * JSON Schema for the `exemptFiles` rule option, for a rule's `meta.schema`.
71
+ *
72
+ * `additionalProperties: false` on purpose: a typo'd option key must be an ESLint
73
+ * config error, not a silently ignored exemption list (which would read as "the
74
+ * rule stopped firing for no reason").
75
+ */
76
+ const EXEMPT_FILES_SCHEMA = Object.freeze({
77
+ type: 'object',
78
+ properties: {
79
+ exemptFiles: {
80
+ type: 'array',
81
+ items: { type: 'string' },
82
+ uniqueItems: true,
83
+ },
84
+ },
85
+ additionalProperties: false,
86
+ });
87
+
88
+ /**
89
+ * Wire a file-shaped exemption to the rule's `exemptFiles` option.
90
+ *
91
+ * An exemption names the ONE file in a SPECIFIC repo that implements the safe
92
+ * replacement, so it cannot be shipped as a useful default: a package publishing
93
+ * `packages/utils/src/path-utils.ts` as a built-in exemption hands every consumer
94
+ * a hole at that path. Hence the empty default here, and hence REPLACE rather
95
+ * than merge semantics — a consumer's list is the whole list.
96
+ *
97
+ * @param {readonly string[]} [defaultPaths] - Fallback used only when the rule is
98
+ * configured with no `exemptFiles` option. Ship this empty unless the rule
99
+ * itself owns the file (no shipped rule in this package does).
100
+ * @returns {(context: object) => (filename: string) => boolean} Resolver taking an
101
+ * ESLint rule context and returning the anchored predicate for that invocation.
102
+ */
103
+ function createConfigurableExemptPathMatcher(defaultPaths = []) {
104
+ const defaultMatcher = createExemptPathMatcher(defaultPaths);
105
+ const cache = new Map();
106
+
107
+ return function exemptMatcherFor(context) {
108
+ const configured = context.options?.[0]?.exemptFiles;
109
+ if (!Array.isArray(configured)) {
110
+ return defaultMatcher;
111
+ }
112
+ // JSON, not join(): a delimiter cheap enough to be collision-free is a raw
113
+ // NUL, which this repo bans in source (git and ripgrep treat the file as
114
+ // binary and skip its contents), and any printable delimiter can legally
115
+ // appear in a path.
116
+ const key = JSON.stringify(configured);
117
+ let matcher = cache.get(key);
118
+ if (!matcher) {
119
+ matcher = createExemptPathMatcher(configured);
120
+ cache.set(key, matcher);
121
+ }
122
+ return matcher;
123
+ };
124
+ }
125
+
126
+ /**
127
+ * `messageId` every rule that accepts `exemptFiles` must declare, so an
128
+ * unanchored entry is reported through the normal lint channel.
129
+ *
130
+ * Not a JSON Schema `pattern` on the option, which would be the obvious place:
131
+ * the schema sees the RAW string, and `./path-utils.ts` contains a `/` while
132
+ * normalizing to exactly the same repo-wide exemption as `path-utils.ts`. A
133
+ * check that the wrong spelling slips past is worse than none. Not a
134
+ * `process.emitWarning` either — a notice on stderr is not a reported finding
135
+ * and gets scrolled past.
136
+ */
137
+ const UNANCHORED_EXEMPT_FILE = 'unanchoredExemptFile';
138
+
139
+ /**
140
+ * The `meta.messages` entry for {@link UNANCHORED_EXEMPT_FILE}.
141
+ *
142
+ * ESLint filenames are ABSOLUTE, and an exemption matches when the filename ends
143
+ * with `/` + the entry. So a bare basename does not mean "the file at the repo
144
+ * root" (as this module's JSDoc used to claim) — it means EVERY file with that
145
+ * name, anywhere in the tree, including ones added later by someone who never
146
+ * saw the config. That is the same repo-wide hole the anchoring rewrite closed,
147
+ * reopened one config entry at a time.
148
+ */
149
+ const UNANCHORED_EXEMPT_MESSAGE =
150
+ 'exemptFiles entry "{{entry}}" is a bare filename, so it exempts EVERY file named ' +
151
+ '"{{entry}}" anywhere in the repo — including files added later. Give the ' +
152
+ 'repo-relative path instead (e.g. "packages/utils/src/{{entry}}").';
153
+
154
+ /**
155
+ * The configured `exemptFiles` entries that are not anchored to a directory.
156
+ *
157
+ * Runs on the NORMALIZED entry, so `./x.ts` and `x.ts` are both caught.
158
+ *
159
+ * @param {object} context - ESLint rule context.
160
+ * @returns {string[]} Offending entries, as the consumer spelled them.
161
+ */
162
+ function findUnanchoredExemptEntries(context) {
163
+ const configured = context.options?.[0]?.exemptFiles;
164
+ if (!Array.isArray(configured)) {
165
+ return [];
166
+ }
167
+ return configured.filter((entry) => {
168
+ const normalized = normalizeForMatch(entry).replace(/^\/+/, '');
169
+ return normalized.length > 0 && !normalized.includes('/');
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Report every unanchored `exemptFiles` entry against the `Program` node.
175
+ *
176
+ * Deliberately stateless — no "warn once per process" dedupe. ESLint caches
177
+ * results per file, so a rule that remembers having warned goes SILENT on the
178
+ * second run against a warm cache, which is precisely when a stale config is
179
+ * least likely to be noticed.
180
+ *
181
+ * @param {object} context - ESLint rule context.
182
+ * @param {object} node - The `Program` node to anchor the report on.
183
+ */
184
+ function reportUnanchoredExemptEntries(context, node) {
185
+ for (const entry of findUnanchoredExemptEntries(context)) {
186
+ context.report({ node, messageId: UNANCHORED_EXEMPT_FILE, data: { entry } });
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Build a predicate that reports whether a linted filename lives UNDER one of
192
+ * `exemptDirs`.
193
+ *
194
+ * The directory flavor of the same bug: `filename.includes('packages/git/')`
195
+ * also exempted `vendor/copy-packages/git/` and `tools/my-packages/git/` — any
196
+ * directory whose name merely ENDS WITH the exempt one. Anchoring means the
197
+ * directory must start the repo-relative path or be preceded by a `/`.
198
+ *
199
+ * @param {readonly string[]} exemptDirs - Repo-relative directories, with or
200
+ * without a trailing slash, e.g. `['packages/git/']`.
201
+ * @returns {(filename: string) => boolean} Anchored, separator-agnostic predicate.
202
+ */
203
+ function createExemptDirectoryMatcher(exemptDirs) {
204
+ // Normalize each directory to a `<dir>/` PREFIX rather than stripping trailing
205
+ // slashes: a trailing-slash strip needs either `/\/+$/` (flagged by
206
+ // sonarjs/slow-regex) or `.split('/')` (banned repo-wide by this very rule pack).
207
+ const prefixes = [...exemptDirs]
208
+ .map((dir) => normalizeForMatch(dir).replace(/^\/+/, ''))
209
+ .filter((dir) => dir.length > 0)
210
+ .map((dir) => (dir.endsWith('/') ? dir : `${dir}/`));
211
+
212
+ return function isUnderExemptDirectory(filename) {
213
+ if (!filename) {
214
+ return false;
215
+ }
216
+ const normalized = normalizeForMatch(filename);
217
+ return prefixes.some(
218
+ (prefix) => normalized.startsWith(prefix) || normalized.includes(`/${prefix}`),
219
+ );
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Test-file naming convention, anchored to the END of the BASENAME.
225
+ *
226
+ * This is a CATEGORY check, not a path exemption, so `createExemptPathMatcher`
227
+ * is the wrong tool: there is no repo-relative path to compare against. What
228
+ * "anchored" means here is that the extension must terminate the last path
229
+ * segment. `filename.includes('.test.ts')` satisfied neither end — it exempted
230
+ * `example.test.ts.bak`, a directory named `.test.ts-helpers/`, and (a real
231
+ * tracked file in this repo) `tsconfig.test.json` via the `.test.js` spelling.
232
+ *
233
+ * The repo convention is `*.test.ts` (unit, `*.integration.test.ts`, and
234
+ * `*.system.test.ts` all land on it); the js/mjs/cjs/mts/tsx variants are
235
+ * covered because a category predicate that only knew one extension would be
236
+ * the next silent hole. `.spec.` is deliberately absent — this repo has zero
237
+ * such files and vitest's `include` globs would not run them.
238
+ */
239
+ const TEST_FILE_EXTENSION = /\.test\.[cm]?[jt]sx?$/;
240
+
241
+ /**
242
+ * @param {string} filename - Path as ESLint reports it (absolute, any separator).
243
+ * @returns {boolean} True when the file itself is a test file.
244
+ */
245
+ function isTestFile(filename) {
246
+ if (!filename) {
247
+ return false;
248
+ }
249
+ const normalized = normalizeForMatch(filename);
250
+ const basename = normalized.slice(normalized.lastIndexOf('/') + 1);
251
+ return TEST_FILE_EXTENSION.test(basename);
252
+ }
253
+
254
+ module.exports = {
255
+ EXEMPT_FILES_SCHEMA,
256
+ UNANCHORED_EXEMPT_FILE,
257
+ UNANCHORED_EXEMPT_MESSAGE,
258
+ createConfigurableExemptPathMatcher,
259
+ createExemptDirectoryMatcher,
260
+ createExemptPathMatcher,
261
+ findUnanchoredExemptEntries,
262
+ isTestFile,
263
+ normalizeForMatch,
264
+ reportUnanchoredExemptEntries,
265
+ };