pi-lens 3.8.70 → 3.8.71

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 (90) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +16 -0
  3. package/dist/clients/bus-events-logger.js +10 -8
  4. package/dist/clients/cache/rule-cache.js +9 -5
  5. package/dist/clients/cache-manager.js +19 -13
  6. package/dist/clients/diagnostic-dispositions.js +298 -0
  7. package/dist/clients/dispatch/auxiliary-lsp.js +85 -3
  8. package/dist/clients/dispatch/dispatcher.js +6 -1
  9. package/dist/clients/dispatch/integration.js +19 -4
  10. package/dist/clients/dispatch/runners/lsp.js +5 -32
  11. package/dist/clients/dispatch/runners/oxlint.js +1 -1
  12. package/dist/clients/dispatch/suppress-writer.js +68 -0
  13. package/dist/clients/dispatch/utils/lsp-diagnostics.js +4 -1
  14. package/dist/clients/disposition-logger.js +39 -0
  15. package/dist/clients/disposition-publish.js +109 -0
  16. package/dist/clients/file-utils.js +94 -33
  17. package/dist/clients/format-events-publish.js +299 -0
  18. package/dist/clients/git-tracked-ignore.js +208 -0
  19. package/dist/clients/instance-registry.js +74 -4
  20. package/dist/clients/json-cache-read.js +65 -0
  21. package/dist/clients/language-profile.js +4 -0
  22. package/dist/clients/lens-map.js +1079 -0
  23. package/dist/clients/lsp/aggregation.js +69 -2
  24. package/dist/clients/lsp/client.js +72 -7
  25. package/dist/clients/lsp/index.js +486 -14
  26. package/dist/clients/lsp/server.js +17 -1
  27. package/dist/clients/lsp/tsserver-sync.js +145 -0
  28. package/dist/clients/lsp/workspace-diagnostics-cache.js +172 -0
  29. package/dist/clients/mcp/session.js +8 -0
  30. package/dist/clients/opengrep-config.js +2 -10
  31. package/dist/clients/path-utils.js +26 -0
  32. package/dist/clients/pipeline.js +4 -0
  33. package/dist/clients/project-diagnostics/cache.js +5 -12
  34. package/dist/clients/project-snapshot.js +3 -6
  35. package/dist/clients/read-guard.js +1 -1
  36. package/dist/clients/review-graph/builder.js +78 -52
  37. package/dist/clients/review-graph/import-resolvers.js +53 -19
  38. package/dist/clients/runtime-agent-end.js +26 -0
  39. package/dist/clients/runtime-coordinator.js +9 -1
  40. package/dist/clients/runtime-session.js +53 -11
  41. package/dist/clients/runtime-tool-call.js +789 -0
  42. package/dist/clients/runtime-tool-result.js +15 -2
  43. package/dist/clients/runtime-turn.js +9 -2
  44. package/dist/clients/session-state-store.js +6 -9
  45. package/dist/clients/sgconfig.js +2 -9
  46. package/dist/clients/source-filter.js +5 -0
  47. package/dist/clients/startup-scan.js +68 -1
  48. package/dist/clients/typos-config.js +2 -11
  49. package/dist/clients/widget-state.js +22 -0
  50. package/dist/clients/zizmor-config.js +2 -10
  51. package/dist/index.js +21089 -18685
  52. package/dist/tools/lens-diagnostic-mark.js +158 -0
  53. package/dist/tools/lens-diagnostics.js +41 -7
  54. package/dist/tools/lsp-diagnostics.js +99 -119
  55. package/docs/agent-tools.md +11 -4
  56. package/docs/dispositions.md +85 -0
  57. package/docs/environment-variables.md +16 -0
  58. package/docs/fable.md +10 -9
  59. package/docs/servercapabilities.md +1 -1
  60. package/docs/tools.md +3 -0
  61. package/package.json +6 -1
  62. package/rules/ast-grep-rules/rule-tests/find-import-file-without-extension-test.yml +4 -0
  63. package/rules/ast-grep-rules/rule-tests/no-javascript-url-js-test.yml +5 -0
  64. package/rules/ast-grep-rules/rule-tests/no-javascript-url-test.yml +9 -0
  65. package/rules/ast-grep-rules/rules/find-import-file-without-extension.yml +3 -1
  66. package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +12 -0
  67. package/rules/ast-grep-rules/rules/no-javascript-url.yml +12 -0
  68. package/docs/agent-tooling-improvement-plan.md +0 -660
  69. package/docs/inspiration_gograph.md +0 -601
  70. package/docs/inspiration_neovim.md +0 -1795
  71. package/docs/inspiration_neovim2.md +0 -402
  72. package/docs/inspiration_tree_sitter_rules.md +0 -369
  73. package/docs/inspiration_tree_sitter_rules_investigation.md +0 -328
  74. package/docs/inspiration_tree_sitter_rules_round2.md +0 -470
  75. package/docs/lsp-bridge-analysis.md +0 -444
  76. package/docs/newinspiration.md +0 -338
  77. package/grammars/tree-sitter-c.wasm +0 -0
  78. package/grammars/tree-sitter-c_sharp.wasm +0 -0
  79. package/grammars/tree-sitter-cpp.wasm +0 -0
  80. package/grammars/tree-sitter-dart.wasm +0 -0
  81. package/grammars/tree-sitter-elixir.wasm +0 -0
  82. package/grammars/tree-sitter-kotlin.wasm +0 -0
  83. package/grammars/tree-sitter-lua.wasm +0 -0
  84. package/grammars/tree-sitter-ocaml.wasm +0 -0
  85. package/grammars/tree-sitter-php.wasm +0 -0
  86. package/grammars/tree-sitter-ruby.wasm +0 -0
  87. package/grammars/tree-sitter-swift.wasm +0 -0
  88. package/grammars/tree-sitter-toml.wasm +0 -0
  89. package/grammars/tree-sitter-vue.wasm +0 -0
  90. package/grammars/tree-sitter-zig.wasm +0 -0
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Shared "untracked AND ignored" file-id computation (#679's `/lens-map`,
3
+ * reused by #694's review-graph ignore-gated node creation) — and, since
4
+ * #703, the sibling "tracked files" set that lets `getProjectIgnoreMatcher`
5
+ * (`file-utils.ts`) honor the OTHER half of the same git semantic.
6
+ *
7
+ * THE critical git semantic this exists to respect: a TRACKED file is never
8
+ * ignored, even when a `.gitignore` pattern matches it (pi-lens's own
9
+ * committed `clients/deps/*.js` vendored sources match the repo's `*.js`
10
+ * ignore pattern and MUST stay graph/map nodes) — which is why this asks git
11
+ * itself (`ls-files --others --ignored --exclude-standard` for the untracked
12
+ * side, `ls-files` alone for the tracked side) instead of running a pattern
13
+ * matcher over `.gitignore` (a matcher-only approach would wrongly drop
14
+ * tracked vendored files, e.g. #703's `clients/test-runner-client.ts`
15
+ * matching `.gitignore`'s `test-*.ts`).
16
+ *
17
+ * Degradation: when git is absent/fails/times out (not a git repo, bare
18
+ * checkout, etc.) both `collectUntrackedIgnoredIds` and `collectTrackedFiles`
19
+ * return `undefined` and every caller SKIPS/degrades — the graph/map shows
20
+ * what's known rather than guessing via a matcher that can't see tracked
21
+ * status, and `getProjectIgnoreMatcher` falls back to pattern-only behavior.
22
+ *
23
+ * Keying asymmetry (deliberate, not an oversight): `collectUntrackedIgnoredIds`
24
+ * keys its ids with `normalizeMapKey` (realpath-backed) because those ids are
25
+ * compared against review-graph/map NODE ids — externally-produced state that
26
+ * genuinely needs canonical on-disk casing/symlink resolution to match up.
27
+ * `collectTrackedFiles`'s ids, by contrast, are only ever compared against a
28
+ * `ProjectIgnoreMatcher`'s own `path.resolve`'d paths, produced by THIS
29
+ * process in the SAME walk — an ephemeral, self-consistent comparison per
30
+ * `normalizeEphemeralMapKey`'s contract (`path-utils.ts`), so it uses the
31
+ * cheap syntactic-only fold instead. Do not "fix" this asymmetry by unifying
32
+ * the two — they solve different problems (#703 review).
33
+ */
34
+ import * as path from "node:path";
35
+ import { isExcludedDirName } from "./file-utils.js";
36
+ import { normalizeEphemeralMapKey, normalizeMapKey } from "./path-utils.js";
37
+ import { safeSpawnAsync } from "./safe-spawn.js";
38
+ /**
39
+ * Parses `git ls-files --others --ignored --exclude-standard` output
40
+ * (repo-relative paths, one per line) into normalized map-key file ids.
41
+ * Exported as a pure function so the parse/normalize step is unit-testable
42
+ * without mocking the spawn.
43
+ */
44
+ export function parseUntrackedIgnoredOutput(stdout, cwd) {
45
+ const ids = new Set();
46
+ for (const line of stdout.split(/\r?\n/)) {
47
+ const rel = line.trim();
48
+ if (!rel)
49
+ continue;
50
+ // Paths inside shared-excluded dirs (node_modules, dist, .git, …) can
51
+ // never be review-graph/map nodes — the graph walk itself routes
52
+ // exclusion through `isExcludedDirName` — so skip them BEFORE paying for
53
+ // `normalizeMapKey` (realpath-backed, per-call filesystem cost): on
54
+ // pi-lens itself this prunes a 66k-line ignored list (node_modules) down
55
+ // to ~1.6k paths that actually need normalizing.
56
+ const dirSegments = rel.split("/").slice(0, -1);
57
+ if (dirSegments.some((segment) => isExcludedDirName(segment)))
58
+ continue;
59
+ ids.add(normalizeMapKey(path.join(cwd, rel)));
60
+ }
61
+ return ids;
62
+ }
63
+ async function fetchUntrackedIgnoredIds(cwd) {
64
+ try {
65
+ const result = await safeSpawnAsync("git", ["ls-files", "--others", "--ignored", "--exclude-standard"], { cwd, timeout: 10_000, resourceLabel: "git-tracked-ignore" });
66
+ if (result.error || result.status !== 0)
67
+ return undefined;
68
+ return parseUntrackedIgnoredOutput(result.stdout, cwd);
69
+ }
70
+ catch {
71
+ return undefined;
72
+ }
73
+ }
74
+ // #694: the review-graph build calls this on EVERY incremental/cascade
75
+ // rebuild (a hot, per-edit path), not just `/lens-map`'s one-shot generation —
76
+ // spawning `git ls-files` per edit would be a real per-keystroke cost. Memoize
77
+ // per cwd with a short time bound: cheap enough that a `.gitignore` edit or a
78
+ // newly-untracked file takes effect within a few seconds, but a burst of
79
+ // per-edit rebuilds within that window shares one spawn.
80
+ const CACHE_TTL_MS = 30_000;
81
+ const _cache = new Map();
82
+ /**
83
+ * The untracked-AND-ignored id set for `cwd`, memoized per-process with a
84
+ * {@link CACHE_TTL_MS} time bound so a hot rebuild loop (review-graph
85
+ * incremental/cascade builds) never spawns `git` per file/per edit — see the
86
+ * module doc for the tracked-file semantic and the degrade-to-undefined
87
+ * contract.
88
+ */
89
+ export function collectUntrackedIgnoredIds(cwd) {
90
+ const key = normalizeMapKey(cwd);
91
+ const now = Date.now();
92
+ const cached = _cache.get(key);
93
+ if (cached && now - cached.fetchedAtMs < CACHE_TTL_MS)
94
+ return cached.promise;
95
+ const promise = fetchUntrackedIgnoredIds(cwd);
96
+ _cache.set(key, { promise, fetchedAtMs: now });
97
+ return promise;
98
+ }
99
+ /** Test hook: drop the memoized cache so a test's fake git state is re-read. */
100
+ export function _resetUntrackedIgnoredCacheForTests() {
101
+ _cache.clear();
102
+ }
103
+ /**
104
+ * Parses `git ls-files` output (repo-relative paths, one per line, tracked
105
+ * files only — no `--others`) into normalized ids, keyed for comparison
106
+ * against a `ProjectIgnoreMatcher`'s own (non-realpath'd) `path.resolve`'d
107
+ * paths — NOT against {@link parseUntrackedIgnoredOutput}'s ids, which are
108
+ * realpath-keyed for a different consumer (review-graph/map node ids). The
109
+ * two sets are NOT directly comparable/mergeable despite the shared
110
+ * dir-exclusion-prune shape — see this file's module doc for the asymmetry.
111
+ *
112
+ * `cwd` is realpath'd exactly ONCE here (not per file) to reconcile the one
113
+ * divergence that matters in practice: on Windows, the matcher's
114
+ * `resolvedRoot` comes from `path.resolve` while this fetch's `cwd` may carry
115
+ * different on-disk casing (e.g. `c:\users` vs `C:\Users`) — a single
116
+ * realpath on the shared root, followed by a cheap syntactic fold
117
+ * (`normalizeEphemeralMapKey`) on every per-file join, reconciles that without
118
+ * paying `realpathSync` per tracked file (#703 perf follow-up — a ~2k-file
119
+ * repo was doing ~2k realpath calls in one synchronous burst per fetch).
120
+ * Accepted edge case: a symlinked or 8.3-short-name project root can still
121
+ * make the cheap per-file fold miss even after this one realpath — degrades
122
+ * to today's pattern-only behavior for that root, consistent with the
123
+ * fail-open contract elsewhere in this module.
124
+ */
125
+ export function parseTrackedFilesOutput(stdout, cwd) {
126
+ const ids = new Set();
127
+ const base = normalizeMapKey(cwd);
128
+ for (const line of stdout.split(/\r?\n/)) {
129
+ const rel = line.trim();
130
+ if (!rel)
131
+ continue;
132
+ const dirSegments = rel.split("/").slice(0, -1);
133
+ if (dirSegments.some((segment) => isExcludedDirName(segment)))
134
+ continue;
135
+ ids.add(normalizeEphemeralMapKey(path.join(base, rel)));
136
+ }
137
+ return ids;
138
+ }
139
+ async function fetchTrackedFiles(cwd) {
140
+ try {
141
+ const result = await safeSpawnAsync("git", ["ls-files"], {
142
+ cwd,
143
+ timeout: 10_000,
144
+ resourceLabel: "git-tracked-ignore",
145
+ });
146
+ if (result.error || result.status !== 0)
147
+ return undefined;
148
+ return parseTrackedFilesOutput(result.stdout, cwd);
149
+ }
150
+ catch {
151
+ return undefined;
152
+ }
153
+ }
154
+ const _trackedCache = new Map();
155
+ // Sync snapshot of the most recently RESOLVED tracked-files fetch per root,
156
+ // keyed the same way as `_trackedCache`. `getProjectIgnoreMatcher`'s
157
+ // `isIgnored` is sync-and-hot (#703 constraint) and can't await a promise, so
158
+ // this is the seam it reads from: `ensureTrackedIndex` (file-utils.ts) awaits
159
+ // `collectTrackedFiles` ONCE per walk, which populates this snapshot as a
160
+ // side effect; `isIgnored` then reads the snapshot synchronously for every
161
+ // file in that same walk. A caller that never primes sees an absent snapshot
162
+ // entry and degrades to pattern-only behavior (fail-open, by design).
163
+ const _trackedSnapshot = new Map();
164
+ /**
165
+ * The tracked-files id set for `cwd` (i.e. `git ls-files`, no `--others`),
166
+ * memoized per-process with the same {@link CACHE_TTL_MS} time bound as
167
+ * {@link collectUntrackedIgnoredIds} — see that function's doc for the hot
168
+ * per-edit-rebuild-loop rationale. Also updates the synchronous snapshot
169
+ * `getTrackedFilesSnapshot` reads, so sync hot-path callers can consult a
170
+ * cheap in-memory Set instead of awaiting this promise per file.
171
+ */
172
+ export function collectTrackedFiles(cwd) {
173
+ // #703 perf follow-up: `normalizeEphemeralMapKey`, not `normalizeMapKey` —
174
+ // this map's only readers/writers are `ensureTrackedIndex`/`isIgnored`
175
+ // (file-utils.ts), both of which derive `cwd` from the SAME matcher's
176
+ // `resolvedRoot` (`path.resolve`, not realpath) within one process, so the
177
+ // cheap syntactic fold is sufficient and avoids a realpath per call — see
178
+ // this file's module doc for why this diverges from `collectUntrackedIgnoredIds`.
179
+ const key = normalizeEphemeralMapKey(cwd);
180
+ const now = Date.now();
181
+ const cached = _trackedCache.get(key);
182
+ if (cached && now - cached.fetchedAtMs < CACHE_TTL_MS)
183
+ return cached.promise;
184
+ const promise = fetchTrackedFiles(cwd).then((result) => {
185
+ _trackedSnapshot.set(key, result);
186
+ return result;
187
+ });
188
+ _trackedCache.set(key, { promise, fetchedAtMs: now });
189
+ return promise;
190
+ }
191
+ /**
192
+ * Synchronous read of the most recent {@link collectTrackedFiles} result for
193
+ * `cwd`. Returns `undefined` when nothing has primed this root yet (never
194
+ * called `collectTrackedFiles`), when git degraded (no repo/spawn failure),
195
+ * OR when a fetch is still in flight — all three collapse to the same
196
+ * fail-open "tracked status unknown, use pattern-only behavior" contract.
197
+ * Called on every `isIgnored` check that reaches the tracked-rescue branch
198
+ * (file-utils.ts's `isTrackedAndRescued`), so this key MUST stay the cheap
199
+ * `normalizeEphemeralMapKey` fold, not a realpath.
200
+ */
201
+ export function getTrackedFilesSnapshot(cwd) {
202
+ return _trackedSnapshot.get(normalizeEphemeralMapKey(cwd));
203
+ }
204
+ /** Test hook: drop the memoized tracked-files cache/snapshot. */
205
+ export function _resetTrackedFilesCacheForTests() {
206
+ _trackedCache.clear();
207
+ _trackedSnapshot.clear();
208
+ }
@@ -23,6 +23,16 @@
23
23
  import * as fs from "node:fs";
24
24
  import * as path from "node:path";
25
25
  import { getGlobalPiLensDir } from "./file-utils.js";
26
+ // #735: reuse the #449/#525 reaper's exact conservative liveness check
27
+ // (`process.kill(pid, 0)`, ESRCH-only-means-dead) rather than inventing a
28
+ // second one — see realIsPidAlive's own docstring, which already calls out
29
+ // clients/lsp-budget.ts as a precedent consumer. This creates a live-binding
30
+ // import cycle with instance-reaper.ts (which imports readInstanceRegistry/
31
+ // isInstanceRegistryEnabled from here); safe under Node ESM because every
32
+ // use on both sides happens inside function bodies, never at module-
33
+ // evaluation time, so both modules are fully initialized before either
34
+ // import is actually invoked.
35
+ import { realIsPidAlive } from "./instance-reaper.js";
26
36
  import { normalizeFilePath } from "./path-utils.js";
27
37
  function registryPath() {
28
38
  return path.join(getGlobalPiLensDir(), "instances.json");
@@ -264,9 +274,32 @@ export function deregisterInstance() {
264
274
  * short-lived and sampled separately per-invocation via
265
275
  * clients/resource-sampler.ts into clients/latency-logger.ts, not carried in
266
276
  * the registry (see the module docstring's scope note).
277
+ *
278
+ * #735: `isPidAlive`, when supplied, drops any instance whose owning pid is
279
+ * confirmed dead BEFORE aggregation — a hard-killed pi process otherwise
280
+ * leaves a registry entry with heartbeat-cached RSS that reads as a live,
281
+ * resource-consuming instance until it eventually ages out (up to
282
+ * `STALE_HEARTBEAT_MS`, see clients/instance-reaper.ts). Dropped rather than
283
+ * flagged `stale: true`: a dead pid is unambiguous (unlike heartbeat
284
+ * staleness, which the reaper deliberately treats as "maybe idle-but-alive"
285
+ * and never uses to justify removing/hiding anything) — the wire shape and
286
+ * every existing caller (chiefly `pilens_health`'s headline instance
287
+ * count/RSS/CPU numbers) simply expects a footprint of currently-live
288
+ * instances. Left `undefined` for pure/synchronous callers (incl. every
289
+ * pre-#735 unit test) that pass a plain snapshot with no intent to check OS
290
+ * process state — no filtering happens, preserving prior behavior exactly.
291
+ * No pid-reuse identity check is applied here (unlike the reaper's child-pid
292
+ * `matchProcess`): `InstanceEntry` never recorded the host's own command
293
+ * line to verify against (same gap #525 called out for the reaper's PARENT
294
+ * pid, deliberately left unfixed there too), and a health-report false
295
+ * positive is a much smaller blast radius than the reaper's mistaken kill —
296
+ * so plain liveness is judged sufficient here.
267
297
  */
268
- export function computeResourceFootprint(instances) {
269
- const perInstance = instances.map((instance) => {
298
+ export function computeResourceFootprint(instances, isPidAlive) {
299
+ const liveInstances = isPidAlive
300
+ ? instances.filter((instance) => isPidAlive(instance.pid))
301
+ : instances;
302
+ const perInstance = liveInstances.map((instance) => {
270
303
  const lspChildRssBytes = instance.lspChildren.reduce((sum, child) => sum + (child.rssBytes ?? 0), 0);
271
304
  const lspChildCpuPercent = instance.lspChildren.reduce((sum, child) => sum + (child.cpuPercent ?? 0), 0);
272
305
  return {
@@ -300,8 +333,45 @@ export function computeResourceFootprint(instances) {
300
333
  * side of "how much CPU/RAM is pi-lens using right now" (#620). Best-effort
301
334
  * (readInstanceRegistry never throws); the answer only reflects whatever
302
335
  * heartbeats have landed so far.
336
+ *
337
+ * #735: defaults to the reaper's `realIsPidAlive` so dead-pid registry
338
+ * entries (a hard-killed pi process) are excluded from both the returned
339
+ * footprint AND — opportunistically, fire-and-forget, best-effort — pruned
340
+ * from the on-disk registry, the same "entry removal only, never blocks the
341
+ * caller" convention `sweepOrphans`/`pruneDeadInstances` already use.
342
+ * Pruning here is a bonus cleanup, not a substitute for the reaper sweep:
343
+ * this path only prunes pids this particular read happened to find dead,
344
+ * while the reaper sweep is the authoritative, scheduled cleanup. Injectable
345
+ * so tests can pass a fake predicate (or omit filtering entirely by passing
346
+ * a function that always returns true) without touching real OS process
347
+ * state.
303
348
  */
304
- export async function getResourceFootprint() {
349
+ export async function getResourceFootprint(isPidAlive = realIsPidAlive) {
305
350
  const instances = await readInstanceRegistry();
306
- return computeResourceFootprint(instances);
351
+ const deadPids = new Set(instances.filter((instance) => !isPidAlive(instance.pid)).map((instance) => instance.pid));
352
+ if (deadPids.size > 0) {
353
+ // Fire-and-forget: a health-report read must never block on, or fail
354
+ // because of, a registry write.
355
+ prunePids(deadPids).catch(() => {
356
+ // best-effort — a dead-pid entry that fails to prune here is simply
357
+ // re-evaluated (and re-dropped from the report) on the next read, and
358
+ // remains catchable by the scheduled reaper sweep regardless.
359
+ });
360
+ }
361
+ return computeResourceFootprint(instances, isPidAlive);
362
+ }
363
+ /** Best-effort removal of specific dead pids' entries from the on-disk
364
+ * registry (#735). Re-reads immediately before writing (rather than reusing
365
+ * an earlier snapshot) to narrow — not eliminate — the last-writer-wins race
366
+ * already accepted for this module's read-modify-write model (see the
367
+ * module docstring). Mirrors clients/instance-reaper.ts's
368
+ * `pruneDeadInstances`, kept local here rather than imported to avoid
369
+ * reaching back across the same import edge `realIsPidAlive` already
370
+ * crosses in the other direction. */
371
+ async function prunePids(deadPids) {
372
+ const file = await readRegistryAsync();
373
+ const remaining = file.instances.filter((entry) => !deadPids.has(entry.pid));
374
+ if (remaining.length === file.instances.length)
375
+ return;
376
+ await writeRegistryAsync({ instances: remaining });
307
377
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Shared "read a JSON cache file off disk" boilerplate (#676).
3
+ *
4
+ * At least 7 sites across the codebase hand-duplicated the same shape:
5
+ * `JSON.parse(fs.readFileSync(path, "utf-8"))` → structural/version guard →
6
+ * return the parsed value, or `undefined`/`null` on ANY throw (missing file,
7
+ * unreadable, corrupt JSON, or a `validate` callback itself throwing). This
8
+ * module extracts only that read/parse/try-catch mechanics; every call
9
+ * site's own "is this actually a valid, current-version cache?" logic stays
10
+ * put, as the `validate` callback — consolidation is only meant to remove
11
+ * the duplicated plumbing, not to unify subtly different validation rules
12
+ * that call sites had for good reason (e.g. a second `ruleHash` field check
13
+ * alongside the version check).
14
+ *
15
+ * `validate` returning `undefined` means "treat this like a parse failure" —
16
+ * the same fail-open posture every existing site already had for a
17
+ * wrong-version/wrong-shape cache.
18
+ */
19
+ import * as fs from "node:fs";
20
+ import { promises as fsPromises } from "node:fs";
21
+ /**
22
+ * Synchronous read: `JSON.parse(fs.readFileSync(path, "utf-8"))`, run through
23
+ * `validate`, with any failure (missing file, unreadable, corrupt JSON,
24
+ * `validate` throwing) swallowed to `undefined`.
25
+ *
26
+ * `onError`, when provided, is invoked with the caught error BEFORE
27
+ * `undefined` is returned — for sites that log a read failure today (e.g.
28
+ * `CacheManager`'s verbose `this.log(...)`) rather than silently swallowing
29
+ * it. `onError` itself is never allowed to throw the failure back out.
30
+ */
31
+ export function readJsonCache(path, validate, onError) {
32
+ try {
33
+ const parsed = JSON.parse(fs.readFileSync(path, "utf-8"));
34
+ return validate(parsed);
35
+ }
36
+ catch (err) {
37
+ try {
38
+ onError?.(err);
39
+ }
40
+ catch {
41
+ /* onError must never mask the original fail-open behavior */
42
+ }
43
+ return undefined;
44
+ }
45
+ }
46
+ /**
47
+ * Async sibling of {@link readJsonCache}, for call sites already on the
48
+ * `fs.promises` API (e.g. `session-state-store.ts`). Same fail-open contract.
49
+ */
50
+ export async function readJsonCacheAsync(path, validate, onError) {
51
+ try {
52
+ const raw = await fsPromises.readFile(path, "utf-8");
53
+ const parsed = JSON.parse(raw);
54
+ return validate(parsed);
55
+ }
56
+ catch (err) {
57
+ try {
58
+ onError?.(err);
59
+ }
60
+ catch {
61
+ /* onError must never mask the original fail-open behavior */
62
+ }
63
+ return undefined;
64
+ }
65
+ }
@@ -282,6 +282,10 @@ const MAX_WARMUP_SOURCE_FILES = 2000;
282
282
  export async function collectSourceFilesForWarmup(rootDir, maxFiles = MAX_WARMUP_SOURCE_FILES, yieldEvery = 100) {
283
283
  const root = path.resolve(rootDir);
284
284
  const ignoreMatcher = getProjectIgnoreMatcher(root);
285
+ // #703: prime the tracked-files set once before the walk so a tracked file
286
+ // matching a `.gitignore`/global pattern still counts toward language
287
+ // detection. Fail-open on no-git/spawn failure.
288
+ await ignoreMatcher.ensureTrackedIndex();
285
289
  const stack = [root];
286
290
  const out = [];
287
291
  let processedSinceYield = 0;