@vibe-agent-toolkit/utils 0.2.0-rc.8 → 0.2.0-rc.9

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/README.md CHANGED
@@ -24,7 +24,7 @@ The last two columns are the ones that matter when choosing. **"Resolves with ze
24
24
 
25
25
  | Subpath | Contents | Node builtins reached | Third-party | Resolves with zero deps installed? |
26
26
  |---|---|---|---|---|
27
- | `./path` | `safePath`, `toForwardSlash`, `toNfc`, `isAbsolutePath`, `isAbsoluteAnyPlatform`, `hasParentTraversalSegment`, `relativeEscapesRoot`, `isSingleFsSegment`, `toAbsolutePath`, `getRelativePath`, `issueLocation` | `path` only | — | **yes** |
27
+ | `./path` | `safePath`, `toForwardSlash`, `toForwardSlashAnyPlatform`, `toNfc`, `isAbsolutePath`, `isAbsoluteAnyPlatform`, `hasParentTraversalSegment`, `relativeEscapesRoot`, `isSingleFsSegment`, `toAbsolutePath`, `getRelativePath`, `issueLocation` | `path` only | — | **yes** |
28
28
  | `./text` | `decodeTextContent` — the one bytes-to-text seam: BOM-announced UTF-8/UTF-16LE/UTF-16BE/UTF-32LE/UTF-32BE, BOM stripped, UTF-8 assumed otherwise; reports the encoding, whether it was a BOM fact or an assumption, and how many U+FFFD the decode substituted | **none** | — | **yes** |
29
29
  | `./zod` | `ZodTypeNames`, `getZodTypeName`, `isZodType`, `unwrapZodType`, `isZodOptional`, `isZodNullable` | **none** | — | **yes** |
30
30
  | `./glob` | `isGlob`, static base extraction, magic remainder | `path` only | — | **yes** |
@@ -128,7 +128,8 @@ FUNCTION, LAZY, PROMISE, and more...
128
128
  These always return forward slashes on every platform, so they are safe for comparisons, `Map` keys, globs, and display.
129
129
 
130
130
  - `safePath.join()` / `.resolve()` / `.relative()` - forward-slash equivalents of the `node:path` functions
131
- - `toForwardSlash()` - explicit converter for any path string
131
+ - `toForwardSlash()` - converter for a NATIVE path (fs, `path.*`, git output): converts only where the host separator is a backslash (win32); on POSIX a backslash is a filename character and is kept
132
+ - `toForwardSlashAnyPlatform()` - converter for AUTHOR-WRITTEN text (hrefs, globs, config values, CLI arguments, archive entry names): converts every backslash on every host. Never for a path read from disk — on POSIX it would turn the one file `docs/x\y.md` into a phantom `docs/x/y.md`
132
133
  - `toNfc()` - Unicode-NFC normalizer for filename **comparison keys** (see the warning below)
133
134
  - `toAbsolutePath()` - resolve a path relative to a base directory
134
135
  - `getRelativePath()` - relative path between two absolute paths
@@ -20,16 +20,21 @@
20
20
  * needs it rather than speculatively. That second package arrived:
21
21
  * `packages/cli`'s `projectionStoreSelected()` reads the same `VAT_CACHE` the
22
22
  * `resources` parse cache does, and two independent readings of one variable is
23
- * the defect, not the fix. The three consumers today:
23
+ * the defect, not the fix. The four consumers today:
24
24
  *
25
25
  * | Caller | Variable | Reads `undefined` as | Why |
26
26
  * |---|---|---|---|
27
27
  * | `link-auth/resolve-token.ts` | `VAT_LINKAUTH_ALLOW_COMMAND` | **deny** | gates a capability — fail closed |
28
28
  * | `resources/parse-cache.ts` | `VAT_CACHE` | cache stays on | gates a cache — an unreadable value must not silently change behaviour |
29
29
  * | `cli/utils/projection-store.ts` | `VAT_CACHE` | not a veto | same variable, same reading, one implementation |
30
+ * | `cli/utils/projection-store.ts` | `VAT_PROJECTION_STORE` | not a veto | the projection store is ON by default; only an explicit off spelling (or an empty value, which that call site handles before asking) turns it off |
30
31
  *
31
32
  * Same parser, different safe sides, each chosen at its own call site. That is
32
33
  * the contract; do not move a default in here.
34
+ *
35
+ * 🔑 The last two rows are the interesting pair: one variable defaults OFF and
36
+ * the other defaults ON, and both are still read through this one function,
37
+ * because what it answers is not "is this on" but "did the operator say no".
33
38
  */
34
39
  /**
35
40
  * Parse an env value as a boolean.
@@ -1 +1 @@
1
- {"version":3,"file":"env-flag.d.ts","sourceRoot":"","sources":["../src/env-flag.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAKH;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,SAAS,CAM5E"}
1
+ {"version":3,"file":"env-flag.d.ts","sourceRoot":"","sources":["../src/env-flag.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAKH;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,SAAS,CAM5E"}
package/dist/env-flag.js CHANGED
@@ -20,16 +20,21 @@
20
20
  * needs it rather than speculatively. That second package arrived:
21
21
  * `packages/cli`'s `projectionStoreSelected()` reads the same `VAT_CACHE` the
22
22
  * `resources` parse cache does, and two independent readings of one variable is
23
- * the defect, not the fix. The three consumers today:
23
+ * the defect, not the fix. The four consumers today:
24
24
  *
25
25
  * | Caller | Variable | Reads `undefined` as | Why |
26
26
  * |---|---|---|---|
27
27
  * | `link-auth/resolve-token.ts` | `VAT_LINKAUTH_ALLOW_COMMAND` | **deny** | gates a capability — fail closed |
28
28
  * | `resources/parse-cache.ts` | `VAT_CACHE` | cache stays on | gates a cache — an unreadable value must not silently change behaviour |
29
29
  * | `cli/utils/projection-store.ts` | `VAT_CACHE` | not a veto | same variable, same reading, one implementation |
30
+ * | `cli/utils/projection-store.ts` | `VAT_PROJECTION_STORE` | not a veto | the projection store is ON by default; only an explicit off spelling (or an empty value, which that call site handles before asking) turns it off |
30
31
  *
31
32
  * Same parser, different safe sides, each chosen at its own call site. That is
32
33
  * the contract; do not move a default in here.
34
+ *
35
+ * 🔑 The last two rows are the interesting pair: one variable defaults OFF and
36
+ * the other defaults ON, and both are still read through this one function,
37
+ * because what it answers is not "is this on" but "did the operator say no".
33
38
  */
34
39
  const TRUE_SPELLINGS = new Set(['1', 'true', 'yes', 'y', 'on']);
35
40
  const FALSE_SPELLINGS = new Set(['0', 'false', 'no', 'n', 'off']);
