@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
package/dist/fs-utils.js CHANGED
@@ -1,9 +1,20 @@
1
1
  /**
2
2
  * Filesystem utilities
3
3
  */
4
+ // ⚠️ The sync half is reached through the DEFAULT object (`nodeFs.existsSync`),
5
+ // not as named imports, and that is load-bearing rather than stylistic. Node
6
+ // snapshots a builtin's named ESM exports at import time, so `vi.spyOn(fs,
7
+ // 'existsSync')` cannot see a call made through a named binding — the spy
8
+ // attaches and counts zero, which reads exactly like "this function performs no
9
+ // I/O". `classifyFilenameCaseFrom` is guarded by precisely that assertion (it
10
+ // must reach neither `readdir` nor this pair), so a "tidy-up" back to named
11
+ // imports would silently disarm the guard. The async half below already uses the
12
+ // default object for the same reason.
13
+ import nodeFs from 'node:fs';
4
14
  import fs from 'node:fs/promises';
5
15
  import path from 'node:path';
6
- import { toForwardSlash } from './path-core.js';
16
+ import { promisify } from 'node:util';
17
+ import { toForwardSlash, toNfc } from './path-core.js';
7
18
  import { safePath } from './path-utils.js';
8
19
  /**
9
20
  * Per-run memo for the two filesystem lookups that validation repeats on values
@@ -21,11 +32,16 @@ import { safePath } from './path-utils.js';
21
32
  * arbitrarily long ago. The intended lifetime is one instance per validation run,
22
33
  * constructed as a local and collected with the run.
23
34
  *
35
+ * Fill first, then judge. The loop holds no `await`, because every listing the
36
+ * loop could have needed was already taken:
37
+ *
24
38
  * @example
25
39
  * ```typescript
26
40
  * const fsCache = new FsLookupCache(); // one per run
27
- * for (const link of links) {
28
- * await verifyCaseSensitiveFilename(link.target, fsCache);
41
+ * const targets = links.map((link) => link.target);
42
+ * const siblingNames = await fillSiblingNames(targets, fsCache); // all the I/O, once
43
+ * for (const target of targets) {
44
+ * classifyFilenameCaseFrom(siblingNames, target); // pure — no syscall
29
45
  * }
30
46
  * ```
31
47
  */
