@herbertgao/pi-extensions 2026.8.5 → 2026.8.6
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/README.md +5 -5
- package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/context.ts +74 -5
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +48 -6
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
- package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +10 -0
- package/node_modules/@herbertgao/pi-subagents/package.json +3 -3
- package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +5 -2
- package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +15 -6
- package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +9 -5
- package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +2 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +20 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +6 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
- package/node_modules/@narumitw/pi-btw/README.md +24 -17
- package/node_modules/@narumitw/pi-btw/package.json +5 -5
- package/node_modules/@narumitw/pi-btw/src/btw.ts +4 -2
- package/node_modules/@narumitw/pi-btw/src/menu.ts +33 -13
- package/node_modules/@narumitw/pi-btw/src/settings.ts +22 -2
- package/node_modules/pi-lens/CHANGELOG.md +95 -0
- package/node_modules/pi-lens/dist/clients/advisory-provenance.js +314 -0
- package/node_modules/pi-lens/dist/clients/agent-nudge.js +14 -7
- package/node_modules/pi-lens/dist/clients/biome-client.js +121 -13
- package/node_modules/pi-lens/dist/clients/bus-events-logger.js +62 -6
- package/node_modules/pi-lens/dist/clients/bus-publish.js +11 -3
- package/node_modules/pi-lens/dist/clients/cascade-format.js +57 -2
- package/node_modules/pi-lens/dist/clients/console-guard-install.js +16 -4
- package/node_modules/pi-lens/dist/clients/dead-code-client.js +135 -30
- package/node_modules/pi-lens/dist/clients/dependency-checker.js +19 -7
- package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +6 -4
- package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +10 -3
- package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +114 -10
- package/node_modules/pi-lens/dist/clients/dispatch/integration.js +101 -16
- package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +37 -4
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +226 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +69 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +230 -50
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +97 -0
- package/node_modules/pi-lens/dist/clients/disposition-publish.js +10 -3
- package/node_modules/pi-lens/dist/clients/eval-timestamp.js +17 -0
- package/node_modules/pi-lens/dist/clients/extension-log.js +296 -3
- package/node_modules/pi-lens/dist/clients/fix-worklog.js +5 -1
- package/node_modules/pi-lens/dist/clients/format-events-publish.js +39 -8
- package/node_modules/pi-lens/dist/clients/git-guard.js +18 -21
- package/node_modules/pi-lens/dist/clients/go-client.js +21 -39
- package/node_modules/pi-lens/dist/clients/govulncheck-client.js +116 -7
- package/node_modules/pi-lens/dist/clients/host-ports.js +1 -1
- package/node_modules/pi-lens/dist/clients/installer/index.js +5 -1
- package/node_modules/pi-lens/dist/clients/jscpd-client.js +4 -9
- package/node_modules/pi-lens/dist/clients/knip-client.js +51 -14
- package/node_modules/pi-lens/dist/clients/latency-logger.js +50 -1
- package/node_modules/pi-lens/dist/clients/lens-events.js +56 -25
- package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
- package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +45 -2
- package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +30 -4
- package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +58 -13
- package/node_modules/pi-lens/dist/clients/lsp/client.js +169 -8
- package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +29 -0
- package/node_modules/pi-lens/dist/clients/lsp/index.js +264 -66
- package/node_modules/pi-lens/dist/clients/lsp/server.js +247 -60
- package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +96 -0
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +21 -5
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +18 -3
- package/node_modules/pi-lens/dist/clients/mcp/analyze.js +4 -0
- package/node_modules/pi-lens/dist/clients/mcp/session.js +30 -17
- package/node_modules/pi-lens/dist/clients/model-provider.js +53 -0
- package/node_modules/pi-lens/dist/clients/pipeline.js +29 -2
- package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +1 -1
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +23 -2
- package/node_modules/pi-lens/dist/clients/review-graph/query.js +24 -0
- package/node_modules/pi-lens/dist/clients/run-duration.js +55 -0
- package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +153 -7
- package/node_modules/pi-lens/dist/clients/runtime-context.js +104 -11
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +170 -22
- package/node_modules/pi-lens/dist/clients/runtime-session.js +28 -5
- package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +124 -10
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +388 -35
- package/node_modules/pi-lens/dist/clients/rust-client.js +21 -37
- package/node_modules/pi-lens/dist/clients/security-scan-client.js +88 -5
- package/node_modules/pi-lens/dist/clients/sg-runner.js +141 -23
- package/node_modules/pi-lens/dist/clients/smells-rollup.js +18 -11
- package/node_modules/pi-lens/dist/clients/startup-timing.js +7 -1
- package/node_modules/pi-lens/dist/clients/test-runner-client.js +431 -24
- package/node_modules/pi-lens/dist/clients/tool-policy.js +2 -0
- package/node_modules/pi-lens/dist/clients/tool-set-policy.js +76 -0
- package/node_modules/pi-lens/dist/clients/warm-attach.js +17 -0
- package/node_modules/pi-lens/dist/clients/word-index.js +305 -33
- package/node_modules/pi-lens/dist/index.js +4827 -1633
- package/node_modules/pi-lens/dist/mcp/server.js +8 -2
- package/node_modules/pi-lens/dist/tools/activate-tools.js +17 -5
- package/node_modules/pi-lens/dist/tools/ast-grep-replace.js +9 -4
- package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +5 -2
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +3 -2
- package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +62 -7
- package/node_modules/pi-lens/dist/tools/symbol-search.js +1 -1
- package/node_modules/pi-lens/docs/agent-guide.md +38 -13
- package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +11 -3
- package/node_modules/pi-lens/docs/features.md +14 -1
- package/node_modules/pi-lens/docs/globalconfig.md +11 -0
- package/node_modules/pi-lens/docs/servercapabilities.md +1 -1
- package/node_modules/pi-lens/docs/settings.md +6 -0
- package/node_modules/pi-lens/docs/usage.md +23 -5
- package/node_modules/pi-lens/docs/word-index.md +35 -0
- package/node_modules/pi-lens/package.json +1 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-chained-type-assertions-test.yml +8 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-js-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +8 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +8 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-laundering-test.yml +11 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +21 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread-js.yml +21 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread.yml +29 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +13 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +16 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +27 -0
- package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +79 -0
- package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +10 -11
- package/node_modules/pi-lens/skills/pi-lens-lsp-navigation/SKILL.md +22 -22
- package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +8 -114
- package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +129 -0
- package/node_modules/pi-lens/skills/pi-lens-write-tree-sitter-rule/SKILL.md +3 -1
- package/node_modules/pi-mcp-adapter/CHANGELOG.md +13 -0
- package/node_modules/pi-mcp-adapter/README.md +4 -1
- package/node_modules/pi-mcp-adapter/agent-dir.ts +12 -4
- package/node_modules/pi-mcp-adapter/cli.js +25 -4
- package/node_modules/pi-mcp-adapter/config.ts +4 -4
- package/node_modules/pi-mcp-adapter/direct-tools.ts +18 -15
- package/node_modules/pi-mcp-adapter/mcp-setup-panel.ts +2 -1
- package/node_modules/pi-mcp-adapter/metadata-cache.ts +18 -8
- package/node_modules/pi-mcp-adapter/package.json +2 -1
- package/node_modules/pi-mcp-adapter/request-headers-command.ts +336 -0
- package/node_modules/pi-mcp-adapter/server-manager.ts +5 -0
- package/node_modules/pi-mcp-adapter/tool-metadata.ts +38 -18
- package/node_modules/pi-mcp-adapter/types.ts +93 -10
- package/node_modules/pi-web-access/CHANGELOG.md +14 -0
- package/node_modules/pi-web-access/README.md +18 -12
- package/node_modules/pi-web-access/auth-fetch.ts +148 -0
- package/node_modules/pi-web-access/chrome-cookies.ts +110 -23
- package/node_modules/pi-web-access/curator-page.ts +5 -3
- package/node_modules/pi-web-access/curator-server.ts +2 -1
- package/node_modules/pi-web-access/extract.ts +106 -34
- package/node_modules/pi-web-access/fetch-params.ts +17 -3
- package/node_modules/pi-web-access/firecrawl.ts +172 -12
- package/node_modules/pi-web-access/gemini-search.ts +18 -4
- package/node_modules/pi-web-access/index.ts +120 -48
- package/node_modules/pi-web-access/package.json +2 -2
- package/node_modules/pi-web-access/summary-review.ts +11 -5
- package/node_modules/pi-web-access/youtube-extract.ts +2 -2
- package/package.json +9 -9
|
@@ -9,8 +9,10 @@ import { resolveRunnerPath, toRunnerDisplayPath, } from "./dispatch/runner-conte
|
|
|
9
9
|
import { getKnipIgnorePatterns } from "./file-utils.js";
|
|
10
10
|
import { isTestRoleCollateral } from "./collateral-test-role.js";
|
|
11
11
|
import { dedupeSecretFindings, fromAstGrepWarnings, fromGitleaks, fromTrivySecrets, isSecretWarning, secretLocationKey, } from "./secret-findings.js";
|
|
12
|
-
import {
|
|
12
|
+
import { deadCodeIssueKey, deadCodeIssues, formatDeadCodeDelta, } from "./dead-code-client.js";
|
|
13
|
+
import { logDeadCodeScan } from "./dead-code-logger.js";
|
|
13
14
|
import { PROJECT_DIAGNOSTICS_CACHE_VERSION, writeProjectDiagnosticsDeltaReport, } from "./project-diagnostics/cache.js";
|
|
15
|
+
import { deadCodeIssueToProjectDiagnostic } from "./project-diagnostics/runner-adapters/dead-code.js";
|
|
14
16
|
import { knipIssuesToProjectDiagnostics } from "./project-diagnostics/runner-adapters/knip.js";
|
|
15
17
|
import { logLatency } from "./latency-logger.js";
|
|
16
18
|
import { getLspBudgetIdleTimeoutMs, shouldShortenLspIdleTimeout, } from "./lsp-budget.js";
|
|
@@ -18,6 +20,19 @@ import { updateHeartbeat } from "./instance-registry.js";
|
|
|
18
20
|
import { emitLensTurnFindings } from "./lens-events.js";
|
|
19
21
|
import { RUNTIME_CONFIG } from "./runtime-config.js";
|
|
20
22
|
import { isSubagentSession } from "./subagent-mode.js";
|
|
23
|
+
import { formatRunDurationMs } from "./run-duration.js";
|
|
24
|
+
import { MAX_ADVISORY_AFFECTED_FILES, dropFindingsForMissingPaths, snapshotAdvisoryProvenance, } from "./advisory-provenance.js";
|
|
25
|
+
/**
|
|
26
|
+
* Would writing `next` over `prev` throw away a good scan for a failed one?
|
|
27
|
+
*
|
|
28
|
+
* A failed run carries no findings. Writing it evicts the last good result, and
|
|
29
|
+
* every later reader then serves the failure as the answer — a 194-byte "not
|
|
30
|
+
* available" record replaced 149 KB of real findings in every dogfood project
|
|
31
|
+
* (#925, #1467). Callers keep the previous cache when this returns true.
|
|
32
|
+
*/
|
|
33
|
+
function wouldPoisonCache(prev, next) {
|
|
34
|
+
return !next.success && prev?.data.success === true;
|
|
35
|
+
}
|
|
21
36
|
// LSP idle reset scheduling — prevents thrashing by delaying shutdown
|
|
22
37
|
let lspIdleResetTimeout = null;
|
|
23
38
|
function emitIdleResetReporterWarning(reportErr) {
|
|
@@ -132,7 +147,15 @@ export async function handleTurnEnd(deps) {
|
|
|
132
147
|
turnState = cacheManager.readTurnState(cwd);
|
|
133
148
|
}
|
|
134
149
|
const files = Object.keys(turnState.files);
|
|
135
|
-
|
|
150
|
+
// R1 (#1443 follow-up): a read-only turn (no files touched) must not take
|
|
151
|
+
// the fast idle-reset path while a carried cascade run — or one still
|
|
152
|
+
// settling — is waiting for its delivery opportunity. Falling through to
|
|
153
|
+
// the normal pipeline lets the settle/drain/merge logic below run exactly
|
|
154
|
+
// as it does for an edit turn, so a carried finding reaches the agent
|
|
155
|
+
// instead of dying unrendered. `hasCascadeRuns()` is a cheap peek (no
|
|
156
|
+
// pending work almost every turn), so the common read-only turn still
|
|
157
|
+
// takes the early return below.
|
|
158
|
+
if (files.length === 0 && !runtime.hasCascadeRuns()) {
|
|
136
159
|
// A genuinely clean session must invalidate the persisted guard record.
|
|
137
160
|
// Blocker records are retained only while the runtime still reports one.
|
|
138
161
|
if (getFlag("lens-guard") && !runtime.gitGuardHasBlockers) {
|
|
@@ -209,10 +232,58 @@ export async function handleTurnEnd(deps) {
|
|
|
209
232
|
const cascadeRuns = runtime.consumeCascadeRuns().filter((run) => {
|
|
210
233
|
const originSeq = run.origin?.projectSeq;
|
|
211
234
|
const originTurn = run.origin?.turnSeq;
|
|
212
|
-
// A deferred result from
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
235
|
+
// A deferred result from AFTER a later write is not current state. Old test
|
|
236
|
+
// fixtures without provenance remain accepted for compatibility.
|
|
237
|
+
//
|
|
238
|
+
// #1443: `turnSeq` alone is NOT a supersede signal, and it used to be an
|
|
239
|
+
// unconditional reject. Every LATE run — one whose compute missed the
|
|
240
|
+
// settle cap and was re-parked by `settleCascadeRuns`, and one the
|
|
241
|
+
// quiet-window reconcile appended after this turn's predecessor already
|
|
242
|
+
// consumed (carried across turn_start by `beginTurn`) — is BY DEFINITION
|
|
243
|
+
// from an earlier turn, so `originTurn === runtime.turnIndex` was always
|
|
244
|
+
// false for exactly the runs the carry-over was built to preserve. Both
|
|
245
|
+
// producers' contracts were dead code: the measured cases were the two
|
|
246
|
+
// highest-fan-out cascades of the day (38 and 40 neighbours).
|
|
247
|
+
//
|
|
248
|
+
// R2 (#1443 follow-up): `projectSeq` alone is NOT a per-file supersede
|
|
249
|
+
// signal — it is GLOBAL, advancing on every pi-observed write anywhere in
|
|
250
|
+
// the project. Rejecting on any mismatch meant an edit to an unrelated
|
|
251
|
+
// file superseded a run that had nothing to do with it, reintroducing the
|
|
252
|
+
// exact 38/40-neighbour loss #1443 was written to fix, one filter down.
|
|
253
|
+
// `getFilesChangedSince` (#451) is the honest per-file signal: a run is
|
|
254
|
+
// superseded only if its own primary file or one of its neighbours was
|
|
255
|
+
// actually rewritten since it launched. A late-but-not-superseded run is
|
|
256
|
+
// surfaced; a superseded one is dropped with a RECORD (never silently),
|
|
257
|
+
// so the loss stays countable.
|
|
258
|
+
if (originSeq !== undefined) {
|
|
259
|
+
const changedSince = runtime.getFilesChangedSince(originSeq);
|
|
260
|
+
if (changedSince.length > 0) {
|
|
261
|
+
const changedSet = new Set(changedSince);
|
|
262
|
+
const primaryKey = normalizeMapKey(path.resolve(run.filePath));
|
|
263
|
+
const neighborKeys = (run.result?.neighbors ?? []).map((n) => normalizeMapKey(path.resolve(n.filePath)));
|
|
264
|
+
const supersededByOwnFile = changedSet.has(primaryKey) ||
|
|
265
|
+
neighborKeys.some((k) => changedSet.has(k));
|
|
266
|
+
if (supersededByOwnFile) {
|
|
267
|
+
logCascade({
|
|
268
|
+
phase: "cascade_carry_over_drop",
|
|
269
|
+
filePath: run.filePath,
|
|
270
|
+
neighborCount: run.neighborCount,
|
|
271
|
+
diagnosticCount: run.diagnosticCount,
|
|
272
|
+
reason: "superseded_by_later_write",
|
|
273
|
+
metadata: {
|
|
274
|
+
originProjectSeq: originSeq,
|
|
275
|
+
projectSeq: runtime.projectSeq,
|
|
276
|
+
originTurnSeq: originTurn,
|
|
277
|
+
turnIndex: runtime.turnIndex,
|
|
278
|
+
carriedTurns: run.carriedTurns,
|
|
279
|
+
changedFiles: changedSince,
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return true;
|
|
216
287
|
});
|
|
217
288
|
const cascadeResults = cascadeRuns.flatMap((r) => r.result ? [r.result] : []);
|
|
218
289
|
if (cascadeResults.length > 0) {
|
|
@@ -231,11 +302,25 @@ export async function handleTurnEnd(deps) {
|
|
|
231
302
|
}
|
|
232
303
|
}
|
|
233
304
|
const parts = [];
|
|
305
|
+
// #1446 item 1: track what actually gets injected — a suppressed result
|
|
306
|
+
// (real formatted cascade text, but every one of its neighbors was claimed
|
|
307
|
+
// by a LATER result — see the reverse-iteration ownership pass above) was
|
|
308
|
+
// previously indistinguishable from "no output"; this counts it explicitly
|
|
309
|
+
// instead of letting it vanish.
|
|
310
|
+
let injectedNeighborCount = 0;
|
|
311
|
+
let injectedDiagnosticCount = 0;
|
|
312
|
+
let suppressedByOwnership = 0;
|
|
234
313
|
for (const result of seen.values()) {
|
|
235
314
|
const pk = normalizeMapKey(result.filePath);
|
|
236
315
|
const ownsAny = result.neighbors.some((n) => neighborOwner.get(normalizeMapKey(n.filePath)) === pk);
|
|
237
|
-
if (ownsAny && result.formatted)
|
|
316
|
+
if (ownsAny && result.formatted) {
|
|
238
317
|
parts.push(result.formatted);
|
|
318
|
+
injectedNeighborCount += result.neighbors.length;
|
|
319
|
+
injectedDiagnosticCount += result.neighbors.reduce((s, n) => s + n.diagnostics.length, 0);
|
|
320
|
+
}
|
|
321
|
+
else if (!ownsAny && result.formatted) {
|
|
322
|
+
suppressedByOwnership++;
|
|
323
|
+
}
|
|
239
324
|
}
|
|
240
325
|
// Suggest tests for cascade neighbors (files with diagnostics)
|
|
241
326
|
const neighborFilesWithErrors = cascadeResults
|
|
@@ -243,9 +328,27 @@ export async function handleTurnEnd(deps) {
|
|
|
243
328
|
.filter((n) => n.diagnostics.length > 0)
|
|
244
329
|
.map((n) => n.filePath);
|
|
245
330
|
const uniqueNeighborFiles = [...new Set(neighborFilesWithErrors)];
|
|
331
|
+
let testSuggestionCount = 0;
|
|
246
332
|
if (uniqueNeighborFiles.length > 0 &&
|
|
247
333
|
typeof testRunnerClient.suggestTestFiles === "function") {
|
|
248
334
|
const testSuggestions = testRunnerClient.suggestTestFiles(uniqueNeighborFiles, cwd);
|
|
335
|
+
testSuggestionCount = testSuggestions.length;
|
|
336
|
+
// #1446 item 2: this path previously emitted nothing to any log — a
|
|
337
|
+
// zero-suggestion outcome (neighbors had errors but no test file
|
|
338
|
+
// resolved for any of them) is the more interesting case, so it is
|
|
339
|
+
// recorded on the same phase rather than only logging on a hit.
|
|
340
|
+
logCascade({
|
|
341
|
+
phase: "cascade_test_targets",
|
|
342
|
+
filePath: files[0] ?? cwd,
|
|
343
|
+
neighborCount: uniqueNeighborFiles.length,
|
|
344
|
+
metadata: {
|
|
345
|
+
neighborFiles: uniqueNeighborFiles.slice(0, 10),
|
|
346
|
+
suggestedTestFiles: testSuggestions.slice(0, 10).map((s) => s.testFile),
|
|
347
|
+
runner: testSuggestions[0]?.runner,
|
|
348
|
+
truncated: testSuggestions.length > 10,
|
|
349
|
+
zeroSuggestions: testSuggestions.length === 0,
|
|
350
|
+
},
|
|
351
|
+
});
|
|
249
352
|
if (testSuggestions.length > 0) {
|
|
250
353
|
const testLines = testSuggestions
|
|
251
354
|
.slice(0, 5)
|
|
@@ -257,8 +360,30 @@ export async function handleTurnEnd(deps) {
|
|
|
257
360
|
parts.push(testSection);
|
|
258
361
|
}
|
|
259
362
|
}
|
|
260
|
-
if (parts.length > 0)
|
|
261
|
-
|
|
363
|
+
if (parts.length > 0) {
|
|
364
|
+
const section = parts.join("\n\n");
|
|
365
|
+
blockerParts.push(section);
|
|
366
|
+
// #1446 item 1: proves the cascade section reached `blockerParts` —
|
|
367
|
+
// i.e. it was QUEUED for persistence into the turn-end advisory — not
|
|
368
|
+
// that it reached the agent. The counters alone (cascade_result,
|
|
369
|
+
// cascade_turn_end) never confirmed even that much, only computation.
|
|
370
|
+
// Actual delivery happens later, via consumeTurnEndFindings/
|
|
371
|
+
// peekTurnEndFindings, and can still be suppressed after this point
|
|
372
|
+
// (e.g. allFilesDeleted, cross-turn dedup, or the session ending
|
|
373
|
+
// before the next turn_end drains it) — this record does not prove
|
|
374
|
+
// the agent ever saw the text.
|
|
375
|
+
logCascade({
|
|
376
|
+
phase: "cascade_injected",
|
|
377
|
+
filePath: files[0] ?? cwd,
|
|
378
|
+
neighborCount: injectedNeighborCount,
|
|
379
|
+
diagnosticCount: injectedDiagnosticCount,
|
|
380
|
+
metadata: {
|
|
381
|
+
sectionChars: section.length,
|
|
382
|
+
testSuggestionCount,
|
|
383
|
+
suppressedByOwnership,
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
}
|
|
262
387
|
logCascade({
|
|
263
388
|
phase: "cascade_turn_end",
|
|
264
389
|
filePath: files[0] ?? cwd,
|
|
@@ -313,7 +438,15 @@ export async function handleTurnEnd(deps) {
|
|
|
313
438
|
const reasons = [...byDetail.keys()].join("; ");
|
|
314
439
|
return `${frame.lead(fileCount, reasons)}\n${lines.join("\n")}`;
|
|
315
440
|
};
|
|
316
|
-
|
|
441
|
+
// #1445: `excluded_by_role` (test files excluded from the graph BY DESIGN,
|
|
442
|
+
// #260) is never agent-facing — it is not a graph failure, and #1080
|
|
443
|
+
// already excludes test-role files from every neighbor surface, so "a
|
|
444
|
+
// clean result does not cover them" would itself be a false claim. It
|
|
445
|
+
// stays visible in the `cascade_indeterminate` log below (metadata-only,
|
|
446
|
+
// info-level) so the log can tell an intentional exclusion from a real
|
|
447
|
+
// graph gap, but it never reaches `buildAdvisory`/the agent.
|
|
448
|
+
const graphRuns = indeterminateRuns.filter((r) => r.indeterminate?.reason !== "lsp_binding_rejected" &&
|
|
449
|
+
r.indeterminate?.reason !== "excluded_by_role");
|
|
317
450
|
const bindingRuns = indeterminateRuns.filter((r) => r.indeterminate?.reason === "lsp_binding_rejected");
|
|
318
451
|
// Factual/informational phrasing — the advisory tier wraps this with an
|
|
319
452
|
// "ℹ️ Advisory — no action required this turn:" label, so an imperative
|
|
@@ -385,8 +518,12 @@ export async function handleTurnEnd(deps) {
|
|
|
385
518
|
// run tool checks every turn. Also back off after a timeout/kill so every
|
|
386
519
|
// agent turn does not spend 30s launching another heavyweight knip process.
|
|
387
520
|
const prevKnip = cacheManager.readCache("knip", cwd);
|
|
521
|
+
// An availability failure is NOT a hard knip failure: knip never ran, so
|
|
522
|
+
// there is nothing to back off from, and backing off would make an
|
|
523
|
+
// expiring probe verdict permanent again (#1467).
|
|
388
524
|
const previousFailedHard = prevKnip &&
|
|
389
525
|
!prevKnip.data.success &&
|
|
526
|
+
!prevKnip.data.failureKind &&
|
|
390
527
|
/(timed out|killed|SIGTERM|SIGKILL|SIGABRT)/i.test(prevKnip.data.summary);
|
|
391
528
|
if (previousFailedHard) {
|
|
392
529
|
dbg(`turn_end: skipping knip after recent failure: ${prevKnip.data.summary}`);
|
|
@@ -394,13 +531,23 @@ export async function handleTurnEnd(deps) {
|
|
|
394
531
|
}
|
|
395
532
|
else {
|
|
396
533
|
const knipResult = await knipClient.analyze(cwd, getKnipIgnorePatterns());
|
|
397
|
-
|
|
534
|
+
// Never overwrite a good scan with a failure (#925, #1467): the last
|
|
535
|
+
// good result stays until a new successful scan replaces it.
|
|
536
|
+
const knipWouldPoison = wouldPoisonCache(prevKnip, knipResult);
|
|
537
|
+
if (knipWouldPoison) {
|
|
538
|
+
dbg(`turn_end: keeping last good knip cache; this run failed: ${knipResult.summary}`);
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
cacheManager.writeCache("knip", knipResult, cwd);
|
|
542
|
+
}
|
|
398
543
|
knipMeta = {
|
|
399
544
|
success: knipResult.success,
|
|
400
545
|
totalIssues: knipResult.issues.length,
|
|
401
546
|
newIssues: 0,
|
|
402
547
|
blockerIssues: 0,
|
|
403
548
|
...(!knipResult.success && { reason: knipResult.summary }),
|
|
549
|
+
...(knipResult.failureKind && { failureKind: knipResult.failureKind }),
|
|
550
|
+
...(knipWouldPoison && { cacheKept: true }),
|
|
404
551
|
};
|
|
405
552
|
if (knipResult.success && knipResult.issues.length > 0) {
|
|
406
553
|
const issueKey = (i) => `${i.type}:${i.file ?? ""}:${i.name}:${i.line ?? 0}:${i.package ?? ""}`;
|
|
@@ -467,6 +614,140 @@ export async function handleTurnEnd(deps) {
|
|
|
467
614
|
durationMs: Date.now() - t2,
|
|
468
615
|
metadata: knipMeta,
|
|
469
616
|
});
|
|
617
|
+
// Cross-file dead-code (#127) for non-JS/TS languages, on knip's contract:
|
|
618
|
+
// re-scan only when this turn touched a file the client owns, then inject the
|
|
619
|
+
// ATTRIBUTABLE delta — symbols in those files that became unused because of
|
|
620
|
+
// the edit. The project-wide list is deliberately NOT injected per turn (the
|
|
621
|
+
// same reasoning as knip above) and stays available via lens_diagnostics.
|
|
622
|
+
// MUST run before the projectDiagnosticsDelta write below, or a dead-code-only
|
|
623
|
+
// turn would persist nothing.
|
|
624
|
+
const tDeadCode = Date.now();
|
|
625
|
+
const deadCodeMeta = {};
|
|
626
|
+
if (runtime.isStartupScanInFlight("dead-code")) {
|
|
627
|
+
dbg("turn_end: skipping dead-code (startup scan still in flight)");
|
|
628
|
+
deadCodeMeta.skipped = true;
|
|
629
|
+
deadCodeMeta.reason = "startup_scan_in_flight";
|
|
630
|
+
}
|
|
631
|
+
else if (deadCodeClients.length === 0) {
|
|
632
|
+
deadCodeMeta.reason = "no_clients";
|
|
633
|
+
}
|
|
634
|
+
else {
|
|
635
|
+
// The modified-file set costs a resolveRunnerPath per file, and that walks
|
|
636
|
+
// every ancestor to the filesystem root on a miss. Build it lazily, only
|
|
637
|
+
// once a client has actually claimed this project, so an all-JS repo with
|
|
638
|
+
// no dead-code client pays nothing per turn. Knip does the same.
|
|
639
|
+
let modifiedSet = null;
|
|
640
|
+
const modifiedFiles = () => (modifiedSet ??= new Set(files.map((f) => resolveRunnerPath(cwd, f))));
|
|
641
|
+
let newIssueTotal = 0;
|
|
642
|
+
const reasons = [];
|
|
643
|
+
// A malformed client or deps object must never abort turn_end. Before the
|
|
644
|
+
// per-turn delta this block only read a cache; now it iterates and awaits,
|
|
645
|
+
// so the whole thing needs the guard, not just `client.analyze`.
|
|
646
|
+
try {
|
|
647
|
+
for (const client of deadCodeClients) {
|
|
648
|
+
if (!client.detect(cwd)) {
|
|
649
|
+
reasons.push(`${client.id}:not_detected`);
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
if (![...modifiedFiles()].some((f) => client.owns(f))) {
|
|
653
|
+
reasons.push(`${client.id}:no_owned_files`);
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
const cacheKey = `dead-code-${client.id}`;
|
|
657
|
+
const prev = cacheManager.readCache(cacheKey, cwd);
|
|
658
|
+
// Back off after a timeout/kill so an unresponsive scanner cannot cost
|
|
659
|
+
// every later turn its full analysis budget (mirrors knip).
|
|
660
|
+
if (prev &&
|
|
661
|
+
!prev.data.success &&
|
|
662
|
+
/(timed out|killed|SIGTERM|SIGKILL|SIGABRT)/i.test(prev.data.summary)) {
|
|
663
|
+
dbg(`turn_end: skipping dead-code after failure: ${prev.data.summary}`);
|
|
664
|
+
deadCodeMeta.skipped = true;
|
|
665
|
+
reasons.push(`${client.id}:backoff:${prev.data.summary}`);
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
const startMs = Date.now();
|
|
669
|
+
try {
|
|
670
|
+
const result = await client.analyze(cwd);
|
|
671
|
+
const durationMs = Date.now() - startMs;
|
|
672
|
+
// Never overwrite a good scan with a failure (#925, #1467): a
|
|
673
|
+
// vulture timeout on one .py turn would otherwise evict the
|
|
674
|
+
// session_start scan, and the backoff above would then latch
|
|
675
|
+
// off the poisoned record.
|
|
676
|
+
if (wouldPoisonCache(prev, result)) {
|
|
677
|
+
dbg(`turn_end: keeping last good dead-code(${client.id}) cache; this run failed: ${result.summary}`);
|
|
678
|
+
deadCodeMeta.cacheKept = true;
|
|
679
|
+
}
|
|
680
|
+
else {
|
|
681
|
+
cacheManager.writeCache(cacheKey, result, cwd, {
|
|
682
|
+
scanDurationMs: durationMs,
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
// One event per cross-file scan (AGENTS.md) — the per-turn scan is
|
|
686
|
+
// now the primary path, so dead-code.log must see it too.
|
|
687
|
+
logDeadCodeScan({
|
|
688
|
+
language: client.language,
|
|
689
|
+
success: result.success,
|
|
690
|
+
cached: false,
|
|
691
|
+
unusedExports: result.unusedExports.length,
|
|
692
|
+
unusedFiles: result.unusedFiles.length,
|
|
693
|
+
unusedDeps: result.unusedDeps.length,
|
|
694
|
+
unlistedDeps: result.unlistedDeps.length,
|
|
695
|
+
durationMs: result.durationMs ?? durationMs,
|
|
696
|
+
...(!result.success && { reason: result.summary }),
|
|
697
|
+
});
|
|
698
|
+
deadCodeMeta.success = result.success;
|
|
699
|
+
if (!result.success) {
|
|
700
|
+
reasons.push(`${client.id}:scan_failed:${result.summary}`);
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
deadCodeMeta.totalIssues =
|
|
704
|
+
(deadCodeMeta.totalIssues ?? 0) + deadCodeIssues(result).length;
|
|
705
|
+
// No baseline means every finding looks new. Report nothing rather
|
|
706
|
+
// than blame the edit for the whole project's pre-existing debt.
|
|
707
|
+
if (!prev?.data.success) {
|
|
708
|
+
reasons.push(`${client.id}:no_previous_scan`);
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
const prevKeys = new Set(deadCodeIssues(prev.data).map(deadCodeIssueKey));
|
|
712
|
+
const modified = modifiedFiles();
|
|
713
|
+
const newIssues = deadCodeIssues(result).filter((issue) => {
|
|
714
|
+
if (prevKeys.has(deadCodeIssueKey(issue)))
|
|
715
|
+
return false;
|
|
716
|
+
if (!issue.file)
|
|
717
|
+
return false;
|
|
718
|
+
return modified.has(resolveRunnerPath(cwd, issue.file));
|
|
719
|
+
});
|
|
720
|
+
if (newIssues.length === 0) {
|
|
721
|
+
reasons.push(`${client.id}:clean`);
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
newIssueTotal += newIssues.length;
|
|
725
|
+
projectDiagnosticsDelta.push(...newIssues.map((issue) => deadCodeIssueToProjectDiagnostic(cwd, issue, result.language)));
|
|
726
|
+
projectDiagnosticsSources.add("dead-code");
|
|
727
|
+
advisoryParts.push(formatDeadCodeDelta(newIssues, result.language));
|
|
728
|
+
}
|
|
729
|
+
catch (err) {
|
|
730
|
+
dbg(`turn_end: dead-code(${client.id}) failed: ${err}`);
|
|
731
|
+
reasons.push(`${client.id}:threw`);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
catch (err) {
|
|
736
|
+
dbg(`turn_end: dead-code block failed: ${err}`);
|
|
737
|
+
reasons.push("block_threw");
|
|
738
|
+
}
|
|
739
|
+
deadCodeMeta.newIssues = newIssueTotal;
|
|
740
|
+
if (reasons.length > 0)
|
|
741
|
+
deadCodeMeta.reason = reasons.join(",");
|
|
742
|
+
}
|
|
743
|
+
logLatency({
|
|
744
|
+
type: "phase",
|
|
745
|
+
toolName: "turn_end",
|
|
746
|
+
filePath: cwd,
|
|
747
|
+
phase: "dead-code",
|
|
748
|
+
durationMs: Date.now() - tDeadCode,
|
|
749
|
+
metadata: deadCodeMeta,
|
|
750
|
+
});
|
|
470
751
|
// govulncheck — surface session_start-cached Go CVE findings as advisory.
|
|
471
752
|
// No per-turn re-run in this slice; the cache refreshes at next session_start.
|
|
472
753
|
const govCacheEntry = cacheManager.readCache("govulncheck", cwd);
|
|
@@ -495,11 +776,25 @@ export async function handleTurnEnd(deps) {
|
|
|
495
776
|
// location so a committed/hardcoded secret is reported ONCE (with combined
|
|
496
777
|
// provenance) — a blocker, since credentials need rotation before merge.
|
|
497
778
|
const gitleaksData = cacheManager.readCache("gitleaks", cwd)?.data;
|
|
779
|
+
// #1461 slice 1 (#1460): the gitleaks cache is TTL-only, so a finding for a
|
|
780
|
+
// file deleted after the scan is still served as a 🔴 blocker for the rest
|
|
781
|
+
// of the 30-minute window — the live case, and 119 of 126 findings in
|
|
782
|
+
// pi-lens's own cache. This read is the single agent-facing consumer of that
|
|
783
|
+
// store (session_start's read only decides whether to re-scan; the
|
|
784
|
+
// project-diagnostics path re-scans fresh and reconciles at load), so the
|
|
785
|
+
// drop belongs here, before the findings enter the shared secret pipeline.
|
|
786
|
+
// gitleaks only in this slice — trivy secrets are slice 1's sibling store.
|
|
787
|
+
const gitleaksFindings = dropFindingsForMissingPaths({
|
|
788
|
+
store: "gitleaks",
|
|
789
|
+
findings: gitleaksData?.findings ?? [],
|
|
790
|
+
cwd,
|
|
791
|
+
citedPath: (finding) => finding.file,
|
|
792
|
+
});
|
|
498
793
|
const astSecretWarnings = runtime
|
|
499
794
|
.peekActionableWarnings()
|
|
500
795
|
.filter(isSecretWarning);
|
|
501
796
|
const sessionSecrets = dedupeSecretFindings([
|
|
502
|
-
...fromGitleaks(
|
|
797
|
+
...fromGitleaks(gitleaksFindings),
|
|
503
798
|
...fromTrivySecrets(trivyCacheEntry?.data?.secrets ?? []),
|
|
504
799
|
]);
|
|
505
800
|
// Locations already surfaced as session-scan secret blockers — used to enrich
|
|
@@ -668,8 +963,32 @@ export async function handleTurnEnd(deps) {
|
|
|
668
963
|
dbg(`turn_end: firing ${targets.length} test target(s) async (non-blocking)`);
|
|
669
964
|
const firedAtTurn = runtime.turnIndex;
|
|
670
965
|
const firedSessionId = runtime.telemetrySessionId;
|
|
966
|
+
const priorTestCache = cacheManager.readCache("test-runner-findings", cwd)?.data;
|
|
967
|
+
const testRunGeneration = (priorTestCache?.testRunGeneration ?? 0) + 1;
|
|
968
|
+
const provenanceFiles = [
|
|
969
|
+
...candidates.map((candidate) => ({ path: candidate.abs, role: "source" })),
|
|
970
|
+
...targets.map((target) => ({ path: target.testFile, role: "test" })),
|
|
971
|
+
];
|
|
972
|
+
const launchedFrom = snapshotAdvisoryProvenance({
|
|
973
|
+
cwd,
|
|
974
|
+
runtime,
|
|
975
|
+
generation: testRunGeneration,
|
|
976
|
+
files: provenanceFiles,
|
|
977
|
+
});
|
|
978
|
+
cacheManager.writeCache("test-runner-findings", { ...(priorTestCache ?? { content: "" }), testRunGeneration }, cwd);
|
|
671
979
|
Promise.allSettled(targets.map((t) => testRunnerClient.runTestFileAsync(t.testFile, cwd, t.runner, t.config)))
|
|
672
980
|
.then((results) => {
|
|
981
|
+
const publishedAgainst = snapshotAdvisoryProvenance({
|
|
982
|
+
cwd,
|
|
983
|
+
runtime,
|
|
984
|
+
generation: testRunGeneration,
|
|
985
|
+
files: provenanceFiles,
|
|
986
|
+
});
|
|
987
|
+
const superseded = launchedFrom.revision.sessionId !== publishedAgainst.revision.sessionId ||
|
|
988
|
+
launchedFrom.revision.projectSeq !== publishedAgainst.revision.projectSeq ||
|
|
989
|
+
launchedFrom.revision.turnIndex !== publishedAgainst.revision.turnIndex ||
|
|
990
|
+
launchedFrom.files.some((file, index) => publishedAgainst.files[index]?.sha256 !== file.sha256 ||
|
|
991
|
+
publishedAgainst.files[index]?.path !== file.path);
|
|
673
992
|
// #628: the turn advancing while tests ran no longer means the
|
|
674
993
|
// results are thrown away — a late result is still real
|
|
675
994
|
// information about what's currently broken. It's tagged `stale`
|
|
@@ -686,9 +1005,29 @@ export async function handleTurnEnd(deps) {
|
|
|
686
1005
|
resultValues.push(r.value);
|
|
687
1006
|
const { file, runner, passed, failed, duration, error } = r.value;
|
|
688
1007
|
const shortFile = path.basename(file);
|
|
1008
|
+
// #1479: `(0ms)` used to be printed for a run nobody
|
|
1009
|
+
// timed — a payload with no suite timestamps, an
|
|
1010
|
+
// unrecognised summary line, or an empty result — and
|
|
1011
|
+
// that is the same string a genuinely sub-millisecond
|
|
1012
|
+
// run produces. A reader could not tell "measured 0"
|
|
1013
|
+
// from "not measured", which is the confusion #1452 was
|
|
1014
|
+
// reported for. `duration` is now absent when it was
|
|
1015
|
+
// never measured, and this line says which one it has.
|
|
1016
|
+
//
|
|
1017
|
+
// #1480: the test is `formatRunDurationMs`, not an
|
|
1018
|
+
// inline comparison. The "absent = unmeasured" contract
|
|
1019
|
+
// was being re-derived at every site that read a
|
|
1020
|
+
// duration, and a site that gets it slightly wrong —
|
|
1021
|
+
// treating a measured `0` as absent — puts the bug back
|
|
1022
|
+
// without touching this comment.
|
|
1023
|
+
const elapsed = formatRunDurationMs(duration);
|
|
1024
|
+
// Lifted out of the template below for the same reason
|
|
1025
|
+
// `elapsed` is: the pair read as a nested ternary, which
|
|
1026
|
+
// this line only got flagged for because #1479 touched it.
|
|
1027
|
+
const verdict = failed > 0 ? "FAIL" : "PASS";
|
|
689
1028
|
const summary = error && passed === 0 && failed === 0
|
|
690
1029
|
? `error: ${error}`
|
|
691
|
-
: `${
|
|
1030
|
+
: `${verdict} ${passed}p/${failed}f (${elapsed})`;
|
|
692
1031
|
dbg(`turn_end: ${stale ? "[stale] " : ""}test ${runner} ${shortFile} → ${summary}`);
|
|
693
1032
|
if (failed > 0) {
|
|
694
1033
|
const formatted = testRunnerClient.formatResult(r.value);
|
|
@@ -697,10 +1036,24 @@ export async function handleTurnEnd(deps) {
|
|
|
697
1036
|
}
|
|
698
1037
|
}
|
|
699
1038
|
if (failures.length > 0) {
|
|
1039
|
+
const currentGeneration = cacheManager.readCache("test-runner-findings", cwd)?.data?.testRunGeneration;
|
|
1040
|
+
if (currentGeneration !== undefined && currentGeneration > testRunGeneration) {
|
|
1041
|
+
dbg(`turn_end: test generation ${testRunGeneration} superseded by ${currentGeneration}`);
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
700
1044
|
const content = stale
|
|
701
1045
|
? `[from a prior turn — the edit that triggered this run had already been superseded by the time results came back]\n\n${failures.join("\n\n")}`
|
|
702
1046
|
: failures.join("\n\n");
|
|
703
|
-
cacheManager.writeCache("test-runner-findings", {
|
|
1047
|
+
cacheManager.writeCache("test-runner-findings", {
|
|
1048
|
+
content,
|
|
1049
|
+
stale,
|
|
1050
|
+
results: resultValues,
|
|
1051
|
+
testRunGeneration,
|
|
1052
|
+
launchedFrom,
|
|
1053
|
+
publishedAgainst,
|
|
1054
|
+
provenance: publishedAgainst,
|
|
1055
|
+
superseded,
|
|
1056
|
+
}, cwd);
|
|
704
1057
|
if (getFlag("lens-guard") && firedSessionId === runtime.telemetrySessionId) {
|
|
705
1058
|
clearGitGuardTestFailure(cacheManager, cwd, runtime, resultValues
|
|
706
1059
|
.filter((value) => value.failed === 0)
|
|
@@ -928,26 +1281,6 @@ export async function handleTurnEnd(deps) {
|
|
|
928
1281
|
},
|
|
929
1282
|
});
|
|
930
1283
|
}
|
|
931
|
-
// Cross-file dead-code (#127): surface the cached session_start scan as an
|
|
932
|
-
// advisory (project-wide unused symbols are slow to compute, so we read the
|
|
933
|
-
// cache rather than re-scanning every turn — the analogue of knip's cache
|
|
934
|
-
// for non-JS/TS languages). Merged across languages for polyglot repos.
|
|
935
|
-
try {
|
|
936
|
-
const deadCodeResults = [];
|
|
937
|
-
for (const client of deadCodeClients) {
|
|
938
|
-
if (!client.detect(cwd))
|
|
939
|
-
continue;
|
|
940
|
-
const cached = cacheManager.readCache(`dead-code-${client.id}`, cwd);
|
|
941
|
-
if (cached?.data)
|
|
942
|
-
deadCodeResults.push(cached.data);
|
|
943
|
-
}
|
|
944
|
-
const advisory = formatDeadCodeAdvisory(deadCodeResults);
|
|
945
|
-
if (advisory)
|
|
946
|
-
advisoryParts.push(advisory);
|
|
947
|
-
}
|
|
948
|
-
catch (err) {
|
|
949
|
-
dbg(`turn_end: dead-code advisory failed: ${err}`);
|
|
950
|
-
}
|
|
951
1284
|
cacheManager.incrementTurnCycle(cwd, currentOwner);
|
|
952
1285
|
const labeledAdvisoryParts = advisoryParts.map((p) => `ℹ️ Advisory — no action required this turn:\n${p}`);
|
|
953
1286
|
const findingParts = [...blockerParts, ...labeledAdvisoryParts];
|
|
@@ -1022,7 +1355,27 @@ export async function handleTurnEnd(deps) {
|
|
|
1022
1355
|
});
|
|
1023
1356
|
}
|
|
1024
1357
|
else {
|
|
1025
|
-
|
|
1358
|
+
const allAffectedFiles = [
|
|
1359
|
+
...files.map((file) => resolveRunnerPath(cwd, file)),
|
|
1360
|
+
...cascadeResults.flatMap((result) => result.neighbors
|
|
1361
|
+
.filter((neighbor) => neighbor.diagnostics.length > 0)
|
|
1362
|
+
.map((neighbor) => resolveRunnerPath(cwd, neighbor.filePath))),
|
|
1363
|
+
];
|
|
1364
|
+
const affectedFiles = [...new Set(allAffectedFiles)]
|
|
1365
|
+
.slice(0, MAX_ADVISORY_AFFECTED_FILES);
|
|
1366
|
+
const affectedFilesTruncated = new Set(allAffectedFiles).size > affectedFiles.length;
|
|
1367
|
+
cacheManager.writeCache("turn-end-findings", {
|
|
1368
|
+
content,
|
|
1369
|
+
affectedFiles,
|
|
1370
|
+
affectedFilesTruncated,
|
|
1371
|
+
provenance: snapshotAdvisoryProvenance({
|
|
1372
|
+
cwd,
|
|
1373
|
+
runtime,
|
|
1374
|
+
generation: 0,
|
|
1375
|
+
files: affectedFiles.map((file) => ({ path: file, role: "affected" })),
|
|
1376
|
+
truncated: affectedFilesTruncated,
|
|
1377
|
+
}),
|
|
1378
|
+
}, cwd);
|
|
1026
1379
|
}
|
|
1027
1380
|
cacheManager.writeCache("turn-end-findings-last", {
|
|
1028
1381
|
signature,
|
|
@@ -7,15 +7,16 @@
|
|
|
7
7
|
* Docs: https://doc.rust-lang.org/cargo/
|
|
8
8
|
*/
|
|
9
9
|
import { createSubsystemLogger } from "./extension-log.js";
|
|
10
|
-
import * as fs from "node:fs";
|
|
11
10
|
import * as path from "node:path";
|
|
12
|
-
import {
|
|
11
|
+
import { createToolchainAvailability, } from "./dispatch/runners/utils/toolchain-availability.js";
|
|
13
12
|
// --- Common install paths ---
|
|
14
13
|
const CARGO_WINDOWS_PATHS = [
|
|
15
14
|
path.join(process.env.USERPROFILE || "", ".cargo", "bin", "cargo.exe"),
|
|
16
15
|
path.join(process.env.SYSTEMDRIVE || "C:", "\\cargo", "bin", "cargo.exe"),
|
|
17
16
|
"cargo.exe", // PATH
|
|
18
17
|
];
|
|
18
|
+
/** Budget for the PATH candidate's `cargo --version` probe, ms. */
|
|
19
|
+
const PROBE_TIMEOUT_MS = 3_000;
|
|
19
20
|
const CARGO_UNIX_PATHS = [
|
|
20
21
|
path.join(process.env.HOME || "", ".cargo", "bin", "cargo"),
|
|
21
22
|
"/usr/local/cargo/bin/cargo",
|
|
@@ -24,56 +25,39 @@ const CARGO_UNIX_PATHS = [
|
|
|
24
25
|
];
|
|
25
26
|
// --- Client ---
|
|
26
27
|
export class RustClient {
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Availability lifecycle, behind the shared transient-aware latch (#1476).
|
|
30
|
+
* The PATH candidate is resolved by spawning `cargo --version` with a 3 s
|
|
31
|
+
* budget, so a host stall could latch "no cargo" for the session and silently
|
|
32
|
+
* disable every Rust diagnostic until restart.
|
|
33
|
+
*/
|
|
34
|
+
availability;
|
|
29
35
|
log;
|
|
30
36
|
constructor(verbose = false) {
|
|
31
37
|
this.log = verbose
|
|
32
38
|
? createSubsystemLogger("rust")
|
|
33
39
|
: () => { };
|
|
40
|
+
this.availability = createToolchainAvailability({
|
|
41
|
+
tool: "cargo",
|
|
42
|
+
label: "Cargo",
|
|
43
|
+
windowsPaths: CARGO_WINDOWS_PATHS,
|
|
44
|
+
unixPaths: CARGO_UNIX_PATHS,
|
|
45
|
+
probeArgs: ["--version"],
|
|
46
|
+
budgetMs: PROBE_TIMEOUT_MS,
|
|
47
|
+
log: (msg) => this.log(msg),
|
|
48
|
+
});
|
|
34
49
|
}
|
|
35
50
|
/**
|
|
36
51
|
* Find cargo executable path (async — probes PATH candidates off the event loop).
|
|
37
52
|
*/
|
|
38
53
|
async findCargoPathAsync() {
|
|
39
|
-
|
|
40
|
-
return this.cargoPath;
|
|
41
|
-
const paths = process.platform === "win32" ? CARGO_WINDOWS_PATHS : CARGO_UNIX_PATHS;
|
|
42
|
-
for (const p of paths) {
|
|
43
|
-
try {
|
|
44
|
-
if (p.includes("\\") || p.includes("/")) {
|
|
45
|
-
if (fs.existsSync(p)) {
|
|
46
|
-
this.cargoPath = p;
|
|
47
|
-
return p;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
else {
|
|
51
|
-
const result = await safeSpawnAsync(p, ["--version"], {
|
|
52
|
-
timeout: 3000,
|
|
53
|
-
});
|
|
54
|
-
if (!result.error && result.status === 0) {
|
|
55
|
-
this.cargoPath = p;
|
|
56
|
-
return p;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
catch (err) {
|
|
61
|
-
void err;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return null;
|
|
54
|
+
return this.availability.findPath();
|
|
65
55
|
}
|
|
66
56
|
/**
|
|
67
57
|
* Check if cargo is installed (cached)
|
|
68
58
|
*/
|
|
69
59
|
async isAvailableAsync() {
|
|
70
|
-
|
|
71
|
-
return this.cargoAvailable;
|
|
72
|
-
this.cargoAvailable = (await this.findCargoPathAsync()) !== null;
|
|
73
|
-
if (this.cargoAvailable) {
|
|
74
|
-
this.log(`Cargo found: ${this.cargoPath}`);
|
|
75
|
-
}
|
|
76
|
-
return this.cargoAvailable;
|
|
60
|
+
return this.availability.isAvailable();
|
|
77
61
|
}
|
|
78
62
|
/**
|
|
79
63
|
* Check if a file is a Rust file
|