@@ -1 +1 @@
1
- {"version":3,"file":"env-flag.js","sourceRoot":"","sources":["../src/env-flag.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,MAAM,cAAc,GAAwB,IAAI,GAAG,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC,CAAC;AACrF,MAAM,eAAe,GAAwB,IAAI,GAAG,CAAC,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC;AAEvF;;;;;;;;;;GAUG;AACH,MAAM,UAAU,eAAe,CAAC,GAAuB;IACrD,IAAI,GAAG,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IACxC,MAAM,UAAU,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IAC5C,IAAI,cAAc,CAAC,GAAG,CAAC,UAAU,CAAC;QAAE,OAAO,IAAI,CAAC;IAChD,IAAI,eAAe,CAAC,GAAG,CAAC,UAAU,CAAC;QAAE,OAAO,KAAK,CAAC;IAClD,OAAO,SAAS,CAAC;AACnB,CAAC"}
1
+ {"version":3,"file":"env-flag.js","sourceRoot":"","sources":["../src/env-flag.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,MAAM,cAAc,GAAwB,IAAI,GAAG,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC,CAAC;AACrF,MAAM,eAAe,GAAwB,IAAI,GAAG,CAAC,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC;AAEvF;;;;;;;;;;GAUG;AACH,MAAM,UAAU,eAAe,CAAC,GAAuB;IACrD,IAAI,GAAG,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IACxC,MAAM,UAAU,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IAC5C,IAAI,cAAc,CAAC,GAAG,CAAC,UAAU,CAAC;QAAE,OAAO,IAAI,CAAC;IAChD,IAAI,eAAe,CAAC,GAAG,CAAC,UAAU,CAAC;QAAE,OAAO,KAAK,CAAC;IAClD,OAAO,SAAS,CAAC;AACnB,CAAC"}
@@ -153,6 +153,24 @@ export declare function withGitSnapshotCache<T>(run: () => T): T;
153
153
  export declare function gitTreeSnapshot(options: {
154
154
  cwd: string;
155
155
  }): GitTreeSnapshot | null;
156
+ /**
157
+ * A snapshot taken NOW, whatever bracket is open — never read from or written to
158
+ * its memo.
159
+ *
160
+ * ⛔ The bracket makes two consumers agree with EACH OTHER; it cannot make them
161
+ * agree with files read later. A population keyed inside a bracket reads the
162
+ * working tree afterwards, so an edit landing in between is read and then filed
163
+ * under the pre-edit key. This is how a caller checks, before filing, that the
164
+ * bracket's answer still describes the tree. It costs a full snapshot, so it
165
+ * belongs on the write path, never on a hit.
166
+ *
167
+ * @param options - Where to look
168
+ * @param options.cwd - Any directory inside the repository of interest
169
+ * @returns The snapshot, or `null` when git could not answer
170
+ */
171
+ export declare function freshGitTreeSnapshot(options: {
172
+ cwd: string;
173
+ }): GitTreeSnapshot | null;
156
174
  /**
157
175
  * The snapshot this bracket ALREADY holds for a repository — never a new one.
158
176
  *
@@ -1 +1 @@
1
- {"version":3,"file":"git-snapshot.d.ts","sourceRoot":"","sources":["../src/git-snapshot.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;AASH,iEAAiE;AACjE,MAAM,WAAW,gBAAgB;IAC/B,4EAA4E;IAC5E,YAAY,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ,8EAA8E;IAC9E,IAAI,EAAE,MAAM,CAAC;IACb,4EAA4E;IAC5E,SAAS,EAAE,OAAO,CAAC;IACnB;;;;;;OAMG;IACH,WAAW,EAAE,OAAO,CAAC;CACtB;AAED,iCAAiC;AACjC,MAAM,WAAW,eAAe;IAC9B;;;;;;OAMG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,cAAc,EAAE,MAAM,CAAC;IACvB,kDAAkD;IAClD,OAAO,EAAE,gBAAgB,EAAE,CAAC;CAC7B;AAuBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,CAAC,CAEvD;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,eAAe,GAAG,IAAI,CA2BhF;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS,CAY5E"}
1
+ {"version":3,"file":"git-snapshot.d.ts","sourceRoot":"","sources":["../src/git-snapshot.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;AASH,iEAAiE;AACjE,MAAM,WAAW,gBAAgB;IAC/B,4EAA4E;IAC5E,YAAY,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ,8EAA8E;IAC9E,IAAI,EAAE,MAAM,CAAC;IACb,4EAA4E;IAC5E,SAAS,EAAE,OAAO,CAAC;IACnB;;;;;;OAMG;IACH,WAAW,EAAE,OAAO,CAAC;CACtB;AAED,iCAAiC;AACjC,MAAM,WAAW,eAAe;IAC9B;;;;;;OAMG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,cAAc,EAAE,MAAM,CAAC;IACvB,kDAAkD;IAClD,OAAO,EAAE,gBAAgB,EAAE,CAAC;CAC7B;AAuBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,CAAC,CAEvD;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,eAAe,GAAG,IAAI,CA2BhF;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,eAAe,GAAG,IAAI,CAErF;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS,CAY5E"}
@@ -164,6 +164,24 @@ export function gitTreeSnapshot(options) {
164
164
  memo?.set(repositoryRoot, snapshot);
165
165
  return snapshot;
166
166
  }
167
+ /**
168
+ * A snapshot taken NOW, whatever bracket is open — never read from or written to
169
+ * its memo.
170
+ *
171
+ * ⛔ The bracket makes two consumers agree with EACH OTHER; it cannot make them
172
+ * agree with files read later. A population keyed inside a bracket reads the
173
+ * working tree afterwards, so an edit landing in between is read and then filed
174
+ * under the pre-edit key. This is how a caller checks, before filing, that the
175
+ * bracket's answer still describes the tree. It costs a full snapshot, so it
176
+ * belongs on the write path, never on a hit.
177
+ *
178
+ * @param options - Where to look
179
+ * @param options.cwd - Any directory inside the repository of interest
180
+ * @returns The snapshot, or `null` when git could not answer
181
+ */
182
+ export function freshGitTreeSnapshot(options) {
183
+ return snapshotsInBracket.exit(() => gitTreeSnapshot(options));
184
+ }
167
185
  /**
168
186
  * The snapshot this bracket ALREADY holds for a repository — never a new one.
169
187
  *
@@ -1 +1 @@
1
- {"version":3,"file":"git-snapshot.js","sourceRoot":"","sources":["../src/git-snapshot.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"git-snapshot.js","sourceRoot":"","sources":["../src/git-snapshot.ts"],"names":[],"mappings":"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"}
package/dist/git.d.ts CHANGED
@@ -25,7 +25,7 @@
25
25
  export { runGit, runGitOrThrow, type GitRunOptions, type GitRunResult, } from './git-run.js';
26
26
  export { gitFindRoot, gitLsFiles, gitLsOthers, isGitIgnored } from './git-utils.js';
27
27
  export type { RefuseListingContext, UnreadablePolicy } from './listing-refusal.js';
28
- export { gitTreeSnapshot, peekGitTreeSnapshot, withGitSnapshotCache, type GitSnapshotEntry, type GitTreeSnapshot, } from './git-snapshot.js';
28
+ export { freshGitTreeSnapshot, gitTreeSnapshot, peekGitTreeSnapshot, withGitSnapshotCache, type GitSnapshotEntry, type GitTreeSnapshot, } from './git-snapshot.js';
29
29
  export { loadGitignoreRules } from './gitignore-checker.js';
30
30
  export { GitTracker, type GitTrackerInitOptions } from './git-tracker.js';
31
31
  export { isGitUrl, nonInteractiveGitOverrides, parseGitUrl, type NonInteractiveGitOverrides, type ParsedGitUrl, } from './git-url.js';
package/dist/git.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"git.d.ts","sourceRoot":"","sources":["../src/git.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EACL,MAAM,EACN,aAAa,EACb,KAAK,aAAa,EAClB,KAAK,YAAY,GAClB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAIpF,YAAY,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AACnF,OAAO,EACL,eAAe,EACf,mBAAmB,EACnB,oBAAoB,EACpB,KAAK,gBAAgB,EACrB,KAAK,eAAe,GACrB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAC5D,OAAO,EAAE,UAAU,EAAE,KAAK,qBAAqB,EAAE,MAAM,kBAAkB,CAAC;AAC1E,OAAO,EACL,QAAQ,EACR,0BAA0B,EAC1B,WAAW,EACX,KAAK,0BAA0B,EAC/B,KAAK,YAAY,GAClB,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"git.d.ts","sourceRoot":"","sources":["../src/git.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EACL,MAAM,EACN,aAAa,EACb,KAAK,aAAa,EAClB,KAAK,YAAY,GAClB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAIpF,YAAY,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AACnF,OAAO,EACL,oBAAoB,EACpB,eAAe,EACf,mBAAmB,EACnB,oBAAoB,EACpB,KAAK,gBAAgB,EACrB,KAAK,eAAe,GACrB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAC5D,OAAO,EAAE,UAAU,EAAE,KAAK,qBAAqB,EAAE,MAAM,kBAAkB,CAAC;AAC1E,OAAO,EACL,QAAQ,EACR,0BAA0B,EAC1B,WAAW,EACX,KAAK,0BAA0B,EAC/B,KAAK,YAAY,GAClB,MAAM,cAAc,CAAC"}
package/dist/git.js CHANGED
@@ -24,7 +24,7 @@
24
24
  */
25
25
  export { runGit, runGitOrThrow, } from './git-run.js';
26
26
  export { gitFindRoot, gitLsFiles, gitLsOthers, isGitIgnored } from './git-utils.js';
27
- export { gitTreeSnapshot, peekGitTreeSnapshot, withGitSnapshotCache, } from './git-snapshot.js';
27
+ export { freshGitTreeSnapshot, gitTreeSnapshot, peekGitTreeSnapshot, withGitSnapshotCache, } from './git-snapshot.js';
28
28
  export { loadGitignoreRules } from './gitignore-checker.js';
29
29
  export { GitTracker } from './git-tracker.js';
30
30
  export { isGitUrl, nonInteractiveGitOverrides, parseGitUrl, } from './git-url.js';
package/dist/git.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"git.js","sourceRoot":"","sources":["../src/git.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EACL,MAAM,EACN,aAAa,GAGd,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAKpF,OAAO,EACL,eAAe,EACf,mBAAmB,EACnB,oBAAoB,GAGrB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAC5D,OAAO,EAAE,UAAU,EAA8B,MAAM,kBAAkB,CAAC;AAC1E,OAAO,EACL,QAAQ,EACR,0BAA0B,EAC1B,WAAW,GAGZ,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"git.js","sourceRoot":"","sources":["../src/git.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EACL,MAAM,EACN,aAAa,GAGd,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAKpF,OAAO,EACL,oBAAoB,EACpB,eAAe,EACf,mBAAmB,EACnB,oBAAoB,GAGrB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAC5D,OAAO,EAAE,UAAU,EAA8B,MAAM,kBAAkB,CAAC;AAC1E,OAAO,EACL,QAAQ,EACR,0BAA0B,EAC1B,WAAW,GAGZ,MAAM,cAAc,CAAC"}
@@ -11,7 +11,7 @@
11
11
  * sets an `escaped` flag when it sees `\`; the *next* character is skipped for
12
12
  * magic detection if `escaped` is true.
13
13
  */
14
- import { toForwardSlash } from '../path-core.js';
14
+ import { toForwardSlashAnyPlatform } from '../path-core.js';
15
15
  /** The glob metacharacters recognised by this module. */
16
16
  const MAGIC_CHARS = new Set(['*', '?', '[']);
17
17
  /** Sentinel returned by {@link staticGlobBase} when the first segment is magic. */
@@ -69,9 +69,9 @@ export function isGlob(source) {
69
69
  * staticGlobBase('foo/bar.txt') // 'foo/bar.txt'
70
70
  */
71
71
  export function staticGlobBase(pattern) {
72
- // Glob patterns are always forward-slash; toForwardSlash() satisfies the
73
- // no-hardcoded-path-split lint rule while being a no-op in practice.
74
- const normalized = toForwardSlash(pattern);
72
+ // A glob is author-written text: a Windows-spelled separator reads as `/` on
73
+ // every host, whatever platform VAT runs on.
74
+ const normalized = toForwardSlashAnyPlatform(pattern);
75
75
  const segments = normalized.split('/');
76
76
  const staticSegments = [];
77
77
  for (const segment of segments) {
@@ -112,7 +112,7 @@ export function staticGlobBase(pattern) {
112
112
  * globMagicRemainder('../mycli/dist/*.mjs') // '*.mjs'
113
113
  */
114
114
  export function globMagicRemainder(pattern) {
115
- const forward = toForwardSlash(pattern);
115
+ const forward = toForwardSlashAnyPlatform(pattern);
116
116
  const base = staticGlobBase(pattern);
117
117
  if (base === DOT) {
118
118
  // First segment was magic — the remainder is the full (forward-slash) pattern.
@@ -1 +1 @@
1
- {"version":3,"file":"glob-pattern.js","sourceRoot":"","sources":["../../src/glob/glob-pattern.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEjD,yDAAyD;AACzD,MAAM,WAAW,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC;AAE7C,mFAAmF;AACnF,MAAM,GAAG,GAAG,GAAG,CAAC;AAEhB;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,MAAM,CAAC,MAAc;IACnC,IAAI,OAAO,GAAG,KAAK,CAAC;IACpB,KAAK,MAAM,EAAE,IAAI,MAAM,EAAE,CAAC;QACxB,IAAI,OAAO,EAAE,CAAC;YACZ,OAAO,GAAG,KAAK,CAAC;YAChB,SAAS;QACX,CAAC;QACD,IAAI,EAAE,KAAK,IAAI,EAAE,CAAC;YAChB,OAAO,GAAG,IAAI,CAAC;YACf,SAAS;QACX,CAAC;QACD,IAAI,WAAW,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC;YACxB,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,UAAU,cAAc,CAAC,OAAe;IAC5C,yEAAyE;IACzE,qEAAqE;IACrE,MAAM,UAAU,GAAG,cAAc,CAAC,OAAO,CAAC,CAAC;IAC3C,MAAM,QAAQ,GAAG,UAAU,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACvC,MAAM,cAAc,GAAa,EAAE,CAAC;IAEpC,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,IAAI,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC;YACpB,MAAM;QACR,CAAC;QACD,cAAc,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAC/B,CAAC;IAED,IAAI,cAAc,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAChC,OAAO,GAAG,CAAC;IACb,CAAC;IACD,IAAI,cAAc,CAAC,MAAM,KAAK,QAAQ,CAAC,MAAM,EAAE,CAAC;QAC9C,oEAAoE;QACpE,OAAO,UAAU,CAAC;IACpB,CAAC;IAED,MAAM,MAAM,GAAG,cAAc,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACxC,IAAI,MAAM,KAAK,EAAE,EAAE,CAAC;QAClB,uEAAuE;QACvE,uEAAuE;QACvE,sEAAsE;QACtE,uEAAuE;QACvE,gDAAgD;QAChD,OAAO,UAAU,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC;IAChD,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,kBAAkB,CAAC,OAAe;IAChD,MAAM,OAAO,GAAG,cAAc,CAAC,OAAO,CAAC,CAAC;IACxC,MAAM,IAAI,GAAG,cAAc,CAAC,OAAO,CAAC,CAAC;IACrC,IAAI,IAAI,KAAK,GAAG,EAAE,CAAC;QACjB,+EAA+E;QAC/E,OAAO,OAAO,CAAC;IACjB,CAAC;IACD,IAAI,IAAI,KAAK,OAAO,EAAE,CAAC;QACrB,yDAAyD;QACzD,OAAO,EAAE,CAAC;IACZ,CAAC;IACD,IAAI,IAAI,KAAK,GAAG,EAAE,CAAC;QACjB,yEAAyE;QACzE,yEAAyE;QACzE,OAAO,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC1B,CAAC;IACD,wDAAwD;IACxD,OAAO,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;AACxC,CAAC"}
1
+ {"version":3,"file":"glob-pattern.js","sourceRoot":"","sources":["../../src/glob/glob-pattern.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,yBAAyB,EAAE,MAAM,iBAAiB,CAAC;AAE5D,yDAAyD;AACzD,MAAM,WAAW,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC;AAE7C,mFAAmF;AACnF,MAAM,GAAG,GAAG,GAAG,CAAC;AAEhB;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,MAAM,CAAC,MAAc;IACnC,IAAI,OAAO,GAAG,KAAK,CAAC;IACpB,KAAK,MAAM,EAAE,IAAI,MAAM,EAAE,CAAC;QACxB,IAAI,OAAO,EAAE,CAAC;YACZ,OAAO,GAAG,KAAK,CAAC;YAChB,SAAS;QACX,CAAC;QACD,IAAI,EAAE,KAAK,IAAI,EAAE,CAAC;YAChB,OAAO,GAAG,IAAI,CAAC;YACf,SAAS;QACX,CAAC;QACD,IAAI,WAAW,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC;YACxB,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,UAAU,cAAc,CAAC,OAAe;IAC5C,6EAA6E;IAC7E,6CAA6C;IAC7C,MAAM,UAAU,GAAG,yBAAyB,CAAC,OAAO,CAAC,CAAC;IACtD,MAAM,QAAQ,GAAG,UAAU,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACvC,MAAM,cAAc,GAAa,EAAE,CAAC;IAEpC,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,IAAI,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC;YACpB,MAAM;QACR,CAAC;QACD,cAAc,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAC/B,CAAC;IAED,IAAI,cAAc,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAChC,OAAO,GAAG,CAAC;IACb,CAAC;IACD,IAAI,cAAc,CAAC,MAAM,KAAK,QAAQ,CAAC,MAAM,EAAE,CAAC;QAC9C,oEAAoE;QACpE,OAAO,UAAU,CAAC;IACpB,CAAC;IAED,MAAM,MAAM,GAAG,cAAc,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACxC,IAAI,MAAM,KAAK,EAAE,EAAE,CAAC;QAClB,uEAAuE;QACvE,uEAAuE;QACvE,sEAAsE;QACtE,uEAAuE;QACvE,gDAAgD;QAChD,OAAO,UAAU,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC;IAChD,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,kBAAkB,CAAC,OAAe;IAChD,MAAM,OAAO,GAAG,yBAAyB,CAAC,OAAO,CAAC,CAAC;IACnD,MAAM,IAAI,GAAG,cAAc,CAAC,OAAO,CAAC,CAAC;IACrC,IAAI,IAAI,KAAK,GAAG,EAAE,CAAC;QACjB,+EAA+E;QAC/E,OAAO,OAAO,CAAC;IACjB,CAAC;IACD,IAAI,IAAI,KAAK,OAAO,EAAE,CAAC;QACrB,yDAAyD;QACzD,OAAO,EAAE,CAAC;IACZ,CAAC;IACD,IAAI,IAAI,KAAK,GAAG,EAAE,CAAC;QACjB,yEAAyE;QACzE,yEAAyE;QACzE,OAAO,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC1B,CAAC;IACD,wDAAwD;IACxD,OAAO,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;AACxC,CAAC"}
@@ -64,8 +64,9 @@ export declare function isAbsoluteAnyPlatform(p: string): boolean;
64
64
  /**
65
65
  * True if `p` contains a `..` parent-directory traversal segment.
66
66
  *
67
- * Forward-slash-normalized, then inspects each `/`-delimited segment — so a
68
- * `..` is caught regardless of the original OS separator. A containment guard
67
+ * Backslashes are read as separators on every host (the input is author-written
68
+ * config), then each `/`-delimited segment is inspected — so a `..` is caught
69
+ * regardless of the separator the author typed. A containment guard
69
70
  * for config-supplied relative paths (skill `files:` dest values, glob magic
70
71
  * remainders) that must never climb above their anchor directory.
71
72
  *
@@ -188,23 +189,42 @@ export declare function toAbsolutePath(p: string, baseDir: string): string;
188
189
  */
189
190
  export declare function getRelativePath(from: string, to: string): string;
190
191
  /**
191
- * Convert a path to forward slashes
192
+ * Convert a NATIVE path — one the filesystem, `path.*`, `readdir` or git
193
+ * handed you — to forward slashes.
192
194
  *
193
- * Windows accepts both forward slashes and backslashes as path separators.
194
- * This function normalizes all paths to use forward slashes for consistency.
195
- * Useful for glob pattern matching, cross-platform comparisons, and string operations.
195
+ * Converts only where a backslash is a separator (win32). On POSIX a backslash is a
196
+ * legal filename character, so `docs/x\y.md` is one file and is returned
197
+ * unchanged: converting it would invent a phantom `docs/x/` directory and, in
198
+ * {@link safePath.joinUnderRoot}, turn an `x\..\..` NAME into a climb.
196
199
  *
197
- * @param p - Path to convert
198
- * @returns Path with forward slashes
200
+ * For AUTHOR-WRITTEN text — an href, a glob, a config value, a CLI argument,
201
+ * an archive entry name — whose backslashes must read as separators on every
202
+ * host, use {@link toForwardSlashAnyPlatform}.
199
203
  *
200
- * @example
201
- * toForwardSlash('C:\\Users\\docs\\README.md')
202
- * // Returns: 'C:/Users/docs/README.md'
204
+ * @param p - A native path
205
+ * @returns The path with forward slashes (identity on POSIX)
203
206
  *
204
- * toForwardSlash('/project/docs/README.md')
205
- * // Returns: '/project/docs/README.md' (unchanged)
207
+ * @example
208
+ * toForwardSlash('C:\\Users\\docs\\README.md') // win32: 'C:/Users/docs/README.md'
209
+ * toForwardSlash('docs/x\\y.md') // POSIX: 'docs/x\\y.md' (unchanged)
206
210
  */
207
211
  export declare function toForwardSlash(p: string): string;
212
+ /**
213
+ * Convert every backslash to a forward slash, on every host.
214
+ *
215
+ * For AUTHOR-WRITTEN text that may carry Windows spellings regardless of where
216
+ * VAT runs — markdown hrefs, globs, config values, CLI arguments, zip entry
217
+ * names — and for containment guards that must refuse `..\x` everywhere.
218
+ * Never use it on a path read from the filesystem or git: on POSIX that
219
+ * backslash is part of a filename. Use {@link toForwardSlash} for those.
220
+ *
221
+ * @param text - Author-written path text
222
+ * @returns The text with every backslash replaced by `/`
223
+ *
224
+ * @example
225
+ * toForwardSlashAnyPlatform('..\\evil') // '../evil' on every host
226
+ */
227
+ export declare function toForwardSlashAnyPlatform(text: string): string;
208
228
  /**
209
229
  * Normalize text to Unicode NFC — the form in which two *visually identical*
210
230
  * filenames compare equal.
@@ -239,7 +259,8 @@ export declare function toNfc(value: string): string;
239
259
  * Cross-platform safe path operations.
240
260
  *
241
261
  * Wraps Node's `path.join()`, `path.resolve()`, and `path.relative()` to always
242
- * return forward-slash paths. On Windows, the native `path.*` functions return
262
+ * return forward-slash paths (converted through {@link toForwardSlash}, so a
263
+ * backslash inside a POSIX filename survives). On Windows, the native `path.*` functions return
243
264
  * backslashes, which causes bugs when paths are used as Map keys, compared as
244
265
  * strings, or matched with glob patterns.
245
266
  *
@@ -1 +1 @@
1
- {"version":3,"file":"path-core.d.ts","sourceRoot":"","sources":["../src/path-core.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAEjD;;;;;;;;;GASG;AACH,qBAAa,oBAAqB,SAAQ,QAAQ;IAChD,2FAA2F;IAC3F,MAAM,CAAC,QAAQ,CAAC,IAAI,uBAAuB;IAE3C;;;OAGG;gBACS,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAGzC;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAEjD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAExD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,yBAAyB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAG5D;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,mBAAmB,CAAC,kBAAkB,EAAE,MAAM,GAAG,OAAO,CAGvE;AAKD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAUvD;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,aAAa,CAAC,cAAc,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAEjF;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAKjE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAMhE;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE3C;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,QAAQ;IACnB,6DAA6D;8BAC9C,MAAM,EAAE,KAAG,MAAM;IAIhC,gEAAgE;iCAC9C,MAAM,EAAE,KAAG,MAAM;IAInC,iEAAiE;8BAClD,MAAM,MAAM,MAAM,KAAG,MAAM;IAI1C;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;mCACiB,MAAM,eAAe,MAAM,EAAE,KAAG,MAAM;CAiClD,CAAC"}
1
+ {"version":3,"file":"path-core.d.ts","sourceRoot":"","sources":["../src/path-core.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAEjD;;;;;;;;;GASG;AACH,qBAAa,oBAAqB,SAAQ,QAAQ;IAChD,2FAA2F;IAC3F,MAAM,CAAC,QAAQ,CAAC,IAAI,uBAAuB;IAE3C;;;OAGG;gBACS,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAGzC;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAEjD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAExD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,yBAAyB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAG5D;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,mBAAmB,CAAC,kBAAkB,EAAE,MAAM,GAAG,OAAO,CAGvE;AAKD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAUvD;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,aAAa,CAAC,cAAc,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAEjF;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAKjE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAMhE;AAKD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAG9D;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE3C;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,eAAO,MAAM,QAAQ;IACnB,6DAA6D;8BAC9C,MAAM,EAAE,KAAG,MAAM;IAIhC,gEAAgE;iCAC9C,MAAM,EAAE,KAAG,MAAM;IAInC,iEAAiE;8BAClD,MAAM,MAAM,MAAM,KAAG,MAAM;IAI1C;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;mCACiB,MAAM,eAAe,MAAM,EAAE,KAAG,MAAM;CAiClD,CAAC"}
package/dist/path-core.js CHANGED
@@ -71,8 +71,9 @@ export function isAbsoluteAnyPlatform(p) {
71
71
  /**
72
72
  * True if `p` contains a `..` parent-directory traversal segment.
73
73
  *
74
- * Forward-slash-normalized, then inspects each `/`-delimited segment — so a
75
- * `..` is caught regardless of the original OS separator. A containment guard
74
+ * Backslashes are read as separators on every host (the input is author-written
75
+ * config), then each `/`-delimited segment is inspected — so a `..` is caught
76
+ * regardless of the separator the author typed. A containment guard
76
77
  * for config-supplied relative paths (skill `files:` dest values, glob magic
77
78
  * remainders) that must never climb above their anchor directory.
78
79
  *
@@ -84,7 +85,7 @@ export function isAbsoluteAnyPlatform(p) {
84
85
  */
85
86
  export function hasParentTraversalSegment(p) {
86
87
  // eslint-disable-next-line local/no-dotdot-containment -- this IS the one lexical `..`-segment test the rule points to; it classifies a config-supplied RELATIVE spelling before any root exists to ask the filesystem about. Sinks use isUnderRoot().
87
- return toForwardSlash(p).split('/').includes('..');
88
+ return toForwardSlashAnyPlatform(p).split('/').includes('..');
88
89
  }
89
90
  /**
90
91
  * True when a root-relative path — as `safePath.relative(root, p)` spells it —
@@ -222,25 +223,49 @@ export function getRelativePath(from, to) {
222
223
  // Calculate relative path from source directory to target file
223
224
  return toForwardSlash(path.relative(fromDir, to));
224
225
  }
226
+ /** True on hosts (win32) where a backslash is a path separator; everywhere else it is a filename character. */
227
+ const BACKSLASH_IS_NATIVE_SEPARATOR = path.sep === '\\';
225
228
  /**
226
- * Convert a path to forward slashes
229
+ * Convert a NATIVE path — one the filesystem, `path.*`, `readdir` or git
230
+ * handed you — to forward slashes.
227
231
  *
228
- * Windows accepts both forward slashes and backslashes as path separators.
229
- * This function normalizes all paths to use forward slashes for consistency.
230
- * Useful for glob pattern matching, cross-platform comparisons, and string operations.
232
+ * Converts only where a backslash is a separator (win32). On POSIX a backslash is a
233
+ * legal filename character, so `docs/x\y.md` is one file and is returned
234
+ * unchanged: converting it would invent a phantom `docs/x/` directory and, in
235
+ * {@link safePath.joinUnderRoot}, turn an `x\..\..` NAME into a climb.
231
236
  *
232
- * @param p - Path to convert
233
- * @returns Path with forward slashes
237
+ * For AUTHOR-WRITTEN text — an href, a glob, a config value, a CLI argument,
238
+ * an archive entry name — whose backslashes must read as separators on every
239
+ * host, use {@link toForwardSlashAnyPlatform}.
234
240
  *
235
- * @example
236
- * toForwardSlash('C:\\Users\\docs\\README.md')
237
- * // Returns: 'C:/Users/docs/README.md'
241
+ * @param p - A native path
242
+ * @returns The path with forward slashes (identity on POSIX)
238
243
  *
239
- * toForwardSlash('/project/docs/README.md')
240
- * // Returns: '/project/docs/README.md' (unchanged)
244
+ * @example
245
+ * toForwardSlash('C:\\Users\\docs\\README.md') // win32: 'C:/Users/docs/README.md'
246
+ * toForwardSlash('docs/x\\y.md') // POSIX: 'docs/x\\y.md' (unchanged)
241
247
  */
242
248
  export function toForwardSlash(p) {
243
- return p.replaceAll('\\', '/');
249
+ return BACKSLASH_IS_NATIVE_SEPARATOR ? toForwardSlashAnyPlatform(p) : p;
250
+ }
251
+ /**
252
+ * Convert every backslash to a forward slash, on every host.
253
+ *
254
+ * For AUTHOR-WRITTEN text that may carry Windows spellings regardless of where
255
+ * VAT runs — markdown hrefs, globs, config values, CLI arguments, zip entry
256
+ * names — and for containment guards that must refuse `..\x` everywhere.
257
+ * Never use it on a path read from the filesystem or git: on POSIX that
258
+ * backslash is part of a filename. Use {@link toForwardSlash} for those.
259
+ *
260
+ * @param text - Author-written path text
261
+ * @returns The text with every backslash replaced by `/`
262
+ *
263
+ * @example
264
+ * toForwardSlashAnyPlatform('..\\evil') // '../evil' on every host
265
+ */
266
+ export function toForwardSlashAnyPlatform(text) {
267
+ // eslint-disable-next-line local/no-manual-path-normalize -- this IS the converter the rule's autofix writes; it cannot call itself.
268
+ return text.replaceAll('\\', '/');
244
269
  }
245
270
  /**
246
271
  * Normalize text to Unicode NFC — the form in which two *visually identical*
@@ -278,7 +303,8 @@ export function toNfc(value) {
278
303
  * Cross-platform safe path operations.
279
304
  *
280
305
  * Wraps Node's `path.join()`, `path.resolve()`, and `path.relative()` to always
281
- * return forward-slash paths. On Windows, the native `path.*` functions return
306
+ * return forward-slash paths (converted through {@link toForwardSlash}, so a
307
+ * backslash inside a POSIX filename survives). On Windows, the native `path.*` functions return
282
308
  * backslashes, which causes bugs when paths are used as Map keys, compared as
283
309
  * strings, or matched with glob patterns.
284
310
  *
@@ -1 +1 @@
1
- {"version":3,"file":"path-core.js","sourceRoot":"","sources":["../src/path-core.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"path-core.js","sourceRoot":"","sources":["../src/path-core.ts"],"names":[],"mappings":"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"}
package/dist/path.d.ts CHANGED
@@ -8,5 +8,5 @@
8
8
  * If you need `normalizedTmpdir`, `mkdirSyncReal`, or `normalizePath`, those
9
9
  * touch the filesystem — import them from `@vibe-agent-toolkit/utils/fs`.
10
10
  */
11
- export { PathEscapesRootError, safePath, toForwardSlash, toNfc, isAbsolutePath, isAbsoluteAnyPlatform, hasParentTraversalSegment, isSingleFsSegment, relativeEscapesRoot, toAbsolutePath, getRelativePath, issueLocation, } from './path-core.js';
11
+ export { PathEscapesRootError, safePath, toForwardSlash, toForwardSlashAnyPlatform, toNfc, isAbsolutePath, isAbsoluteAnyPlatform, hasParentTraversalSegment, isSingleFsSegment, relativeEscapesRoot, toAbsolutePath, getRelativePath, issueLocation, } from './path-core.js';
12
12
  //# sourceMappingURL=path.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"path.d.ts","sourceRoot":"","sources":["../src/path.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EACL,oBAAoB,EACpB,QAAQ,EACR,cAAc,EACd,KAAK,EACL,cAAc,EACd,qBAAqB,EACrB,yBAAyB,EACzB,iBAAiB,EACjB,mBAAmB,EACnB,cAAc,EACd,eAAe,EACf,aAAa,GACd,MAAM,gBAAgB,CAAC"}
1
+ {"version":3,"file":"path.d.ts","sourceRoot":"","sources":["../src/path.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EACL,oBAAoB,EACpB,QAAQ,EACR,cAAc,EACd,yBAAyB,EACzB,KAAK,EACL,cAAc,EACd,qBAAqB,EACrB,yBAAyB,EACzB,iBAAiB,EACjB,mBAAmB,EACnB,cAAc,EACd,eAAe,EACf,aAAa,GACd,MAAM,gBAAgB,CAAC"}
package/dist/path.js CHANGED
@@ -8,5 +8,5 @@
8
8
  * If you need `normalizedTmpdir`, `mkdirSyncReal`, or `normalizePath`, those
9
9
  * touch the filesystem — import them from `@vibe-agent-toolkit/utils/fs`.
10
10
  */
11
- export { PathEscapesRootError, safePath, toForwardSlash, toNfc, isAbsolutePath, isAbsoluteAnyPlatform, hasParentTraversalSegment, isSingleFsSegment, relativeEscapesRoot, toAbsolutePath, getRelativePath, issueLocation, } from './path-core.js';
11
+ export { PathEscapesRootError, safePath, toForwardSlash, toForwardSlashAnyPlatform, toNfc, isAbsolutePath, isAbsoluteAnyPlatform, hasParentTraversalSegment, isSingleFsSegment, relativeEscapesRoot, toAbsolutePath, getRelativePath, issueLocation, } from './path-core.js';
12
12
  //# sourceMappingURL=path.js.map
package/dist/path.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"path.js","sourceRoot":"","sources":["../src/path.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EACL,oBAAoB,EACpB,QAAQ,EACR,cAAc,EACd,KAAK,EACL,cAAc,EACd,qBAAqB,EACrB,yBAAyB,EACzB,iBAAiB,EACjB,mBAAmB,EACnB,cAAc,EACd,eAAe,EACf,aAAa,GACd,MAAM,gBAAgB,CAAC"}
1
+ {"version":3,"file":"path.js","sourceRoot":"","sources":["../src/path.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EACL,oBAAoB,EACpB,QAAQ,EACR,cAAc,EACd,yBAAyB,EACzB,KAAK,EACL,cAAc,EACd,qBAAqB,EACrB,yBAAyB,EACzB,iBAAiB,EACjB,mBAAmB,EACnB,cAAc,EACd,eAAe,EACf,aAAa,GACd,MAAM,gBAAgB,CAAC"}
package/eslint/README.md CHANGED
@@ -79,8 +79,8 @@ The table is **generated** from each rule's `meta.docs` by `bun run generate:cla
79
79
 
80
80
  | Rule | Bans | Use instead | Subpath | Fix | `recommended` |
81
81
  |---|---|---|---|---|---|
82
- | `no-hardcoded-path-split` | `split('/')` / `split('\\')` on a path | `path.basename()`, or `toForwardSlash()` first | `/path` | | `error` |
83
- | `no-manual-path-normalize` | hand-rolled `.replace(/\\/g, '/')` | `toForwardSlash()` | `/path` | ✓ | `error` |
82
+ | `no-hardcoded-path-split` | `split('/')` / `split('\\')` on a path | `path.basename()`, or `toForwardSlash()` / `toForwardSlashAnyPlatform()` first | `/path` | | `error` |
83
+ | `no-manual-path-normalize` | hand-rolled `.replace(/\\/g, '/')` / `split(path.sep).join('/')` | `toForwardSlash()` (native paths) / `toForwardSlashAnyPlatform()` (authored text) | `/path` | ✓ | `error` |
84
84
  | `no-path-operations-in-comparisons` | raw `path.*()` results in string comparisons | wrap in `toForwardSlash()` | `/path` | | `error` |
85
85
  | `no-path-sep-in-strings` | `path.sep` embedded in a string literal | `toForwardSlash()` | `/path` | | `error` |
86
86
  | `no-path-startswith` | `path.startsWith()` on a raw path | `toForwardSlash()` first | `/path` | | `error` |
@@ -36,6 +36,7 @@
36
36
 
37
37
  /** Forward-slash a path and drop any leading `./` or `/` noise used for anchoring. */
38
38
  function normalizeForMatch(value) {
39
+ // eslint-disable-next-line local/no-manual-path-normalize -- the rule pack is standalone CommonJS and cannot import the ESM helper; config paths and linted filenames are matched separator-agnostically on purpose.
39
40
  return String(value).replaceAll('\\', '/').replace(/^(?:\.\/)+/, '');
40
41
  }
41
42
 
@@ -103,6 +103,7 @@ function isSourceFile(filename) {
103
103
  if (!filename) {
104
104
  return false;
105
105
  }
106
+ // eslint-disable-next-line local/no-manual-path-normalize -- the rule pack is standalone CommonJS and cannot import the ESM helper; config paths and linted filenames are matched separator-agnostically on purpose.
106
107
  const normalized = String(filename).replaceAll('\\', '/');
107
108
  return normalized.includes('/src/') && !isTestFile(normalized);
108
109
  }
@@ -2,10 +2,13 @@
2
2
  * ESLint rule to disallow splitting strings by hardcoded path separators
3
3
  *
4
4
  * Using .split('/') or .split('\\') on file paths breaks on Windows/Unix.
5
- * Use path.basename(), path.dirname(), or normalize with toForwardSlash() first.
5
+ * Use path.basename(), path.dirname(), or normalize first: toForwardSlash() for a
6
+ * NATIVE path (fs, path.*, the VCS — converts only where the host separator is a
7
+ * backslash), toForwardSlashAnyPlatform() for AUTHOR-WRITTEN text (hrefs, globs,
8
+ * config values, archive entries — converts every backslash on every host).
6
9
  *
7
10
  * This rule is smart enough to detect when paths are already normalized:
8
- * - Inline: toForwardSlash(path).split('/') ✅
11
+ * - Inline: toForwardSlash(path).split('/') ✅ (either converter)
9
12
  * - Variable: const normalized = toForwardSlash(path); normalized.split('/') ✅
10
13
  *
11
14
  * @example
@@ -24,6 +27,9 @@
24
27
  * // ✅ GOOD - normalize then split (variable)
25
28
  * const normalizedPath = toForwardSlash(filePath);
26
29
  * const parts = normalizedPath.split('/');
30
+ *
31
+ * // ✅ GOOD - authored text (a glob, an href) converts on every host
32
+ * const segments = toForwardSlashAnyPlatform(pattern).split('/');
27
33
  */
28
34
 
29
35
  const {
@@ -32,6 +38,14 @@ const {
32
38
  resolveSafeModule,
33
39
  } = require('./safe-import.cjs');
34
40
 
41
+ /** The converters whose result is forward-slashed, so splitting it on `/` is safe. */
42
+ const NORMALIZERS = new Set(['toForwardSlash', 'toForwardSlashAnyPlatform']);
43
+
44
+ /** A direct call to one of {@link NORMALIZERS}? */
45
+ function isNormalizerCall(node) {
46
+ return node?.type === 'CallExpression' && node.callee.type === 'Identifier' && NORMALIZERS.has(node.callee.name);
47
+ }
48
+
35
49
  module.exports = {
36
50
  meta: {
37
51
  type: 'problem',
@@ -40,7 +54,7 @@ module.exports = {
40
54
  'Disallow splitting strings by hardcoded path separators',
41
55
  category: 'Path handling',
42
56
  bans: "`split('/')` / `split('\\\\')` on a path",
43
- useInstead: '`path.basename()`, or `toForwardSlash()` first',
57
+ useInstead: '`path.basename()`, or `toForwardSlash()` / `toForwardSlashAnyPlatform()` first',
44
58
  subpath: '/path',
45
59
  recommended: true,
46
60
  recommendedSeverity: 'error',
@@ -48,26 +62,22 @@ module.exports = {
48
62
  messages: {
49
63
  noHardcodedSplit:
50
64
  String.raw`Avoid .split('/') or .split('\') on file paths (breaks on Windows/Unix). ` +
51
- 'Use path.basename() to extract filename, or toForwardSlash() from {{safeModule}} to normalize paths first.',
65
+ 'Use path.basename() to extract filename, or normalize first with toForwardSlash() (native paths) ' +
66
+ 'or toForwardSlashAnyPlatform() (authored text such as globs and hrefs) from {{safeModule}}.',
52
67
  },
53
68
  schema: [SAFE_MODULE_ONLY_SCHEMA],
54
69
  },
55
70
 
56
71
  create(context) {
57
72
  const reportData = { safeModule: resolveSafeModule(context, SAFE_PATH_MODULE) };
58
- // Track variables that were assigned from toForwardSlash()
73
+ // Track variables that were assigned from a normalizer
59
74
  const normalizedVariables = new Set();
60
75
 
61
76
  return {
62
77
  // Track variable declarations
63
78
  VariableDeclarator(node) {
64
- // Check if this variable is assigned from toForwardSlash()
65
- if (
66
- node.init?.type === 'CallExpression' &&
67
- node.init.callee.type === 'Identifier' &&
68
- node.init.callee.name === 'toForwardSlash' &&
69
- node.id.type === 'Identifier'
70
- ) {
79
+ // Check if this variable is assigned from a normalizer
80
+ if (isNormalizerCall(node.init) && node.id.type === 'Identifier') {
71
81
  normalizedVariables.add(node.id.name);
72
82
  }
73
83
  },
@@ -107,11 +117,7 @@ module.exports = {
107
117
  const object = node.callee.object;
108
118
 
109
119
  // Case 1: Inline normalization - toForwardSlash(...).split('/')
110
- if (
111
- object.type === 'CallExpression' &&
112
- object.callee.type === 'Identifier' &&
113
- object.callee.name === 'toForwardSlash'
114
- ) {
120
+ if (isNormalizerCall(object)) {
115
121
  return; // Safe - normalized inline
116
122
  }
117
123
 
@@ -54,6 +54,7 @@ function isUnitTierFile(filename) {
54
54
  if (!filename) {
55
55
  return false;
56
56
  }
57
+ // eslint-disable-next-line local/no-manual-path-normalize -- the rule pack is standalone CommonJS and cannot import the ESM helper; config paths and linted filenames are matched separator-agnostically on purpose.
57
58
  const normalized = String(filename).replaceAll('\\', '/');
58
59
  return isTestFile(normalized) && PACKAGE_TEST_DIR.test(normalized) && !OTHER_TIER.test(normalized);
59
60
  }
@@ -1,16 +1,32 @@
1
1
  /**
2
- * ESLint rule to enforce using toForwardSlash() instead of manual normalization
2
+ * ESLint rule to enforce the forward-slash converters instead of hand-rolled
3
+ * normalization.
3
4
  *
4
- * Detects manual path normalization patterns and suggests using the utility function.
5
+ * Two converters, because a backslash means two different things:
6
+ *
7
+ * - `toForwardSlash(p)` — a NATIVE path (from `fs`, `path.*`, git). Converts
8
+ * only where the host's separator is a backslash; on POSIX a backslash is a
9
+ * filename character and is kept. `split(path.sep).join('/')` is exactly
10
+ * this, so it autofixes here.
11
+ * - `toForwardSlashAnyPlatform(text)` — AUTHOR-WRITTEN text (an href, a glob, a
12
+ * config value, an archive entry name). Converts every backslash on every
13
+ * host. A literal-backslash `split('\\').join('/')`, `replaceAll('\\', '/')`
14
+ * or `replace(/\\/g, '/')` is exactly this, so each autofixes here — never to
15
+ * `toForwardSlash`, which would silently stop converting on POSIX.
16
+ *
17
+ * Whether a given literal-backslash site is really author text is the author's
18
+ * call; the fix preserves behaviour, and a native-path site should then be
19
+ * switched to `toForwardSlash` by hand.
5
20
  *
6
21
  * @example
7
22
  * // ❌ BAD - manual normalization
8
- * const normalized = relativePath.split(path.sep).join('/');
9
- * const normalized = somePath.split('\\').join('/');
23
+ * const a = relativePath.split(path.sep).join('/');
24
+ * const b = href.replaceAll('\\', '/');
10
25
  *
11
- * // ✅ GOOD - use utility function
12
- * import { toForwardSlash } from '@vibe-agent-toolkit/utils/path';
13
- * const normalized = toForwardSlash(relativePath);
26
+ * // ✅ GOOD - use the utility functions
27
+ * import { toForwardSlash, toForwardSlashAnyPlatform } from '@vibe-agent-toolkit/utils/path';
28
+ * const a = toForwardSlash(relativePath);
29
+ * const b = toForwardSlashAnyPlatform(href);
14
30
  */
15
31
 
16
32
  const {
@@ -26,8 +42,62 @@ const {
26
42
  resolveSafeModule,
27
43
  } = require('./safe-import.cjs');
28
44
 
29
- const SAFE_FN = 'toForwardSlash';
45
+ const NATIVE_FN = 'toForwardSlash';
46
+ const ANY_PLATFORM_FN = 'toForwardSlashAnyPlatform';
47
+ const MESSAGE_FOR = { [NATIVE_FN]: 'useToForwardSlash', [ANY_PLATFORM_FN]: 'useToForwardSlashAnyPlatform' };
30
48
  const PATH_MODULES = new Set(['node:path', 'path']);
49
+ const BACKSLASH = '\\';
50
+
51
+ /** `.name(...)` on some receiver, with exactly `arity` arguments? */
52
+ function isMethodCall(node, name, arity) {
53
+ return (
54
+ node?.type === 'CallExpression' &&
55
+ node.callee.type === 'MemberExpression' &&
56
+ node.callee.property.name === name &&
57
+ node.arguments.length === arity
58
+ );
59
+ }
60
+
61
+ function isStringLiteral(node, value) {
62
+ return node.type === 'Literal' && node.value === value;
63
+ }
64
+
65
+ /** `/\\/g` — one literal backslash, global. */
66
+ function isGlobalBackslashRegex(node) {
67
+ return node.type === 'Literal' && node.regex?.pattern === String.raw`\\` && node.regex.flags.includes('g');
68
+ }
69
+
70
+ function isPathSep(node) {
71
+ return node.type === 'MemberExpression' && node.object.name === 'path' && node.property.name === 'sep';
72
+ }
73
+
74
+ /**
75
+ * `<x>.split(<sep>).join('/')` → the converter it is equivalent to.
76
+ *
77
+ * Splitting on a TWO-backslash SEQUENCE (e.g. collapsing a UNC server prefix)
78
+ * is a different operation that neither converter matches, so it is not
79
+ * reported.
80
+ */
81
+ function matchSplitJoin(node) {
82
+ if (!isMethodCall(node, 'join', 1) || !isStringLiteral(node.arguments[0], '/')) return undefined;
83
+ const split = node.callee.object;
84
+ if (!isMethodCall(split, 'split', 1)) return undefined;
85
+ const [separator] = split.arguments;
86
+ if (isPathSep(separator)) return { receiver: split.callee.object, fn: NATIVE_FN };
87
+ if (isStringLiteral(separator, BACKSLASH)) return { receiver: split.callee.object, fn: ANY_PLATFORM_FN };
88
+ return undefined;
89
+ }
90
+
91
+ /** `<x>.replaceAll('\\', '/')` or `<x>.replace(/\\/g, '/')` → the any-platform converter. */
92
+ function matchReplace(node) {
93
+ const isReplace = isMethodCall(node, 'replace', 2) || isMethodCall(node, 'replaceAll', 2);
94
+ if (!isReplace || !isStringLiteral(node.arguments[1], '/')) return undefined;
95
+ const [pattern] = node.arguments;
96
+ const allBackslashes =
97
+ isGlobalBackslashRegex(pattern) ||
98
+ (node.callee.property.name === 'replaceAll' && isStringLiteral(pattern, BACKSLASH));
99
+ return allBackslashes ? { receiver: node.callee.object, fn: ANY_PLATFORM_FN } : undefined;
100
+ }
31
101
 
32
102
  module.exports = {
33
103
  meta: {
@@ -36,8 +106,8 @@ module.exports = {
36
106
  description:
37
107
  'Disallow manual path normalization patterns',
38
108
  category: 'Path handling',
39
- bans: "hand-rolled `.replace(/\\\\/g, '/')`",
40
- useInstead: '`toForwardSlash()`',
109
+ bans: "hand-rolled `.replace(/\\\\/g, '/')` / `split(path.sep).join('/')`",
110
+ useInstead: '`toForwardSlash()` (native paths) / `toForwardSlashAnyPlatform()` (authored text)',
41
111
  subpath: '/path',
42
112
  recommended: true,
43
113
  recommendedSeverity: 'error',
@@ -46,7 +116,11 @@ module.exports = {
46
116
  messages: {
47
117
  useToForwardSlash:
48
118
  'Use toForwardSlash() from {{safeModule}} instead of manual path normalization. ' +
49
- 'Manual normalization is error-prone and less maintainable.',
119
+ 'It converts a native path only where the host separator is a backslash.',
120
+ useToForwardSlashAnyPlatform:
121
+ 'Use toForwardSlashAnyPlatform() from {{safeModule}} instead of a hand-rolled backslash replace. ' +
122
+ 'If this string is a native filesystem/git path rather than authored text, use toForwardSlash() — ' +
123
+ 'on POSIX a backslash is a filename character.',
50
124
  [DEAD_UNSAFE_IMPORT]: DEAD_UNSAFE_IMPORT_MESSAGE,
51
125
  },
52
126
  schema: [SAFE_MODULE_ONLY_SCHEMA],
@@ -55,24 +129,43 @@ module.exports = {
55
129
  create(context) {
56
130
  const sourceCode = context.getSourceCode();
57
131
  const targetModule = resolveSafeModule(context, SAFE_PATH_MODULE);
58
- // Seeded from SCOPE: a file that already imports `toForwardSlash` from the
59
- // barrel must have the call rewritten WITHOUT gaining a second binding of
60
- // the same name — that is a SyntaxError. See `safe-import.cjs`.
61
- let hasToForwardSlashImport = isNameAlreadyBound(sourceCode, SAFE_FN);
132
+ // Seeded from SCOPE: a file that already binds the name must have the call
133
+ // rewritten WITHOUT gaining a second binding of the same name — that is a
134
+ // SyntaxError. See `safe-import.cjs`.
135
+ const bound = {
136
+ [NATIVE_FN]: isNameAlreadyBound(sourceCode, NATIVE_FN),
137
+ [ANY_PLATFORM_FN]: isNameAlreadyBound(sourceCode, ANY_PLATFORM_FN),
138
+ };
62
139
  // Never mutated — the dead-import leg must not be armed by a flag that a
63
140
  // suppressed report's `fix()` can spend. See `dead-import.cjs`.
64
- const safeBoundInSource = hasToForwardSlashImport;
141
+ const safeBoundInSource = bound[NATIVE_FN];
65
142
  // The dead-import leg's OTHER gate: a `toForwardSlash(…)` call is the text
66
- // this fixer writes, and the only evidence available that it wrote it here.
67
- // Without it, any file with `toForwardSlash` in scope armed the leg — see
68
- // `dead-import.cjs`. Read from the source, never from a `fix()`.
143
+ // this fixer writes for `path.sep`, and the only evidence available that it
144
+ // wrote it here. Read from the source, never from a `fix()`.
69
145
  let safeReplacementCalled = false;
70
146
  let utilsImportNode = null;
71
147
  // `path.sep` is the last `path.*` reference in plenty of files, and
72
- // `toForwardSlash(raw)` consumes it — leaving the same dead `node:path`
73
- // binding the `safePath` rules used to leave.
148
+ // `toForwardSlash(raw)` consumes it — leaving a dead `node:path` binding.
74
149
  const pathImportNodes = [];
75
150
 
151
+ /**
152
+ * Add `fn` to the import, when nothing binds it yet.
153
+ *
154
+ * NOT latched: two reports insert identical text at the identical anchor, so
155
+ * ESLint applies one and drops the other as overlapping. Latching is not
156
+ * free — ESLint runs `fix()` for a SUPPRESSED problem before the
157
+ * `eslint-disable` filter discards it, so a latch could be spent by a report
158
+ * that is then thrown away.
159
+ */
160
+ function importFix(fixer, fn) {
161
+ if (bound[fn]) return [];
162
+ if (utilsImportNode) {
163
+ return [fixer.insertTextAfter(utilsImportNode.specifiers.at(-1), `, ${fn}`)];
164
+ }
165
+ const newImport = `import { ${fn} } from '${targetModule}';\n`;
166
+ return [insertAboveWithComments(fixer, sourceCode, sourceCode.ast.body[0], newImport)];
167
+ }
168
+
76
169
  return {
77
170
  'Program:exit'() {
78
171
  reportDeadUnsafeImports(
@@ -88,99 +181,30 @@ module.exports = {
88
181
  if (PATH_MODULES.has(node.source.value)) {
89
182
  pathImportNodes.push(node);
90
183
  }
91
- if (node.source.value === targetModule) {
92
- utilsImportNode = node;
93
- for (const spec of node.specifiers) {
94
- if (spec.type === 'ImportSpecifier' && spec.imported.name === SAFE_FN) {
95
- hasToForwardSlashImport = true;
96
- }
184
+ if (node.source.value !== targetModule) return;
185
+ utilsImportNode = node;
186
+ for (const spec of node.specifiers) {
187
+ if (spec.type === 'ImportSpecifier' && spec.imported.name in bound) {
188
+ bound[spec.imported.name] = true;
97
189
  }
98
190
  }
99
191
  },
100
192
 
101
193
  CallExpression(node) {
102
- if (node.callee.type === 'Identifier' && node.callee.name === SAFE_FN) {
194
+ if (node.callee.type === 'Identifier' && node.callee.name === NATIVE_FN) {
103
195
  safeReplacementCalled = true;
104
196
  }
105
-
106
- // Check for .split(...).join('/') pattern
107
- if (
108
- node.callee.type === 'MemberExpression' &&
109
- node.callee.property.name === 'join' &&
110
- node.arguments.length === 1 &&
111
- node.arguments[0].type === 'Literal' &&
112
- node.arguments[0].value === '/'
113
- ) {
114
- // Check if the object is a .split() call
115
- const splitCall = node.callee.object;
116
- if (
117
- splitCall.type === 'CallExpression' &&
118
- splitCall.callee.type === 'MemberExpression' &&
119
- splitCall.callee.property.name === 'split' &&
120
- splitCall.arguments.length === 1
121
- ) {
122
- const splitArg = splitCall.arguments[0];
123
-
124
- // Split on path.sep, or on a single backslash character
125
- // (source literal '\\'). Splitting on a two-backslash SEQUENCE
126
- // (source literal '\\\\', decoded value: two backslash characters)
127
- // is a different, rarer operation -- e.g. collapsing a UNC path's
128
- // leading double-backslash server prefix -- and toForwardSlash()
129
- // is not equivalent to it. Autofixing that case would silently
130
- // change program behavior, so it is deliberately excluded here.
131
- const isSplittingByPathSep =
132
- (splitArg.type === 'MemberExpression' &&
133
- splitArg.object.name === 'path' &&
134
- splitArg.property.name === 'sep') ||
135
- (splitArg.type === 'Literal' && splitArg.value === '\\');
136
-
137
- if (isSplittingByPathSep) {
138
- const variableBeingSplit = splitCall.callee.object;
139
-
140
- context.report({
141
- node,
142
- messageId: 'useToForwardSlash',
143
- data: { safeModule: targetModule },
144
- fix(fixer) {
145
- const fixes = [];
146
-
147
- // Replace the entire .split(...).join('/') with toForwardSlash(...)
148
- const originalVar = sourceCode.getText(variableBeingSplit);
149
- fixes.push(fixer.replaceText(node, `${SAFE_FN}(${originalVar})`));
150
-
151
- // Add import if needed
152
- if (!hasToForwardSlashImport) {
153
- if (utilsImportNode) {
154
- // Add to existing utils import
155
- const lastSpecifier = utilsImportNode.specifiers.at(-1);
156
- fixes.push(fixer.insertTextAfter(lastSpecifier, `, ${SAFE_FN}`));
157
- } else {
158
- // Create new import at the top
159
- const firstNode = sourceCode.ast.body[0];
160
- const newImport = `import { ${SAFE_FN} } from '${targetModule}';\n`;
161
- fixes.push(insertAboveWithComments(fixer, sourceCode, firstNode, newImport));
162
- }
163
- // NOT latched. The comment here used to claim that without a
164
- // `hasToForwardSlashImport = true` a second occurrence would
165
- // insert the import twice; an adversarial run could not
166
- // reproduce that at any occurrence count. It cannot happen:
167
- // both reports insert identical text at the identical anchor,
168
- // so the ranges coincide and ESLint applies one and drops the
169
- // other as overlapping.
170
- //
171
- // Latching it is not free, either. ESLint runs `fix()` for a
172
- // SUPPRESSED problem before the `eslint-disable` filter
173
- // discards it, so the first report could spend the flag and
174
- // then be thrown away — leaving later occurrences rewritten
175
- // to a `toForwardSlash` nothing imports.
176
- }
177
-
178
- return fixes;
179
- },
180
- });
181
- }
182
- }
183
- }
197
+ const match = matchSplitJoin(node) ?? matchReplace(node);
198
+ if (!match) return;
199
+ context.report({
200
+ node,
201
+ messageId: MESSAGE_FOR[match.fn],
202
+ data: { safeModule: targetModule },
203
+ fix(fixer) {
204
+ const receiver = sourceCode.getText(match.receiver);
205
+ return [fixer.replaceText(node, `${match.fn}(${receiver})`), ...importFix(fixer, match.fn)];
206
+ },
207
+ });
184
208
  },
185
209
  };
186
210
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-agent-toolkit/utils",
3
- "version": "0.2.0-rc.8",
3
+ "version": "0.2.0-rc.9",
4
4
  "type": "module",
5
5
  "description": "Core utility functions shared across the vibe-agent-toolkit packages",
6
6
  "sideEffects": [