@@ -34,10 +50,137 @@ export class FsLookupCache {
34
50
  #listings = new Map();
35
51
  /** Path → its canonical path, falling back to the resolved path. */
36
52
  #realpaths = new Map();
53
+ /** Path → the existence/kind pair recorded the first time it was probed. */
54
+ #probes = new Map();
55
+ /** Probe calls received, and how many of them reached the filesystem. */
56
+ #probeCount = 0;
57
+ #probeMisses = 0;
58
+ /**
59
+ * Probe counters, for tests and `--debug` output.
60
+ *
61
+ * A memo whose tests never assert its hit count is theatre: every assertion
62
+ * about *values* still passes when the memo is disabled, because an
63
+ * always-miss cache returns the same answers — only more slowly. This is the
64
+ * one observable that dies when the memo does.
65
+ */
66
+ get probeStats() {
67
+ return { probes: this.#probeCount, misses: this.#probeMisses };
68
+ }
69
+ /**
70
+ * Does this path exist, and is it a directory — asked once per run.
71
+ *
72
+ * **Both syscalls are preserved, in order, exactly as an uncached caller
73
+ * would make them.** `existsSync` then `statSync` is not the same as one
74
+ * `statSync`: the pair distinguishes "absent" from "present but unstattable",
75
+ * and the link walker's classifier branches differently on each. Collapsing
76
+ * them would be a behaviour change wearing the shape of an optimization, so
77
+ * this method deduplicates the pair rather than replacing it.
78
+ *
79
+ * Synchronous, unlike this class's other two lookups, because its caller (the
80
+ * skill link-graph walker) is synchronous throughout. One oracle answering
81
+ * both shapes beats a second class that differs only in colour.
82
+ *
83
+ * @param targetPath - Path to probe
84
+ * @returns The recorded existence/kind pair
85
+ */
86
+ probe(targetPath) {
87
+ this.#probeCount++;
88
+ const cached = this.#probes.get(targetPath);
89
+ if (cached !== undefined)
90
+ return cached;
91
+ this.#probeMisses++;
92
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- caller-validated path
93
+ const exists = nodeFs.existsSync(targetPath);
94
+ let isDirectory = null;
95
+ if (exists) {
96
+ try {
97
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- caller-validated path
98
+ isDirectory = nodeFs.statSync(targetPath).isDirectory();
99
+ }
100
+ catch {
101
+ // Present to `existsSync` but unstattable. `null` records "no answer"
102
+ // rather than guessing `false`, which would read as "it is a file".
103
+ isDirectory = null;
104
+ }
105
+ }
106
+ const result = { exists, isDirectory };
107
+ this.#probes.set(targetPath, result);
108
+ return result;
109
+ }
37
110
  /**
38
- * Canonical path for `targetPath`, falling back to `safePath.resolve()` when the
39
- * path does not exist or cannot be resolved (a non-existent file has no realpath,
40
- * and callers comparing paths still need an answer).
111
+ * Canonical path for `targetPath`. A path that cannot be canonicalized is
112
+ * answered from its **deepest existing ancestor** that ancestor's realpath
113
+ * with the missing remainder re-appended because a non-existent file has no
114
+ * realpath and callers comparing paths still need an answer.
115
+ *
116
+ * ⚠️ **The fallback must stay in the same NAMESPACE as the success path, which
117
+ * a lexical `safePath.resolve()` is not.** The only consumer of this column
118
+ * compares one canonical path against another (`isWithinProject` /
119
+ * `isWithinProjectFrom`), so an answer resolved lexically is being compared
120
+ * against an answer resolved through symlinks. Where the root traverses a
121
+ * symlink — macOS `/tmp → /private/tmp`, bind mounts, a worktree under a
122
+ * symlinked path — the two spellings differ and the comparison is nonsense.
123
+ * Measured truth table for `isWithinProject(file, root)` under a `link → real`
124
+ * root, before the walk:
125
+ *
126
+ * ```text
127
+ * existing file, symlinked root : true
128
+ * MISSING file, symlinked root : false ← lexical fallback, wrong namespace
129
+ * MISSING file, plain root : true
130
+ * symlink inside pointing out : false (correct either way)
131
+ * ```
132
+ *
133
+ * The middle row is user-visible: a merely BROKEN root-absolute markdown link
134
+ * was reported as *escaping the project*. The walk fixes it without widening
135
+ * containment, because the ancestor is exactly where an escaping symlink
136
+ * lives — a missing file behind a directory link that points outside still
137
+ * canonicalizes outside.
138
+ *
139
+ * The recursion goes through `this.realpath(parent)`, not a private helper, so
140
+ * ancestors land in the same memo and share in-flight promises. A missing
141
+ * file's parent directory is almost always already cached, so the common case
142
+ * costs no extra syscall. **The fixpoint guard is mandatory**: `path.dirname`
143
+ * is idempotent at a root (`'/'` on posix, `'C:/'` for a drive, `'//server/share/'`
144
+ * for a UNC share), so without it the walk never terminates.
145
+ *
146
+ * Errno is deliberately not inspected. EACCES on an existing file and ELOOP on
147
+ * a symlink cycle land in the same catch as ENOENT, and for all three the
148
+ * ancestor's namespace is a strictly better answer than the lexical one.
149
+ *
150
+ * ⚠️ **`promisify(nodeFs.realpath)` — NOT `fs/promises.realpath`. Node ships two
151
+ * different realpaths and they do not agree.** `fs.realpathSync` and the
152
+ * `fs.realpath` *callback* form run Node's own JS implementation: an
153
+ * lstat/readlink walk that preserves the casing you asked for. `fs/promises.realpath`
154
+ * and `fs.realpath.native` call `uv_fs_realpath` (`realpath(3)` /
155
+ * `GetFinalPathNameByHandleW`), which reports the casing **on disk**. On a
156
+ * case-insensitive filesystem — macOS and Windows — those are different strings,
157
+ * and this column feeds *synchronous* judges that previously called
158
+ * `fs.realpathSync` themselves. A column that does not match `realpathSync` byte
159
+ * for byte flips containment verdicts and emits findings the un-refactored code
160
+ * does not. Measured, Node v24.13.1 / darwin, disk holding `<B>/Sub/Target.TXT`,
161
+ * asked for `<B>/sub/target.txt`:
162
+ *
163
+ * ```text
164
+ * realpathSync : <B>/sub/target.txt ← the contract
165
+ * promisify(fs.realpath) : <B>/sub/target.txt ✅ matches (this call)
166
+ * fs/promises.realpath : <B>/Sub/Target.TXT ❌ on-disk casing
167
+ * fs.realpath.native : <B>/Sub/Target.TXT ❌ on-disk casing
168
+ * ```
169
+ *
170
+ * They also disagree on `''`, where the sync form resolves to the cwd and the
171
+ * native form throws `ENOENT`. **Do not "modernize" this back to `fs/promises`** —
172
+ * it reads tidier and silently changes output. `packages/utils/test/fs-utils.test.ts`
173
+ * → *"answers a mis-cased path exactly as realpathSync does, not as the native
174
+ * resolver does"* pins the equivalence (and skips itself on a case-sensitive
175
+ * filesystem, where the two routes cannot be told apart).
176
+ *
177
+ * `promisify` is applied **per call, on the default object**, not once at module
178
+ * scope: an eagerly captured function bypasses any `vi.spyOn(nodeFs, 'realpath')`
179
+ * installed after import, so this method's I/O would count zero — indistinguishable
180
+ * from performing none. (`fs.realpath` carries no `util.promisify.custom`, so this
181
+ * promisification really does get the JS implementation; it is verified, not
182
+ * assumed — see *"routes canonicalization through the node:fs default object"*.)
183
+ * The wrapper is allocated only on a cache MISS, i.e. once per actual syscall.
41
184
  *
42
185
  * @param targetPath - Path to canonicalize
43
186
  * @returns Canonical path with forward slashes on every platform
@@ -48,14 +191,39 @@ export class FsLookupCache {
48
191
  return cached;
49
192
  // Stored before the first `await` anywhere can run, so concurrent callers
50
193
  // reaching this method share the one in-flight promise.
51
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- caller-validated path
52
- const pending = fs
53
- .realpath(targetPath)
194
+ //
195
+ // No `security/detect-non-literal-fs-filename` suppression here, unlike the
196
+ // sibling lookups: the rule matches a member call on an fs object, and the
197
+ // path is passed to the promisified wrapper instead. The path is
198
+ // caller-validated all the same.
199
+ const pending = promisify(nodeFs.realpath)(targetPath)
54
200
  .then(toForwardSlash)
55
- .catch(() => safePath.resolve(targetPath));
201
+ .catch(() => this.#canonicalizeViaAncestor(targetPath));
56
202
  this.#realpaths.set(targetPath, pending);
57
203
  return pending;
58
204
  }
205
+ /**
206
+ * The ancestor walk behind {@link FsLookupCache.realpath}'s fallback: canonicalize
207
+ * the parent — through the public method, so the memo and in-flight sharing
208
+ * apply — and re-append this path's own basename.
209
+ *
210
+ * Runs inside the already-stored promise's `.catch()`, which is what keeps the
211
+ * store-before-await property intact: the row for `targetPath` is in the map
212
+ * before any of this can start.
213
+ *
214
+ * @param targetPath - Path that could not be canonicalized
215
+ * @returns Canonical ancestor plus the missing remainder, forward-slashed
216
+ */
217
+ async #canonicalizeViaAncestor(targetPath) {
218
+ const absolutePath = safePath.resolve(targetPath);
219
+ const parent = toForwardSlash(path.dirname(absolutePath));
220
+ // Fixpoint at a filesystem root — `/`, `C:/`, `//server/share/` — where
221
+ // `dirname` returns its own input. Nothing left to walk, and no guard means
222
+ // no termination.
223
+ if (parent === absolutePath)
224
+ return absolutePath;
225
+ return safePath.join(await this.realpath(parent), path.basename(absolutePath));
226
+ }
59
227
  /**
60
228
  * Entry names of `dirPath`, or `null` when it cannot be read (missing directory,
61
229
  * no permission). The unreadable answer is cached too — re-asking is the same
@@ -100,61 +268,262 @@ export async function copyDirectory(src, dest) {
100
268
  }
101
269
  }
102
270
  /**
103
- * Verify that a file exists with the exact case-sensitive filename.
271
+ * List the parent directory of every path in `filePaths` — the only place I/O is
272
+ * legal for this fact, and the pass that must run *before* any judging.
104
273
  *
105
- * On case-insensitive filesystems (Windows, macOS), a file might be found even if
106
- * the case doesn't match. This function checks that the actual filename on disk
107
- * matches the requested path exactly (case-sensitive).
274
+ * ⚠️ **It takes FILE paths, not directory paths, deliberately.** It derives each
275
+ * parent with `path.dirname` itself, so exactly one function in the system owns
276
+ * the key derivation and a caller cannot construct a key that
277
+ * {@link siblingNamesFrom} then misses. Do not "simplify" this to take
278
+ * directories: that hands the derivation back to every call site and reopens the
279
+ * silent-miss class this shape closes.
108
280
  *
109
- * Answering requires listing the target's parent directory. Callers checking many
110
- * paths (every link in a corpus) hit the same handful of directories over and over,
111
- * so the listing comes from a caller-supplied {@link FsLookupCache}.
281
+ * Distinct parents are listed **concurrently**: the shape this replaced asked one
282
+ * link at a time at judgement time, which serialised every `readdir` behind the
283
+ * previous link's `await`. De-duplication is by parent, so N files in one
284
+ * directory cost one listing; the listing itself goes through
285
+ * {@link FsLookupCache.readdir}, which memoizes and shares in-flight promises
286
+ * across fills.
112
287
  *
113
- * The cache parameter is **required rather than defaulted on purpose**: a default
114
- * would let an unmigrated call site silently keep the un-memoized behaviour, which
115
- * is a no-op wearing the shape of a fix. `new FsLookupCache()` per call reproduces
116
- * the old behaviour exactly, so migrating is mechanical — but it has to be a
117
- * decision someone made.
118
- *
119
- * @param filePath - Absolute path to the file to verify
288
+ * @param filePaths - File paths whose parent directories should be listed
120
289
  * @param fsCache - Per-run lookup cache (one instance per validation run)
121
- * @returns Object with exists flag and actual filename (or null if not found)
290
+ * @returns The filled table; empty input yields an empty table with no syscalls
291
+ */
292
+ export async function fillSiblingNames(filePaths, fsCache) {
293
+ const parentDirs = new Set();
294
+ for (const filePath of filePaths) {
295
+ parentDirs.add(path.dirname(filePath));
296
+ }
297
+ const table = new Map();
298
+ await Promise.all([...parentDirs].map(async (parentDir) => {
299
+ // Stored EXACTLY as `readdir` returned it — no Unicode folding, no copy.
300
+ //
301
+ // The fill used to fold every entry to NFC here (and `siblingNamesFrom`
302
+ // folded `expectedName` to match), which left the judge a pure `===` over
303
+ // pre-reconciled strings. It also destroyed the only evidence that could
304
+ // distinguish "these two spellings are the same bytes" from "these two
305
+ // spellings are equal only after folding" — and those are different facts
306
+ // on a byte-exact filesystem. Comparison semantics now live entirely in
307
+ // {@link classifyFilenameCase}, so the fill has no opinion to disagree
308
+ // with, and a hand-written row is raw `readdir` output rather than a form
309
+ // only the fill knew how to produce.
310
+ //
311
+ // The array is the cache's own and is deliberately not copied: the table
312
+ // type is `readonly string[]`, several tables may share one listing, and
313
+ // copying every listing per fill is exactly the per-run cost this pair
314
+ // exists to avoid. Treat it as immutable.
315
+ table.set(parentDir, await fsCache.readdir(parentDir));
316
+ }));
317
+ return table;
318
+ }
319
+ /**
320
+ * Read the row for `filePath` out of an already-filled table. Pure.
122
321
  *
123
- * @example
124
- * ```typescript
125
- * // On case-insensitive filesystem with file "README.md"
126
- * const fsCache = new FsLookupCache();
127
- * const result1 = await verifyCaseSensitiveFilename('/project/README.md', fsCache);
128
- * // { exists: true, actualName: 'README.md' }
322
+ * **A miss throws rather than degrading to `names: null`.** The fill set is
323
+ * derived from exactly the paths the judge will be asked about, so a missing
324
+ * parent is a programming error a path judged that nobody filled. The `null`
325
+ * fallback would answer it as "the directory is unreadable", which reports every
326
+ * file under that directory as *missing*: a wrong answer wearing the shape of a
327
+ * graceful degradation, and one no test of the verdict would catch.
129
328
  *
130
- * const result2 = await verifyCaseSensitiveFilename('/project/readme.md', fsCache);
131
- * // { exists: false, actualName: 'README.md' } - case mismatch!
132
- * ```
329
+ * Internal on purpose {@link classifyFilenameCaseFrom} is the public judge.
330
+ *
331
+ * @param table - Table filled by {@link fillSiblingNames}
332
+ * @param filePath - Path being asked about
333
+ * @returns The row: the expected basename plus the parent's entries
334
+ * @throws If `table` holds no entry for the path's parent directory
133
335
  */
134
- export async function verifyCaseSensitiveFilename(filePath, fsCache) {
135
- // Get parent directory and expected filename
336
+ export function siblingNamesFrom(table, filePath) {
136
337
  const parentDir = path.dirname(filePath);
137
- const expectedName = path.basename(filePath);
138
- // Read actual directory entries
139
- const entries = await fsCache.readdir(parentDir);
140
- if (entries === null) {
141
- // Parent directory doesn't exist (or can't be read)
142
- return { exists: false, actualName: null };
338
+ // `undefined` can only mean "absent key": a filled entry is an array or an
339
+ // explicit `null`, never `undefined`.
340
+ const names = table.get(parentDir);
341
+ if (names === undefined) {
342
+ throw new Error(`No sibling listing for directory "${parentDir}" (asked about "${filePath}"). ` +
343
+ `Fill it with fillSiblingNames() before judging.`);
344
+ }
345
+ // Raw on this side too. This lookup reads a row; it does not judge, and
346
+ // folding here would be judging — see {@link classifyFilenameCase}, which
347
+ // needs the spelling the caller actually asked for in order to tell a
348
+ // byte-exact hit from a fold-only one.
349
+ return { expectedName: path.basename(filePath), names };
350
+ }
351
+ /**
352
+ * Decide whether `row.expectedName` names a real entry, and how faithfully.
353
+ *
354
+ * Pure: no filesystem, no cache, no path parsing — it reads only the columns it
355
+ * is handed, which is what makes hand-written listings a legitimate test input.
356
+ * Both columns arrive **raw**, exactly as `readdir` and `path.basename` produced
357
+ * them; this function owns every comparison rule, so nothing upstream can
358
+ * disagree with it about what "the same filename" means.
359
+ *
360
+ * **Three passes, strictly in this order, first match wins — the order IS the
361
+ * contract**, because each pass accepts a strictly weaker notion of sameness and
362
+ * a weaker pass reached first would mislabel a file that is genuinely there:
363
+ *
364
+ * 1. **byte-exact** `entry === expectedName`. On a case-insensitive filesystem a
365
+ * listing can hold both `readme.md` and `README.md`, in either order; asking
366
+ * for `README.md` must report it present regardless of which one `readdir`
367
+ * happened to return first. Same argument, one form weaker, for pass 2.
368
+ * 2. **NFC-folded** `toNfc(entry) === toNfc(expectedName)`. `é` has two encodings
369
+ * (NFC `U+00E9` vs NFD `e` + `U+0301`) that are `!==` and that case-folding
370
+ * does not reconcile, so without this pass an accented file that plainly
371
+ * exists was reported flatly *missing* — not even a case-mismatch hint, since
372
+ * that needs pass 3 to match (ledger entry D7).
373
+ * 3. **case-insensitive, on the folded forms.** Folding first is required, not
374
+ * tidy: `toLowerCase()` does not reconcile NFC against NFD, so a name that
375
+ * differs in *both* case and normalization falls out as `absent` and the
376
+ * author loses the suggestion.
377
+ *
378
+ * ⚠️ **Passes 1 and 2 are not the same verdict, and collapsing them is a
379
+ * silently-wrong answer rather than a lost nicety.** The fix for D7 originally
380
+ * folded both sides *before* comparing, which repaired the false "missing" on
381
+ * macOS/APFS — and over-corrected into the opposite error on Linux/ext4, where
382
+ * the filesystem is byte-exact: a markdown link spelling a filename NFD while
383
+ * disk holds NFC genuinely 404s there, and the folded judge answered "exists,
384
+ * exact match, no issue". `match` is what keeps both facts: the link resolves
385
+ * (so it must not be reported broken), *and* it resolves only by folding (so a
386
+ * caller can warn). {@link classifyFilenameCaseFrom}'s consumer in
387
+ * `@vibe-agent-toolkit/resources` turns `'normalized'` into
388
+ * `LINK_NORMALIZATION_MISMATCH`.
389
+ *
390
+ * **Folding is deferred to the miss path, and that is a real saving.** Pass 1
391
+ * calls `toNfc` zero times, so a corpus whose links all resolve byte-exactly —
392
+ * every pure-ASCII corpus, i.e. nearly all of them — normalizes nothing at all.
393
+ * The older shape folded every entry of every directory in the fill,
394
+ * unconditionally.
395
+ *
396
+ * @param row - The listing row, read out of a filled table by {@link siblingNamesFrom}
397
+ * @returns The verdict: whether it resolves, the entry really on disk, and which pass matched
398
+ */
399
+ export function classifyFilenameCase(row) {
400
+ const { expectedName, names } = row;
401
+ if (names === null) {
402
+ // Parent directory doesn't exist (or can't be read).
403
+ return { exists: false, actualName: null, match: 'absent' };
404
+ }
405
+ // Pass 1 — byte-exact.
406
+ // Tested against `undefined` rather than for truthiness: `readdir` never
407
+ // yields an empty entry name, but hand-written rows are this function's
408
+ // advertised input now that it is pure, and `''` is falsy — it would fall
409
+ // through to a later pass and come back as `actualName: ''` with the wrong
410
+ // `match`.
411
+ const exactMatch = names.find(entry => entry === expectedName);
412
+ if (exactMatch !== undefined) {
413
+ return { exists: true, actualName: exactMatch, match: 'exact' };
143
414
  }
144
- // Find the actual filename (case-sensitive exact match)
145
- const exactMatch = entries.find(entry => entry === expectedName);
146
- if (exactMatch) {
147
- // Found exact case match - file exists with correct case
148
- return { exists: true, actualName: exactMatch };
415
+ // Pass 2 equal only after NFC folding. Reached only when pass 1 missed, so
416
+ // an all-ASCII corpus never pays for it.
417
+ const foldedExpected = toNfc(expectedName);
418
+ const normalizedMatch = names.find(entry => toNfc(entry) === foldedExpected);
419
+ if (normalizedMatch !== undefined) {
420
+ return { exists: true, actualName: normalizedMatch, match: 'normalized' };
421
+ }
422
+ // Pass 3 — case-insensitive over the folded forms.
423
+ const loweredExpected = foldedExpected.toLowerCase();
424
+ const caseInsensitiveMatch = names.find(entry => toNfc(entry).toLowerCase() === loweredExpected);
425
+ return caseInsensitiveMatch === undefined
426
+ ? { exists: false, actualName: null, match: 'absent' }
427
+ : { exists: false, actualName: caseInsensitiveMatch, match: 'case_mismatch' };
428
+ }
429
+ /**
430
+ * Judge `filePath` against an already-filled {@link SiblingNamesTable}.
431
+ *
432
+ * This is the judging half of the two-pass shape: {@link fillSiblingNames} does
433
+ * every listing first, then this runs over as many paths as you like with no
434
+ * interleaved I/O.
435
+ *
436
+ * **The signature is not what keeps this free of I/O — a test is.** `fs-utils.ts`
437
+ * imports `node:fs` and `node:fs/promises` at module scope, so this function's
438
+ * module reaches the filesystem freely; taking no {@link FsLookupCache} and no
439
+ * `fs` parameter constrains a future edit not at all, which could call
440
+ * `nodeFs.statSync` on the next line and still typecheck. What actually holds the
441
+ * property is `packages/utils/test/fs-utils.test.ts` →
442
+ * *"judges from a filled table, reaching neither readdir nor the sync stat pair"*:
443
+ * it spies `fs.readdir`, `nodeFs.existsSync` and `nodeFs.statSync` on the very
444
+ * default objects this module imports, drives a positive control through each so
445
+ * a zero cannot mean "the instrument never attached", and asserts the counts do
446
+ * not move across judgement. If a future check needs another fact about the parent
447
+ * directory, widen the *table* rather than reaching for `fs` here — and expect
448
+ * that test, not this signature, to be what stops you.
449
+ *
450
+ * @param table - Table filled by {@link fillSiblingNames}
451
+ * @param filePath - Absolute path to judge
452
+ * @returns The verdict — see {@link FilenameCaseVerdict}
453
+ * @throws If `table` holds no entry for the path's parent directory — see
454
+ * {@link siblingNamesFrom}
455
+ */
456
+ export function classifyFilenameCaseFrom(table, filePath) {
457
+ return classifyFilenameCase(siblingNamesFrom(table, filePath));
458
+ }
459
+ /**
460
+ * Canonicalize every path in `paths` — the only place I/O is legal for this
461
+ * fact, and the pass that must run *before* any judging.
462
+ *
463
+ * ⚠️ **Rows are keyed by the input path string exactly as given** — not a
464
+ * dirname, not a re-resolved form. {@link realpathFrom} looks that same string
465
+ * up, so any normalization applied here and not there is a silent miss (a loud
466
+ * one, in fact: the judge throws). Contrast {@link fillSiblingNames}, which keys
467
+ * by `path.dirname` *because* many files share one listing; here the answer is
468
+ * per path, so the path is the key.
469
+ *
470
+ * Distinct paths are canonicalized **concurrently**: the shape this replaces
471
+ * asked one path at a time at judgement time, which serialised every `realpath`
472
+ * behind the previous path's `await`. De-duplication is by path, so the same
473
+ * path passed N times costs one syscall; the call itself goes through
474
+ * {@link FsLookupCache.realpath}, which memoizes and shares in-flight promises
475
+ * across fills.
476
+ *
477
+ * @param paths - Paths to canonicalize
478
+ * @param fsCache - Per-run lookup cache (one instance per validation run)
479
+ * @returns The filled table; empty input yields an empty table with no syscalls
480
+ */
481
+ export async function fillRealpaths(paths, fsCache) {
482
+ const distinctPaths = new Set(paths);
483
+ const table = new Map();
484
+ await Promise.all([...distinctPaths].map(async (filePath) => {
485
+ table.set(filePath, await fsCache.realpath(filePath));
486
+ }));
487
+ return table;
488
+ }
489
+ /**
490
+ * Read the canonical path for `filePath` out of an already-filled table. Pure.
491
+ *
492
+ * **A miss throws rather than degrading to a recomputed realpath.** The fill set
493
+ * is derived from exactly the paths the judge will be asked about, so a missing
494
+ * key is a programming error — a path judged that nobody filled. Recomputing it
495
+ * would answer *correctly* and silently reintroduce the per-path syscall this
496
+ * column exists to remove: a regression no test of the verdict could catch,
497
+ * because the verdict would be identical, only slower.
498
+ *
499
+ * Public, unlike {@link siblingNamesFrom}: a sibling-names row is not yet an
500
+ * answer (it still needs {@link classifyFilenameCase}), whereas here the row IS
501
+ * the answer — so this lookup is itself the judge for this column, and there is
502
+ * nothing left to keep internal.
503
+ *
504
+ * **The signature is not what keeps this free of I/O — a test is.** As with
505
+ * {@link classifyFilenameCaseFrom}, this module imports `node:fs` and
506
+ * `node:fs/promises` at module scope, so withholding a {@link FsLookupCache} from
507
+ * the parameter list prevents nothing. The guard is
508
+ * `packages/utils/test/fs-utils.test.ts` → *"judges from a filled table, reaching
509
+ * neither the async nor the sync realpath"*, which spies `nodeFs.realpath` and
510
+ * `nodeFs.realpathSync` on the module default objects, proves both instruments
511
+ * attached with a positive control, and asserts zero calls across judgement.
512
+ *
513
+ * @param table - Table filled by {@link fillRealpaths}
514
+ * @param filePath - Path being asked about, as it was handed to the fill
515
+ * @returns The canonical path, with forward slashes on every platform
516
+ * @throws If `table` holds no row for `filePath`
517
+ */
518
+ export function realpathFrom(table, filePath) {
519
+ // `undefined` can only mean "absent key": a filled row is always a string,
520
+ // because `FsLookupCache.realpath` falls back to the deepest existing
521
+ // ancestor's canonical path rather than leaving an unresolvable path without
522
+ // an answer.
523
+ const realPath = table.get(filePath);
524
+ if (realPath === undefined) {
525
+ throw new Error(`No canonical path for "${filePath}". Fill it with fillRealpaths() before judging.`);
149
526
  }
150
- // No exact match - check for case-insensitive match
151
- const caseInsensitiveMatch = entries.find(entry => entry.toLowerCase() === expectedName.toLowerCase());
152
- // Return result:
153
- // - If case-insensitive match found: exists=false (wrong case), actualName=<actual>
154
- // - If no match at all: exists=false, actualName=null
155
- return {
156
- exists: false,
157
- actualName: caseInsensitiveMatch ?? null,
158
- };
527
+ return realPath;
159
528
  }
160
529
  //# sourceMappingURL=fs-utils.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"fs-utils.js","sourceRoot":"","sources":["../src/fs-utils.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"fs-utils.js","sourceRoot":"","sources":["../src/fs-utils.ts"],"names":[],"mappings":"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"}
package/dist/fs.d.ts CHANGED
@@ -8,5 +8,6 @@
8
8
  * `@vibe-agent-toolkit/utils/path` instead — it is dramatically cheaper.
9
9
  */
10
10
  export { normalizePath, normalizedTmpdir, mkdirSyncReal, resolveFromImportMeta, dynamicImportPath, } from './path-utils.js';
11
- export { copyDirectory, FsLookupCache, verifyCaseSensitiveFilename } from './fs-utils.js';
11
+ export { classifyFilenameCaseFrom, copyDirectory, fillRealpaths, fillSiblingNames, FsLookupCache, realpathFrom, } from './fs-utils.js';
12
+ export type { FilenameCaseVerdict, FilenameMatch, PathProbe, PathProbeStats, RealpathTable, SiblingNamesTable, } from './fs-utils.js';
12
13
  //# sourceMappingURL=fs.d.ts.map
package/dist/fs.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"fs.d.ts","sourceRoot":"","sources":["../src/fs.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EACL,aAAa,EACb,gBAAgB,EAChB,aAAa,EACb,qBAAqB,EACrB,iBAAiB,GAClB,MAAM,iBAAiB,CAAC;AAEzB,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,2BAA2B,EAAE,MAAM,eAAe,CAAC"}
1
+ {"version":3,"file":"fs.d.ts","sourceRoot":"","sources":["../src/fs.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EACL,aAAa,EACb,gBAAgB,EAChB,aAAa,EACb,qBAAqB,EACrB,iBAAiB,GAClB,MAAM,iBAAiB,CAAC;AAQzB,OAAO,EACL,wBAAwB,EACxB,aAAa,EACb,aAAa,EACb,gBAAgB,EAChB,aAAa,EACb,YAAY,GACb,MAAM,eAAe,CAAC;AACvB,YAAY,EACV,mBAAmB,EACnB,aAAa,EACb,SAAS,EACT,cAAc,EACd,aAAa,EACb,iBAAiB,GAClB,MAAM,eAAe,CAAC"}
package/dist/fs.js CHANGED
@@ -8,5 +8,11 @@
8
8
  * `@vibe-agent-toolkit/utils/path` instead — it is dramatically cheaper.
9
9
  */
10
10
  export { normalizePath, normalizedTmpdir, mkdirSyncReal, resolveFromImportMeta, dynamicImportPath, } from './path-utils.js';
11
- export { copyDirectory, FsLookupCache, verifyCaseSensitiveFilename } from './fs-utils.js';
11
+ // Two materialized columns, each a fill+judge pair and nothing else. For sibling
12
+ // names the judge is `classifyFilenameCaseFrom`, and the internal
13
+ // `classifyFilenameCase`/`siblingNamesFrom` members plus the `SiblingNames` row
14
+ // type they trade in stay module-local — a row there is not yet an answer. For
15
+ // realpaths the row IS the answer, so the row lookup `realpathFrom` is itself the
16
+ // judge and is exported. See `index.ts` for the full reasoning.
17
+ export { classifyFilenameCaseFrom, copyDirectory, fillRealpaths, fillSiblingNames, FsLookupCache, realpathFrom, } from './fs-utils.js';
12
18
  //# sourceMappingURL=fs.js.map
package/dist/fs.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"fs.js","sourceRoot":"","sources":["../src/fs.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EACL,aAAa,EACb,gBAAgB,EAChB,aAAa,EACb,qBAAqB,EACrB,iBAAiB,GAClB,MAAM,iBAAiB,CAAC;AAEzB,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,2BAA2B,EAAE,MAAM,eAAe,CAAC"}
1
+ {"version":3,"file":"fs.js","sourceRoot":"","sources":["../src/fs.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EACL,aAAa,EACb,gBAAgB,EAChB,aAAa,EACb,qBAAqB,EACrB,iBAAiB,GAClB,MAAM,iBAAiB,CAAC;AAEzB,iFAAiF;AACjF,kEAAkE;AAClE,gFAAgF;AAChF,+EAA+E;AAC/E,kFAAkF;AAClF,gEAAgE;AAChE,OAAO,EACL,wBAAwB,EACxB,aAAa,EACb,aAAa,EACb,gBAAgB,EAChB,aAAa,EACb,YAAY,GACb,MAAM,eAAe,CAAC"}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The module-level memo behind `gitFindRoot`, in its own leaf module.
3
+ *
4
+ * Two modules need it and they must not need each other:
5
+ *
6
+ * - `git-utils.ts` reads and writes it (it owns the walk).
7
+ * - `project-utils.ts` clears it from `resetProjectRootCaches()`, so callers
8
+ * have ONE reset to remember rather than one per walk-up cache.
9
+ *
10
+ * Having `project-utils.ts` import `git-utils.ts` for that would drag `which`
11
+ * and `node:child_process` into the `./project` entry, which exists precisely so
12
+ * root discovery costs no third-party packages to reach. This file imports
13
+ * nothing, so both sides can depend on it.
14
+ *
15
+ * Deliberately not re-exported from `index.ts` or any subpath: the memo is an
16
+ * implementation detail of `gitFindRoot`, and a second public reset name is a
17
+ * reset callers forget to call.
18
+ */
19
+ /**
20
+ * Look up a memoized git root.
21
+ *
22
+ * @param dir - Resolved directory to look up
23
+ * @returns The memoized answer (possibly `null`), or `undefined` if unknown
24
+ */
25
+ export declare function lookupGitRoot(dir: string): string | null | undefined;
26
+ /**
27
+ * Record `gitRoot` as the answer for every directory a walk climbed through.
28
+ *
29
+ * @param climbed - Directories visited by the walk, deepest first
30
+ * @param gitRoot - The answer they all share
31
+ * @returns `gitRoot`, so a walk can `return rememberGitRoot(...)`
32
+ */
33
+ export declare function rememberGitRoot(climbed: readonly string[], gitRoot: string | null): string | null;
34
+ /**
35
+ * Drop every memoized git root.
36
+ *
37
+ * Not called directly by application code — `resetProjectRootCaches()` calls it,
38
+ * so one reset invalidates both walk-up caches. Necessary because a `.git`
39
+ * directory can appear or vanish while the process lives (tests build fixtures
40
+ * mid-run; a long-lived host re-enters `vat audit` between edits), and a stale
41
+ * entry would outlive the change.
42
+ */
43
+ export declare function resetGitRootCache(): void;
44
+ //# sourceMappingURL=git-root-cache.d.ts.map