pi-lens 4.0.0 → 4.0.1
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 +95 -0
- package/dist/clients/advisory-provenance.js +314 -0
- package/dist/clients/agent-nudge.js +14 -7
- package/dist/clients/biome-client.js +121 -13
- package/dist/clients/bus-events-logger.js +62 -6
- package/dist/clients/bus-publish.js +11 -3
- package/dist/clients/cascade-format.js +57 -2
- package/dist/clients/console-guard-install.js +16 -4
- package/dist/clients/dead-code-client.js +135 -30
- package/dist/clients/dependency-checker.js +19 -7
- package/dist/clients/diagnostic-dispositions.js +6 -4
- package/dist/clients/diagnostics-publish.js +10 -3
- package/dist/clients/dispatch/dispatcher.js +114 -10
- package/dist/clients/dispatch/integration.js +101 -16
- package/dist/clients/dispatch/runners/lsp.js +37 -4
- package/dist/clients/dispatch/runners/utils/availability-policy.js +226 -0
- package/dist/clients/dispatch/runners/utils/candidate-probe.js +69 -0
- package/dist/clients/dispatch/runners/utils/runner-helpers.js +230 -50
- package/dist/clients/dispatch/runners/utils/toolchain-availability.js +97 -0
- package/dist/clients/disposition-publish.js +10 -3
- package/dist/clients/eval-timestamp.js +17 -0
- package/dist/clients/extension-log.js +296 -3
- package/dist/clients/fix-worklog.js +5 -1
- package/dist/clients/format-events-publish.js +39 -8
- package/dist/clients/git-guard.js +18 -21
- package/dist/clients/go-client.js +21 -39
- package/dist/clients/govulncheck-client.js +116 -7
- package/dist/clients/host-ports.js +1 -1
- package/dist/clients/installer/index.js +5 -1
- package/dist/clients/jscpd-client.js +4 -9
- package/dist/clients/knip-client.js +51 -14
- package/dist/clients/latency-logger.js +50 -1
- package/dist/clients/lens-events.js +56 -25
- package/dist/clients/lens-flag-registry.js +8 -0
- package/dist/clients/live-bus-emitter.js +45 -2
- package/dist/clients/lsp/aggregation.js +30 -4
- package/dist/clients/lsp/cascade-tier.js +58 -13
- package/dist/clients/lsp/client.js +169 -8
- package/dist/clients/lsp/diagnostic-binding.js +29 -0
- package/dist/clients/lsp/index.js +264 -66
- package/dist/clients/lsp/server.js +247 -60
- package/dist/clients/lsp/tsserver-sync.js +96 -0
- package/dist/clients/lsp/wait-policy/classification.js +21 -5
- package/dist/clients/lsp/wait-policy/strategies.js +18 -3
- package/dist/clients/mcp/analyze.js +4 -0
- package/dist/clients/mcp/session.js +30 -17
- package/dist/clients/model-provider.js +53 -0
- package/dist/clients/pipeline.js +29 -2
- package/dist/clients/project-diagnostics/fresh-fetch.js +1 -1
- package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +23 -2
- package/dist/clients/review-graph/query.js +24 -0
- package/dist/clients/run-duration.js +55 -0
- package/dist/clients/runtime-agent-end.js +153 -7
- package/dist/clients/runtime-context.js +104 -11
- package/dist/clients/runtime-coordinator.js +170 -22
- package/dist/clients/runtime-session.js +28 -5
- package/dist/clients/runtime-tool-result.js +124 -10
- package/dist/clients/runtime-turn.js +388 -35
- package/dist/clients/rust-client.js +21 -37
- package/dist/clients/security-scan-client.js +88 -5
- package/dist/clients/sg-runner.js +141 -23
- package/dist/clients/smells-rollup.js +18 -11
- package/dist/clients/startup-timing.js +7 -1
- package/dist/clients/test-runner-client.js +431 -24
- package/dist/clients/tool-policy.js +2 -0
- package/dist/clients/tool-set-policy.js +76 -0
- package/dist/clients/warm-attach.js +17 -0
- package/dist/clients/word-index.js +305 -33
- package/dist/index.js +4827 -1633
- package/dist/mcp/server.js +8 -2
- package/dist/tools/activate-tools.js +17 -5
- package/dist/tools/ast-grep-replace.js +9 -4
- package/dist/tools/lens-diagnostic-mark.js +5 -2
- package/dist/tools/lens-diagnostics.js +3 -2
- package/dist/tools/lsp-diagnostics.js +62 -7
- package/dist/tools/symbol-search.js +1 -1
- package/docs/agent-guide.md +38 -13
- package/docs/ast-grep_rules_catalog.md +11 -3
- package/docs/features.md +14 -1
- package/docs/globalconfig.md +11 -0
- package/docs/servercapabilities.md +1 -1
- package/docs/settings.md +6 -0
- package/docs/usage.md +23 -5
- package/docs/word-index.md +35 -0
- package/package.json +1 -1
- package/rules/ast-grep-rules/rule-tests/no-chained-type-assertions-test.yml +8 -0
- package/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-js-test.yml +9 -0
- package/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-test.yml +9 -0
- package/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +7 -0
- package/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +7 -0
- package/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +8 -0
- package/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +8 -0
- package/rules/ast-grep-rules/rule-tests/no-unknown-laundering-test.yml +11 -0
- package/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +21 -0
- package/rules/ast-grep-rules/rules/no-conditional-empty-object-spread-js.yml +21 -0
- package/rules/ast-grep-rules/rules/no-conditional-empty-object-spread.yml +29 -0
- package/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +9 -0
- package/rules/ast-grep-rules/rules/no-reflect-apply.yml +9 -0
- package/rules/ast-grep-rules/rules/no-reflect-get-js.yml +13 -0
- package/rules/ast-grep-rules/rules/no-reflect-get.yml +16 -0
- package/rules/ast-grep-rules/rules/no-unknown-laundering.yml +27 -0
- package/scripts/analyze-pi-lens-logs.mjs +79 -0
- package/skills/pi-lens-ast-grep/SKILL.md +10 -11
- package/skills/pi-lens-lsp-navigation/SKILL.md +22 -22
- package/skills/pi-lens-write-ast-grep-rule/SKILL.md +8 -114
- package/skills/pi-lens-write-ast-grep-rule/reference.md +129 -0
- package/skills/pi-lens-write-tree-sitter-rule/SKILL.md +3 -1
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Persistent NDJSON trace of `pi.events` bus publish attempts
|
|
3
3
|
* (`pilens:files:touched` #482 / `pilens:diagnostics` #502 /
|
|
4
|
-
* `pilens:
|
|
4
|
+
* `pilens:diagnostic:disposition` / `pilens:format:queued` +
|
|
5
|
+
* `pilens:format:start` + `pilens:autofix:start` #673/#684 /
|
|
6
|
+
* `pi-lens/analysis-complete` + `pi-lens/findings` + `pi-lens/turn-findings`
|
|
7
|
+
* #1415) — nine event names across five producers.
|
|
5
8
|
*
|
|
6
|
-
* All
|
|
7
|
-
* clients/format-events-publish.ts
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* All five producers (clients/bus-publish.ts, clients/diagnostics-publish.ts,
|
|
10
|
+
* clients/disposition-publish.ts, clients/format-events-publish.ts,
|
|
11
|
+
* clients/lens-events.ts) are fire-and-forget: on failure or on a structural
|
|
12
|
+
* no-op (never wired, kill switch off) the four #482/#502/#673/#684
|
|
13
|
+
* producers only invoke an optional `dbg` callback, which varies by host and
|
|
14
|
+
* is a documented no-op in the MCP host (clients/mcp/session.ts's
|
|
15
|
+
* `dbg: noop`); `lens-events.ts` (#1415) has no `dbg` param at all — its
|
|
16
|
+
* events are purely observational inter-extension telemetry, so this NDJSON
|
|
17
|
+
* trace is its ONLY failure-visible surface. Either way, that leaves
|
|
11
18
|
* bus-publish outcomes invisible in exactly the context where they matter
|
|
12
19
|
* most — same failure shape as the #544 MCP session_start incident this repo
|
|
13
20
|
* already fixed once.
|
|
@@ -26,6 +33,9 @@
|
|
|
26
33
|
* spam one identical line per write batch for an entire session with zero
|
|
27
34
|
* new information after the first. Both are gated log-once-per-process, the
|
|
28
35
|
* same `hasLoggedFailure` shape the producers already use for emit_failed.
|
|
36
|
+
* `skipped_stale_session` is an info-level per-occurrence outcome: the emit
|
|
37
|
+
* seam intentionally declined a target whose associated ctx was confirmed
|
|
38
|
+
* stale, so it is neither a bus failure nor input for the smells rollup.
|
|
29
39
|
* The empty-batch branch (`paths.length === 0` / `files.length === 0`) is
|
|
30
40
|
* NOT logged at all: every call site already guards against invoking these
|
|
31
41
|
* functions with nothing to report (see clients/pipeline.ts,
|
|
@@ -37,17 +47,63 @@ import { isTestMode } from "./env-utils.js";
|
|
|
37
47
|
import { getGlobalPiLensDir } from "./file-utils.js";
|
|
38
48
|
import { createNdjsonLogger } from "./ndjson-logger.js";
|
|
39
49
|
import { getMaxLogSizeMB } from "./log-cleanup.js";
|
|
50
|
+
import { logLatency } from "./latency-logger.js";
|
|
40
51
|
const BUS_EVENTS_LOG_FILE = path.join(getGlobalPiLensDir(), "bus-events.log");
|
|
41
52
|
const writer = createNdjsonLogger({
|
|
42
53
|
filePath: BUS_EVENTS_LOG_FILE,
|
|
43
54
|
maxBytes: getMaxLogSizeMB() * 1024 * 1024,
|
|
44
55
|
});
|
|
56
|
+
const eventRollupCounts = new Map();
|
|
57
|
+
function bumpRollupCount(event, outcome) {
|
|
58
|
+
const existing = eventRollupCounts.get(event) ?? {
|
|
59
|
+
emitted: 0,
|
|
60
|
+
skipped_stale_session: 0,
|
|
61
|
+
emit_failed: 0,
|
|
62
|
+
};
|
|
63
|
+
existing[outcome] += 1;
|
|
64
|
+
eventRollupCounts.set(event, existing);
|
|
65
|
+
}
|
|
45
66
|
export function logBusEvent(entry) {
|
|
67
|
+
if (entry.outcome === "emitted" ||
|
|
68
|
+
entry.outcome === "skipped_stale_session" ||
|
|
69
|
+
entry.outcome === "emit_failed") {
|
|
70
|
+
bumpRollupCount(entry.event, entry.outcome);
|
|
71
|
+
}
|
|
46
72
|
if (isTestMode()) {
|
|
47
73
|
return;
|
|
48
74
|
}
|
|
49
75
|
writer.log({ ts: new Date().toISOString(), ...entry });
|
|
50
76
|
}
|
|
77
|
+
/** Snapshot the current session's rollup, keyed by event name. Non-mutating —
|
|
78
|
+
* pair with {@link resetBusEventRollupCounts} at session end. */
|
|
79
|
+
export function getBusEventRollupCounts() {
|
|
80
|
+
return Object.fromEntries(eventRollupCounts);
|
|
81
|
+
}
|
|
82
|
+
/** Clear the rollup — call once the session-end snapshot has been logged
|
|
83
|
+
* (or from tests) so a new session starts from zero. */
|
|
84
|
+
export function resetBusEventRollupCounts() {
|
|
85
|
+
eventRollupCounts.clear();
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Log one `session_end_bus_rollup` latency row per event NAME that had any
|
|
89
|
+
* activity this session, then clear the rollup. Called from index.ts's
|
|
90
|
+
* `session_shutdown` handler (the session-end hook this repo's other
|
|
91
|
+
* teardown work — LSP fleet shutdown, cache-prefix eviction — already runs
|
|
92
|
+
* from). A no-op (logs nothing) when nothing was ever published, matching
|
|
93
|
+
* `formatSmellsSessionStartLine`'s "no noise on an ordinary session" shape.
|
|
94
|
+
*/
|
|
95
|
+
export function emitBusEventRollupAtSessionEnd(cwd) {
|
|
96
|
+
for (const [event, counts] of eventRollupCounts) {
|
|
97
|
+
logLatency({
|
|
98
|
+
type: "phase",
|
|
99
|
+
phase: "session_end_bus_rollup",
|
|
100
|
+
filePath: cwd,
|
|
101
|
+
durationMs: 0,
|
|
102
|
+
metadata: { event, ...counts },
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
resetBusEventRollupCounts();
|
|
106
|
+
}
|
|
51
107
|
export function getBusEventsLogPath() {
|
|
52
108
|
return BUS_EVENTS_LOG_FILE;
|
|
53
109
|
}
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { logBusEvent } from "./bus-events-logger.js";
|
|
21
21
|
import { normalizeFilePath } from "./path-utils.js";
|
|
22
22
|
import { appendRecentTouches } from "./recent-touches.js";
|
|
23
|
-
import { createLiveBusEmitter, recordStaleBusFailure, } from "./live-bus-emitter.js";
|
|
23
|
+
import { createLiveBusEmitter, recordStaleBusFailure, resolveLiveBusEmitter, } from "./live-bus-emitter.js";
|
|
24
24
|
export const BUS_FILES_TOUCHED_EVENT = "pilens:files:touched";
|
|
25
25
|
export const BUS_FILES_TOUCHED_VERSION = 1;
|
|
26
26
|
const liveEmitter = createLiveBusEmitter();
|
|
@@ -106,8 +106,14 @@ export function publishFilesTouched(args) {
|
|
|
106
106
|
}).catch((err) => {
|
|
107
107
|
args.dbg?.(`bus-publish: recent-touches append failed: ${err}`);
|
|
108
108
|
});
|
|
109
|
-
const
|
|
110
|
-
|
|
109
|
+
const resolution = resolveLiveBusEmitter(liveEmitter, () => ({
|
|
110
|
+
event: BUS_FILES_TOUCHED_EVENT,
|
|
111
|
+
cwd: normalizeFilePath(args.cwd),
|
|
112
|
+
reason: args.reason,
|
|
113
|
+
}));
|
|
114
|
+
if (resolution.outcome === "stale-session")
|
|
115
|
+
return;
|
|
116
|
+
if (resolution.outcome === "unwired") {
|
|
111
117
|
if (!hasLoggedUnwired) {
|
|
112
118
|
hasLoggedUnwired = true;
|
|
113
119
|
logBusEvent({
|
|
@@ -119,6 +125,7 @@ export function publishFilesTouched(args) {
|
|
|
119
125
|
}
|
|
120
126
|
return;
|
|
121
127
|
}
|
|
128
|
+
const busEmit = resolution.emit;
|
|
122
129
|
try {
|
|
123
130
|
const payload = {
|
|
124
131
|
v: BUS_FILES_TOUCHED_VERSION,
|
|
@@ -150,6 +157,7 @@ export function publishFilesTouched(args) {
|
|
|
150
157
|
cwd: normalizeFilePath(args.cwd),
|
|
151
158
|
reason: args.reason,
|
|
152
159
|
error: String(err),
|
|
160
|
+
ctxSource: resolution.ctxSource,
|
|
153
161
|
});
|
|
154
162
|
if (!hasLoggedFailure) {
|
|
155
163
|
hasLoggedFailure = true;
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import { convertLspDiagnostics } from "./dispatch/utils/lsp-diagnostics.js";
|
|
1
2
|
import { toRunnerDisplayPath } from "./dispatch/runner-context.js";
|
|
2
3
|
export function formatCascadeNeighborDiagnostics(cwd, neighbors, options = {}) {
|
|
3
4
|
const withErrors = neighbors.filter((n) => n.diagnostics.length > 0);
|
|
4
|
-
|
|
5
|
+
const inconclusive = neighbors.filter((n) => n.inconclusive === true && n.diagnostics.length === 0);
|
|
6
|
+
if (withErrors.length === 0 && inconclusive.length === 0)
|
|
5
7
|
return "";
|
|
6
8
|
const noun = options.noun ?? "neighbor";
|
|
7
|
-
let out =
|
|
9
|
+
let out = withErrors.length > 0
|
|
10
|
+
? `📐 Cascade errors in ${withErrors.length} ${noun} file(s) — fix before finishing turn:`
|
|
11
|
+
: "";
|
|
8
12
|
for (const neighbor of withErrors) {
|
|
9
13
|
const display = toRunnerDisplayPath(cwd, neighbor.filePath);
|
|
10
14
|
const reason = options.includeReason ? ` reason="${neighbor.reason}"` : "";
|
|
@@ -17,5 +21,56 @@ export function formatCascadeNeighborDiagnostics(cwd, neighbors, options = {}) {
|
|
|
17
21
|
}
|
|
18
22
|
out += "\n</diagnostics>";
|
|
19
23
|
}
|
|
24
|
+
if (inconclusive.length > 0) {
|
|
25
|
+
if (out)
|
|
26
|
+
out += "\n";
|
|
27
|
+
out += `⚠️ Cascade diagnostics inconclusive for ${inconclusive.length} ${noun} file(s) — no clean result was confirmed:`;
|
|
28
|
+
for (const neighbor of inconclusive) {
|
|
29
|
+
out += `\n ${toRunnerDisplayPath(cwd, neighbor.filePath)}`;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
20
32
|
return out;
|
|
21
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Build the turn-end `CascadeRun` for a neighbour whose diagnostics landed only
|
|
36
|
+
* AFTER its cascade touch skipped the in-lane wait (#1023's `resolved-found`
|
|
37
|
+
* quiet-window outcome; #1444 made native TS7 take that same path). Returns
|
|
38
|
+
* `undefined` when there is nothing agent-facing to say — no ERROR-severity
|
|
39
|
+
* diagnostics, or nothing the formatter renders.
|
|
40
|
+
*
|
|
41
|
+
* Lives here rather than inline in the quiet-window callback so the delivery
|
|
42
|
+
* path (reconcile → run → turn_end) is testable end to end; index.ts only wires
|
|
43
|
+
* it to `runtime.appendCascadeRun`.
|
|
44
|
+
*/
|
|
45
|
+
export function buildResolvedFoundCascadeRun(cwd, neighbor) {
|
|
46
|
+
const { filePath } = neighbor;
|
|
47
|
+
const diagnostics = convertLspDiagnostics(neighbor.diagnostics.filter((d) => d.severity === 1), filePath, { tool: "lsp" });
|
|
48
|
+
if (diagnostics.length === 0)
|
|
49
|
+
return undefined;
|
|
50
|
+
const neighbors = [
|
|
51
|
+
{ filePath, reason: "references", diagnostics, lspTouched: true },
|
|
52
|
+
];
|
|
53
|
+
const formatted = formatCascadeNeighborDiagnostics(cwd, neighbors, {
|
|
54
|
+
noun: "cold neighbor",
|
|
55
|
+
});
|
|
56
|
+
if (!formatted)
|
|
57
|
+
return undefined;
|
|
58
|
+
return {
|
|
59
|
+
filePath,
|
|
60
|
+
result: {
|
|
61
|
+
filePath,
|
|
62
|
+
impact: {
|
|
63
|
+
filePath,
|
|
64
|
+
changedSymbols: [],
|
|
65
|
+
directImporters: [],
|
|
66
|
+
directCallers: [],
|
|
67
|
+
neighborFiles: [filePath],
|
|
68
|
+
riskFlags: [],
|
|
69
|
+
},
|
|
70
|
+
neighbors,
|
|
71
|
+
formatted,
|
|
72
|
+
},
|
|
73
|
+
neighborCount: 1,
|
|
74
|
+
diagnosticCount: diagnostics.length,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { performance } from "node:perf_hooks";
|
|
2
1
|
// Capture this before the guard's other module initialization so host boot is
|
|
3
|
-
// not charged to extension evaluation (#1374).
|
|
4
|
-
|
|
2
|
+
// not charged to extension evaluation (#1374). Lives in its own
|
|
3
|
+
// side-effect-free module (#1434 S3d) — see eval-timestamp.ts's docstring for
|
|
4
|
+
// why: `startup-timing.ts` needs this constant but must never trigger a
|
|
5
|
+
// console-guard install merely by importing it.
|
|
6
|
+
import { PI_LENS_EVAL_STARTED_MS } from "./eval-timestamp.js";
|
|
5
7
|
// #1333: install the console guard as an import side-effect so it runs before
|
|
6
8
|
// any other module's initialization code. Installing inside the extension
|
|
7
9
|
// factory is too late — by the time the factory runs, every static/transitive
|
|
@@ -10,5 +12,15 @@ export const PI_LENS_EVAL_STARTED_MS = performance.now();
|
|
|
10
12
|
// MUST remain the first import of index.ts; the enforcement test pins that.
|
|
11
13
|
// installConsoleGuard() itself is idempotent and no-ops under test mode and
|
|
12
14
|
// PI_LENS_CONSOLE_GUARD=0.
|
|
13
|
-
|
|
15
|
+
// #1434: the guard captures only while pi-lens owns execution, so the module
|
|
16
|
+
// window must be open for the rest of the import graph. index.ts closes it on
|
|
17
|
+
// its last line; an unref'd backstop closes it if that never runs.
|
|
18
|
+
import { installConsoleGuard, openModuleLoadConsoleWindow, } from "./extension-log.js";
|
|
14
19
|
installConsoleGuard();
|
|
20
|
+
openModuleLoadConsoleWindow();
|
|
21
|
+
// Not re-exported: nothing should import the timestamp from this module.
|
|
22
|
+
// `clients/startup-timing.ts` imports it straight from `eval-timestamp.ts`,
|
|
23
|
+
// which is what keeps that import side-effect-free (S3d). The `void` keeps
|
|
24
|
+
// the import from looking unused to lint while documenting that this module
|
|
25
|
+
// only needs the ordering (evaluate eval-timestamp.ts first), not the value.
|
|
26
|
+
void PI_LENS_EVAL_STARTED_MS;
|
|
@@ -15,6 +15,7 @@ import { createSubsystemLogger } from "./extension-log.js";
|
|
|
15
15
|
import * as path from "node:path";
|
|
16
16
|
import { findNearestMarkerRoot } from "./path-utils.js";
|
|
17
17
|
import { safeSpawnAsync } from "./safe-spawn.js";
|
|
18
|
+
import { classifyProbeFailure, createAvailabilityLatch, logAvailabilityDecision, startHostStallSampler, } from "./dispatch/runners/utils/availability-policy.js";
|
|
18
19
|
function emptyResult(language) {
|
|
19
20
|
return {
|
|
20
21
|
success: false,
|
|
@@ -39,6 +40,21 @@ const VULTURE_EXCLUDES = [
|
|
|
39
40
|
"*/__pycache__/*",
|
|
40
41
|
"*/.eggs/*",
|
|
41
42
|
];
|
|
43
|
+
// Decorators whose target is called by a framework, never by name in the tree.
|
|
44
|
+
// vulture cannot see those call sites, so every such symbol is a permanent
|
|
45
|
+
// false positive that reappears in every scan — the fastest way to teach a
|
|
46
|
+
// reader to stop reading the advisory. Glob-matched by vulture itself.
|
|
47
|
+
const VULTURE_IGNORE_DECORATORS = [
|
|
48
|
+
"@pytest.fixture",
|
|
49
|
+
"@pytest.fixture(*",
|
|
50
|
+
"@fixture",
|
|
51
|
+
"@fixture(*",
|
|
52
|
+
"@app.*",
|
|
53
|
+
"@router.*",
|
|
54
|
+
"@celery.task*",
|
|
55
|
+
"@task",
|
|
56
|
+
"@shared_task*",
|
|
57
|
+
];
|
|
42
58
|
// vulture line: `path/to/file.py:12: unused function 'foo' (60% confidence)`
|
|
43
59
|
const VULTURE_LINE = /^(.*?):(\d+): unused (\w[\w ]*?) '([^']+)' \((\d+)% confidence\)\s*$/;
|
|
44
60
|
/**
|
|
@@ -86,7 +102,12 @@ export function parseVultureOutput(output, root) {
|
|
|
86
102
|
export class PythonDeadCodeClient {
|
|
87
103
|
id = "python";
|
|
88
104
|
language = "Python";
|
|
89
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Transient-aware memo (#1467): only a durable "vulture is not installed"
|
|
107
|
+
* verdict is remembered for the session. A probe that timed out expires and
|
|
108
|
+
* is re-probed, so vulture cannot be disabled for the process by one stall.
|
|
109
|
+
*/
|
|
110
|
+
availabilityLatch = createAvailabilityLatch();
|
|
90
111
|
resolved = null;
|
|
91
112
|
ensureInFlight = null;
|
|
92
113
|
inFlight = new Map();
|
|
@@ -103,6 +124,10 @@ export class PythonDeadCodeClient {
|
|
|
103
124
|
detect(cwd) {
|
|
104
125
|
return this.resolveProjectRoot(cwd) !== null;
|
|
105
126
|
}
|
|
127
|
+
owns(filePath) {
|
|
128
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
129
|
+
return ext === ".py" || ext === ".pyi";
|
|
130
|
+
}
|
|
106
131
|
/**
|
|
107
132
|
* Nearest dir with a Python project marker, never at/above $HOME and never
|
|
108
133
|
* escaping a VCS boundary — same containment rules as KnipClient so a scan
|
|
@@ -121,8 +146,9 @@ export class PythonDeadCodeClient {
|
|
|
121
146
|
], { boundaries: [".git", ".hg", ".svn"], homeDir: homeDirOverride });
|
|
122
147
|
}
|
|
123
148
|
async ensureAvailable() {
|
|
124
|
-
|
|
125
|
-
|
|
149
|
+
const memo = this.availabilityLatch.read();
|
|
150
|
+
if (memo !== null)
|
|
151
|
+
return memo;
|
|
126
152
|
if (this.ensureInFlight)
|
|
127
153
|
return this.ensureInFlight;
|
|
128
154
|
this.ensureInFlight = this.doEnsureAvailable();
|
|
@@ -146,19 +172,81 @@ export class PythonDeadCodeClient {
|
|
|
146
172
|
{ cmd: "python", prefix: ["-m", "vulture"] },
|
|
147
173
|
{ cmd: "python3", prefix: ["-m", "vulture"] },
|
|
148
174
|
];
|
|
175
|
+
// A timeout on ANY candidate means the machine, not the tool, answered —
|
|
176
|
+
// the run gets a bounded retry instead of a permanent skip (#1467).
|
|
177
|
+
let sawTransient = false;
|
|
178
|
+
let transientCause = "probe-timeout";
|
|
179
|
+
// Accumulated across ALL candidates, because the failure verdicts below
|
|
180
|
+
// are about the whole sweep rather than any one probe. Reporting zero
|
|
181
|
+
// here would erase the evidence that cracked #1467: four 5s probes and
|
|
182
|
+
// a stalled host look identical to an absent tool without these two
|
|
183
|
+
// numbers.
|
|
184
|
+
const sweepStartedAt = Date.now();
|
|
185
|
+
let sweepHostStallMs = 0;
|
|
149
186
|
for (const c of candidates) {
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
187
|
+
const sampler = startHostStallSampler();
|
|
188
|
+
const startedAt = Date.now();
|
|
189
|
+
let probe;
|
|
190
|
+
let hostStallMs;
|
|
191
|
+
try {
|
|
192
|
+
probe = await safeSpawnAsync(c.cmd, [...c.prefix, "--version"], {
|
|
193
|
+
timeout: 5000,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
hostStallMs = sampler.stop();
|
|
198
|
+
sweepHostStallMs += hostStallMs;
|
|
199
|
+
}
|
|
153
200
|
if (!probe.error && probe.status === 0) {
|
|
154
201
|
this.resolved = c;
|
|
155
|
-
this.
|
|
202
|
+
this.availabilityLatch.noteAvailable();
|
|
156
203
|
this.log(`vulture found: ${[c.cmd, ...c.prefix].join(" ")}`);
|
|
204
|
+
logAvailabilityDecision({
|
|
205
|
+
tool: "vulture",
|
|
206
|
+
verdict: "available",
|
|
207
|
+
outcome: "success",
|
|
208
|
+
cause: "ok",
|
|
209
|
+
elapsedMs: Date.now() - startedAt,
|
|
210
|
+
latched: true,
|
|
211
|
+
hostStallMs,
|
|
212
|
+
budgetMs: 5000,
|
|
213
|
+
});
|
|
157
214
|
return true;
|
|
158
215
|
}
|
|
216
|
+
const classified = classifyProbeFailure(probe, { hostStallMs });
|
|
217
|
+
if (classified.outcome === "transient") {
|
|
218
|
+
sawTransient = true;
|
|
219
|
+
transientCause = classified.cause;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (sawTransient) {
|
|
223
|
+
const retryAfterMs = this.availabilityLatch.noteUnavailable("transient", transientCause);
|
|
224
|
+
this.log("vulture probe timed out; will retry (not treated as missing)");
|
|
225
|
+
logAvailabilityDecision({
|
|
226
|
+
tool: "vulture",
|
|
227
|
+
verdict: "unavailable",
|
|
228
|
+
outcome: "transient",
|
|
229
|
+
cause: transientCause,
|
|
230
|
+
elapsedMs: Date.now() - sweepStartedAt,
|
|
231
|
+
hostStallMs: sweepHostStallMs,
|
|
232
|
+
latched: false,
|
|
233
|
+
retryAfterMs,
|
|
234
|
+
budgetMs: 5000,
|
|
235
|
+
});
|
|
236
|
+
return false;
|
|
159
237
|
}
|
|
160
|
-
this.
|
|
238
|
+
this.availabilityLatch.noteUnavailable("missing", "not-found");
|
|
161
239
|
this.log("vulture not installed; skipping (no auto-install)");
|
|
240
|
+
logAvailabilityDecision({
|
|
241
|
+
tool: "vulture",
|
|
242
|
+
verdict: "unavailable",
|
|
243
|
+
outcome: "missing",
|
|
244
|
+
cause: "not-found",
|
|
245
|
+
elapsedMs: Date.now() - sweepStartedAt,
|
|
246
|
+
hostStallMs: sweepHostStallMs,
|
|
247
|
+
latched: true,
|
|
248
|
+
budgetMs: 5000,
|
|
249
|
+
});
|
|
162
250
|
return false;
|
|
163
251
|
}
|
|
164
252
|
async analyze(cwd) {
|
|
@@ -193,6 +281,7 @@ export class PythonDeadCodeClient {
|
|
|
193
281
|
".",
|
|
194
282
|
`--min-confidence=${this.minConfidence}`,
|
|
195
283
|
`--exclude=${VULTURE_EXCLUDES.join(",")}`,
|
|
284
|
+
`--ignore-decorators=${VULTURE_IGNORE_DECORATORS.join(",")}`,
|
|
196
285
|
];
|
|
197
286
|
const result = await safeSpawnAsync(invocation.cmd, args, {
|
|
198
287
|
timeout: ANALYSIS_TIMEOUT_MS,
|
|
@@ -258,30 +347,46 @@ export function deadCodeIssueCount(result) {
|
|
|
258
347
|
result.unusedDeps.length +
|
|
259
348
|
result.unlistedDeps.length);
|
|
260
349
|
}
|
|
350
|
+
/** Every bucket flattened — delta diffing and diagnostic mapping want one list. */
|
|
351
|
+
export function deadCodeIssues(result) {
|
|
352
|
+
return [
|
|
353
|
+
...result.unusedExports,
|
|
354
|
+
...result.unusedFiles,
|
|
355
|
+
...result.unusedDeps,
|
|
356
|
+
...result.unlistedDeps,
|
|
357
|
+
];
|
|
358
|
+
}
|
|
261
359
|
/**
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
360
|
+
* Stable identity for diffing one scan against the previous one.
|
|
361
|
+
*
|
|
362
|
+
* Deliberately excludes the line number. An edit shifts every line below it,
|
|
363
|
+
* and the delta is then filtered to exactly the files the edit touched — so a
|
|
364
|
+
* line in the key turns each shifted pre-existing finding into a "newly unused"
|
|
365
|
+
* report under a heading that blames the agent's own edit for orphaning it.
|
|
366
|
+
* Inserting four lines above one real finding produced four false ones.
|
|
266
367
|
*/
|
|
267
|
-
export function
|
|
268
|
-
|
|
269
|
-
|
|
368
|
+
export function deadCodeIssueKey(issue) {
|
|
369
|
+
return `${issue.category}:${issue.file ?? ""}:${issue.name}`;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Format this turn's ATTRIBUTABLE delta — symbols that became unused in files
|
|
373
|
+
* the agent just edited — or "" when there is nothing to report. Mirrors the
|
|
374
|
+
* Knip advisory's contract deliberately: the project-wide list is not injected
|
|
375
|
+
* per turn (hundreds of pre-existing findings would drown the blockers and burn
|
|
376
|
+
* context every turn), it stays available on demand via lens_diagnostics.
|
|
377
|
+
*/
|
|
378
|
+
export function formatDeadCodeDelta(issues, language, max = 5) {
|
|
379
|
+
if (issues.length === 0)
|
|
270
380
|
return "";
|
|
271
|
-
|
|
272
|
-
for (const
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
}
|
|
281
|
-
if (r.unusedExports.length > maxPerLang) {
|
|
282
|
-
lines.push(` … and ${r.unusedExports.length - maxPerLang} more`);
|
|
283
|
-
}
|
|
381
|
+
let report = `💀 Newly unused ${language} symbols in files you edited — check if callers need updating (dead-code):\n`;
|
|
382
|
+
for (const issue of issues.slice(0, max)) {
|
|
383
|
+
const loc = issue.file
|
|
384
|
+
? `${issue.file}${issue.line ? `:${issue.line}` : ""}`
|
|
385
|
+
: "(unknown)";
|
|
386
|
+
report += ` ${loc} — unused ${issue.kind} ${issue.name}\n`;
|
|
387
|
+
}
|
|
388
|
+
if (issues.length > max) {
|
|
389
|
+
report += ` … and ${issues.length - max} more\n`;
|
|
284
390
|
}
|
|
285
|
-
return
|
|
286
|
-
lines.join("\n "));
|
|
391
|
+
return report;
|
|
287
392
|
}
|
|
@@ -15,6 +15,7 @@ import { findNodeToolBinary } from "./package-manager.js";
|
|
|
15
15
|
import { isFullyQualified } from "./path-utils.js";
|
|
16
16
|
import { safeSpawnAsync } from "./safe-spawn.js";
|
|
17
17
|
import { createAvailabilityChecker, discoverManagedTool, getManagedToolEnvironment, resolveAvailableOrInstall, } from "./dispatch/runners/utils/runner-helpers.js";
|
|
18
|
+
import { createAvailabilityLatch } from "./dispatch/runners/utils/availability-policy.js";
|
|
18
19
|
/**
|
|
19
20
|
* Build madge's argv for a circular-dependency scan.
|
|
20
21
|
*
|
|
@@ -158,7 +159,8 @@ export class DependencyChecker {
|
|
|
158
159
|
environment: (cwd) => getManagedToolEnvironment("madge", cwd),
|
|
159
160
|
unclassifiedFailureOutcome: "missing",
|
|
160
161
|
});
|
|
161
|
-
|
|
162
|
+
/** Transient-aware availability memo — see `ensureAvailable` (#1467). */
|
|
163
|
+
availabilityLatch = createAvailabilityLatch();
|
|
162
164
|
ensureInFlight = null;
|
|
163
165
|
checkInFlight = new Map();
|
|
164
166
|
scanInFlight = new Map();
|
|
@@ -320,12 +322,17 @@ export class DependencyChecker {
|
|
|
320
322
|
this.circularFiles = circularFiles;
|
|
321
323
|
}
|
|
322
324
|
/**
|
|
323
|
-
* Check if madge is available, auto-install if not
|
|
325
|
+
* Check if madge is available, auto-install if not.
|
|
326
|
+
*
|
|
327
|
+
* Shares knip's latch policy (#1467): only a durable verdict is memoized, so
|
|
328
|
+
* a probe that timed out — madge's `--version` is ~0.7 s today, but a host
|
|
329
|
+
* event-loop stall can expire any budget — does not disable madge for the
|
|
330
|
+
* life of the process.
|
|
324
331
|
*/
|
|
325
332
|
async ensureAvailable(cwd = process.cwd()) {
|
|
326
|
-
|
|
327
|
-
if (
|
|
328
|
-
return
|
|
333
|
+
const memo = this.availabilityLatch.read();
|
|
334
|
+
if (memo !== null)
|
|
335
|
+
return memo;
|
|
329
336
|
if (this.ensureInFlight)
|
|
330
337
|
return this.ensureInFlight;
|
|
331
338
|
this.ensureInFlight = this.doEnsureAvailable(cwd);
|
|
@@ -338,8 +345,13 @@ export class DependencyChecker {
|
|
|
338
345
|
}
|
|
339
346
|
async doEnsureAvailable(cwd) {
|
|
340
347
|
const resolved = await resolveAvailableOrInstall(this.madgeAvailability, "madge", cwd);
|
|
341
|
-
|
|
342
|
-
|
|
348
|
+
if (resolved !== null) {
|
|
349
|
+
this.availabilityLatch.noteAvailable();
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
const verdict = this.madgeAvailability.getVerdict(cwd);
|
|
353
|
+
this.availabilityLatch.noteUnavailable(verdict.outcome ?? "missing", verdict.cause ?? "not-found");
|
|
354
|
+
return false;
|
|
343
355
|
}
|
|
344
356
|
/**
|
|
345
357
|
* Check if a file is part of a circular dependency (from cache)
|
|
@@ -278,7 +278,7 @@ export function _resetStateCacheForTests() {
|
|
|
278
278
|
* Neither can throw into the mark path; both are already internally
|
|
279
279
|
* fail-safe, but the try/catch keeps a future regression in either from
|
|
280
280
|
* breaking a mark. */
|
|
281
|
-
function emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing) {
|
|
281
|
+
function emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing, identity) {
|
|
282
282
|
try {
|
|
283
283
|
logDispositionEvent({
|
|
284
284
|
event: "mark",
|
|
@@ -290,6 +290,8 @@ function emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing) {
|
|
|
290
290
|
reason,
|
|
291
291
|
anchor,
|
|
292
292
|
previousDisposition: existing?.disposition,
|
|
293
|
+
model: identity?.model || undefined,
|
|
294
|
+
provider: identity?.provider || undefined,
|
|
293
295
|
});
|
|
294
296
|
publishDisposition({
|
|
295
297
|
cwd,
|
|
@@ -317,7 +319,7 @@ function emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing) {
|
|
|
317
319
|
* hang off it, so the agent tool and any future UI caller are covered without
|
|
318
320
|
* per-caller wiring.
|
|
319
321
|
*/
|
|
320
|
-
export function markDisposition(cwd, target, disposition, reason) {
|
|
322
|
+
export function markDisposition(cwd, target, disposition, reason, identity) {
|
|
321
323
|
const anchor = disposition === "false-positive"
|
|
322
324
|
? computeStrictAnchor(target)
|
|
323
325
|
: computeWeakAnchor(target);
|
|
@@ -327,7 +329,7 @@ export function markDisposition(cwd, target, disposition, reason) {
|
|
|
327
329
|
const existing = readState(cwd).dispositions?.[anchor];
|
|
328
330
|
if (disposition === "defer") {
|
|
329
331
|
deferredThisSession.add(anchor);
|
|
330
|
-
emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing);
|
|
332
|
+
emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing, identity);
|
|
331
333
|
return anchor;
|
|
332
334
|
}
|
|
333
335
|
const now = new Date().toISOString();
|
|
@@ -344,7 +346,7 @@ export function markDisposition(cwd, target, disposition, reason) {
|
|
|
344
346
|
lineText,
|
|
345
347
|
};
|
|
346
348
|
commitDisposition(cwd, anchor, entry);
|
|
347
|
-
emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing);
|
|
349
|
+
emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing, identity);
|
|
348
350
|
return anchor;
|
|
349
351
|
}
|
|
350
352
|
export function getDisposition(cwd, anchor) {
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
*/
|
|
62
62
|
import { logBusEvent } from "./bus-events-logger.js";
|
|
63
63
|
import { normalizeFilePath } from "./path-utils.js";
|
|
64
|
-
import { createLiveBusEmitter, recordStaleBusFailure, } from "./live-bus-emitter.js";
|
|
64
|
+
import { createLiveBusEmitter, recordStaleBusFailure, resolveLiveBusEmitter, } from "./live-bus-emitter.js";
|
|
65
65
|
import { isBusPublishEnabled } from "./bus-publish.js";
|
|
66
66
|
export const BUS_DIAGNOSTICS_EVENT = "pilens:diagnostics";
|
|
67
67
|
export const BUS_DIAGNOSTICS_VERSION = 1;
|
|
@@ -149,8 +149,13 @@ export function publishDiagnostics(args) {
|
|
|
149
149
|
}
|
|
150
150
|
return;
|
|
151
151
|
}
|
|
152
|
-
const
|
|
153
|
-
|
|
152
|
+
const resolution = resolveLiveBusEmitter(liveEmitter, () => ({
|
|
153
|
+
event: BUS_DIAGNOSTICS_EVENT,
|
|
154
|
+
cwd: normalizeFilePath(args.cwd),
|
|
155
|
+
}));
|
|
156
|
+
if (resolution.outcome === "stale-session")
|
|
157
|
+
return;
|
|
158
|
+
if (resolution.outcome === "unwired") {
|
|
154
159
|
if (!hasLoggedUnwired) {
|
|
155
160
|
hasLoggedUnwired = true;
|
|
156
161
|
logBusEvent({
|
|
@@ -161,6 +166,7 @@ export function publishDiagnostics(args) {
|
|
|
161
166
|
}
|
|
162
167
|
return;
|
|
163
168
|
}
|
|
169
|
+
const busEmit = resolution.emit;
|
|
164
170
|
try {
|
|
165
171
|
const fileEntries = args.files.map((f) => {
|
|
166
172
|
const normPath = normalizeFilePath(f.path);
|
|
@@ -204,6 +210,7 @@ export function publishDiagnostics(args) {
|
|
|
204
210
|
outcome: "emit_failed",
|
|
205
211
|
cwd: normalizeFilePath(args.cwd),
|
|
206
212
|
error: String(err),
|
|
213
|
+
ctxSource: resolution.ctxSource,
|
|
207
214
|
});
|
|
208
215
|
if (!hasLoggedFailure) {
|
|
209
216
|
hasLoggedFailure = true;
|