pi-lens 3.8.71 → 3.8.72
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/CHANGELOG.md +106 -1
- package/README.md +8 -0
- package/config/markdownlint/core.json +6 -0
- package/dist/clients/atomic-write.js +83 -0
- package/dist/clients/config-enabled-shape.js +39 -0
- package/dist/clients/diagnostic-dispositions.js +64 -2
- package/dist/clients/dispatch/integration.js +2 -1
- package/dist/clients/dispatch/runners/lsp.js +54 -25
- package/dist/clients/dispatch/runners/markdownlint.js +7 -4
- package/dist/clients/dispatch/runners/pyright.js +1 -0
- package/dist/clients/dispatch/runners/tree-sitter.js +1 -1
- package/dist/clients/env-utils.js +38 -0
- package/dist/clients/file-utils.js +21 -3
- package/dist/clients/instance-registry.js +20 -20
- package/dist/clients/jscpd-client.js +52 -39
- package/dist/clients/language-profile.js +12 -19
- package/dist/clients/lens-config.js +106 -27
- package/dist/clients/lens-engine.js +129 -3
- package/dist/clients/lsp/cascade-tier.js +1 -38
- package/dist/clients/lsp/client.js +3 -1
- package/dist/clients/lsp/index.js +621 -95
- package/dist/clients/lsp/server-strategies.js +3 -224
- package/dist/clients/lsp/server.js +1 -1
- package/dist/clients/lsp/wait-policy/capability-snapshot.js +1 -0
- package/dist/clients/lsp/wait-policy/classification.js +63 -0
- package/dist/clients/lsp/wait-policy/index.js +10 -0
- package/dist/clients/lsp/wait-policy/strategies.js +244 -0
- package/dist/clients/lsp-budget.js +54 -16
- package/dist/clients/mcp/analyze.js +1 -1
- package/dist/clients/mcp/host-shim.js +12 -8
- package/dist/clients/mcp/ipc.js +118 -0
- package/dist/clients/mcp/session.js +2 -2
- package/dist/clients/pipeline.js +11 -6
- package/dist/clients/project-diagnostics/fresh-fetch.js +20 -1
- package/dist/clients/project-diagnostics/scanner.js +37 -6
- package/dist/clients/project-lens-config.js +167 -3
- package/dist/clients/project-report.js +627 -0
- package/dist/clients/project-scale.js +239 -0
- package/dist/clients/project-scan-policy.js +11 -1
- package/dist/clients/recent-touches.js +6 -11
- package/dist/clients/review-graph/builder.js +170 -13
- package/dist/clients/review-graph/import-resolvers.js +149 -13
- package/dist/clients/review-graph/tsconfig-paths.js +242 -0
- package/dist/clients/review-graph/workspace-modules.js +21 -9
- package/dist/clients/rules-scanner.js +15 -2
- package/dist/clients/runtime-agent-end.js +104 -17
- package/dist/clients/runtime-config.js +4 -0
- package/dist/clients/runtime-coordinator.js +50 -1
- package/dist/clients/runtime-session.js +41 -3
- package/dist/clients/runtime-tool-result.js +4 -3
- package/dist/clients/runtime-turn.js +4 -1
- package/dist/clients/safe-spawn.js +264 -40
- package/dist/clients/session-lifecycle.js +36 -0
- package/dist/clients/session-state-store.js +8 -3
- package/dist/clients/source-filter.js +136 -61
- package/dist/clients/source-walker.js +89 -0
- package/dist/clients/startup-scan.js +189 -111
- package/dist/clients/subagent-mode.js +14 -3
- package/dist/clients/todo-scanner.js +5 -1
- package/dist/clients/tool-policy.js +26 -4
- package/dist/clients/warm-attach.js +204 -0
- package/dist/clients/word-index.js +36 -5
- package/dist/clients/workspace-topology.js +266 -0
- package/dist/index.js +13826 -10251
- package/dist/mcp/server.js +125 -10
- package/dist/tools/ast-grep-search.js +21 -0
- package/dist/tools/lens-diagnostic-mark.js +198 -7
- package/dist/tools/lens-diagnostics.js +119 -21
- package/dist/tools/lsp-diagnostics.js +17 -1
- package/dist/tools/lsp-navigation.js +11 -2
- package/dist/tools/project-report.js +94 -0
- package/dist/tools/symbol-search.js +32 -8
- package/docs/environment-variables.md +10 -0
- package/docs/features.md +7 -1
- package/docs/globalconfig.md +70 -0
- package/docs/servercapabilities.md +1 -1
- package/docs/usage.md +13 -2
- package/package.json +1 -1
- package/scripts/download-grammars.js +1 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single "project scale" knob deriving pi-lens's five independent
|
|
3
|
+
* project-size budgets (#776).
|
|
4
|
+
*
|
|
5
|
+
* Before this module, five subsystems each hardcoded their own file/entry
|
|
6
|
+
* ceiling and degraded independently as a repo grew:
|
|
7
|
+
*
|
|
8
|
+
* - project-diagnostics scanner: 500 files (clients/project-diagnostics/scanner.ts)
|
|
9
|
+
* - review graph: 1,000 files (clients/runtime-config.ts)
|
|
10
|
+
* - startup scan: 2,000 source files (clients/startup-scan.ts)
|
|
11
|
+
* - jscpd: 6,000 dir entries (clients/jscpd-client.ts)
|
|
12
|
+
* - word index: 6,000 files (clients/word-index.ts)
|
|
13
|
+
*
|
|
14
|
+
* A large-but-healthy (monorepo-scale) repo would trip these one at a time,
|
|
15
|
+
* silently, with no single lever to say "this repo is big; believe in it".
|
|
16
|
+
*
|
|
17
|
+
* This module centralizes the base value (`maxProjectFiles`, default 2,000 —
|
|
18
|
+
* chosen so every ratio below reproduces today's five defaults exactly) and
|
|
19
|
+
* exposes one `deriveBudget(ratio, cwd)` helper subsystems call instead of
|
|
20
|
+
* declaring their own local constant. Raising `maxProjectFiles` in one place
|
|
21
|
+
* scales all five budgets coherently; this refactor changes no default value.
|
|
22
|
+
*
|
|
23
|
+
* Ratio table (documents today's five defaults at the default base of 2,000):
|
|
24
|
+
*
|
|
25
|
+
* | Subsystem | Ratio | Derived @ base 2,000 | Today |
|
|
26
|
+
* |-------------------------------|-------|----------------------|--------|
|
|
27
|
+
* | project-diagnostics scanner | 0.25× | 500 | 500 |
|
|
28
|
+
* | review graph (#775 R1: tapered above 4,000, see below) | 0.5× | 1,000 | 1,000 |
|
|
29
|
+
* | startup scan (source files) | 1× | 2,000 | 2,000 |
|
|
30
|
+
* | jscpd (dir entries) | 3× | 6,000 | 6,000 |
|
|
31
|
+
* | word index (files) | 3× | 6,000 | 6,000 |
|
|
32
|
+
*
|
|
33
|
+
* The review graph is the ONE budget in this table that stops being a flat
|
|
34
|
+
* ratio above a threshold: {@link getReviewGraphMaxFilesDerived} tapers its
|
|
35
|
+
* growth toward a hard ceiling instead of scaling linearly forever (or
|
|
36
|
+
* cliff-dropping like the pre-#775 flat cap did) — see that function's doc
|
|
37
|
+
* comment for the full rationale. Every OTHER row in this table (and the
|
|
38
|
+
* review graph itself at or below the taper's linear boundary) is still the
|
|
39
|
+
* flat ratio shown.
|
|
40
|
+
*
|
|
41
|
+
* `maxProjectFiles` resolution order (highest priority first) — NOTE this is
|
|
42
|
+
* the base-value chain only; each subsystem's own PRE-EXISTING per-subsystem
|
|
43
|
+
* env override (e.g. `PI_LENS_REVIEW_GRAPH_MAX_FILES`,
|
|
44
|
+
* `PI_LENS_STARTUP_SCAN_MAX_ENTRIES`) is still checked at the call site
|
|
45
|
+
* BEFORE falling back to this module's derived value, so it always wins:
|
|
46
|
+
*
|
|
47
|
+
* 1. `maxProjectFiles` in the project's `.pi-lens.json` (per-project — see
|
|
48
|
+
* `clients/project-lens-config.ts`). Requires a `cwd`; a call site with
|
|
49
|
+
* no cwd in hand skips straight to (2).
|
|
50
|
+
* 2. `PI_LENS_MAX_PROJECT_FILES` environment variable.
|
|
51
|
+
* 3. Default: {@link DEFAULT_PROJECT_SCALE_BASE} (2,000).
|
|
52
|
+
*
|
|
53
|
+
* Two entry-UNIT budgets were deliberately left OUT of this derivation:
|
|
54
|
+
* `startup-scan.ts`'s `MAX_STARTUP_SCAN_ENTRIES` (50,000 directory entries)
|
|
55
|
+
* and `source-filter.ts`'s `DEFAULT_MAX_SCAN_ENTRIES` (200,000 directory
|
|
56
|
+
* entries). Both bound directory entries VISITED (a raw tree-walk safety
|
|
57
|
+
* valve, #758/#760), not source files KEPT — a very different quantity whose
|
|
58
|
+
* healthy ratio to file count varies wildly by project shape (a repo with a
|
|
59
|
+
* huge non-source data/asset tree needs a much larger entries budget than its
|
|
60
|
+
* source-file count would suggest). Coupling them to `maxProjectFiles` would
|
|
61
|
+
* risk silently changing behavior on exactly the pathological-tree shapes
|
|
62
|
+
* these ceilings exist to protect, for no behavior-neutral gain; they already
|
|
63
|
+
* have their own env overrides for callers who need to retune them. Left
|
|
64
|
+
* standalone per the issue's "derive or standalone, justify" instruction.
|
|
65
|
+
*/
|
|
66
|
+
import { lazyEnvNumber, toPositiveFinite } from "./env-utils.js";
|
|
67
|
+
import { loadPiLensProjectConfig } from "./project-lens-config.js";
|
|
68
|
+
/**
|
|
69
|
+
* Default base value: chosen so every ratio in the table above reproduces
|
|
70
|
+
* today's five hardcoded defaults exactly. Do not change without re-deriving
|
|
71
|
+
* every subsystem's default (that re-tuning is explicitly out of scope here —
|
|
72
|
+
* see #775).
|
|
73
|
+
*/
|
|
74
|
+
export const DEFAULT_PROJECT_SCALE_BASE = 2_000;
|
|
75
|
+
const _envBase = lazyEnvNumber("PI_LENS_MAX_PROJECT_FILES", DEFAULT_PROJECT_SCALE_BASE);
|
|
76
|
+
/** Test-only: clears the memoized `PI_LENS_MAX_PROJECT_FILES` read so a
|
|
77
|
+
* subsequent call re-reads the env var (matching the `_resetForTests`
|
|
78
|
+
* convention used across `env-utils.ts` consumers). */
|
|
79
|
+
export function _resetProjectScaleBaseForTests() {
|
|
80
|
+
_envBase._resetForTests();
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Resolve the base `maxProjectFiles` value.
|
|
84
|
+
*
|
|
85
|
+
* Pass the caller's project root as `cwd` whenever one is available — that's
|
|
86
|
+
* what lets a per-project `.pi-lens.json` override win. Call sites that only
|
|
87
|
+
* have a bare module-level constant today (no cwd in hand) may omit it; they
|
|
88
|
+
* fall back to the `PI_LENS_MAX_PROJECT_FILES` env var / default chain only
|
|
89
|
+
* (documented per-call-site in each subsystem's own comment).
|
|
90
|
+
*/
|
|
91
|
+
export function getProjectScaleBase(cwd) {
|
|
92
|
+
if (cwd) {
|
|
93
|
+
const config = loadPiLensProjectConfig(cwd);
|
|
94
|
+
const configBase = toPositiveFinite(config.maxProjectFiles);
|
|
95
|
+
if (configBase > 0)
|
|
96
|
+
return Math.floor(configBase);
|
|
97
|
+
}
|
|
98
|
+
return _envBase.get();
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Ratios calibrated to reproduce today's five hardcoded defaults exactly at
|
|
102
|
+
* {@link DEFAULT_PROJECT_SCALE_BASE}. The unit each budget is expressed in
|
|
103
|
+
* stays subsystem-appropriate (files vs. directory entries); this table is
|
|
104
|
+
* the single place that encodes the relationship between them.
|
|
105
|
+
*/
|
|
106
|
+
export const PROJECT_SCALE_RATIOS = {
|
|
107
|
+
/** project-diagnostics scanner: files kept. 0.25 * 2,000 = 500. */
|
|
108
|
+
projectDiagnosticsScanner: 0.25,
|
|
109
|
+
/** review graph: files kept. 0.5 * 2,000 = 1,000. */
|
|
110
|
+
reviewGraph: 0.5,
|
|
111
|
+
/** startup scan: source files counted. 1 * 2,000 = 2,000. */
|
|
112
|
+
startupScan: 1,
|
|
113
|
+
/** jscpd: directory entries visited (its own gate, not source-filter's). 3 * 2,000 = 6,000. */
|
|
114
|
+
jscpd: 3,
|
|
115
|
+
/** word index: files indexed. 3 * 2,000 = 6,000. */
|
|
116
|
+
wordIndex: 3,
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Scale a ratio from {@link PROJECT_SCALE_RATIOS} by the resolved base,
|
|
120
|
+
* rounding to the nearest integer and flooring at 1 (a budget of 0 would
|
|
121
|
+
* degrade every scan to "nothing scanned", never the intent of a large-repo
|
|
122
|
+
* knob).
|
|
123
|
+
*/
|
|
124
|
+
export function deriveBudget(ratio, cwd) {
|
|
125
|
+
return Math.max(1, Math.round(getProjectScaleBase(cwd) * ratio));
|
|
126
|
+
}
|
|
127
|
+
/** Derived project-diagnostics scanner budget (files). See {@link deriveBudget}. */
|
|
128
|
+
export function getProjectDiagnosticsScannerMaxFiles(cwd) {
|
|
129
|
+
return deriveBudget(PROJECT_SCALE_RATIOS.projectDiagnosticsScanner, cwd);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Review-graph budget: adaptive taper (#775 R1).
|
|
133
|
+
*
|
|
134
|
+
* At or below {@link REVIEW_GRAPH_LINEAR_CEILING_BASE} this is IDENTICAL to
|
|
135
|
+
* the flat `PROJECT_SCALE_RATIOS.reviewGraph` (0.5x) ratio every other
|
|
136
|
+
* caller of {@link deriveBudget} still uses — every existing small/medium
|
|
137
|
+
* project derivation (including the default-base 1,000-file case) is
|
|
138
|
+
* bit-for-bit unchanged by this function.
|
|
139
|
+
*
|
|
140
|
+
* Above that base, growth SLOWS instead of continuing linearly (a monorepo
|
|
141
|
+
* with a huge `maxProjectFiles` would otherwise get an equally huge,
|
|
142
|
+
* untested review-graph budget) or cutting off at a cliff (today's actual
|
|
143
|
+
* bug: a flat cap truncates the graph to empty above it, per #775's top
|
|
144
|
+
* risk). A hyperbolic taper closes the gap to
|
|
145
|
+
* {@link REVIEW_GRAPH_HARD_CEILING} asymptotically, never reaching or
|
|
146
|
+
* exceeding it: at `base = REVIEW_GRAPH_LINEAR_CEILING_BASE +
|
|
147
|
+
* REVIEW_GRAPH_TAPER_SCALE`, exactly HALF the remaining gap to the ceiling
|
|
148
|
+
* is closed (a standard property of this taper shape) — e.g. base 12,000
|
|
149
|
+
* (6x default) derives ~3,500 files; base 20,000 (10x default) derives
|
|
150
|
+
* ~4,000.
|
|
151
|
+
*
|
|
152
|
+
* {@link REVIEW_GRAPH_HARD_CEILING} (5,000) is grounded FIRST by the
|
|
153
|
+
* strongest real constraint on this number — `review-graph/builder.ts`'s
|
|
154
|
+
* persist circuit-breaker (`GRAPH_PERSIST_MAX_ELEMENTS_DEFAULT`, 200,000
|
|
155
|
+
* graph elements: file/symbol nodes + cross-file edges). Above that element
|
|
156
|
+
* count the graph is NEVER persisted to disk (logged + skipped, #260's OOM
|
|
157
|
+
* guard) — not "slow", but PERMANENTLY uncached, so every session pays a
|
|
158
|
+
* full cold build instead of the ~20 ms cached path (see the CHANGELOG
|
|
159
|
+
* timing note below) engaging exactly once. For symbol-dense repos, element
|
|
160
|
+
* count runs on the order of ~30/file (file node + per-symbol nodes +
|
|
161
|
+
* import/call/reference edges), so a hard ceiling must leave real headroom
|
|
162
|
+
* below 200,000 elements, not creep up to it: 5,000 files x ~30 ⇒ ~150,000
|
|
163
|
+
* elements, a ~25% margin. The earlier 6,000-file candidate (~180,000
|
|
164
|
+
* elements) left too little margin — a slightly denser-than-average repo at
|
|
165
|
+
* exactly the ceiling could tip over and silently lose persistence forever,
|
|
166
|
+
* the opposite of this issue's "no silent caps" goal. 5,000 is also still
|
|
167
|
+
* coherent with the jscpd/wordIndex 3x-ratio precedent (6,000 at the
|
|
168
|
+
* default base) as "the ceiling sits just below that established top
|
|
169
|
+
* tier" rather than an exact match, so it isn't a fresh unrelated
|
|
170
|
+
* constant either.
|
|
171
|
+
*
|
|
172
|
+
* SECOND, independently, the number is consistent with the review graph's
|
|
173
|
+
* own measured per-file BUILD cost: CHANGELOG 3.8.43 ("Persist review graph
|
|
174
|
+
* to disk") documents a full cold tree-sitter + import-fact build taking
|
|
175
|
+
* "2-4 s" at the then-1,000-file cap — roughly 2-4 ms/file. 5,000 files at
|
|
176
|
+
* that rate is a ~10-20 s worst-case cold build — tolerable as a rare
|
|
177
|
+
* one-time cost when the persist cap above is respected (the disk-cached
|
|
178
|
+
* path costs "~20 ms JSON parse + rebuildIndexes" instead, and per-edit
|
|
179
|
+
* builds are incremental — see `diffSignatureMaps`). This build-time
|
|
180
|
+
* argument alone would tolerate a larger ceiling; the persist-cap margin
|
|
181
|
+
* above is the binding constraint.
|
|
182
|
+
*/
|
|
183
|
+
export const REVIEW_GRAPH_LINEAR_CEILING_BASE = 4_000;
|
|
184
|
+
/**
|
|
185
|
+
* At `base = REVIEW_GRAPH_LINEAR_CEILING_BASE + REVIEW_GRAPH_TAPER_SCALE`
|
|
186
|
+
* the taper has closed exactly half the remaining gap to
|
|
187
|
+
* {@link REVIEW_GRAPH_HARD_CEILING} — see {@link taperedReviewGraphMaxFiles}.
|
|
188
|
+
*/
|
|
189
|
+
export const REVIEW_GRAPH_TAPER_SCALE = 8_000;
|
|
190
|
+
/** See the rationale in {@link taperedReviewGraphMaxFiles}'s doc comment. */
|
|
191
|
+
export const REVIEW_GRAPH_HARD_CEILING = 5_000;
|
|
192
|
+
/**
|
|
193
|
+
* Pure taper function — see {@link getReviewGraphMaxFilesDerived}'s doc
|
|
194
|
+
* comment for the full rationale. Exported standalone so tests can probe
|
|
195
|
+
* the shape (below/at/above the linear boundary, ceiling asymptote)
|
|
196
|
+
* independent of `getProjectScaleBase`'s env/config resolution.
|
|
197
|
+
*/
|
|
198
|
+
export function taperedReviewGraphMaxFiles(base) {
|
|
199
|
+
const linearValue = base * PROJECT_SCALE_RATIOS.reviewGraph;
|
|
200
|
+
if (base <= REVIEW_GRAPH_LINEAR_CEILING_BASE) {
|
|
201
|
+
return Math.max(1, Math.round(linearValue));
|
|
202
|
+
}
|
|
203
|
+
const boundaryValue = REVIEW_GRAPH_LINEAR_CEILING_BASE * PROJECT_SCALE_RATIOS.reviewGraph;
|
|
204
|
+
const excess = base - REVIEW_GRAPH_LINEAR_CEILING_BASE;
|
|
205
|
+
const taperedExtra = (REVIEW_GRAPH_HARD_CEILING - boundaryValue) *
|
|
206
|
+
(excess / (excess + REVIEW_GRAPH_TAPER_SCALE));
|
|
207
|
+
return Math.max(1, Math.round(boundaryValue + taperedExtra));
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Derived review-graph budget (files), honoring — in priority order — the
|
|
211
|
+
* project's `.pi-lens.json#reviewGraph.maxFiles` knob (#775 R2, an explicit
|
|
212
|
+
* opt-in for repos that want a bigger graph than the taper would derive;
|
|
213
|
+
* see `project-lens-config.ts`), then {@link taperedReviewGraphMaxFiles} of
|
|
214
|
+
* the resolved `maxProjectFiles` base. Callers still check their own
|
|
215
|
+
* PRE-EXISTING `PI_LENS_REVIEW_GRAPH_MAX_FILES` env override BEFORE calling
|
|
216
|
+
* this (see `review-graph/builder.ts#getReviewGraphMaxFiles`) — that always
|
|
217
|
+
* wins outright, unchanged by this function.
|
|
218
|
+
*/
|
|
219
|
+
export function getReviewGraphMaxFilesDerived(cwd) {
|
|
220
|
+
if (cwd) {
|
|
221
|
+
const config = loadPiLensProjectConfig(cwd);
|
|
222
|
+
const configOverride = config.reviewGraph?.maxFiles;
|
|
223
|
+
if (configOverride !== undefined)
|
|
224
|
+
return configOverride;
|
|
225
|
+
}
|
|
226
|
+
return taperedReviewGraphMaxFiles(getProjectScaleBase(cwd));
|
|
227
|
+
}
|
|
228
|
+
/** Derived startup-scan budget (source files). See {@link deriveBudget}. */
|
|
229
|
+
export function getStartupScanMaxSourceFilesDerived(cwd) {
|
|
230
|
+
return deriveBudget(PROJECT_SCALE_RATIOS.startupScan, cwd);
|
|
231
|
+
}
|
|
232
|
+
/** Derived jscpd budget (directory entries). See {@link deriveBudget}. */
|
|
233
|
+
export function getJscpdMaxEntriesDerived(cwd) {
|
|
234
|
+
return deriveBudget(PROJECT_SCALE_RATIOS.jscpd, cwd);
|
|
235
|
+
}
|
|
236
|
+
/** Derived word-index budget (files). See {@link deriveBudget}. */
|
|
237
|
+
export function getWordIndexMaxFilesDerived(cwd) {
|
|
238
|
+
return deriveBudget(PROJECT_SCALE_RATIOS.wordIndex, cwd);
|
|
239
|
+
}
|
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { getProjectIgnoreMatcher, isExcludedDirName, } from "./file-utils.js";
|
|
4
4
|
import { isGeneratedArtifactDirectoryName, isGeneratedOrArtifact, } from "./generated-artifacts.js";
|
|
5
|
-
import { collectSourceFiles, collectSourceFilesAsync, } from "./source-filter.js";
|
|
5
|
+
import { collectSourceFiles, collectSourceFilesAsync, collectSourceFilesWithBudgetAsync, } from "./source-filter.js";
|
|
6
6
|
export function shouldSkipProjectPath(filePath, options) {
|
|
7
7
|
const isDirectory = options.isDirectory === true;
|
|
8
8
|
const matcher = options.ignoreMatcher ?? getProjectIgnoreMatcher(options.rootDir);
|
|
@@ -47,3 +47,13 @@ export function collectProjectSourceFiles(rootDir, options) {
|
|
|
47
47
|
export function collectProjectSourceFilesAsync(rootDir, options) {
|
|
48
48
|
return collectSourceFilesAsync(rootDir, options);
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Budget-aware twin of {@link collectProjectSourceFilesAsync} (#760): same
|
|
52
|
+
* walk, but returns `{ files, entryBudgetExceeded }` so a caller on a hot
|
|
53
|
+
* path (e.g. the per-edit cascade graph rebuild) can observe that the walk
|
|
54
|
+
* stopped at its `maxScanEntries` entry budget and got a truncated
|
|
55
|
+
* best-effort list rather than a complete enumeration.
|
|
56
|
+
*/
|
|
57
|
+
export function collectProjectSourceFilesWithBudgetAsync(rootDir, options) {
|
|
58
|
+
return collectSourceFilesWithBudgetAsync(rootDir, options);
|
|
59
|
+
}
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
*/
|
|
33
33
|
import * as fs from "node:fs";
|
|
34
34
|
import * as path from "node:path";
|
|
35
|
+
import { writeFileAtomicAsync } from "./atomic-write.js";
|
|
35
36
|
import { getProjectDataDir } from "./file-utils.js";
|
|
36
37
|
import { normalizeFilePath } from "./path-utils.js";
|
|
37
38
|
export const RECENT_TOUCHES_MAX_ENTRIES = 50;
|
|
@@ -71,27 +72,21 @@ async function readRecentTouchesAsync(cwd) {
|
|
|
71
72
|
return { entries: [] };
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
|
-
// --- Write (atomic tmp + rename
|
|
75
|
+
// --- Write (atomic tmp + rename via clients/atomic-write.ts, #762) ---
|
|
75
76
|
async function writeRecentTouchesAsync(cwd, file) {
|
|
76
77
|
const dir = getProjectDataDir(cwd);
|
|
77
78
|
const target = recentTouchesPath(cwd);
|
|
78
|
-
const tmpPath = `${target}.tmp-${process.pid}`;
|
|
79
79
|
try {
|
|
80
80
|
await fs.promises.mkdir(dir, { recursive: true });
|
|
81
|
-
await fs.promises.writeFile(tmpPath, JSON.stringify(file), "utf-8");
|
|
82
|
-
await fs.promises.rename(tmpPath, target);
|
|
83
81
|
}
|
|
84
82
|
catch {
|
|
85
|
-
// Best-effort record — a failed
|
|
83
|
+
// Best-effort record — a failed mkdir just means this batch of touches
|
|
86
84
|
// never reaches other instances; never throw for the caller (the
|
|
87
85
|
// producer seam is the fire-and-forget bus-publish path).
|
|
88
|
-
|
|
89
|
-
await fs.promises.rm(tmpPath, { force: true });
|
|
90
|
-
}
|
|
91
|
-
catch {
|
|
92
|
-
// ignore
|
|
93
|
-
}
|
|
86
|
+
return;
|
|
94
87
|
}
|
|
88
|
+
// bestEffort (default): same reasoning as the mkdir failure above.
|
|
89
|
+
await writeFileAtomicAsync(target, JSON.stringify(file));
|
|
95
90
|
}
|
|
96
91
|
/**
|
|
97
92
|
* Append this process's touched paths to the shared record (read-modify-
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { writeFileAtomic } from "../atomic-write.js";
|
|
4
5
|
import { fileContentProvider } from "../dispatch/facts/file-content.js";
|
|
6
|
+
import { lazyEnvNumber } from "../env-utils.js";
|
|
5
7
|
import { featureHintMetadata } from "../feature-hints.js";
|
|
6
8
|
import { detectFileKind, KIND_EXTENSIONS } from "../file-kinds.js";
|
|
7
9
|
import { detectFileRole } from "../file-role.js";
|
|
@@ -9,8 +11,9 @@ import { getProjectDataDir } from "../file-utils.js";
|
|
|
9
11
|
import { collectUntrackedIgnoredIds } from "../git-tracked-ignore.js";
|
|
10
12
|
import { logLatency } from "../latency-logger.js";
|
|
11
13
|
import { isAtOrAboveHomeDir, normalizeFilePath, normalizeMapKey, } from "../path-utils.js";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
+
import { collectProjectSourceFilesWithBudgetAsync } from "../project-scan-policy.js";
|
|
15
|
+
import { getReviewGraphMaxFilesDerived } from "../project-scale.js";
|
|
16
|
+
import { jsTsCandidatePaths, resolveAliasedImport, resolveImportToFiles, resolveProjectReferenceImport, resolveWorkspacePackageImport, } from "./import-resolvers.js";
|
|
14
17
|
import { RUNTIME_CONFIG } from "../runtime-config.js";
|
|
15
18
|
import { buildQualifiedName, findOwnerName } from "../symbol-containment.js";
|
|
16
19
|
import { getSharedTreeSitterClient } from "../tree-sitter-shared.js";
|
|
@@ -104,6 +107,7 @@ export function clearGraphCache() {
|
|
|
104
107
|
export function clearReviewGraphWorkspaceCache() {
|
|
105
108
|
_buildCache.clear();
|
|
106
109
|
_workspaceGraphCache.clear();
|
|
110
|
+
_sizeSkipVerdicts.clear();
|
|
107
111
|
_lastGraphBuildInfo = { reused: false, mode: "full", graphChanged: true };
|
|
108
112
|
}
|
|
109
113
|
// #300 Edge 2: the review-graph's cross-worktree isolation is INCIDENTAL to
|
|
@@ -169,6 +173,16 @@ function ensureIndexed(graph) {
|
|
|
169
173
|
*/
|
|
170
174
|
export function getCachedReviewGraph(cwd) {
|
|
171
175
|
const key = normalizeMapKey(cwd);
|
|
176
|
+
// #782: a fresh size-skip verdict means the LAST build attempt found the
|
|
177
|
+
// repo over the file cap — any graph cached/persisted from before that
|
|
178
|
+
// (necessarily built over a smaller file set, since the too_many_files
|
|
179
|
+
// branch never populates either cache tier) would silently under-report
|
|
180
|
+
// fan-in/blastRadius as if the repo were still that small. Stop serving it
|
|
181
|
+
// while the verdict is fresh rather than let it look current; it comes back
|
|
182
|
+
// automatically the moment the verdict expires (repo shrunk, or the cap was
|
|
183
|
+
// raised) and a build succeeds again.
|
|
184
|
+
if (getReviewGraphSizeSkipVerdict(cwd))
|
|
185
|
+
return undefined;
|
|
172
186
|
const cached = _workspaceGraphCache.get(key);
|
|
173
187
|
if (cached) {
|
|
174
188
|
ensureIndexed(cached.graph);
|
|
@@ -360,11 +374,17 @@ function diffSignatureMaps(previous, next) {
|
|
|
360
374
|
}
|
|
361
375
|
return { added, removed, changed };
|
|
362
376
|
}
|
|
363
|
-
|
|
377
|
+
// #776: `PI_LENS_REVIEW_GRAPH_MAX_FILES` (the existing per-subsystem env
|
|
378
|
+
// override) still wins outright; below it, the derived `maxProjectFiles`
|
|
379
|
+
// scale-knob value (see `project-scale.ts`) replaces the old hardcoded
|
|
380
|
+
// `RUNTIME_CONFIG.reviewGraph.maxFiles` constant as the fallback — the ratio
|
|
381
|
+
// table reproduces that same 1,000-file default at the default base, so this
|
|
382
|
+
// is behavior-neutral when nothing is configured.
|
|
383
|
+
function getReviewGraphMaxFiles(cwd) {
|
|
364
384
|
const override = Number.parseInt(process.env.PI_LENS_REVIEW_GRAPH_MAX_FILES ?? "", 10);
|
|
365
385
|
return Number.isFinite(override) && override > 0
|
|
366
386
|
? override
|
|
367
|
-
:
|
|
387
|
+
: getReviewGraphMaxFilesDerived(cwd);
|
|
368
388
|
}
|
|
369
389
|
function getReviewGraphMaxFileBytes() {
|
|
370
390
|
const override = Number.parseInt(process.env.PI_LENS_REVIEW_GRAPH_MAX_FILE_BYTES ?? "", 10);
|
|
@@ -380,6 +400,66 @@ function isWithinReviewGraphSizeLimit(file) {
|
|
|
380
400
|
return false;
|
|
381
401
|
}
|
|
382
402
|
}
|
|
403
|
+
// #782: a size-skipped build (too_many_files below) is otherwise
|
|
404
|
+
// indistinguishable, at the read layer, from an ordinary cold cache — and a
|
|
405
|
+
// graph cached/persisted BEFORE the repo crossed the cap kept being served by
|
|
406
|
+
// getCachedReviewGraph forever. Recording a TTL'd "size-skip verdict" per cwd
|
|
407
|
+
// fixes both: getCachedReviewGraph consults it below to stop serving a
|
|
408
|
+
// pre-cap graph while the repo is confirmed over cap, and consumers
|
|
409
|
+
// (project_report) can read it to render an honest "disabled, not cold" hint
|
|
410
|
+
// instead of "retry shortly".
|
|
411
|
+
//
|
|
412
|
+
// In-memory only, per cwd, mirroring `_workspaceGraphCache` — not persisted
|
|
413
|
+
// to disk. Every real caller either rebuilds within a session (the edit
|
|
414
|
+
// pipeline, project_report's background trigger) or restarts the process (a
|
|
415
|
+
// fresh in-memory verdict is recomputed on the very next build attempt), so
|
|
416
|
+
// there's no cross-process staleness gap worth the extra persistence
|
|
417
|
+
// machinery `too-many-source-files` needed (that verdict is cached across
|
|
418
|
+
// process starts specifically to skip a slow walk — this one is a cheap
|
|
419
|
+
// flag alongside a walk that already ran).
|
|
420
|
+
//
|
|
421
|
+
// TTL default matches project-report.ts's STALE_THRESHOLD_MS (15 minutes):
|
|
422
|
+
// long enough that a single flag isn't thrashed by back-to-back builds, short
|
|
423
|
+
// enough that a repo shrink or a `.pi-lens.json`/env cap raise is noticed
|
|
424
|
+
// without restarting the process.
|
|
425
|
+
const DEFAULT_REVIEW_GRAPH_SIZE_SKIP_TTL_MS = 15 * 60_000;
|
|
426
|
+
const _sizeSkipTtl = lazyEnvNumber("PI_LENS_REVIEW_GRAPH_SIZE_SKIP_TTL_MS", DEFAULT_REVIEW_GRAPH_SIZE_SKIP_TTL_MS);
|
|
427
|
+
/** Test-only: clears the memoized TTL so a subsequent call re-reads the env var. */
|
|
428
|
+
export const _resetReviewGraphSizeSkipTtlForTests = _sizeSkipTtl._resetForTests;
|
|
429
|
+
const _sizeSkipVerdicts = new Map();
|
|
430
|
+
/** Test-only: clears every recorded size-skip verdict. */
|
|
431
|
+
export function _resetReviewGraphSizeSkipVerdictsForTests() {
|
|
432
|
+
_sizeSkipVerdicts.clear();
|
|
433
|
+
}
|
|
434
|
+
function recordReviewGraphSizeSkip(cwd, sourceFileCount, maxFileCount) {
|
|
435
|
+
_sizeSkipVerdicts.set(normalizeMapKey(cwd), {
|
|
436
|
+
sourceFileCount,
|
|
437
|
+
maxFileCount,
|
|
438
|
+
skippedAt: Date.now(),
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
function clearReviewGraphSizeSkip(cwd) {
|
|
442
|
+
_sizeSkipVerdicts.delete(normalizeMapKey(cwd));
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* The most recent size-skip verdict for `cwd`, if one was recorded and it's
|
|
446
|
+
* still within its TTL — undefined once expired (a shrink or a raised cap
|
|
447
|
+
* gets re-checked on the next build attempt) or if no skip has ever been
|
|
448
|
+
* recorded. Consumers (project_report) use this to tell "graph disabled
|
|
449
|
+
* because the repo is over the file cap" apart from "cold cache, build in
|
|
450
|
+
* progress".
|
|
451
|
+
*/
|
|
452
|
+
export function getReviewGraphSizeSkipVerdict(cwd, now = Date.now()) {
|
|
453
|
+
const key = normalizeMapKey(cwd);
|
|
454
|
+
const verdict = _sizeSkipVerdicts.get(key);
|
|
455
|
+
if (!verdict)
|
|
456
|
+
return undefined;
|
|
457
|
+
if (now - verdict.skippedAt >= _sizeSkipTtl.get()) {
|
|
458
|
+
_sizeSkipVerdicts.delete(key);
|
|
459
|
+
return undefined;
|
|
460
|
+
}
|
|
461
|
+
return verdict;
|
|
462
|
+
}
|
|
383
463
|
async function getGraphSourceFiles(cwd) {
|
|
384
464
|
// Async, chunked-yield walk (identical output to the sync collector) so the
|
|
385
465
|
// per-edit cascade graph rebuild doesn't block the event loop on a large repo.
|
|
@@ -389,13 +469,28 @@ async function getGraphSourceFiles(cwd) {
|
|
|
389
469
|
// and paying a statSync per file before the caller bails on count (#250). When
|
|
390
470
|
// the cap is hit the caller skips the build on count alone, so the unfiltered
|
|
391
471
|
// over-limit list is all it needs — see _doBuildGraph's too_many_files branch.
|
|
392
|
-
const maxGraphFiles = getReviewGraphMaxFiles();
|
|
393
|
-
|
|
472
|
+
const maxGraphFiles = getReviewGraphMaxFiles(cwd);
|
|
473
|
+
// #760: the maxFiles cap above bounds results FOUND, not entries VISITED —
|
|
474
|
+
// a mixed tree with few source files among a huge pile of non-source files
|
|
475
|
+
// never trips it. The walk's default entry budget (DEFAULT_MAX_SCAN_ENTRIES)
|
|
476
|
+
// bounds the visit count; a truncated best-effort list is acceptable for the
|
|
477
|
+
// graph (it degrades to a partial graph, same as maxFiles trimming), so just
|
|
478
|
+
// log the truncation for observability rather than failing the build.
|
|
479
|
+
const { files: collected, entryBudgetExceeded } = await collectProjectSourceFilesWithBudgetAsync(cwd, {
|
|
394
480
|
// Only walk graph-relevant extensions so the cap counts what the graph
|
|
395
481
|
// keeps (post-filter), not JSON/YAML/MD noise it would discard anyway.
|
|
396
482
|
extensions: MAIN_KIND_EXTENSIONS,
|
|
397
483
|
maxFiles: maxGraphFiles + 1,
|
|
398
484
|
});
|
|
485
|
+
if (entryBudgetExceeded) {
|
|
486
|
+
logLatency({
|
|
487
|
+
type: "phase",
|
|
488
|
+
phase: "review_graph_source_walk_entry_budget",
|
|
489
|
+
filePath: cwd,
|
|
490
|
+
durationMs: 0,
|
|
491
|
+
metadata: { cwd, collectedFiles: collected.length },
|
|
492
|
+
});
|
|
493
|
+
}
|
|
399
494
|
if (collected.length > maxGraphFiles) {
|
|
400
495
|
// Contents are unused by the too_many_files branch; return the capped list
|
|
401
496
|
// so the caller's `length > maxGraphFiles` check still trips.
|
|
@@ -616,6 +711,20 @@ function writePending(key) {
|
|
|
616
711
|
// corrupt and silently forces a full rebuild. rename() replaces the
|
|
617
712
|
// destination atomically on both POSIX and Windows (libuv uses
|
|
618
713
|
// MOVEFILE_REPLACE_EXISTING).
|
|
714
|
+
//
|
|
715
|
+
// #762: intentionally NOT migrated onto clients/atomic-write.ts's
|
|
716
|
+
// writeFileAtomic/writeFileAtomicAsync. This callback-style writer
|
|
717
|
+
// (fs.mkdir/writeFile/rename, not fs.promises) logs a DIFFERENT,
|
|
718
|
+
// step-specific console.error per failure (mkdir vs write vs rename) so
|
|
719
|
+
// operators can tell which stage failed; the shared helper collapses all
|
|
720
|
+
// failures into one silent (bestEffort) or one rethrown (non-bestEffort)
|
|
721
|
+
// outcome and has no hook for per-step logging. Forcing this onto the
|
|
722
|
+
// helper would either lose that diagnostic granularity or require
|
|
723
|
+
// awaiting a promise-based helper from a plain `void`-returning
|
|
724
|
+
// fire-and-forget function, changing this path from non-blocking
|
|
725
|
+
// callback I/O to an async microtask chain. The sync exit-hook writer
|
|
726
|
+
// just below (`ensurePersistExitHook`) uses the shared helper instead —
|
|
727
|
+
// it has no per-step logging to preserve.
|
|
619
728
|
const tmpPath = `${pending.cachePath}.tmp-${process.pid}`;
|
|
620
729
|
fs.writeFile(tmpPath, json, "utf-8", (writeErr) => {
|
|
621
730
|
if (writeErr) {
|
|
@@ -643,11 +752,10 @@ function ensurePersistExitHook() {
|
|
|
643
752
|
for (const [, pending] of _pendingPersist) {
|
|
644
753
|
try {
|
|
645
754
|
fs.mkdirSync(pending.cacheDir, { recursive: true });
|
|
646
|
-
// Same atomic tmp+rename as writePending
|
|
647
|
-
// mid-write must not leave a
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
fs.renameSync(tmpPath, pending.cachePath);
|
|
755
|
+
// Same atomic tmp+rename as writePending (via clients/atomic-write.ts,
|
|
756
|
+
// #762): even at teardown a crash mid-write must not leave a
|
|
757
|
+
// truncated snapshot for the next start.
|
|
758
|
+
writeFileAtomic(pending.cachePath, JSON.stringify(pending.data));
|
|
651
759
|
}
|
|
652
760
|
catch {
|
|
653
761
|
// Teardown is best-effort; a missed persist just re-confirms next start.
|
|
@@ -726,10 +834,30 @@ export function flushReviewGraphPersistsForTests() {
|
|
|
726
834
|
* invariant (#243) reaches import-resolution-created nodes too, not just the
|
|
727
835
|
* initial file walk. Undefined ⇒ no filtering (the fetch degraded or wasn't
|
|
728
836
|
* requested).
|
|
837
|
+
*
|
|
838
|
+
* A bare specifier (`react`, `@scope/pkg[/subpath]`) is resolved against
|
|
839
|
+
* known workspace package names (#775) via `resolveWorkspacePackageImport` —
|
|
840
|
+
* the same resolver the cold module_report path (`resolveJsTs`) uses — before
|
|
841
|
+
* falling back to `undefined` (external dep, unchanged behavior).
|
|
729
842
|
*/
|
|
730
843
|
function localImportToFile(cwd, filePath, source, ignoredIds) {
|
|
731
|
-
if (!source.startsWith("."))
|
|
844
|
+
if (!source.startsWith(".")) {
|
|
845
|
+
const aliased = resolveAliasedImport(cwd, source, path.dirname(filePath));
|
|
846
|
+
const referenced = aliased.length
|
|
847
|
+
? []
|
|
848
|
+
: resolveProjectReferenceImport(cwd, source, path.dirname(filePath));
|
|
849
|
+
const resolved = aliased.length
|
|
850
|
+
? aliased
|
|
851
|
+
: referenced.length
|
|
852
|
+
? referenced
|
|
853
|
+
: resolveWorkspacePackageImport(cwd, source);
|
|
854
|
+
for (const normalized of resolved) {
|
|
855
|
+
if (ignoredIds?.has(normalized))
|
|
856
|
+
continue;
|
|
857
|
+
return normalized;
|
|
858
|
+
}
|
|
732
859
|
return undefined;
|
|
860
|
+
}
|
|
733
861
|
const root = path.resolve(cwd);
|
|
734
862
|
for (const candidate of jsTsCandidatePaths(filePath, source)) {
|
|
735
863
|
if (!candidate.startsWith(root) || !fs.existsSync(candidate))
|
|
@@ -772,6 +900,7 @@ async function ensureReviewGraphFacts(filePath, cwd, facts) {
|
|
|
772
900
|
// populated before the graph reads them.
|
|
773
901
|
await importFactProvider.run(ctx, facts);
|
|
774
902
|
await functionFactProvider.run(ctx, facts);
|
|
903
|
+
// pi-lens-ignore: missing-error-propagation
|
|
775
904
|
}
|
|
776
905
|
catch (err) {
|
|
777
906
|
console.error(`[pi-lens] review-graph structural facts disabled (degraded mode): ${err?.message ?? String(err)}`);
|
|
@@ -1689,7 +1818,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
|
|
|
1689
1818
|
}
|
|
1690
1819
|
const filesToBuild = await getGraphSourceFiles(cwd);
|
|
1691
1820
|
const ignoredIds = await ignoredIdsPromise;
|
|
1692
|
-
const maxGraphFiles = getReviewGraphMaxFiles();
|
|
1821
|
+
const maxGraphFiles = getReviewGraphMaxFiles(cwd);
|
|
1693
1822
|
if (filesToBuild.length > maxGraphFiles) {
|
|
1694
1823
|
const graph = createEmptyGraph();
|
|
1695
1824
|
graph.version = REVIEW_GRAPH_VERSION;
|
|
@@ -1697,6 +1826,29 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
|
|
|
1697
1826
|
for (const file of normalizedChanged) {
|
|
1698
1827
|
upsertChangedSymbols(graph, facts, file);
|
|
1699
1828
|
}
|
|
1829
|
+
// #782: record a TTL'd verdict so getCachedReviewGraph can stop serving
|
|
1830
|
+
// any graph cached/persisted from before the repo crossed the cap, and so
|
|
1831
|
+
// project_report can render an honest "disabled at N files" hint instead
|
|
1832
|
+
// of "retry shortly" — see getReviewGraphSizeSkipVerdict.
|
|
1833
|
+
recordReviewGraphSizeSkip(cwd, filesToBuild.length, maxGraphFiles);
|
|
1834
|
+
// #775 R3: `_lastGraphBuildInfo`/the size-skip verdict above are only
|
|
1835
|
+
// SURFACED by callers that happen to read them (dispatch/integration.ts's
|
|
1836
|
+
// cascade path logs a `graph_build` phase; lens-map.ts, project-report.ts,
|
|
1837
|
+
// mcp/analyze.ts, runtime-session.ts, and tree-sitter.ts's runner do not).
|
|
1838
|
+
// Log unconditionally here, at the one place every caller funnels through,
|
|
1839
|
+
// so a monorepo crossing the cap is never a SILENT truncation — no caller
|
|
1840
|
+
// wiring required (AGENTS.md: no silent caps).
|
|
1841
|
+
logLatency({
|
|
1842
|
+
type: "phase",
|
|
1843
|
+
phase: "review_graph_size_skip",
|
|
1844
|
+
filePath: cwd,
|
|
1845
|
+
durationMs: 0,
|
|
1846
|
+
metadata: {
|
|
1847
|
+
cwd,
|
|
1848
|
+
sourceFileCount: filesToBuild.length,
|
|
1849
|
+
maxFileCount: maxGraphFiles,
|
|
1850
|
+
},
|
|
1851
|
+
});
|
|
1700
1852
|
_lastGraphBuildInfo = {
|
|
1701
1853
|
reused: false,
|
|
1702
1854
|
mode: "skipped",
|
|
@@ -1713,6 +1865,11 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
|
|
|
1713
1865
|
facts.setSessionFact("session.reviewGraph", graph);
|
|
1714
1866
|
return graph;
|
|
1715
1867
|
}
|
|
1868
|
+
// #782: the repo is within the cap on this build attempt — drop any
|
|
1869
|
+
// previously recorded size-skip verdict immediately (rather than waiting
|
|
1870
|
+
// out the TTL) so a shrink or a raised cap re-enables reads the moment a
|
|
1871
|
+
// build actually succeeds.
|
|
1872
|
+
clearReviewGraphSizeSkip(cwd);
|
|
1716
1873
|
const fileSignatures = await sourceSignatureMapAsync(filesToBuild);
|
|
1717
1874
|
const signature = sourceSignatureFromMap(fileSignatures);
|
|
1718
1875
|
// Tier 1: in-memory cache (hot path — same process, already built this session)
|