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

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 (51) 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 +1 -1
  46. package/eslint/rules/dead-import.cjs +61 -11
  47. package/eslint/rules/eslint-rule-factory.cjs +16 -1
  48. package/eslint/rules/no-manual-path-normalize.cjs +24 -3
  49. package/eslint/rules/path-function-rule-factory.cjs +99 -20
  50. package/eslint/rules/prefer-startswith-over-regex.cjs +24 -1
  51. package/package.json +2 -2
@@ -1 +1 @@
1
- {"version":3,"file":"test-helpers.js","sourceRoot":"","sources":["../src/test-helpers.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"test-helpers.js","sourceRoot":"","sources":["../src/test-helpers.ts"],"names":[],"mappings":"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"}
package/eslint/README.md CHANGED
@@ -197,7 +197,7 @@ So the rules now report it themselves, as a separate `deadUnsafeImport` finding
197
197
  Deliberately narrow:
198
198
 
199
199
  - **A closed list of modules** — `node:path`, `node:os`, `node:fs`, `node:fs/promises`, `node:child_process` and their bare spellings. All Node builtins, all side-effect-free with certainty decided when the rule was written. This is *not* a general unused-import rule and will not become one; for blanket cleanup, `eslint-plugin-unused-imports` already exists and already autofixes.
200
- - **Only in a file these rules migrated** — the safe symbol must already be bound. A dead import in a file this pack never touched is somebody else's business.
200
+ - **Only in a file this rule migrated** — the safe symbol must be bound AND the rule's own replacement must actually be called in the file (`safePath.join(…)`, `normalizedTmpdir()`, `toForwardSlash(…)`). "The safe symbol is in scope" alone is not evidence: `safePath` arrives for reasons that have nothing to do with a call this pack consumed, and an import that was dead already — dead before the pack ever ran — was then deleted as though this fixer had orphaned it. A dead import in a file this pack never touched is somebody else's business.
201
201
  - **Only whole declarations, only with zero references left** — evaluated against the source as it stands on that pass, so the removal always lands *after* the rewrite that consumed the last reference, never speculatively beside it. A partially-dead declaration (`import path, { sep }` with `sep` still live) is left alone, as are bare `import 'node:path'` side-effect imports and anything carrying a `type` specifier.
202
202
 
203
203
  This cannot be delegated: `@typescript-eslint/no-unused-vars` declares `meta.fixable: 'code'` but emits only a **suggestion** for an unused import, and `--fix` never applies suggestions; `sonarjs/unused-import` declares no fixer at all. Verified with both enabled alongside these rules in one `verifyAndFix` — the import survived. Those rules abstain for a good reason, since removing an import can change behaviour; a rule that *created* the orphan knows it just consumed the last reference and knows the module, so it can act where a generic rule cannot.
@@ -63,9 +63,16 @@ const REMOVABLE_MODULES = new Set([
63
63
  ]);
64
64
 
65
65
  const DEAD_UNSAFE_IMPORT = 'deadUnsafeImport';
66
+ // States what the rule can actually observe, and no more. The earlier wording —
67
+ // "this rule's autofix rewrote the last call that referenced it" — asserted a
68
+ // CAUSE, and the leg cannot see causes: by the time a binding reads as dead the
69
+ // rewrite that killed it is a pass in the past and left no trace naming which
70
+ // import it drew on. On a file with two imports of the same module, one of them
71
+ // dead since before the pack ever ran, that sentence was simply false about one
72
+ // of the two. See `reportDeadUnsafeImports` for what the gate does prove.
66
73
  const DEAD_UNSAFE_IMPORT_MESSAGE =
67
- "'{{local}}' is no longer used this rule's autofix rewrote the last call that " +
68
- "referenced it. Remove the '{{module}}' import.";
74
+ "'{{local}}' has no remaining references, and this file already calls the safe " +
75
+ "replacement this rule's autofix writes. Remove the '{{module}}' import.";
69
76
 
70
77
  /**
71
78
  * Does this declaration bring in anything the type checker alone can see?
@@ -128,12 +135,46 @@ function isDeadRemovableImport(sourceCode, node) {
128
135
  * reported. The removal therefore lands on a later pass, after the rewrite that
129
136
  * consumed the last reference — never speculatively alongside it.
130
137
  *
131
- * `migrated` gates the whole leg on the safe symbol being bound in the file, and
132
- * is what keeps this a REPAIR leg rather than a general unused-import rule. It
133
- * must be read from the SOURCE and never from a flag a `fix()` can flip: ESLint
134
- * runs `fix()` for a suppressed problem before the `eslint-disable` filter
135
- * discards it, so any mutable "did I add the import?" flag is already spent and
136
- * lying by the time this runs.
138
+ * ## The two-part gate, and the evidence it cannot get
139
+ *
140
+ * Both gates keep this a REPAIR leg rather than a general unused-import rule,
141
+ * and both must be read from the SOURCE, never from a flag a `fix()` can flip:
142
+ * ESLint runs `fix()` for a suppressed problem before the `eslint-disable`
143
+ * filter discards it, so any mutable "did I add the import?" flag is already
144
+ * spent and lying by the time this runs.
145
+ *
146
+ * - `safeBoundInSource` — the safe symbol is in scope at all.
147
+ * - `replacementCalled` — THIS rule's own replacement is actually CALLED in this
148
+ * file (`safePath.join(…)`, `normalizedTmpdir()`, `toForwardSlash(…)`), which
149
+ * is the text its fixer writes and nothing else does.
150
+ *
151
+ * The first alone was the whole gate once, and it let an unrelated coincidence
152
+ * arm the leg: `safePath` reaches scope for reasons that have nothing to do with
153
+ * a call this pack consumed, and any `node:path` import that happened to be dead
154
+ * already — dead before the pack ever ran, for somebody else's reason — was then
155
+ * deleted under a message claiming this fixer had orphaned it. That is the
156
+ * general unused-import rule the module docstring above declines to be, reached
157
+ * by accident. The second gate closes it: no call to this rule's replacement
158
+ * means this rule rewrote nothing here, whatever else is in scope.
159
+ *
160
+ * It does NOT get down to the individual declaration, and it cannot. Per-import
161
+ * evidence has to name WHICH import a consumed reference belonged to, and the
162
+ * only pass that knows is the one doing the rewrite — where every reference is
163
+ * still live and nothing is dead to report. Removing speculatively in that pass
164
+ * instead is the trap the rest of this pack is built around: a suppressed call
165
+ * report still runs its `fix()`, so the removal lands while the call it was
166
+ * paired with survives, and the file is left with a dangling reference (146 of
167
+ * them, measured, in the defect this file exists to prevent). Latching state
168
+ * across passes to carry the answer forward is the rejected `WeakMap` approach
169
+ * for the same reason, one layer worse. So a file holding two imports of one
170
+ * module, one of them dead all along, still gets both removed and one true
171
+ * message plus one that merely describes the file rather than the history. The
172
+ * message is worded to stay true of both; the residual is a redundant deletion,
173
+ * never a broken one.
174
+ *
175
+ * Being over-strict here is cheap by construction: a gate that declines leaves
176
+ * an unused import, which is a lint finding a human reads. Only firing wrongly
177
+ * costs anything, and only the CALL rewrite can break a file.
137
178
  *
138
179
  * Its own report, with its own `fix`, deliberately — so the deletion appears in
139
180
  * lint output and can be suppressed at the import line, rather than a rewrite
@@ -161,10 +202,19 @@ function isDeadRemovableImport(sourceCode, node) {
161
202
  * @param {object} context - ESLint rule context.
162
203
  * @param {object} sourceCode - ESLint `SourceCode` for the file being linted.
163
204
  * @param {object[]} importNodes - Unsafe-module `ImportDeclaration`s seen this pass.
164
- * @param {boolean} migrated - Was the safe symbol already bound in the SOURCE?
205
+ * @param {boolean} safeBoundInSource - Was the safe symbol already bound in the SOURCE?
206
+ * @param {boolean} replacementCalled - Does the SOURCE call this rule's own safe
207
+ * replacement? Both are required, and both are the caller's to compute from
208
+ * source — see the gate discussion above for why neither may be defaulted.
165
209
  */
166
- function reportDeadUnsafeImports(context, sourceCode, importNodes, migrated) {
167
- if (!migrated) {
210
+ function reportDeadUnsafeImports(
211
+ context,
212
+ sourceCode,
213
+ importNodes,
214
+ safeBoundInSource,
215
+ replacementCalled,
216
+ ) {
217
+ if (!safeBoundInSource || !replacementCalled) {
168
218
  return;
169
219
  }
170
220
  for (const node of importNodes) {
@@ -207,6 +207,11 @@ module.exports = function createNoUnsafeRule(config) {
207
207
  // not be armed by a flag a suppressed report can spend — ESLint runs
208
208
  // `fix()` before the `eslint-disable` filter discards the problem.
209
209
  const safeBoundInSource = hasSafeImport;
210
+ // The dead-import leg's OTHER gate: does this file actually call `safeFn`,
211
+ // the free function this fixer writes? "The safe symbol is in scope" alone
212
+ // let an unrelated coincidence arm the leg — see `dead-import.cjs`. Read
213
+ // from the source during traversal, never from a `fix()`.
214
+ let safeReplacementCalled = false;
210
215
  let unsafeImportNode = null;
211
216
  const unsafeImportNodes = [];
212
217
  let safeImportNode = null;
@@ -223,7 +228,13 @@ module.exports = function createNoUnsafeRule(config) {
223
228
  },
224
229
 
225
230
  'Program:exit'() {
226
- reportDeadUnsafeImports(context, sourceCode, unsafeImportNodes, safeBoundInSource);
231
+ reportDeadUnsafeImports(
232
+ context,
233
+ sourceCode,
234
+ unsafeImportNodes,
235
+ safeBoundInSource,
236
+ safeReplacementCalled,
237
+ );
227
238
  },
228
239
 
229
240
  ImportDeclaration(node) {
@@ -281,6 +292,10 @@ module.exports = function createNoUnsafeRule(config) {
281
292
  },
282
293
 
283
294
  CallExpression(node) {
295
+ if (node.callee.type === 'Identifier' && node.callee.name === safeFn) {
296
+ safeReplacementCalled = true;
297
+ }
298
+
284
299
  let isUnsafeCall = false;
285
300
 
286
301
  // Check for direct function call: unsafeFn()
@@ -57,6 +57,11 @@ module.exports = {
57
57
  // Never mutated — the dead-import leg must not be armed by a flag that a
58
58
  // suppressed report's `fix()` can spend. See `dead-import.cjs`.
59
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;
60
65
  let utilsImportNode = null;
61
66
  // `path.sep` is the last `path.*` reference in plenty of files, and
62
67
  // `toForwardSlash(raw)` consumes it — leaving the same dead `node:path`
@@ -65,7 +70,13 @@ module.exports = {
65
70
 
66
71
  return {
67
72
  'Program:exit'() {
68
- reportDeadUnsafeImports(context, sourceCode, pathImportNodes, safeBoundInSource);
73
+ reportDeadUnsafeImports(
74
+ context,
75
+ sourceCode,
76
+ pathImportNodes,
77
+ safeBoundInSource,
78
+ safeReplacementCalled,
79
+ );
69
80
  },
70
81
 
71
82
  ImportDeclaration(node) {
@@ -83,6 +94,10 @@ module.exports = {
83
94
  },
84
95
 
85
96
  CallExpression(node) {
97
+ if (node.callee.type === 'Identifier' && node.callee.name === SAFE_FN) {
98
+ safeReplacementCalled = true;
99
+ }
100
+
86
101
  // Check for .split(...).join('/') pattern
87
102
  if (
88
103
  node.callee.type === 'MemberExpression' &&
@@ -101,12 +116,18 @@ module.exports = {
101
116
  ) {
102
117
  const splitArg = splitCall.arguments[0];
103
118
 
104
- // 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.
105
126
  const isSplittingByPathSep =
106
127
  (splitArg.type === 'MemberExpression' &&
107
128
  splitArg.object.name === 'path' &&
108
129
  splitArg.property.name === 'sep') ||
109
- (splitArg.type === 'Literal' && (splitArg.value === '\\' || splitArg.value === '\\\\'));
130
+ (splitArg.type === 'Literal' && splitArg.value === '\\');
110
131
 
111
132
  if (isSplittingByPathSep) {
112
133
  const variableBeingSplit = splitCall.callee.object;
@@ -147,11 +147,38 @@ function isIdentifierBound(sourceCode, node, name) {
147
147
  return false;
148
148
  }
149
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
+
150
174
  /**
151
175
  * Check if a call expression is an unsafe path function call.
152
176
  *
153
177
  * Returns `{ isNamed }` — or `{ importOnly: true }` for a call that is already
154
- * correct and merely missing its import — or null if not a match.
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.
155
182
  */
156
183
  function classifyCall(node, unsafeFn, state, sourceCode) {
157
184
  const isMember = node.callee.type === 'MemberExpression' && node.callee.property.type === 'Identifier';
@@ -166,15 +193,33 @@ function classifyCall(node, unsafeFn, state, sourceCode) {
166
193
  // `--fix` reached a stable fixpoint over source that no longer compiles and
167
194
  // exited clean.
168
195
  //
169
- // Gated on `safePath` already being bound, which is what makes it a repair
170
- // rather than a second, sloppier detector. An unbound `join` is NOT reliably
171
- // our `join`: ESLint scope analysis does not bind `declare global { function
172
- // join() }`, and it cannot see an ambient global from a `globals.d.ts`, an
173
- // `@types` package, or a bundler `resolve` and `relative` are entirely
174
- // plausible as those. Requiring `safePath` in scope narrows this to the
175
- // half-migrated file it exists to finish, where the name really was ours.
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.
176
221
  if (state.safePathBoundInSource && !isIdentifierBound(sourceCode, node.callee, unsafeFn)) {
177
- return { isNamed: false };
222
+ return { isNamed: false, isRepair: true };
178
223
  }
179
224
  return null;
180
225
  }
@@ -363,23 +408,58 @@ module.exports = function createPathFunctionRule(config) {
363
408
  // in any file that also has a `path.join()` to fix.
364
409
  safePathBoundInSource: isNameAlreadyBound(sourceCode, SAFE_OBJECT),
365
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: [],
366
419
  // EVERY path-module declaration, not just the one carrying `unsafeFn`.
367
420
  // A file's dead binding is `import path from 'node:path'`, which
368
421
  // `trackPathImport` only ever recorded as a NAME. See `dead-import.cjs`.
369
422
  pathImportNodes: [],
370
423
  };
371
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
+ });
437
+ };
438
+
372
439
  return {
373
440
  Program(node) {
374
441
  reportUnanchoredExemptEntries(context, node);
375
442
  },
376
443
 
377
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
+ }
378
457
  reportDeadUnsafeImports(
379
458
  context,
380
459
  sourceCode,
381
460
  state.pathImportNodes,
382
461
  state.safePathBoundInSource,
462
+ state.safeReplacementCalled,
383
463
  );
384
464
  },
385
465
 
@@ -394,22 +474,21 @@ module.exports = function createPathFunctionRule(config) {
394
474
  },
395
475
 
396
476
  CallExpression(node) {
477
+ if (isSafeReplacementCall(node, unsafeFn)) {
478
+ state.safeReplacementCalled = true;
479
+ }
480
+
397
481
  const classification = classifyCall(node, unsafeFn, state, sourceCode);
398
482
  if (!classification) {
399
483
  return;
400
484
  }
401
485
 
402
- context.report({
403
- node,
404
- messageId: 'noUnsafePathFn',
405
- // The module name reaches the message through `{{safeModule}}` rather
406
- // than being spelled out in each rule's string, so the advice cannot
407
- // drift from where the fixer actually writes the import.
408
- data: { safeModule: state.safeModule },
409
- fix(fixer) {
410
- return buildFix(fixer, node, unsafeFn, classification, sourceCode, state);
411
- },
412
- });
486
+ if (classification.isRepair) {
487
+ state.deferredRepairs.push(node);
488
+ return;
489
+ }
490
+
491
+ report(node, classification);
413
492
  },
414
493
  };
415
494
  },
@@ -92,6 +92,29 @@ function literalEquivalent(patternBody) {
92
92
  return literal;
93
93
  }
94
94
 
95
+ /**
96
+ * Count the run of consecutive `\\` characters immediately preceding `index`
97
+ * in `text`.
98
+ *
99
+ * Used to decide whether a trailing `$` is an anchor or an escaped literal
100
+ * dollar sign: an EVEN run (including zero) means the `$` itself is
101
+ * unescaped — a genuine end-of-string anchor. An ODD run means the last of
102
+ * those backslashes escapes the `$`, making it a literal character. Looking
103
+ * only at the last two characters of the pattern (`\\$`) gets this wrong for
104
+ * two or more consecutive backslashes: `/\\\\$/` (an escaped backslash `\\\\`
105
+ * followed by an unescaped `$` anchor) also ends in the two characters `\\$`,
106
+ * but the `$` there IS an anchor.
107
+ */
108
+ function countTrailingBackslashes(text, index) {
109
+ let count = 0;
110
+ let position = index - 1;
111
+ while (position >= 0 && text[position] === '\\') {
112
+ count += 1;
113
+ position -= 1;
114
+ }
115
+ return count;
116
+ }
117
+
95
118
  /**
96
119
  * Find the variable `identifier` resolves to, searching outward from its scope.
97
120
  */
@@ -245,7 +268,7 @@ module.exports = {
245
268
  }
246
269
  }
247
270
 
248
- if (pattern.endsWith('$') && !pattern.endsWith(String.raw`\$`)) {
271
+ if (pattern.endsWith('$') && countTrailingBackslashes(pattern, pattern.length - 1) % 2 === 0) {
249
272
  const literal = literalEquivalent(pattern.slice(0, -1));
250
273
  if (literal !== null && literal !== '') {
251
274
  report('preferEndsWith', literal);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-agent-toolkit/utils",
3
- "version": "0.1.42",
3
+ "version": "0.2.0-rc.2",
4
4
  "type": "module",
5
5
  "description": "Core utility functions shared across the vibe-agent-toolkit packages",
6
6
  "sideEffects": false,
@@ -84,7 +84,7 @@
84
84
  "README.md"
85
85
  ],
86
86
  "scripts": {
87
- "build": "tsc && tsx ../dev-tools/src/copy-yaml-assets.ts",
87
+ "build": "rimraf --glob dist \"*.tsbuildinfo\" && tsc && tsx ../dev-tools/src/copy-yaml-assets.ts",
88
88
  "test:unit": "vitest run",
89
89
  "test:integration": "vitest run --config vitest.integration.config.ts",
90
90
  "test:system": "vitest run --config vitest.system.config.ts",