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.
Files changed (79) hide show
  1. package/CHANGELOG.md +106 -1
  2. package/README.md +8 -0
  3. package/config/markdownlint/core.json +6 -0
  4. package/dist/clients/atomic-write.js +83 -0
  5. package/dist/clients/config-enabled-shape.js +39 -0
  6. package/dist/clients/diagnostic-dispositions.js +64 -2
  7. package/dist/clients/dispatch/integration.js +2 -1
  8. package/dist/clients/dispatch/runners/lsp.js +54 -25
  9. package/dist/clients/dispatch/runners/markdownlint.js +7 -4
  10. package/dist/clients/dispatch/runners/pyright.js +1 -0
  11. package/dist/clients/dispatch/runners/tree-sitter.js +1 -1
  12. package/dist/clients/env-utils.js +38 -0
  13. package/dist/clients/file-utils.js +21 -3
  14. package/dist/clients/instance-registry.js +20 -20
  15. package/dist/clients/jscpd-client.js +52 -39
  16. package/dist/clients/language-profile.js +12 -19
  17. package/dist/clients/lens-config.js +106 -27
  18. package/dist/clients/lens-engine.js +129 -3
  19. package/dist/clients/lsp/cascade-tier.js +1 -38
  20. package/dist/clients/lsp/client.js +3 -1
  21. package/dist/clients/lsp/index.js +621 -95
  22. package/dist/clients/lsp/server-strategies.js +3 -224
  23. package/dist/clients/lsp/server.js +1 -1
  24. package/dist/clients/lsp/wait-policy/capability-snapshot.js +1 -0
  25. package/dist/clients/lsp/wait-policy/classification.js +63 -0
  26. package/dist/clients/lsp/wait-policy/index.js +10 -0
  27. package/dist/clients/lsp/wait-policy/strategies.js +244 -0
  28. package/dist/clients/lsp-budget.js +54 -16
  29. package/dist/clients/mcp/analyze.js +1 -1
  30. package/dist/clients/mcp/host-shim.js +12 -8
  31. package/dist/clients/mcp/ipc.js +118 -0
  32. package/dist/clients/mcp/session.js +2 -2
  33. package/dist/clients/pipeline.js +11 -6
  34. package/dist/clients/project-diagnostics/fresh-fetch.js +20 -1
  35. package/dist/clients/project-diagnostics/scanner.js +37 -6
  36. package/dist/clients/project-lens-config.js +167 -3
  37. package/dist/clients/project-report.js +627 -0
  38. package/dist/clients/project-scale.js +239 -0
  39. package/dist/clients/project-scan-policy.js +11 -1
  40. package/dist/clients/recent-touches.js +6 -11
  41. package/dist/clients/review-graph/builder.js +170 -13
  42. package/dist/clients/review-graph/import-resolvers.js +149 -13
  43. package/dist/clients/review-graph/tsconfig-paths.js +242 -0
  44. package/dist/clients/review-graph/workspace-modules.js +21 -9
  45. package/dist/clients/rules-scanner.js +15 -2
  46. package/dist/clients/runtime-agent-end.js +104 -17
  47. package/dist/clients/runtime-config.js +4 -0
  48. package/dist/clients/runtime-coordinator.js +50 -1
  49. package/dist/clients/runtime-session.js +41 -3
  50. package/dist/clients/runtime-tool-result.js +4 -3
  51. package/dist/clients/runtime-turn.js +4 -1
  52. package/dist/clients/safe-spawn.js +264 -40
  53. package/dist/clients/session-lifecycle.js +36 -0
  54. package/dist/clients/session-state-store.js +8 -3
  55. package/dist/clients/source-filter.js +136 -61
  56. package/dist/clients/source-walker.js +89 -0
  57. package/dist/clients/startup-scan.js +189 -111
  58. package/dist/clients/subagent-mode.js +14 -3
  59. package/dist/clients/todo-scanner.js +5 -1
  60. package/dist/clients/tool-policy.js +26 -4
  61. package/dist/clients/warm-attach.js +204 -0
  62. package/dist/clients/word-index.js +36 -5
  63. package/dist/clients/workspace-topology.js +266 -0
  64. package/dist/index.js +13826 -10251
  65. package/dist/mcp/server.js +125 -10
  66. package/dist/tools/ast-grep-search.js +21 -0
  67. package/dist/tools/lens-diagnostic-mark.js +198 -7
  68. package/dist/tools/lens-diagnostics.js +119 -21
  69. package/dist/tools/lsp-diagnostics.js +17 -1
  70. package/dist/tools/lsp-navigation.js +11 -2
  71. package/dist/tools/project-report.js +94 -0
  72. package/dist/tools/symbol-search.js +32 -8
  73. package/docs/environment-variables.md +10 -0
  74. package/docs/features.md +7 -1
  75. package/docs/globalconfig.md +70 -0
  76. package/docs/servercapabilities.md +1 -1
  77. package/docs/usage.md +13 -2
  78. package/package.json +1 -1
  79. 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, same pattern as instance-registry.ts) ---
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 write just means this batch of touches
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
- try {
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 { collectProjectSourceFilesAsync } from "../project-scan-policy.js";
13
- import { jsTsCandidatePaths, resolveImportToFiles } from "./import-resolvers.js";
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
- function getReviewGraphMaxFiles() {
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
- : RUNTIME_CONFIG.reviewGraph.maxFiles;
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
- const collected = await collectProjectSourceFilesAsync(cwd, {
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: even at teardown a crash
647
- // mid-write must not leave a truncated snapshot for the next start.
648
- const tmpPath = `${pending.cachePath}.tmp-${process.pid}`;
649
- fs.writeFileSync(tmpPath, JSON.stringify(pending.data), "utf-8");
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)