pi-lens 3.8.71 → 3.8.73

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 (83) hide show
  1. package/CHANGELOG.md +152 -1
  2. package/README.md +31 -22
  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/deps/js-yaml.js +1 -1
  7. package/dist/clients/diagnostic-dispositions.js +64 -2
  8. package/dist/clients/dispatch/integration.js +2 -1
  9. package/dist/clients/dispatch/runners/ast-grep-napi.js +60 -7
  10. package/dist/clients/dispatch/runners/lsp.js +54 -25
  11. package/dist/clients/dispatch/runners/markdownlint.js +7 -4
  12. package/dist/clients/dispatch/runners/pyright.js +1 -0
  13. package/dist/clients/dispatch/runners/tree-sitter.js +1 -1
  14. package/dist/clients/env-utils.js +38 -0
  15. package/dist/clients/file-utils.js +21 -3
  16. package/dist/clients/instance-registry.js +32 -21
  17. package/dist/clients/jscpd-client.js +52 -39
  18. package/dist/clients/language-profile.js +12 -19
  19. package/dist/clients/lens-config.js +106 -27
  20. package/dist/clients/lens-engine.js +129 -3
  21. package/dist/clients/lsp/cascade-tier.js +1 -38
  22. package/dist/clients/lsp/client.js +3 -1
  23. package/dist/clients/lsp/index.js +707 -109
  24. package/dist/clients/lsp/server-strategies.js +3 -224
  25. package/dist/clients/lsp/server.js +1 -1
  26. package/dist/clients/lsp/wait-policy/capability-snapshot.js +1 -0
  27. package/dist/clients/lsp/wait-policy/classification.js +63 -0
  28. package/dist/clients/lsp/wait-policy/index.js +10 -0
  29. package/dist/clients/lsp/wait-policy/strategies.js +244 -0
  30. package/dist/clients/lsp-budget.js +54 -16
  31. package/dist/clients/mcp/analyze.js +1 -1
  32. package/dist/clients/mcp/host-shim.js +12 -8
  33. package/dist/clients/mcp/ipc.js +118 -0
  34. package/dist/clients/mcp/session.js +2 -2
  35. package/dist/clients/pipeline.js +11 -6
  36. package/dist/clients/project-diagnostics/fresh-fetch.js +20 -1
  37. package/dist/clients/project-diagnostics/scanner.js +37 -6
  38. package/dist/clients/project-lens-config.js +167 -3
  39. package/dist/clients/project-report.js +627 -0
  40. package/dist/clients/project-scale.js +239 -0
  41. package/dist/clients/project-scan-policy.js +11 -1
  42. package/dist/clients/recent-touches.js +6 -11
  43. package/dist/clients/review-graph/builder.js +170 -13
  44. package/dist/clients/review-graph/import-resolvers.js +149 -13
  45. package/dist/clients/review-graph/tsconfig-paths.js +242 -0
  46. package/dist/clients/review-graph/workspace-modules.js +21 -9
  47. package/dist/clients/rules-scanner.js +15 -2
  48. package/dist/clients/runtime-agent-end.js +104 -17
  49. package/dist/clients/runtime-config.js +4 -0
  50. package/dist/clients/runtime-coordinator.js +50 -1
  51. package/dist/clients/runtime-session.js +41 -3
  52. package/dist/clients/runtime-tool-result.js +4 -3
  53. package/dist/clients/runtime-turn.js +4 -1
  54. package/dist/clients/safe-spawn.js +264 -40
  55. package/dist/clients/session-lifecycle.js +36 -0
  56. package/dist/clients/session-state-store.js +8 -3
  57. package/dist/clients/source-filter.js +136 -61
  58. package/dist/clients/source-walker.js +89 -0
  59. package/dist/clients/startup-scan.js +189 -111
  60. package/dist/clients/subagent-mode.js +14 -3
  61. package/dist/clients/todo-scanner.js +5 -1
  62. package/dist/clients/tool-policy.js +26 -4
  63. package/dist/clients/warm-attach.js +204 -0
  64. package/dist/clients/word-index.js +36 -5
  65. package/dist/clients/workspace-topology.js +266 -0
  66. package/dist/index.js +15205 -11330
  67. package/dist/mcp/server.js +125 -10
  68. package/dist/tools/ast-grep-search.js +21 -0
  69. package/dist/tools/lens-diagnostic-mark.js +198 -7
  70. package/dist/tools/lens-diagnostics.js +119 -21
  71. package/dist/tools/lsp-diagnostics.js +143 -10
  72. package/dist/tools/lsp-navigation.js +11 -2
  73. package/dist/tools/project-report.js +94 -0
  74. package/dist/tools/symbol-search.js +32 -8
  75. package/docs/environment-variables.md +10 -0
  76. package/docs/features.md +7 -1
  77. package/docs/globalconfig.md +70 -0
  78. package/docs/servercapabilities.md +1 -1
  79. package/docs/usage.md +13 -2
  80. package/package.json +8 -9
  81. package/rules/tree-sitter-queries/typescript/no-equality-in-for-condition.yml +1 -0
  82. package/rules/tree-sitter-queries/typescript/no-jump-in-finally.yml +1 -0
  83. package/scripts/download-grammars.js +1 -0
