@vibe-agent-toolkit/utils 0.1.42-rc.1 → 0.2.0-rc.1

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 (52) hide show
  1. package/README.md +19 -3
  2. package/dist/file-crawler.d.ts.map +1 -1
  3. package/dist/file-crawler.js +88 -2
  4. package/dist/file-crawler.js.map +1 -1
  5. package/dist/fs-utils.d.ts +389 -30
  6. package/dist/fs-utils.d.ts.map +1 -1
  7. package/dist/fs-utils.js +425 -56
  8. package/dist/fs-utils.js.map +1 -1
  9. package/dist/fs.d.ts +2 -1
  10. package/dist/fs.d.ts.map +1 -1
  11. package/dist/fs.js +7 -1
  12. package/dist/fs.js.map +1 -1
  13. package/dist/git-root-cache.d.ts +44 -0
  14. package/dist/git-root-cache.d.ts.map +1 -0
  15. package/dist/git-root-cache.js +68 -0
  16. package/dist/git-root-cache.js.map +1 -0
  17. package/dist/git-utils.d.ts +11 -0
  18. package/dist/git-utils.d.ts.map +1 -1
  19. package/dist/git-utils.js +28 -8
  20. package/dist/git-utils.js.map +1 -1
  21. package/dist/index.d.ts +3 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +37 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/numeric-args.d.ts +24 -0
  26. package/dist/numeric-args.d.ts.map +1 -0
  27. package/dist/numeric-args.js +37 -0
  28. package/dist/numeric-args.js.map +1 -0
  29. package/dist/path-core.d.ts +30 -0
  30. package/dist/path-core.d.ts.map +1 -1
  31. package/dist/path-core.js +32 -0
  32. package/dist/path-core.js.map +1 -1
  33. package/dist/path.d.ts +1 -1
  34. package/dist/path.d.ts.map +1 -1
  35. package/dist/path.js +1 -1
  36. package/dist/path.js.map +1 -1
  37. package/dist/project-utils.d.ts +7 -1
  38. package/dist/project-utils.d.ts.map +1 -1
  39. package/dist/project-utils.js +9 -1
  40. package/dist/project-utils.js.map +1 -1
  41. package/dist/test-helpers.d.ts +16 -0
  42. package/dist/test-helpers.d.ts.map +1 -1
  43. package/dist/test-helpers.js +28 -1
  44. package/dist/test-helpers.js.map +1 -1
  45. package/eslint/README.md +27 -1
  46. package/eslint/rules/dead-import.cjs +251 -0
  47. package/eslint/rules/eslint-rule-factory.cjs +213 -29
  48. package/eslint/rules/no-manual-path-normalize.cjs +59 -7
  49. package/eslint/rules/path-function-rule-factory.cjs +314 -34
  50. package/eslint/rules/prefer-startswith-over-regex.cjs +209 -40
  51. package/eslint/rules/safe-import.cjs +23 -0
  52. package/package.json +2 -2
@@ -13,14 +13,21 @@
13
13
  * const normalized = toForwardSlash(relativePath);
14
14
  */
15
15
 
16
+ const {
17
+ DEAD_UNSAFE_IMPORT,
18
+ DEAD_UNSAFE_IMPORT_MESSAGE,
19
+ reportDeadUnsafeImports,
20
+ } = require('./dead-import.cjs');
16
21
  const {
17
22
  SAFE_MODULE_ONLY_SCHEMA,
18
23
  SAFE_PATH_MODULE,
24
+ insertAboveWithComments,
19
25
  isNameAlreadyBound,
20
26
  resolveSafeModule,
21
27
  } = require('./safe-import.cjs');
22
28
 
23
29
  const SAFE_FN = 'toForwardSlash';
30
+ const PATH_MODULES = new Set(['node:path', 'path']);
24
31
 
