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
|
@@ -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
|
|
24
|
-
*
|
|
25
|
-
* the
|
|
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
|
|
77
|
-
const
|
|
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
|
|
7
|
-
* server (no pi process, no CLI flags), we back it by
|
|
8
|
-
* optional per-call overrides instead. This is the *entire* host
|
|
9
|
-
* MCP path has to satisfy — everything else under `clients/` is
|
|
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
|
|
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
|
}
|
package/dist/clients/mcp/ipc.js
CHANGED
|
@@ -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);
|
package/dist/clients/pipeline.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|