@@ -20,9 +20,9 @@
20
20
  * the primary language server per file. Auxiliaries are cross-cutting
21
21
  * scanners layered on top of (not required for) core diagnostics, so this is
22
22
  * the cheapest, highest-signal thing to shed under machine-wide pressure.
23
- * The issue's OTHER suggested mechanism — shortening this session's own
24
- * idle-reaper timeout — is a documented follow-up, not implemented here (see
25
- * the module docstring's "Not implemented" note below).
23
+ * The cached decision also shortens this session's idle reset and lets
24
+ * pull-capable servers skip their push fallback. All three degrade mechanisms
25
+ * read the same session-boundary snapshot; pressure never changes mid-touch.
26
26
  *
27
27
  * Ceiling default (`DEFAULT_LSP_BUDGET_CEILING = 16`): a rough RAM-budget
28
28
  * back-of-envelope, not a measured figure (#620's CPU/RSS sampling had not
@@ -43,10 +43,11 @@
43
43
  * (session-lifecycle.ts / runtime-config.ts).
44
44
  */
45
45
  import { isInstanceRegistryEnabled, readInstanceRegistry, } from "./instance-registry.js";
46
- import { realIsPidAlive } from "./instance-reaper.js";
46
+ import { realIsPidAlive, STALE_HEARTBEAT_MS } from "./instance-reaper.js";
47
47
  import { logLatency } from "./latency-logger.js";
48
48
  /** See the module docstring for the derivation. */
49
49
  export const DEFAULT_LSP_BUDGET_CEILING = 16;
50
+ export const DEFAULT_LSP_BUDGET_IDLE_TIMEOUT_MS = 60_000;
50
51
  /** `PI_LENS_CROSS_PROCESS_BUDGET=0` disables the budget check entirely —
51
52
  * lazy env read (house style), never memoized so tests can flip it mid-run. */
52
53
  export function isCrossProcessBudgetEnabled() {
@@ -60,6 +61,17 @@ export function getLspBudgetCeiling() {
60
61
  const raw = Number(process.env.PI_LENS_LSP_BUDGET_CEILING);
61
62
  return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_LSP_BUDGET_CEILING;
62
63
  }
64
+ /** Optional aggregate host + LSP-child RSS ceiling. Undefined means disabled. */
65
+ export function getLspBudgetRssCeilingBytes() {
66
+ const raw = Number(process.env.PI_LENS_LSP_BUDGET_RSS_MB);
67
+ return Number.isFinite(raw) && raw > 0 ? raw * 1024 * 1024 : undefined;
68
+ }
69
+ export function getLspBudgetIdleTimeoutMs() {
70
+ const raw = Number(process.env.PI_LENS_LSP_BUDGET_IDLE_TIMEOUT_MS);
71
+ return Number.isFinite(raw) && raw > 0
72
+ ? raw
73
+ : DEFAULT_LSP_BUDGET_IDLE_TIMEOUT_MS;
74
+ }
63
75
  /**
64
76
  * PURE decision function — no I/O. Mirrors `decideOrphanReaping`'s shape
65
77
  * (clients/instance-reaper.ts) for the same reason: injectable liveness
@@ -72,14 +84,37 @@ export function getLspBudgetCeiling() {
72
84
  * already being cleaned up (or was already cleaned up and the entry just
73
85
  * hasn't been pruned from this snapshot yet).
74
86
  */
75
- export function decideLspBudget(registry, isPidAlive, ceiling) {
76
- const totalLiveLspServers = registry.reduce((sum, instance) => isPidAlive(instance.pid) ? sum + instance.lspChildren.length : sum, 0);
77
- const overBudget = totalLiveLspServers >= ceiling;
87
+ export function decideLspBudget(registry, isPidAlive, ceiling, rssCeilingBytes, now = Date.now()) {
88
+ const liveInstances = registry.filter((instance) => isPidAlive(instance.pid));
89
+ const totalLiveLspServers = liveInstances.reduce((sum, instance) => sum + instance.lspChildren.length, 0);
90
+ const hasCompleteFreshSamples = rssCeilingBytes !== undefined &&
91
+ liveInstances.length > 0 &&
92
+ liveInstances.every((instance) => {
93
+ const heartbeatMs = Date.parse(instance.heartbeatAt);
94
+ return (Number.isFinite(heartbeatMs) &&
95
+ now - heartbeatMs <= STALE_HEARTBEAT_MS &&
96
+ Number.isFinite(instance.rssBytes) &&
97
+ instance.lspChildren.every((child) => Number.isFinite(child.rssBytes)));
98
+ });
99
+ const totalRssBytes = hasCompleteFreshSamples
100
+ ? liveInstances.reduce((sum, instance) => sum +
101
+ instance.rssBytes +
102
+ instance.lspChildren.reduce((childSum, child) => childSum + (child.rssBytes ?? 0), 0), 0)
103
+ : undefined;
104
+ const rssPressure = totalRssBytes !== undefined &&
105
+ rssCeilingBytes !== undefined &&
106
+ totalRssBytes >= rssCeilingBytes;
107
+ const overBudget = totalLiveLspServers >= ceiling || rssPressure;
78
108
  return {
79
109
  totalLiveLspServers,
80
110
  ceiling,
111
+ totalRssBytes,
112
+ rssCeilingBytes,
113
+ rssPressure,
81
114
  overBudget,
82
115
  degradeAuxiliary: overBudget,
116
+ shortenIdleTimeout: overBudget,
117
+ preferPullOnly: overBudget,
83
118
  };
84
119
  }
85
120
  // --- Module-scope decision cache, read by clients/dispatch/auxiliary-lsp.ts ---
@@ -98,6 +133,12 @@ let cachedDecision;
98
133
  export function shouldDegradeAuxiliaryLsp() {
99
134
  return cachedDecision?.degradeAuxiliary ?? false;
100
135
  }
136
+ export function shouldShortenLspIdleTimeout() {
137
+ return cachedDecision?.shortenIdleTimeout ?? false;
138
+ }
139
+ export function shouldPreferPullOnlyDiagnostics() {
140
+ return cachedDecision?.preferPullOnly ?? false;
141
+ }
101
142
  /** Test-only: reset the module-scope cache between tests. */
102
143
  export function _resetLspBudgetDecisionForTests() {
103
144
  cachedDecision = undefined;
@@ -110,22 +151,16 @@ export function _resetLspBudgetDecisionForTests() {
110
151
  * default, matching every other best-effort registry consumer in this
111
152
  * codebase (registerInstance/sweepOrphans).
112
153
  *
113
- * Not implemented in this prototype (documented follow-up, see the issue):
114
- * shortening THIS session's own idle-reaper timeout when over budget. Skip-
115
- * auxiliary was chosen as the single mechanism for the first slice because
116
- * it's a pure spawn-time decision (no interaction with already-warm clients),
117
- * whereas an idle-timeout change would need to reach into already-configured
118
- * per-server wait/reap state.
119
154
  */
120
- export async function checkCrossProcessLspBudget() {
155
+ export async function checkCrossProcessLspBudget(testOverrides = {}) {
121
156
  if (!isCrossProcessBudgetEnabled() || !isInstanceRegistryEnabled())
122
157
  return;
123
158
  try {
124
- const registry = await readInstanceRegistry();
159
+ const registry = testOverrides.registry ?? (await readInstanceRegistry());
125
160
  if (registry.length === 0)
126
161
  return; // nothing to be over budget against
127
162
  const ceiling = getLspBudgetCeiling();
128
- const decision = decideLspBudget(registry, realIsPidAlive, ceiling);
163
+ const decision = decideLspBudget(registry, testOverrides.isPidAlive ?? realIsPidAlive, ceiling, getLspBudgetRssCeilingBytes());
129
164
  cachedDecision = decision;
130
165
  if (decision.overBudget) {
131
166
  logLatency({
@@ -136,6 +171,9 @@ export async function checkCrossProcessLspBudget() {
136
171
  metadata: {
137
172
  totalLiveLspServers: decision.totalLiveLspServers,
138
173
  ceiling: decision.ceiling,
174
+ totalRssBytes: decision.totalRssBytes,
175
+ rssCeilingBytes: decision.rssCeilingBytes,
176
+ rssPressure: decision.rssPressure,
139
177
  instanceCount: registry.length,
140
178
  },
141
179
  });
@@ -147,7 +147,7 @@ export async function analyzeFile(filePath, cwd, options = {}) {
147
147
  : path.resolve(cwd, filePath);
148
148
  // no-delta by default → a full snapshot every call (not delta-filtered);
149
149
  // caller flags win over the default.
150
- const host = createMcpHost({ "no-delta": true, ...(options.flags ?? {}) });
150
+ const host = createMcpHost({ "no-delta": true, ...(options.flags ?? {}) }, cwd);
151
151
  if (options.warmLsp !== false) {
152
152
  await warmLspForFile(absPath, host);
153
153
  }
@@ -3,25 +3,29 @@
3
3
  *
4
4
  * pi-lens's dispatch core only couples to the host through a single-method
5
5
  * interface — `PiAgentAPI.getFlag`. When pi-lens runs *inside pi*, that flag
6
- * resolver is backed by pi's CLI flags + global config. When it runs as an MCP
7
- * server (no pi process, no CLI flags), we back it by the global config + env +
8
- * optional per-call overrides instead. This is the *entire* host coupling the
9
- * MCP path has to satisfy — everything else under `clients/` is host-neutral.
6
+ * resolver is backed by pi's CLI flags + project/global config. When it runs as
7
+ * an MCP server (no pi process, no CLI flags), we back it by project/global
8
+ * config + env + optional per-call overrides instead. This is the *entire* host
9
+ * coupling the MCP path has to satisfy — everything else under `clients/` is
10
+ * host-neutral.
10
11
  */
11
12
  import { loadPiLensGlobalConfig, resolvePiLensFlag, } from "../lens-config.js";
13
+ import { loadPiLensProjectConfig } from "../project-lens-config.js";
12
14
  /**
13
15
  * Build a `PiAgentAPI` for the MCP path. `overrides` lets a single MCP tool call
14
16
  * pin flags for that analysis (e.g. `no-lsp: true` to bench the non-LSP path)
15
- * without mutating global config. Precedence: override → global config default.
17
+ * without mutating config. Precedence: override → project config → global
18
+ * config default.
16
19
  */
17
- export function createMcpHost(overrides) {
20
+ export function createMcpHost(overrides, projectRoot = process.cwd()) {
18
21
  const config = loadPiLensGlobalConfig();
22
+ const projectConfig = loadPiLensProjectConfig(projectRoot);
19
23
  return {
20
- getFlag(name) {
24
+ getFlag(name, filePath) {
21
25
  if (overrides && Object.hasOwn(overrides, name)) {
22
26
  return overrides[name];
23
27
  }
24
- return resolvePiLensFlag(name, undefined, config);
28
+ return resolvePiLensFlag(name, undefined, config, projectConfig, filePath, projectRoot);
25
29
  },
26
30
  };
27
31
  }
@@ -16,6 +16,8 @@ import * as crypto from "node:crypto";
16
16
  import * as net from "node:net";
17
17
  import * as os from "node:os";
18
18
  import * as path from "node:path";
19
+ export const WARM_DIAGNOSTICS_SCHEMA_VERSION = 1;
20
+ export const WARM_CODE_ACTION_LOOKUP_LIMIT = 6;
19
21
  /**
20
22
  * Stable per-workspace endpoint path. The server (from its launch cwd) and the
21
23
  * hook (from the PostToolUse cwd) must resolve the same path — both hash the
@@ -36,6 +38,122 @@ export function ipcPathForCwd(cwd) {
36
38
  }
37
39
  return path.join(os.tmpdir(), `pi-lens-mcp-${hash}.sock`);
38
40
  }
41
+ /** PID-scoped endpoint used by pi sessions. The legacy MCP analyze endpoint
42
+ * remains workspace-scoped for compatibility with the PostToolUse hook. */
43
+ export function diagnosticsIpcPathForCwd(cwd, pid) {
44
+ const base = ipcPathForCwd(cwd);
45
+ if (process.platform === "win32")
46
+ return `${base}-diagnostics-${pid}`;
47
+ return base.replace(/\.sock$/, `-diagnostics-${pid}.sock`);
48
+ }
49
+ export function contentHash(content) {
50
+ return crypto.createHash("sha256").update(content).digest("hex");
51
+ }
52
+ /**
53
+ * Shared one-shot request/response transport for the warm-attach IPC routes
54
+ * (#822): connect to the incumbent's PID-scoped endpoint, write one JSON
55
+ * line, read one JSON line back, classify. The per-route `validate` callback
56
+ * returns a failure reason for an on-time but unusable reply (schema skew,
57
+ * staleness) or `undefined` to accept it; transport failures map uniformly to
58
+ * timeout/ipc-error. One transport, N routes — the diagnostics and
59
+ * code-action clients cannot drift apart.
60
+ */
61
+ function requestOverWarmIpc(cwd, incumbentPid, timeoutMs, buildRequest, validate) {
62
+ return new Promise((resolve) => {
63
+ let settled = false;
64
+ const finish = (value) => {
65
+ if (settled)
66
+ return;
67
+ settled = true;
68
+ clearTimeout(timer);
69
+ socket.destroy();
70
+ resolve(value);
71
+ };
72
+ const deadlineAt = Date.now() + timeoutMs;
73
+ const socket = net.createConnection(diagnosticsIpcPathForCwd(cwd, incumbentPid));
74
+ socket.setEncoding("utf8");
75
+ let buffer = "";
76
+ const timer = setTimeout(() => finish({ available: false, reason: "timeout" }), timeoutMs);
77
+ timer.unref();
78
+ socket.on("connect", () => {
79
+ socket.write(`${JSON.stringify(buildRequest(deadlineAt))}\n`);
80
+ });
81
+ socket.on("data", (chunk) => {
82
+ buffer += chunk;
83
+ const newline = buffer.indexOf("\n");
84
+ if (newline === -1)
85
+ return;
86
+ try {
87
+ const message = JSON.parse(buffer.slice(0, newline));
88
+ const result = message.result;
89
+ if (message.error || !result) {
90
+ finish({ available: false, reason: "ipc-error" });
91
+ return;
92
+ }
93
+ const reason = validate(result, deadlineAt);
94
+ if (reason === undefined) {
95
+ finish({ available: true, response: result });
96
+ }
97
+ else {
98
+ finish({ available: false, reason });
99
+ }
100
+ }
101
+ catch {
102
+ finish({ available: false, reason: "schema-mismatch" });
103
+ }
104
+ });
105
+ socket.on("error", () => finish({ available: false, reason: "ipc-error" }));
106
+ socket.on("close", () => finish({ available: false, reason: "ipc-error" }));
107
+ });
108
+ }
109
+ export function requestWarmDiagnostics(cwd, incumbentPid, file, content, timeoutMs) {
110
+ const expectedHash = contentHash(content);
111
+ return requestOverWarmIpc(cwd, incumbentPid, timeoutMs, (deadlineAt) => ({
112
+ route: "diagnostics",
113
+ version: WARM_DIAGNOSTICS_SCHEMA_VERSION,
114
+ file,
115
+ cwd,
116
+ content,
117
+ contentHash: expectedHash,
118
+ deadlineAt,
119
+ }), (result, deadlineAt) => {
120
+ if (result.route !== "diagnostics" ||
121
+ result.version !== WARM_DIAGNOSTICS_SCHEMA_VERSION) {
122
+ return "schema-mismatch";
123
+ }
124
+ if (!result.fresh ||
125
+ result.inconclusive ||
126
+ result.contentHash !== expectedHash ||
127
+ result.servedAt > deadlineAt) {
128
+ return "stale-answer";
129
+ }
130
+ return undefined;
131
+ });
132
+ }
133
+ export function requestWarmCodeActions(cwd, incumbentPid, file, expectedContentHash, ranges, timeoutMs) {
134
+ return requestOverWarmIpc(cwd, incumbentPid, timeoutMs, (deadlineAt) => ({
135
+ route: "code-actions",
136
+ version: WARM_DIAGNOSTICS_SCHEMA_VERSION,
137
+ file,
138
+ cwd,
139
+ contentHash: expectedContentHash,
140
+ ranges,
141
+ deadlineAt,
142
+ }), (result, deadlineAt) => {
143
+ if (result.route !== "code-actions" ||
144
+ result.version !== WARM_DIAGNOSTICS_SCHEMA_VERSION ||
145
+ !Array.isArray(result.actions) ||
146
+ result.actions.length !== ranges.length ||
147
+ result.actions.some((actions) => !Array.isArray(actions))) {
148
+ return "schema-mismatch";
149
+ }
150
+ if (result.contentHash !== expectedContentHash ||
151
+ result.servedAt > deadlineAt) {
152
+ return "stale-answer";
153
+ }
154
+ return undefined;
155
+ });
156
+ }
39
157
  /**
40
158
  * Ask the warm server to analyze a file. Resolves the server's result, or
41
159
  * `undefined` on ANY failure (no server, refused, stale socket, timeout, bad
@@ -58,7 +58,7 @@ function joinMessages(consumed) {
58
58
  */
59
59
  export async function runSessionStart(cwd) {
60
60
  const ctx = await getMcpSessionContext();
61
- const host = createMcpHost();
61
+ const host = createMcpHost(undefined, cwd);
62
62
  await handleSessionStart({
63
63
  ctxCwd: cwd,
64
64
  // The MCP server has no TUI keystroke latency to protect, so the
@@ -112,7 +112,7 @@ export async function runSessionStart(cwd) {
112
112
  */
113
113
  export async function runTurnEnd(cwd, files = []) {
114
114
  const ctx = await getMcpSessionContext();
115
- const host = createMcpHost();
115
+ const host = createMcpHost(undefined, cwd);
116
116
  let registered = 0;
117
117
  for (const file of files) {
118
118
  const abs = path.isAbsolute(file) ? file : path.resolve(cwd, file);
@@ -315,9 +315,9 @@ async function tryDartFix(filePath) {
315
315
  return diffProjectSnapshot(pubspecDir, before);
316
316
  }
317
317
  // --- Pipeline phase helpers ---
318
- export async function runAutofix(filePath, cwd, getFlag, dbg, deps) {
318
+ export async function runAutofix(filePath, cwd, getFlag, dbg, deps, getFlagSource) {
319
319
  const { biomeClient, ruffClient, fixedThisTurn } = deps;
320
- const noAutofix = getFlag("no-autofix");
320
+ const noAutofix = getFlag("no-autofix", filePath);
321
321
  let fixedCount = 0;
322
322
  const autofixTools = [];
323
323
  const attemptedTools = [];
@@ -336,7 +336,8 @@ export async function runAutofix(filePath, cwd, getFlag, dbg, deps) {
336
336
  };
337
337
  }
338
338
  if (noAutofix) {
339
- dbg(`autofix: skipped for ${filePath} (--no-autofix)`);
339
+ const source = getFlagSource?.("no-autofix", filePath);
340
+ dbg(`autofix: skipped for ${filePath} (--no-autofix${source ? `, source=${source}` : ""})`);
340
341
  return {
341
342
  fixedCount,
342
343
  autofixTools,
@@ -758,7 +759,7 @@ function buildEnrichedBlockerOutput(blockers, fileContent) {
758
759
  }
759
760
  // --- Main Pipeline ---
760
761
  export async function runPipeline(ctx, deps) {
761
- const { filePath, cwd, toolName, getFlag, dbg } = ctx;
762
+ const { filePath, cwd, toolName, getFlag, getFlagSource, dbg } = ctx;
762
763
  const { getFormatService } = deps;
763
764
  const phase = createPhaseTracker(toolName, filePath);
764
765
  const pipelineStart = Date.now();
@@ -780,7 +781,7 @@ export async function runPipeline(ctx, deps) {
780
781
  let formattersUsed = [];
781
782
  let formatFailures = [];
782
783
  const piChangedFiles = new Set();
783
- const autoformatDisabled = !!getFlag("no-autoformat");
784
+ const autoformatDisabled = !!getFlag("no-autoformat", filePath);
784
785
  const immediateFormat = !!getFlag("immediate-format");
785
786
  const formatDeferred = !autoformatDisabled && !immediateFormat && !!fileContent;
786
787
  if (!autoformatDisabled && immediateFormat && fileContent) {
@@ -808,6 +809,10 @@ export async function runPipeline(ctx, deps) {
808
809
  else if (formatDeferred) {
809
810
  dbg(`autoformat: deferred until agent_end for ${filePath}`);
810
811
  }
812
+ else if (autoformatDisabled) {
813
+ const source = getFlagSource?.("no-autoformat", filePath);
814
+ dbg(`autoformat: skipped for ${filePath} (--no-autoformat${source ? `, source=${source}` : ""})`);
815
+ }
811
816
  phase.end("format", {
812
817
  formattersUsed,
813
818
  formatChanged,
@@ -815,7 +820,7 @@ export async function runPipeline(ctx, deps) {
815
820
  });
816
821
  // --- 3. Auto-fix ---
817
822
  phase.start("autofix");
818
- const { fixedCount, autofixTools, attemptedTools, changedFiles: autofixChangedFiles, needsContentRefresh: fixRefresh, skipReason: autofixSkipReason, } = await runAutofix(filePath, cwd, getFlag, dbg, deps);
823
+ const { fixedCount, autofixTools, attemptedTools, changedFiles: autofixChangedFiles, needsContentRefresh: fixRefresh, skipReason: autofixSkipReason, } = await runAutofix(filePath, cwd, getFlag, dbg, deps, getFlagSource);
819
824
  for (const changedFile of autofixChangedFiles) {
820
825
  piChangedFiles.add(path.resolve(changedFile));
821
826
  }
@@ -69,6 +69,7 @@
69
69
  import * as fs from "node:fs";
70
70
  import * as path from "node:path";
71
71
  import { getKnipIgnorePatterns } from "../file-utils.js";
72
+ import { isAtOrAboveHomeDir } from "../path-utils.js";
72
73
  import { GitleaksClient } from "../gitleaks-client.js";
73
74
  import { GovulncheckClient } from "../govulncheck-client.js";
74
75
  import { TrivyClient } from "../trivy-client.js";
@@ -107,8 +108,26 @@ function pushUnique(list, id) {
107
108
  * (see the module header for why this races rather than cancels in-flight
108
109
  * spawns).
109
110
  */
110
- export async function fetchFreshProjectDiagnostics(cacheManager, cwd, clients, signal) {
111
+ export async function fetchFreshProjectDiagnostics(cacheManager, cwd, clients, signal, options = {}) {
111
112
  const analysisRoot = path.resolve(cwd);
113
+ // #747: refuse to spawn any heavyweight analyzer when the analysis root is
114
+ // at — or above — the home directory (the #250/#253 escape class). Every
115
+ // analyzer here treats `analysisRoot` as a whole tree to walk; from $HOME
116
+ // that means scanning every unrelated repo under it (observed: a jscpd run
117
+ // from a WSL home reached 44 GB RSS and OOM-killed the whole instance).
118
+ // Same ceiling as startup-scan.ts / runtime-session.ts's resolveSnapshotRoot
119
+ // / review-graph's buildOrUpdateGraph; like the latter, there is no safe
120
+ // substitute root to fall back to — the caller's `paths` scope only filters
121
+ // REPORTED results, it never narrows what these analyzers walk.
122
+ if (isAtOrAboveHomeDir(analysisRoot, options.homeDir)) {
123
+ return {
124
+ diagnostics: [],
125
+ runners: [],
126
+ cold: [...ANALYZER_IDS],
127
+ timings: {},
128
+ unsafeRoot: true,
129
+ };
130
+ }
112
131
  const diagnostics = [];
113
132
  const runners = [];
114
133
  const cold = [];
@@ -6,13 +6,14 @@ import { runProviders } from "../dispatch/fact-runner.js";
6
6
  import { FactStore } from "../dispatch/fact-store.js";
7
7
  import { canHandle as astGrepCanHandle, evaluateAstGrepRules, getLang as astGrepGetLang, loadSg, } from "../dispatch/runners/ast-grep-napi.js";
8
8
  import { isTestFile } from "../file-utils.js";
9
- import { collectSourceFilesAsync } from "../source-filter.js";
9
+ import { isAtOrAboveHomeDir } from "../path-utils.js";
10
+ import { getProjectDiagnosticsScannerMaxFiles } from "../project-scale.js";
11
+ import { collectSourceFilesWithBudgetAsync } from "../source-filter.js";
10
12
  import { getSharedTreeSitterClient } from "../tree-sitter-shared.js";
11
13
  import { TreeSitterQueryLoader } from "../tree-sitter-query-loader.js";
12
14
  import { PROJECT_DIAGNOSTICS_CACHE_VERSION, saveProjectDiagnosticsSnapshot, } from "./cache.js";
13
15
  // Side-effect import: registers fact providers and fact rules.
14
16
  import "../dispatch/integration.js";
15
- const DEFAULT_MAX_FILES = 500;
16
17
  // Skip files this large: matches the per-edit ast-grep runner's guard so a single
17
18
  // generated megafile can't dominate a project scan.
18
19
  const AST_GREP_MAX_FILE_BYTES = 1024 * 1024;
@@ -196,10 +197,36 @@ async function scanAstGrepNapi(cwd, files) {
196
197
  export async function scanProjectDiagnostics(options) {
197
198
  const cwd = path.resolve(options.cwd);
198
199
  const { signal } = options;
199
- const maxFiles = Math.max(1, options.maxFiles ?? DEFAULT_MAX_FILES);
200
- const files = options.files
201
- ? options.files.slice(0, maxFiles)
202
- : await collectSourceFilesAsync(cwd, { maxFiles });
200
+ // #747/#250: refuse to WALK from a cwd at — or above — the home directory.
201
+ // The cheap tier is bounded by DEFAULT_MAX_FILES, but from $HOME it still
202
+ // traverses a huge unrelated tree until it happens to keep that many source
203
+ // files. An explicit `files` list (#461) is a caller-chosen subset, not a
204
+ // walk, so it is never refused here. Same ceiling as fresh-fetch.ts.
205
+ if (!options.files && isAtOrAboveHomeDir(cwd, options.homeDir)) {
206
+ return {
207
+ version: PROJECT_DIAGNOSTICS_CACHE_VERSION,
208
+ cwd,
209
+ tier: options.tier,
210
+ scannedAt: new Date().toISOString(),
211
+ diagnostics: [],
212
+ filesScanned: 0,
213
+ runners: [],
214
+ unsafeRoot: true,
215
+ };
216
+ }
217
+ const maxFiles = Math.max(1, options.maxFiles ?? getProjectDiagnosticsScannerMaxFiles(cwd));
218
+ // #760: bound the walk by entries VISITED, not just files kept — a mixed
219
+ // tree with few source files among a huge pile of non-source files never
220
+ // trips `maxFiles`. Unlike `unsafeRoot` this is not a refusal: when the
221
+ // budget trips we scan the truncated best-effort list and flag the snapshot
222
+ // so callers don't read the partial result as a complete sweep.
223
+ const collected = options.files
224
+ ? { files: options.files.slice(0, maxFiles), entryBudgetExceeded: false }
225
+ : await collectSourceFilesWithBudgetAsync(cwd, {
226
+ maxFiles,
227
+ maxScanEntries: options.maxScanEntries,
228
+ });
229
+ const files = collected.files;
203
230
  // Check cancellation at each phase boundary so a full-mode scan stops
204
231
  // promptly when the agent/user aborts (#341). The per-phase runners are
205
232
  // already file-capped, so phase granularity is enough to bound the work.
@@ -226,6 +253,10 @@ export async function scanProjectDiagnostics(options) {
226
253
  filesScanned: files.length,
227
254
  runners,
228
255
  };
256
+ // #760: only present when true — keeps existing snapshots/serializations
257
+ // byte-identical for the untruncated (normal) case.
258
+ if (collected.entryBudgetExceeded)
259
+ snapshot.scanTruncated = true;
229
260
  // A cancelled scan yields a partial snapshot; don't persist it as the
230
261
  // authoritative cross-session cache — only a complete run is cacheable.
231
262
  // Likewise, an explicit `files` scan (#461) only covers a caller-chosen