25
32
  module.exports = {
26
33
  meta: {
@@ -35,6 +42,7 @@ module.exports = {
35
42
  useToForwardSlash:
36
43
  'Use toForwardSlash() from {{safeModule}} instead of manual path normalization. ' +
37
44
  'Manual normalization is error-prone and less maintainable.',
45
+ [DEAD_UNSAFE_IMPORT]: DEAD_UNSAFE_IMPORT_MESSAGE,
38
46
  },
39
47
  schema: [SAFE_MODULE_ONLY_SCHEMA],
40
48
  },
@@ -46,10 +54,35 @@ module.exports = {
46
54
  // barrel must have the call rewritten WITHOUT gaining a second binding of
47
55
  // the same name — that is a SyntaxError. See `safe-import.cjs`.
48
56
  let hasToForwardSlashImport = isNameAlreadyBound(sourceCode, SAFE_FN);
57
+ // Never mutated — the dead-import leg must not be armed by a flag that a
58
+ // suppressed report's `fix()` can spend. See `dead-import.cjs`.
59
+ const safeBoundInSource = hasToForwardSlashImport;
60
+ // The dead-import leg's OTHER gate: a `toForwardSlash(…)` call is the text
61
+ // this fixer writes, and the only evidence available that it wrote it here.
62
+ // Without it, any file with `toForwardSlash` in scope armed the leg — see
63
+ // `dead-import.cjs`. Read from the source, never from a `fix()`.
64
+ let safeReplacementCalled = false;
49
65
  let utilsImportNode = null;
66
+ // `path.sep` is the last `path.*` reference in plenty of files, and
67
+ // `toForwardSlash(raw)` consumes it — leaving the same dead `node:path`
68
+ // binding the `safePath` rules used to leave.
69
+ const pathImportNodes = [];
50
70
 
51
71
  return {
72
+ 'Program:exit'() {
73
+ reportDeadUnsafeImports(
74
+ context,
75
+ sourceCode,
76
+ pathImportNodes,
77
+ safeBoundInSource,
78
+ safeReplacementCalled,
79
+ );
80
+ },
81
+
52
82
  ImportDeclaration(node) {
83
+ if (PATH_MODULES.has(node.source.value)) {
84
+ pathImportNodes.push(node);
85
+ }
53
86
  if (node.source.value === targetModule) {
54
87
  utilsImportNode = node;
55
88
  for (const spec of node.specifiers) {
@@ -61,6 +94,10 @@ module.exports = {
61
94
  },
62
95
 
63
96
  CallExpression(node) {
97
+ if (node.callee.type === 'Identifier' && node.callee.name === SAFE_FN) {
98
+ safeReplacementCalled = true;
99
+ }
100
+
64
101
  // Check for .split(...).join('/') pattern
65
102
  if (
66
103
  node.callee.type === 'MemberExpression' &&
@@ -79,12 +116,18 @@ module.exports = {
79
116
  ) {
80
117
  const splitArg = splitCall.arguments[0];
81
118
 
82
- // Check if splitting by path.sep, '\\', or '\\\\'
119
+ // Split on path.sep, or on a single backslash character
120
+ // (source literal '\\'). Splitting on a two-backslash SEQUENCE
121
+ // (source literal '\\\\', decoded value: two backslash characters)
122
+ // is a different, rarer operation -- e.g. collapsing a UNC path's
123
+ // leading double-backslash server prefix -- and toForwardSlash()
124
+ // is not equivalent to it. Autofixing that case would silently
125
+ // change program behavior, so it is deliberately excluded here.
83
126
  const isSplittingByPathSep =
84
127
  (splitArg.type === 'MemberExpression' &&
85
128
  splitArg.object.name === 'path' &&
86
129
  splitArg.property.name === 'sep') ||
87
- (splitArg.type === 'Literal' && (splitArg.value === '\\' || splitArg.value === '\\\\'));
130
+ (splitArg.type === 'Literal' && splitArg.value === '\\');
88
131
 
89
132
  if (isSplittingByPathSep) {
90
133
  const variableBeingSplit = splitCall.callee.object;
@@ -110,12 +153,21 @@ module.exports = {
110
153
  // Create new import at the top
111
154
  const firstNode = sourceCode.ast.body[0];
112
155
  const newImport = `import { ${SAFE_FN} } from '${targetModule}';\n`;
113
- fixes.push(fixer.insertTextBefore(firstNode, newImport));
156
+ fixes.push(insertAboveWithComments(fixer, sourceCode, firstNode, newImport));
114
157
  }
115
- // Multiple reports in one pass share this closure; without
116
- // this, a second occurrence in the same file inserts the
117
- // import a second time.
118
- hasToForwardSlashImport = true;
158
+ // NOT latched. The comment here used to claim that without a
159
+ // `hasToForwardSlashImport = true` a second occurrence would
160
+ // insert the import twice; an adversarial run could not
161
+ // reproduce that at any occurrence count. It cannot happen:
162
+ // both reports insert identical text at the identical anchor,
163
+ // so the ranges coincide and ESLint applies one and drops the
164
+ // other as overlapping.
165
+ //
166
+ // Latching it is not free, either. ESLint runs `fix()` for a
167
+ // SUPPRESSED problem before the `eslint-disable` filter
168
+ // discards it, so the first report could spend the flag and
169
+ // then be thrown away — leaving later occurrences rewritten
170
+ // to a `toForwardSlash` nothing imports.
119
171
  }
120
172
 
121
173
  return fixes;
@@ -9,6 +9,11 @@
9
9
  * subpath that owns `safePath`, NOT the barrel. See `safe-import.cjs`.
10
10
  */
11
11
 
12
+ const {
13
+ DEAD_UNSAFE_IMPORT,
14
+ DEAD_UNSAFE_IMPORT_MESSAGE,
15
+ reportDeadUnsafeImports,
16
+ } = require('./dead-import.cjs');
12
17
  const {
13
18
  UNANCHORED_EXEMPT_FILE,
14
19
  UNANCHORED_EXEMPT_MESSAGE,
@@ -18,6 +23,7 @@ const {
18
23
  const {
19
24
  EXEMPT_AND_SAFE_MODULE_SCHEMA,
20
25
  SAFE_PATH_MODULE,
26
+ insertAboveWithComments,
21
27
  isNameAlreadyBound,
22
28
  resolveSafeModule,
23
29
  } = require('./safe-import.cjs');
@@ -58,10 +64,31 @@ function removeSpecifier(fixer, sourceCode, importNode, spec) {
58
64
 
59
65
  /**
60
66
  * Track path module specifiers from an import declaration.
67
+ *
68
+ * Two specifier shapes are deliberately NOT tracked, because tracking them is
69
+ * what let the fixer delete them:
70
+ *
71
+ * - **Type-only** (`import { type join, … }` / `import type { join }`). The
72
+ * binding exists only for the type checker; there is no call to rewrite, and
73
+ * removing the specifier silently breaks every `typeof join` that referenced
74
+ * it. `no-undef` cannot see the damage — it is a TYPE reference.
75
+ * - **Aliased** (`import { join as pathJoin }`). The rule never reported
76
+ * `pathJoin(...)` in the first place — `classifyCall` matches on the callee's
77
+ * name — so tracking the specifier bought nothing and cost the whole import:
78
+ * an unrelated unbound `join(` elsewhere in the file made the fixer remove
79
+ * the alias, breaking every working `pathJoin` call site.
61
80
  */
62
81
  function trackPathImport(node, unsafeFn, state) {
82
+ if (node.importKind === 'type') {
83
+ return;
84
+ }
63
85
  for (const spec of node.specifiers) {
64
- if (spec.type === 'ImportSpecifier' && spec.imported.name === unsafeFn) {
86
+ if (
87
+ spec.type === 'ImportSpecifier' &&
88
+ spec.importKind !== 'type' &&
89
+ spec.imported.name === unsafeFn &&
90
+ spec.local.name === unsafeFn
91
+ ) {
65
92
  state.namedImportSpec = spec;
66
93
  state.namedImportNode = node;
67
94
  }
@@ -71,6 +98,24 @@ function trackPathImport(node, unsafeFn, state) {
71
98
  }
72
99
  }
73
100
 
101
+ /**
102
+ * Is `name` re-exported by a bare `export { name }` in this file?
103
+ *
104
+ * Removing the import specifier then leaves the export naming nothing, and the
105
+ * result does not PARSE — `Export 'join' is not defined`. An autofix whose
106
+ * output cannot be parsed is the worst outcome available, so the specifier
107
+ * stays and the call sites are still rewritten. Whatever is left is a lint
108
+ * finding a human can read, not a broken file.
109
+ */
110
+ function isReExported(sourceCode, name) {
111
+ return sourceCode.ast.body.some(
112
+ (node) =>
113
+ node.type === 'ExportNamedDeclaration' &&
114
+ !node.source &&
115
+ node.specifiers.some((spec) => spec.local?.name === name),
116
+ );
117
+ }
118
+
74
119
  /**
75
120
  * Track safe module import from an import declaration.
76
121
  */
@@ -83,51 +128,228 @@ function trackSafeImport(node, state) {
83
128
  }
84
129
  }
85
130
 
131
+ /**
132
+ * Is `name` resolvable from `node`'s scope outward — a parameter, a local, an
133
+ * import, or a configured global?
134
+ *
135
+ * Used only to decide whether a bare `join(...)` with no `node:path` import is
136
+ * OUR `join` or somebody else's. `import { join } from 'lodash'` binds the name
137
+ * and is not our business; an unbound `join` is a ReferenceError waiting to
138
+ * happen, and — see `classifyCall` — is exactly what a half-applied autofix
139
+ * leaves behind.
140
+ */
141
+ function isIdentifierBound(sourceCode, node, name) {
142
+ for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) {
143
+ if (scope.variables.some((variable) => variable.name === name)) {
144
+ return true;
145
+ }
146
+ }
147
+ return false;
148
+ }
149
+
150
+ /**
151
+ * Is this the call THIS rule's fixer writes — `safePath.<unsafeFn>(…)`?
152
+ *
153
+ * The evidence that this rule migrated something in this file, and the reason it
154
+ * is not enough to ask whether `safePath` is merely in scope. Read by BOTH legs
155
+ * that would otherwise mistake a file-wide fact for a claim about `unsafeFn`:
156
+ * the dead-import leg, and the bare-call repair leg in `classifyCall`.
157
+ * Matched whether or not `safePath` is bound: an ORPHANED `safePath.join(…)` —
158
+ * the repair leg's own subject — is still this fixer's handiwork.
159
+ *
160
+ * @param {object} node - A `CallExpression`.
161
+ * @param {string} unsafeFn - The member this rule migrates.
162
+ * @returns {boolean} True if the callee is `safePath.<unsafeFn>`.
163
+ */
164
+ function isSafeReplacementCall(node, unsafeFn) {
165
+ return (
166
+ node.callee.type === 'MemberExpression' &&
167
+ node.callee.object.type === 'Identifier' &&
168
+ node.callee.object.name === SAFE_OBJECT &&
169
+ node.callee.property.type === 'Identifier' &&
170
+ node.callee.property.name === unsafeFn
171
+ );
172
+ }
173
+
86
174
  /**
87
175
  * Check if a call expression is an unsafe path function call.
88
- * Returns { isUnsafe, isNamed } or null if not a match.
176
+ *
177
+ * Returns `{ isNamed }` — or `{ importOnly: true }` for a call that is already
178
+ * correct and merely missing its import — or `{ isRepair: true }` for a bare call
179
+ * that is ours only if the file also shows THIS function mid-migration, which
180
+ * `Program:exit` decides once the whole file has been seen — or null if not a
181
+ * match.
89
182
  */
90
- function classifyCall(node, unsafeFn, state) {
91
- // Direct call from named import: join(...)
92
- if (
93
- node.callee.type === 'Identifier' &&
94
- node.callee.name === unsafeFn &&
95
- state.namedImportSpec
96
- ) {
97
- return { isNamed: true };
183
+ function classifyCall(node, unsafeFn, state, sourceCode) {
184
+ const isMember = node.callee.type === 'MemberExpression' && node.callee.property.type === 'Identifier';
185
+
186
+ // Direct call: join(...)
187
+ if (node.callee.type === 'Identifier' && node.callee.name === unsafeFn) {
188
+ if (state.namedImportSpec) {
189
+ return { isNamed: true };
190
+ }
191
+ // REPAIR LEG. Keying detection on "did I see the import?" made this rule
192
+ // stop reporting the moment a fix removed the specifier, so a partial
193
+ // `--fix` reached a stable fixpoint over source that no longer compiles and
194
+ // exited clean.
195
+ //
196
+ // An unbound `join` is NOT reliably our `join`: ESLint scope analysis does
197
+ // not bind `declare global { function join() }`, and it cannot see an ambient
198
+ // global from a `globals.d.ts`, an `@types` package, or a bundler — `resolve`
199
+ // and `relative` are entirely plausible as those. So the leg needs a second
200
+ // condition, and `safePathBoundInSource` alone was the wrong one: it is a
201
+ // fact about the FILE, not about `unsafeFn`, and a SIBLING instance of this
202
+ // factory hands it over for free. `no-path-resolve` rewrites a
203
+ // `path.resolve(...)` and imports `safePath`; on the next `--fix` pass the
204
+ // `join` instance sees a file with `safePath` bound and an ambient-global
205
+ // `join(...)` that predates any of this pack's involvement, and rewrites it
206
+ // to a different function. Measured with both rules enabled over one file.
207
+ //
208
+ // So this is only a CANDIDATE. `Program:exit` admits it once the file has
209
+ // been seen to contain a `safePath.<unsafeFn>(...)` call — the same positive
210
+ // evidence the dead-import leg reads, and the only in-file signal that THIS
211
+ // function is mid-migration HERE. Every genuine strand carries it:
212
+ // `removeSpecifier` only ever ships inside the same report's fix as the
213
+ // callee rewrite that consumed the specifier, and ESLint merges a report's
214
+ // fixes into one all-or-nothing range — so a file cannot lose the `node:path`
215
+ // specifier without gaining a `safePath.<unsafeFn>(` call.
216
+ //
217
+ // What this gives up: a file a HUMAN half-migrated by hand — import deleted,
218
+ // not one call rewritten — is no longer finished by `--fix`. That file is a
219
+ // loud `no-undef`/`tsc` error rather than a silent one, and it was never this
220
+ // pack's doing. Silently redirecting a live call to another function is.
221
+ if (state.safePathBoundInSource && !isIdentifierBound(sourceCode, node.callee, unsafeFn)) {
222
+ return { isNamed: false, isRepair: true };
223
+ }
224
+ return null;
98
225
  }
99
- // Member expression: path.join(...)
226
+
227
+ // Namespace call: path.join(...)
100
228
  if (
101
- node.callee.type === 'MemberExpression' &&
229
+ isMember &&
102
230
  node.callee.object.type === 'Identifier' &&
103
231
  node.callee.object.name === state.defaultImportName &&
104
- node.callee.property.type === 'Identifier' &&
105
232
  node.callee.property.name === unsafeFn
106
233
  ) {
107
234
  return { isNamed: false };
108
235
  }
236
+
237
+ // REPAIR LEG, the other half: `safePath.join(...)` with no `safePath` in
238
+ // scope. This is what a partially-applied fix leaves — and without it, that
239
+ // state is PERMANENT rather than transient.
240
+ //
241
+ // ESLint runs `fix()` for a problem BEFORE the `eslint-disable` filter
242
+ // discards it, so a suppressed report on the first call site consumes the
243
+ // once-per-file import edit and then throws it away. Every other call is
244
+ // rewritten to `safePath.join`, nothing imports `safePath`, and no report
245
+ // survives to carry the import on any later pass. Recognising the orphaned
246
+ // call is what closes that loop; it costs one extra pass, and only in a file
247
+ // that is already broken.
248
+ if (
249
+ isMember &&
250
+ node.callee.object.type === 'Identifier' &&
251
+ node.callee.object.name === SAFE_OBJECT &&
252
+ node.callee.property.name === unsafeFn &&
253
+ !isIdentifierBound(sourceCode, node.callee.object, SAFE_OBJECT)
254
+ ) {
255
+ return { importOnly: true };
256
+ }
257
+
109
258
  return null;
110
259
  }
111
260
 
112
261
  /**
113
262
  * Build auto-fix for an unsafe path function call.
263
+ *
264
+ * ## Why the import edits are emitted at most ONCE per file
265
+ *
266
+ * ESLint merges the fixes one `fix()` yields into a SINGLE range spanning
267
+ * `min..max`, and applies only non-overlapping ranges per pass. A fix that
268
+ * touches both the import and its own call site therefore spans everything in
269
+ * between — so N such reports produce N nested ranges, ESLint keeps the
270
+ * shortest and DISCARDS THE REST.
271
+ *
272
+ * That is not an edge case, it is every file with more than one call site. The
273
+ * import edit landed, the other calls did not, and (before `classifyCall` grew
274
+ * its bare-call leg) the next pass could no longer see them because the
275
+ * specifier it keyed on was gone. `--fix` reached a stable fixpoint over source
276
+ * that does not compile and exited clean. An adopter measured 146 files left
277
+ * with a dangling reference across one sweep — worst single file, 75 call sites.
278
+ *
279
+ * So: the shared edits belong to the first report, and every later report emits
280
+ * a fix LOCAL to its own callee. Nothing overlaps, and one pass fixes the file.
281
+ * `no-manual-path-normalize.cjs` carries the same guard for the same reason.
282
+ *
283
+ * Only the FIRST report's fix is self-sufficient, and that is load-bearing:
284
+ * applying a later one ALONE — an editor's "fix this problem", or an
285
+ * `eslint-disable` on the first call site — rewrites the call without adding
286
+ * the import. ESLint runs `fix()` before the disable filter, so a suppressed
287
+ * report consumes the once-per-file edit and then discards it.
288
+ *
289
+ * That state is recoverable rather than permanent ONLY because `classifyCall`
290
+ * has a repair leg for an orphaned `safePath.join(...)`. Without it the file
291
+ * stays broken through every subsequent `--fix`, because no report is left to
292
+ * carry the import — measured, not reasoned about. An earlier draft of this
293
+ * comment asserted the recovery came free from `hasSafePathImport` being seeded
294
+ * from scope; that was wrong, and an adversarial run produced the stable broken
295
+ * fixpoint to prove it.
296
+ *
297
+ * The shared edits still cannot be hoisted onto their own report: removing
298
+ * `join` from the import while a suppressed `join(...)` call survives is the
299
+ * same broken output reached a different way. `exemptFiles` opts a whole file
300
+ * out.
114
301
  */
115
- function buildFix(fixer, node, unsafeFn, isNamed, sourceCode, state) {
302
+ function importSafePath(fixer, sourceCode, state) {
303
+ if (state.safeImportNode) {
304
+ const lastSpec = state.safeImportNode.specifiers.at(-1);
305
+ return fixer.insertTextAfter(lastSpec, `, ${SAFE_OBJECT}`);
306
+ }
307
+ const targetNode = state.namedImportNode || sourceCode.ast.body[0];
308
+ const declaration = `import { ${SAFE_OBJECT} } from '${state.safeModule}';`;
309
+ // Land the new import next to the imports, not after arbitrary code. A file
310
+ // reported only through a repair leg may have no path import at all, and
311
+ // `insertTextAfter(body[0])` would push the declaration below the statement
312
+ // that needs it — legal, since imports hoist, but it reads as though the
313
+ // fixer lost track of the file.
314
+ return targetNode.type === 'ImportDeclaration'
315
+ ? fixer.insertTextAfter(targetNode, `\n${declaration}`)
316
+ : insertAboveWithComments(fixer, sourceCode, targetNode, `${declaration}\n`);
317
+ }
318
+
319
+ function buildFix(fixer, node, unsafeFn, classification, sourceCode, state) {
320
+ // REPAIR: an orphaned `safePath.join(...)` is already the call we want, and
321
+ // the only thing missing is the import that a discarded report was carrying.
322
+ //
323
+ // This deliberately ignores `state.hasSafePathImport`. That flag is mutated
324
+ // inside `fix()`, and ESLint runs `fix()` for a SUPPRESSED problem before the
325
+ // disable filter throws it away — so on every pass the suppressed report
326
+ // spends the flag first and the repair emits nothing. The file then never
327
+ // recovers, which is precisely the stable broken fixpoint this leg exists to
328
+ // break. The gate that makes ignoring the flag safe is immutable: this
329
+ // classification is only reached when `safePath` is unbound in the SOURCE.
330
+ //
331
+ // Several orphaned calls yield the identical insert at the identical anchor,
332
+ // so ESLint applies one and drops the rest as overlapping — which is the
333
+ // desired outcome, not a hazard.
334
+ if (classification.importOnly) {
335
+ return [importSafePath(fixer, sourceCode, state)];
336
+ }
337
+
116
338
  const fixes = [fixer.replaceText(node.callee, `${SAFE_OBJECT}.${unsafeFn}`)];
117
339
 
118
340
  if (!state.hasSafePathImport) {
119
- if (state.safeImportNode) {
120
- const lastSpec = state.safeImportNode.specifiers.at(-1);
121
- fixes.push(fixer.insertTextAfter(lastSpec, `, ${SAFE_OBJECT}`));
122
- } else {
123
- const targetNode = state.namedImportNode || sourceCode.ast.body[0];
124
- fixes.push(fixer.insertTextAfter(targetNode, `\nimport { ${SAFE_OBJECT} } from '${state.safeModule}';`));
125
- }
341
+ fixes.push(importSafePath(fixer, sourceCode, state));
126
342
  state.hasSafePathImport = true;
127
343
  }
128
344
 
129
- if (isNamed && state.namedImportNode) {
345
+ if (
346
+ classification.isNamed &&
347
+ state.namedImportNode &&
348
+ !state.namedImportRemoved &&
349
+ !isReExported(sourceCode, unsafeFn)
350
+ ) {
130
351
  fixes.push(...removeSpecifier(fixer, sourceCode, state.namedImportNode, state.namedImportSpec));
352
+ state.namedImportRemoved = true;
131
353
  }
132
354
 
133
355
  return fixes;
@@ -148,6 +370,7 @@ module.exports = function createPathFunctionRule(config) {
148
370
  schema: [EXEMPT_AND_SAFE_MODULE_SCHEMA],
149
371
  messages: {
150
372
  noUnsafePathFn: message,
373
+ [DEAD_UNSAFE_IMPORT]: DEAD_UNSAFE_IMPORT_MESSAGE,
151
374
  [UNANCHORED_EXEMPT_FILE]: UNANCHORED_EXEMPT_MESSAGE,
152
375
  },
153
376
  },
@@ -170,12 +393,47 @@ module.exports = function createPathFunctionRule(config) {
170
393
  safeModule: resolveSafeModule(context, SAFE_PATH_MODULE),
171
394
  namedImportSpec: null,
172
395
  namedImportNode: null,
396
+ // Both of these guard a SHARED edit against being emitted by more than
397
+ // one report — see `buildFix` for what ESLint does with the overlap.
398
+ namedImportRemoved: false,
173
399
  defaultImportName: null,
174
400
  // Seeded from SCOPE, not from "did I see an import from SAFE_MODULE?".
175
401
  // A file already importing `safePath` from the barrel needs the call
176
402
  // rewritten but must NOT gain a second binding of the same name.
177
403
  hasSafePathImport: isNameAlreadyBound(sourceCode, SAFE_OBJECT),
404
+ // The SAME question, answered once and never mutated. `hasSafePathImport`
405
+ // flips to true the moment a fix inserts the import, and gating the
406
+ // repair leg on a flag that the first report can flip would arm it for
407
+ // the rest of THIS pass — re-admitting the ambient-global false positive
408
+ // in any file that also has a `path.join()` to fix.
409
+ safePathBoundInSource: isNameAlreadyBound(sourceCode, SAFE_OBJECT),
178
410
  safeImportNode: null,
411
+ // Set from the SOURCE as traversal walks it, and read only at
412
+ // `Program:exit` — so it is a fact about the text being linted, not
413
+ // about what a `fix()` intends to write. See `dead-import.cjs`, and the
414
+ // repair leg in `classifyCall`, which gates on the same flag.
415
+ safeReplacementCalled: false,
416
+ // Bare unbound calls awaiting that evidence. Held rather than reported,
417
+ // because a candidate can precede the migrated call that vouches for it.
418
+ deferredRepairs: [],
419
+ // EVERY path-module declaration, not just the one carrying `unsafeFn`.
420
+ // A file's dead binding is `import path from 'node:path'`, which
421
+ // `trackPathImport` only ever recorded as a NAME. See `dead-import.cjs`.
422
+ pathImportNodes: [],
423
+ };
424
+
425
+ const report = (node, classification) => {
426
+ context.report({
427
+ node,
428
+ messageId: 'noUnsafePathFn',
429
+ // The module name reaches the message through `{{safeModule}}` rather
430
+ // than being spelled out in each rule's string, so the advice cannot
431
+ // drift from where the fixer actually writes the import.
432
+ data: { safeModule: state.safeModule },
433
+ fix(fixer) {
434
+ return buildFix(fixer, node, unsafeFn, classification, sourceCode, state);
435
+ },
436
+ });
179
437
  };
180
438
 
181
439
  return {
@@ -183,8 +441,31 @@ module.exports = function createPathFunctionRule(config) {
183
441
  reportUnanchoredExemptEntries(context, node);
184
442
  },
185
443
 
444
+ 'Program:exit'() {
445
+ // Holding these back cannot change any FIX, only when it is computed.
446
+ // A repair candidate is classified only when `safePath` is bound in the
447
+ // source, so `hasSafePathImport` starts true and `namedImportSpec` is
448
+ // null: the report carries no import insert and no specifier removal,
449
+ // and therefore nothing shared that an earlier report could have spent
450
+ // first. (`importOnly` is the mirror case — it requires `safePath`
451
+ // UNBOUND — so the two can never arise in the same file.)
452
+ if (state.safeReplacementCalled) {
453
+ for (const candidate of state.deferredRepairs) {
454
+ report(candidate, { isNamed: false });
455
+ }
456
+ }
457
+ reportDeadUnsafeImports(
458
+ context,
459
+ sourceCode,
460
+ state.pathImportNodes,
461
+ state.safePathBoundInSource,
462
+ state.safeReplacementCalled,
463
+ );
464
+ },
465
+
186
466
  ImportDeclaration(node) {
187
467
  if (PATH_MODULES.has(node.source.value)) {
468
+ state.pathImportNodes.push(node);
188
469
  trackPathImport(node, unsafeFn, state);
189
470
  }
190
471
  if (node.source.value === state.safeModule) {
@@ -193,22 +474,21 @@ module.exports = function createPathFunctionRule(config) {
193
474
  },
194
475
 
195
476
  CallExpression(node) {
196
- const classification = classifyCall(node, unsafeFn, state);
477
+ if (isSafeReplacementCall(node, unsafeFn)) {
478
+ state.safeReplacementCalled = true;
479
+ }
480
+
481
+ const classification = classifyCall(node, unsafeFn, state, sourceCode);
197
482
  if (!classification) {
198
483
  return;
199
484
  }
200
485
 
201
- context.report({
202
- node,
203
- messageId: 'noUnsafePathFn',
204
- // The module name reaches the message through `{{safeModule}}` rather
205
- // than being spelled out in each rule's string, so the advice cannot
206
- // drift from where the fixer actually writes the import.
207
- data: { safeModule: state.safeModule },
208
- fix(fixer) {
209
- return buildFix(fixer, node, unsafeFn, classification.isNamed, sourceCode, state);
210
- },
211
- });
486
+ if (classification.isRepair) {
487
+ state.deferredRepairs.push(node);
488
+ return;
489
+ }
490
+
491
+ report(node, classification);
212
492
  },
213
493
  };
214
494
  },