pi-lens 3.8.61 → 3.8.63
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 +37 -0
- package/README.md +5 -4
- package/banner.png +0 -0
- package/banner.svg +54 -46
- package/dist/clients/ast-grep-client.js +160 -3
- package/dist/clients/ast-grep-tool-logger.js +1 -1
- package/dist/clients/ast-grep-yaml-synth.js +30 -6
- package/dist/clients/bootstrap.js +99 -33
- package/dist/clients/complexity-client.js +1 -1
- package/dist/clients/dead-code-client.js +304 -0
- package/dist/clients/dead-code-logger.js +46 -0
- package/dist/clients/deadline-utils.js +70 -0
- package/dist/clients/dependency-checker.js +55 -16
- package/dist/clients/deps/ast-grep-napi.js +3 -0
- package/dist/clients/deps/js-yaml.js +9 -0
- package/dist/clients/deps/minimatch.js +4 -0
- package/dist/clients/deps/pi-tui.js +5 -0
- package/dist/clients/deps/typebox.js +6 -0
- package/dist/clients/deps/typescript.js +15 -0
- package/dist/clients/deps/vscode-jsonrpc.js +1 -0
- package/dist/clients/deps/web-tree-sitter.js +3 -0
- package/dist/clients/dispatch/fact-runner.js +59 -0
- package/dist/clients/dispatch/facts/comment-facts.js +1 -1
- package/dist/clients/dispatch/facts/function-facts.js +1 -1
- package/dist/clients/dispatch/facts/import-facts.js +1 -1
- package/dist/clients/dispatch/facts/try-catch-facts.js +1 -1
- package/dist/clients/dispatch/integration.js +7 -20
- package/dist/clients/dispatch/rules/quality-rules.js +1 -1
- package/dist/clients/dispatch/rules/sonar-rules.js +1 -1
- package/dist/clients/dispatch/runners/ast-grep-napi.js +2 -1
- package/dist/clients/dispatch/runners/yaml-rule-parser.js +1 -1
- package/dist/clients/event-loop-monitor.js +18 -3
- package/dist/clients/file-utils.js +1 -1
- package/dist/clients/grammar-source.js +71 -0
- package/dist/clients/install-diagnostics.js +141 -0
- package/dist/clients/installer/index.js +114 -9
- package/dist/clients/knip-client.js +62 -43
- package/dist/clients/lsp/client.js +113 -62
- package/dist/clients/lsp/index.js +35 -8
- package/dist/clients/lsp/interactive-install.js +26 -24
- package/dist/clients/lsp/launch.js +2 -2
- package/dist/clients/lsp/server.js +30 -23
- package/dist/clients/mcp/session.js +2 -0
- package/dist/clients/module-report-lsp.js +1 -16
- package/dist/clients/module-report.js +1121 -59
- package/dist/clients/pipeline.js +58 -37
- package/dist/clients/project-diagnostics/runner-adapters/knip.js +3 -0
- package/dist/clients/project-diagnostics/scanner.js +23 -6
- package/dist/clients/read-expansion.js +1 -14
- package/dist/clients/read-guard-tool-lines.js +5 -1
- package/dist/clients/review-graph/builder.js +15 -4
- package/dist/clients/runtime-session.js +44 -4
- package/dist/clients/runtime-turn.js +75 -13
- package/dist/clients/tree-sitter-client.js +80 -37
- package/dist/clients/tree-sitter-symbol-extractor.js +86 -1
- package/dist/clients/ts-service.js +1 -1
- package/dist/clients/typescript-client.js +1 -1
- package/dist/clients/widget-state.js +1 -1
- package/dist/index.js +41 -11
- package/dist/tools/ast-dump.js +76 -29
- package/dist/tools/ast-grep-outline.js +148 -0
- package/dist/tools/ast-grep-replace.js +13 -1
- package/dist/tools/ast-grep-search.js +405 -112
- package/dist/tools/lens-diagnostics.js +57 -9
- package/dist/tools/lsp-diagnostics.js +17 -1
- package/dist/tools/lsp-navigation.js +14 -1
- package/dist/tools/module-report.js +257 -27
- package/dist/tools/render-compact.js +95 -0
- package/package.json +5 -1
- package/rules/ast-grep-rules/rule-tests/incomplete-string-escaping-js-test.yml +21 -0
- package/rules/ast-grep-rules/rule-tests/incomplete-string-escaping-test.yml +21 -0
- package/rules/ast-grep-rules/rules/incomplete-string-escaping-js.yml +19 -0
- package/rules/ast-grep-rules/rules/incomplete-string-escaping.yml +19 -0
- package/scripts/install-selftest.mjs +173 -0
- package/scripts/rpc-load-check.mjs +80 -0
- package/skills/ast-grep/SKILL.md +26 -6
- package/skills/write-ast-grep-rule/SKILL.md +36 -26
|
@@ -1,41 +1,107 @@
|
|
|
1
1
|
let bootstrapPromise = null;
|
|
2
|
+
/**
|
|
3
|
+
* A stand-in for an analysis client whose module failed to load (an unresolved
|
|
4
|
+
* runtime dependency under a package-manager layout the resolver can't traverse
|
|
5
|
+
* — #285/#335). Every method call no-ops to `undefined`, which every analyzer
|
|
6
|
+
* consumer already treats as "nothing to report", so a single failed analyzer
|
|
7
|
+
* degrades to silence instead of taking down the whole extension. This keeps the
|
|
8
|
+
* fail-soft in ONE seam (the bootstrap) so consumers never special-case it —
|
|
9
|
+
* the same single-seam principle as the clients/deps/* accessors.
|
|
10
|
+
*/
|
|
11
|
+
export function degradedClient() {
|
|
12
|
+
return new Proxy({}, {
|
|
13
|
+
get(_target, prop) {
|
|
14
|
+
// Not thenable (so `await stub` / Promise.resolve(stub) won't treat it
|
|
15
|
+
// as a promise), not iterable, no surprising coercion.
|
|
16
|
+
if (typeof prop === "symbol" || prop === "then")
|
|
17
|
+
return undefined;
|
|
18
|
+
return () => undefined;
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* One or more client modules failed to load — almost always an unresolved
|
|
24
|
+
* runtime dependency under a package-manager layout the runtime's resolver can't
|
|
25
|
+
* traverse (#285/#335). Name each disabled analyzer, then emit ONE paste-able
|
|
26
|
+
* environment fingerprint so a reporter can tell us exactly what failed and
|
|
27
|
+
* where. Best-effort: never let the diagnostic itself mask the failure.
|
|
28
|
+
*/
|
|
29
|
+
async function logBootstrapFailures(failures) {
|
|
30
|
+
for (const { name, err } of failures) {
|
|
31
|
+
console.error(`[pi-lens] analyzer "${name}" disabled (degraded mode): ${err?.message ?? String(err)}`);
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const { collectInstallDiagnostics, formatInstallDiagnostics } = await import("./install-diagnostics.js");
|
|
35
|
+
console.error(formatInstallDiagnostics(collectInstallDiagnostics(), failures[0]?.err));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// the per-analyzer lines above already named the failures
|
|
39
|
+
}
|
|
40
|
+
}
|
|
2
41
|
export function loadBootstrapClients() {
|
|
3
42
|
bootstrapPromise ??= (async () => {
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
43
|
+
const failures = [];
|
|
44
|
+
// Load + construct one client in isolation; on failure record it and
|
|
45
|
+
// substitute a degraded no-op stub so the others still load — single-seam
|
|
46
|
+
// fail-soft, consumers never special-case it.
|
|
47
|
+
async function load(name, make) {
|
|
48
|
+
try {
|
|
49
|
+
return await make();
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
failures.push({ name, err });
|
|
53
|
+
return degradedClient();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Lists degrade to empty (a stub Proxy isn't iterable), e.g. dead-code.
|
|
57
|
+
async function loadList(name, make) {
|
|
58
|
+
try {
|
|
59
|
+
return await make();
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
failures.push({ name, err });
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const [ruffClient, biomeClient, knipClient, todoScanner, jscpdClient, typeCoverageClient, depChecker, testRunnerClient, metricsClient, complexityClient, goClient, govulncheckClient, gitleaksClient, trivyClient, rustClient, agentBehaviorClient, deadCodeClients,] = await Promise.all([
|
|
67
|
+
load("ruff", async () => new (await import("./ruff-client.js")).RuffClient()),
|
|
68
|
+
load("biome", async () => new (await import("./biome-client.js")).BiomeClient()),
|
|
69
|
+
load("knip", async () => new (await import("./knip-client.js")).KnipClient()),
|
|
70
|
+
load("todo", async () => new (await import("./todo-scanner.js")).TodoScanner()),
|
|
71
|
+
load("jscpd", async () => new (await import("./jscpd-client.js")).JscpdClient()),
|
|
72
|
+
load("type-coverage", async () => new (await import("./type-coverage-client.js")).TypeCoverageClient()),
|
|
73
|
+
load("dependency-checker", async () => new (await import("./dependency-checker.js")).DependencyChecker()),
|
|
74
|
+
load("test-runner", async () => new (await import("./test-runner-client.js")).TestRunnerClient()),
|
|
75
|
+
load("metrics", async () => new (await import("./metrics-client.js")).MetricsClient()),
|
|
76
|
+
load("complexity", async () => new (await import("./complexity-client.js")).ComplexityClient()),
|
|
77
|
+
load("go", async () => new (await import("./go-client.js")).GoClient()),
|
|
78
|
+
load("govulncheck", async () => new (await import("./govulncheck-client.js")).GovulncheckClient()),
|
|
79
|
+
load("gitleaks", async () => new (await import("./gitleaks-client.js")).GitleaksClient()),
|
|
80
|
+
load("trivy", async () => new (await import("./trivy-client.js")).TrivyClient()),
|
|
81
|
+
load("rust", async () => new (await import("./rust-client.js")).RustClient()),
|
|
82
|
+
load("agent-behavior", async () => new (await import("./agent-behavior-client.js")).AgentBehaviorClient()),
|
|
83
|
+
loadList("dead-code", async () => (await import("./dead-code-client.js")).getDeadCodeClients()),
|
|
21
84
|
]);
|
|
85
|
+
if (failures.length > 0)
|
|
86
|
+
await logBootstrapFailures(failures);
|
|
22
87
|
return {
|
|
23
|
-
ruffClient
|
|
24
|
-
biomeClient
|
|
25
|
-
knipClient
|
|
26
|
-
todoScanner
|
|
27
|
-
jscpdClient
|
|
28
|
-
typeCoverageClient
|
|
29
|
-
depChecker
|
|
30
|
-
testRunnerClient
|
|
31
|
-
metricsClient
|
|
32
|
-
complexityClient
|
|
33
|
-
goClient
|
|
34
|
-
govulncheckClient
|
|
35
|
-
gitleaksClient
|
|
36
|
-
trivyClient
|
|
37
|
-
rustClient
|
|
38
|
-
agentBehaviorClient
|
|
88
|
+
ruffClient,
|
|
89
|
+
biomeClient,
|
|
90
|
+
knipClient,
|
|
91
|
+
todoScanner,
|
|
92
|
+
jscpdClient,
|
|
93
|
+
typeCoverageClient,
|
|
94
|
+
depChecker,
|
|
95
|
+
testRunnerClient,
|
|
96
|
+
metricsClient,
|
|
97
|
+
complexityClient,
|
|
98
|
+
goClient,
|
|
99
|
+
govulncheckClient,
|
|
100
|
+
gitleaksClient,
|
|
101
|
+
trivyClient,
|
|
102
|
+
rustClient,
|
|
103
|
+
agentBehaviorClient,
|
|
104
|
+
deadCodeClients,
|
|
39
105
|
};
|
|
40
106
|
})();
|
|
41
107
|
return bootstrapPromise;
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import * as fs from "node:fs";
|
|
18
18
|
import * as path from "node:path";
|
|
19
|
-
import
|
|
19
|
+
import { ts } from "./deps/typescript.js";
|
|
20
20
|
import { isFileKind } from "./file-kinds.js";
|
|
21
21
|
// --- Constants ---
|
|
22
22
|
// Nodes that increase cyclomatic complexity
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-file dead-code detection for non-JS/TS ecosystems (#127).
|
|
3
|
+
*
|
|
4
|
+
* Knip (clients/knip-client.ts) gives JS/TS projects project-wide unused
|
|
5
|
+
* exports/files/deps at session_start. Per-file dispatch linters can't do that
|
|
6
|
+
* for other languages — "this exported function is unused anywhere" needs a
|
|
7
|
+
* whole-project scan. This module is the per-language harness that closes the
|
|
8
|
+
* gap, paralleling KnipClient's lifecycle (detect → ensureAvailable → analyze,
|
|
9
|
+
* cached at session_start, surfaced as a turn_end advisory).
|
|
10
|
+
*
|
|
11
|
+
* Phase 1 ships Python via `vulture`. Future phases add Go/Rust/etc. by
|
|
12
|
+
* implementing DeadCodeClient and adding to getDeadCodeClients().
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as os from "node:os";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { isAtOrAboveHomeDir } from "./path-utils.js";
|
|
18
|
+
import { safeSpawnAsync } from "./safe-spawn.js";
|
|
19
|
+
function emptyResult(language) {
|
|
20
|
+
return {
|
|
21
|
+
success: false,
|
|
22
|
+
language,
|
|
23
|
+
unusedExports: [],
|
|
24
|
+
unusedFiles: [],
|
|
25
|
+
unusedDeps: [],
|
|
26
|
+
unlistedDeps: [],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const ANALYSIS_TIMEOUT_MS = 30_000;
|
|
30
|
+
// Directories never worth scanning for the user's own dead code.
|
|
31
|
+
const VULTURE_EXCLUDES = [
|
|
32
|
+
"*/.venv/*",
|
|
33
|
+
"*/venv/*",
|
|
34
|
+
"*/.tox/*",
|
|
35
|
+
"*/build/*",
|
|
36
|
+
"*/dist/*",
|
|
37
|
+
"*/node_modules/*",
|
|
38
|
+
"*/.git/*",
|
|
39
|
+
"*/site-packages/*",
|
|
40
|
+
"*/__pycache__/*",
|
|
41
|
+
"*/.eggs/*",
|
|
42
|
+
];
|
|
43
|
+
// vulture line: `path/to/file.py:12: unused function 'foo' (60% confidence)`
|
|
44
|
+
const VULTURE_LINE = /^(.*?):(\d+): unused (\w[\w ]*?) '([^']+)' \((\d+)% confidence\)\s*$/;
|
|
45
|
+
/**
|
|
46
|
+
* Parse vulture's text output into normalized issues. Pure (no spawn/fs) so the
|
|
47
|
+
* parser is unit-testable against captured output. Unrecognized lines (banner,
|
|
48
|
+
* `unreachable code` without a quoted name) are ignored. `root` makes file
|
|
49
|
+
* paths project-relative when possible.
|
|
50
|
+
*/
|
|
51
|
+
export function parseVultureOutput(output, root) {
|
|
52
|
+
const issues = [];
|
|
53
|
+
for (const raw of output.split(/\r?\n/)) {
|
|
54
|
+
const m = raw.match(VULTURE_LINE);
|
|
55
|
+
if (!m)
|
|
56
|
+
continue;
|
|
57
|
+
const [, file, line, kind, name, confidence] = m;
|
|
58
|
+
// All map to the "export" bucket (a defined symbol used nowhere); the
|
|
59
|
+
// `kind` preserves function/class/import/etc. for display.
|
|
60
|
+
let rel = file;
|
|
61
|
+
try {
|
|
62
|
+
rel = path.relative(root, file) || file;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
rel = file;
|
|
66
|
+
}
|
|
67
|
+
issues.push({
|
|
68
|
+
category: "export",
|
|
69
|
+
kind: kind.trim(),
|
|
70
|
+
name,
|
|
71
|
+
file: rel,
|
|
72
|
+
line: Number.parseInt(line, 10),
|
|
73
|
+
confidence: Number.parseInt(confidence, 10),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return issues;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Python dead-code via vulture (https://github.com/jendrikseipp/vulture).
|
|
80
|
+
*
|
|
81
|
+
* vulture finds unused functions, classes, methods, imports, variables and
|
|
82
|
+
* attributes by static analysis of the whole tree. It has no JSON reporter, so
|
|
83
|
+
* we parse its stable one-line-per-finding text output. It exits 1 when it
|
|
84
|
+
* FINDS dead code (linter convention), so a non-zero exit with parseable
|
|
85
|
+
* output is success, not failure.
|
|
86
|
+
*/
|
|
87
|
+
export class PythonDeadCodeClient {
|
|
88
|
+
id = "python";
|
|
89
|
+
language = "Python";
|
|
90
|
+
available = null;
|
|
91
|
+
resolved = null;
|
|
92
|
+
ensureInFlight = null;
|
|
93
|
+
inFlight = new Map();
|
|
94
|
+
log;
|
|
95
|
+
constructor(verbose = false) {
|
|
96
|
+
this.log = verbose
|
|
97
|
+
? (msg) => console.error(`[dead-code:python] ${msg}`)
|
|
98
|
+
: () => { };
|
|
99
|
+
}
|
|
100
|
+
get minConfidence() {
|
|
101
|
+
const raw = Number.parseInt(process.env.PI_LENS_VULTURE_MIN_CONFIDENCE ?? "60", 10);
|
|
102
|
+
return Number.isFinite(raw) ? Math.min(100, Math.max(0, raw)) : 60;
|
|
103
|
+
}
|
|
104
|
+
detect(cwd) {
|
|
105
|
+
return this.resolveProjectRoot(cwd) !== null;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Nearest dir with a Python project marker, never at/above $HOME and never
|
|
109
|
+
* escaping a VCS boundary — same containment rules as KnipClient so a scan
|
|
110
|
+
* launched from a bare cwd can't recurse the whole home tree (#250/#296).
|
|
111
|
+
*/
|
|
112
|
+
resolveProjectRoot(startDir, homeDirOverride) {
|
|
113
|
+
const markers = [
|
|
114
|
+
"pyproject.toml",
|
|
115
|
+
"setup.py",
|
|
116
|
+
"setup.cfg",
|
|
117
|
+
"requirements.txt",
|
|
118
|
+
"Pipfile",
|
|
119
|
+
"tox.ini",
|
|
120
|
+
];
|
|
121
|
+
const boundaries = [".git", ".hg", ".svn"];
|
|
122
|
+
const homeDir = path.resolve(homeDirOverride ?? os.homedir());
|
|
123
|
+
let current = path.resolve(startDir);
|
|
124
|
+
for (let depth = 0; depth < 64; depth++) {
|
|
125
|
+
if (isAtOrAboveHomeDir(current, homeDir))
|
|
126
|
+
return null;
|
|
127
|
+
if (markers.some((m) => fs.existsSync(path.join(current, m)))) {
|
|
128
|
+
return current;
|
|
129
|
+
}
|
|
130
|
+
if (boundaries.some((m) => fs.existsSync(path.join(current, m)))) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
const parent = path.dirname(current);
|
|
134
|
+
if (parent === current)
|
|
135
|
+
return null;
|
|
136
|
+
current = parent;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
async ensureAvailable() {
|
|
141
|
+
if (this.available !== null)
|
|
142
|
+
return this.available;
|
|
143
|
+
if (this.ensureInFlight)
|
|
144
|
+
return this.ensureInFlight;
|
|
145
|
+
this.ensureInFlight = this.doEnsureAvailable();
|
|
146
|
+
try {
|
|
147
|
+
return await this.ensureInFlight;
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
this.ensureInFlight = null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async doEnsureAvailable() {
|
|
154
|
+
// Presence-gated, NOT auto-installed. vulture is a pure-Python package
|
|
155
|
+
// with no standalone binary, so "auto-install" would mean `pip install`
|
|
156
|
+
// into whatever Python environment happens to be active — wrong and
|
|
157
|
+
// intrusive for uv / poetry / conda / pipx users. So we only use vulture
|
|
158
|
+
// when the user already has it, probing both the `vulture` console script
|
|
159
|
+
// and `python -m vulture` (the script dir is frequently not on PATH even
|
|
160
|
+
// when the package is installed). Mirrors govulncheck's no-install gating.
|
|
161
|
+
const candidates = [
|
|
162
|
+
{ cmd: "vulture", prefix: [] },
|
|
163
|
+
{ cmd: "python", prefix: ["-m", "vulture"] },
|
|
164
|
+
{ cmd: "python3", prefix: ["-m", "vulture"] },
|
|
165
|
+
];
|
|
166
|
+
for (const c of candidates) {
|
|
167
|
+
const probe = await safeSpawnAsync(c.cmd, [...c.prefix, "--version"], {
|
|
168
|
+
timeout: 5000,
|
|
169
|
+
});
|
|
170
|
+
if (!probe.error && probe.status === 0) {
|
|
171
|
+
this.resolved = c;
|
|
172
|
+
this.available = true;
|
|
173
|
+
this.log(`vulture found: ${[c.cmd, ...c.prefix].join(" ")}`);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
this.available = false;
|
|
178
|
+
this.log("vulture not installed; skipping (no auto-install)");
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
async analyze(cwd) {
|
|
182
|
+
const root = this.resolveProjectRoot(cwd || process.cwd());
|
|
183
|
+
if (!root) {
|
|
184
|
+
return {
|
|
185
|
+
...emptyResult(this.language),
|
|
186
|
+
success: true,
|
|
187
|
+
summary: "No Python project root found; vulture skipped",
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
if (!(await this.ensureAvailable())) {
|
|
191
|
+
return {
|
|
192
|
+
...emptyResult(this.language),
|
|
193
|
+
success: true,
|
|
194
|
+
summary: "vulture not installed; skipped. Install vulture (pip/uv/pipx) to enable Python dead-code detection.",
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const key = path.resolve(root);
|
|
198
|
+
const existing = this.inFlight.get(key);
|
|
199
|
+
if (existing)
|
|
200
|
+
return existing;
|
|
201
|
+
const promise = this.runAnalyze(key).finally(() => this.inFlight.delete(key));
|
|
202
|
+
this.inFlight.set(key, promise);
|
|
203
|
+
return promise;
|
|
204
|
+
}
|
|
205
|
+
async runAnalyze(root) {
|
|
206
|
+
const startMs = Date.now();
|
|
207
|
+
const invocation = this.resolved ?? { cmd: "vulture", prefix: [] };
|
|
208
|
+
const args = [
|
|
209
|
+
...invocation.prefix,
|
|
210
|
+
".",
|
|
211
|
+
`--min-confidence=${this.minConfidence}`,
|
|
212
|
+
`--exclude=${VULTURE_EXCLUDES.join(",")}`,
|
|
213
|
+
];
|
|
214
|
+
const result = await safeSpawnAsync(invocation.cmd, args, {
|
|
215
|
+
timeout: ANALYSIS_TIMEOUT_MS,
|
|
216
|
+
cwd: root,
|
|
217
|
+
});
|
|
218
|
+
const durationMs = Date.now() - startMs;
|
|
219
|
+
// Spawn-level failure (ENOENT, timeout) — not a "found issues" exit.
|
|
220
|
+
if (result.error) {
|
|
221
|
+
this.log(`scan error: ${result.error.message}`);
|
|
222
|
+
return {
|
|
223
|
+
...emptyResult(this.language),
|
|
224
|
+
summary: `Error: ${result.error.message}`,
|
|
225
|
+
durationMs,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
// vulture writes parse/usage errors to stderr and exits 1 with no
|
|
229
|
+
// stdout findings; distinguish that from "found dead code" (exit 1 WITH
|
|
230
|
+
// findings on stdout).
|
|
231
|
+
const output = result.stdout || "";
|
|
232
|
+
if (!output.trim()) {
|
|
233
|
+
const stderr = (result.stderr || "").trim();
|
|
234
|
+
if (result.status !== 0 && stderr) {
|
|
235
|
+
return {
|
|
236
|
+
...emptyResult(this.language),
|
|
237
|
+
summary: `vulture error: ${stderr.split("\n")[0]}`,
|
|
238
|
+
durationMs,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
...emptyResult(this.language),
|
|
243
|
+
success: true,
|
|
244
|
+
summary: "No dead code found",
|
|
245
|
+
durationMs,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
return { ...this.parseOutput(output, root), durationMs };
|
|
249
|
+
}
|
|
250
|
+
parseOutput(output, root) {
|
|
251
|
+
const unusedExports = parseVultureOutput(output, root);
|
|
252
|
+
const total = unusedExports.length;
|
|
253
|
+
return {
|
|
254
|
+
...emptyResult(this.language),
|
|
255
|
+
success: true,
|
|
256
|
+
unusedExports,
|
|
257
|
+
summary: total === 0
|
|
258
|
+
? "No dead code found"
|
|
259
|
+
: `Found ${total} unused Python symbol(s)`,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The dead-code clients to run at session_start. Each is offered every project;
|
|
265
|
+
* the orchestrator calls detect() to decide which actually apply (polyglot
|
|
266
|
+
* repos may run several). Phase 1: Python only.
|
|
267
|
+
*/
|
|
268
|
+
export function getDeadCodeClients(verbose = false) {
|
|
269
|
+
return [new PythonDeadCodeClient(verbose)];
|
|
270
|
+
}
|
|
271
|
+
/** Total issue count across all buckets — convenience for advisories/telemetry. */
|
|
272
|
+
export function deadCodeIssueCount(result) {
|
|
273
|
+
return (result.unusedExports.length +
|
|
274
|
+
result.unusedFiles.length +
|
|
275
|
+
result.unusedDeps.length +
|
|
276
|
+
result.unlistedDeps.length);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Format cached dead-code results into one turn_end advisory, or "" when there
|
|
280
|
+
* is nothing to report. Merges multiple languages (polyglot repos) under one
|
|
281
|
+
* `[Dead code]` heading so it reads consistently next to the Knip advisory.
|
|
282
|
+
* Advisory-only: these are project-wide signals, never blockers.
|
|
283
|
+
*/
|
|
284
|
+
export function formatDeadCodeAdvisory(results, maxPerLang = 10) {
|
|
285
|
+
const withFindings = results.filter((r) => r.success && deadCodeIssueCount(r) > 0);
|
|
286
|
+
if (withFindings.length === 0)
|
|
287
|
+
return "";
|
|
288
|
+
const lines = [];
|
|
289
|
+
for (const r of withFindings) {
|
|
290
|
+
const count = deadCodeIssueCount(r);
|
|
291
|
+
lines.push(`${r.language}: ${count} unused symbol(s)`);
|
|
292
|
+
for (const issue of r.unusedExports.slice(0, maxPerLang)) {
|
|
293
|
+
const loc = issue.file
|
|
294
|
+
? ` (${issue.file}${issue.line ? `:${issue.line}` : ""})`
|
|
295
|
+
: "";
|
|
296
|
+
lines.push(` - unused ${issue.kind} '${issue.name}'${loc}`);
|
|
297
|
+
}
|
|
298
|
+
if (r.unusedExports.length > maxPerLang) {
|
|
299
|
+
lines.push(` … and ${r.unusedExports.length - maxPerLang} more`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return ("💀 [Dead code] project-wide unused symbols — verify before removing:\n " +
|
|
303
|
+
lines.join("\n "));
|
|
304
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NDJSON telemetry for cross-file dead-code scans (#127). One event per
|
|
3
|
+
* session_start scan per language, so we can answer: which languages get
|
|
4
|
+
* scanned, how many findings, and how long the whole-project scan takes (the
|
|
5
|
+
* input to phasing decisions in the issue). Mirrors `ast-grep-tool-logger.ts`
|
|
6
|
+
* for shape + size-based rotation.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { isTestMode } from "./env-utils.js";
|
|
11
|
+
import { getGlobalPiLensDir } from "./file-utils.js";
|
|
12
|
+
const LOG_DIR = getGlobalPiLensDir();
|
|
13
|
+
const LOG_FILE = path.join(LOG_DIR, "dead-code.log");
|
|
14
|
+
const LOG_BACKUP_FILE = path.join(LOG_DIR, "dead-code.log.1");
|
|
15
|
+
const MAX_LOG_BYTES = Math.max(128 * 1024, Number.parseInt(process.env.PI_LENS_DEAD_CODE_LOG_MAX_BYTES ?? "1048576", 10) ||
|
|
16
|
+
1048576);
|
|
17
|
+
function rotateIfNeeded() {
|
|
18
|
+
try {
|
|
19
|
+
const stat = fs.statSync(LOG_FILE);
|
|
20
|
+
if (stat.size >= MAX_LOG_BYTES) {
|
|
21
|
+
fs.renameSync(LOG_FILE, LOG_BACKUP_FILE);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// no file yet, or rename raced — nothing to rotate
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Append one scan event. Fire-and-forget: telemetry must never break a scan, so
|
|
30
|
+
* every fs error is swallowed. Skipped under test mode to keep the suite from
|
|
31
|
+
* writing to the user's real ~/.pi-lens.
|
|
32
|
+
*/
|
|
33
|
+
export function logDeadCodeScan(event) {
|
|
34
|
+
if (isTestMode())
|
|
35
|
+
return;
|
|
36
|
+
try {
|
|
37
|
+
if (!fs.existsSync(LOG_DIR))
|
|
38
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
39
|
+
rotateIfNeeded();
|
|
40
|
+
const row = JSON.stringify({ ts: new Date().toISOString(), ...event });
|
|
41
|
+
fs.appendFileSync(LOG_FILE, row + "\n");
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// telemetry is best-effort
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One implementation of the "race a promise against a timer" pattern that had
|
|
3
|
+
* drifted into three near-identical copies (#366): `withTimeout` (clients/lsp),
|
|
4
|
+
* `withBudget` (read-expansion), and `withinRemaining` (module-report-lsp).
|
|
5
|
+
*
|
|
6
|
+
* The differences between them were real — timeout can *reject* or *resolve
|
|
7
|
+
* undefined*, and the raced promise's own rejection can *propagate* or be
|
|
8
|
+
* *swallowed* — so they are kept as named adapters over one core. Consolidating
|
|
9
|
+
* fixes two latent bugs the copies had: `withBudget` did not suppress the loser
|
|
10
|
+
* promise's late rejection (an unhandled rejection if the timer won first), and
|
|
11
|
+
* `withinRemaining` never cleared its timer.
|
|
12
|
+
*/
|
|
13
|
+
export function withDeadline(promise, options) {
|
|
14
|
+
const onTimeout = options.onTimeout ?? "reject";
|
|
15
|
+
const onReject = options.onReject ?? "propagate";
|
|
16
|
+
const ms = options.ms ??
|
|
17
|
+
(options.deadlineAt !== undefined ? options.deadlineAt - Date.now() : 0);
|
|
18
|
+
// Past deadline / non-positive budget: settle immediately, no timer.
|
|
19
|
+
if (ms <= 0) {
|
|
20
|
+
return onTimeout === "undefined"
|
|
21
|
+
? Promise.resolve(undefined)
|
|
22
|
+
: Promise.reject(new Error(`Timeout after ${Math.max(0, ms)}ms`));
|
|
23
|
+
}
|
|
24
|
+
// Base promise with rejection handled per `onReject`. In propagate mode we
|
|
25
|
+
// still attach a no-op catch so that if the timer wins the race, the loser
|
|
26
|
+
// promise's later rejection does not surface as an unhandled rejection.
|
|
27
|
+
const base = onReject === "undefined" ? promise.catch(() => undefined) : promise;
|
|
28
|
+
if (onReject === "propagate")
|
|
29
|
+
promise.catch(() => { });
|
|
30
|
+
let timer;
|
|
31
|
+
const timeoutPromise = new Promise((resolve, reject) => {
|
|
32
|
+
timer = setTimeout(() => {
|
|
33
|
+
if (onTimeout === "undefined")
|
|
34
|
+
resolve(undefined);
|
|
35
|
+
else
|
|
36
|
+
reject(new Error(`Timeout after ${ms}ms`));
|
|
37
|
+
}, ms);
|
|
38
|
+
});
|
|
39
|
+
return Promise.race([base, timeoutPromise]).finally(() => {
|
|
40
|
+
if (timer)
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Resolve `promise`, or reject with `Error("Timeout after <ms>ms")` once
|
|
46
|
+
* `timeoutMs` elapses. The raced promise's own rejection propagates.
|
|
47
|
+
*/
|
|
48
|
+
export function withTimeout(promise, timeoutMs) {
|
|
49
|
+
return withDeadline(promise, { ms: timeoutMs });
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolve `promise`, or `undefined` once `budgetMs` elapses (a non-positive
|
|
53
|
+
* budget resolves `undefined` immediately). The raced promise's own rejection
|
|
54
|
+
* propagates.
|
|
55
|
+
*/
|
|
56
|
+
export function withBudget(promise, budgetMs) {
|
|
57
|
+
return withDeadline(promise, { ms: budgetMs, onTimeout: "undefined" });
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Resolve `promise`, or `undefined` once the shared `deadlineAt` passes (a past
|
|
61
|
+
* deadline resolves `undefined` immediately). The raced promise's own rejection
|
|
62
|
+
* is swallowed to `undefined`.
|
|
63
|
+
*/
|
|
64
|
+
export function withinRemaining(promise, deadlineAt) {
|
|
65
|
+
return withDeadline(promise, {
|
|
66
|
+
deadlineAt,
|
|
67
|
+
onTimeout: "undefined",
|
|
68
|
+
onReject: "undefined",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -11,6 +11,54 @@
|
|
|
11
11
|
import * as fs from "node:fs";
|
|
12
12
|
import * as path from "node:path";
|
|
13
13
|
import { safeSpawnAsync } from "./safe-spawn.js";
|
|
14
|
+
/**
|
|
15
|
+
* Build madge's argv for a circular-dependency scan.
|
|
16
|
+
*
|
|
17
|
+
* Two correctness levers beyond the bare `--circular`:
|
|
18
|
+
* - `mjs,cjs` extensions: without them madge ignores explicit ESM/CJS files,
|
|
19
|
+
* dropping their edges from the graph.
|
|
20
|
+
* - `--ts-config <tsconfig.json>`: madge can only resolve TypeScript `paths`
|
|
21
|
+
* aliases (`@/foo`, `~/bar`) when pointed at the tsconfig. Without it those
|
|
22
|
+
* imports are silently unresolved, so cycles that route through an alias are
|
|
23
|
+
* MISSED (false negatives). Passed only when a tsconfig actually exists, so
|
|
24
|
+
* non-TS / alias-free projects are unaffected.
|
|
25
|
+
*/
|
|
26
|
+
export function buildMadgeArgs(target, projectRoot) {
|
|
27
|
+
const args = ["--circular", "--extensions", "ts,tsx,js,jsx,mjs,cjs"];
|
|
28
|
+
const tsConfig = path.join(projectRoot, "tsconfig.json");
|
|
29
|
+
if (fs.existsSync(tsConfig)) {
|
|
30
|
+
args.push("--ts-config", tsConfig);
|
|
31
|
+
}
|
|
32
|
+
// --warning surfaces files madge couldn't resolve into the graph (to stderr;
|
|
33
|
+
// stdout JSON is unaffected). Without it those skips are SILENT — and a
|
|
34
|
+
// skipped *local* file could hide a real cycle. We log them (see
|
|
35
|
+
// parseMadgeSkips) instead of discarding stderr.
|
|
36
|
+
args.push("--warning", "--json", target);
|
|
37
|
+
return args;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Parse madge's `--warning` stderr for skipped (unresolvable) files. External
|
|
41
|
+
* package specifiers (bare names / subpaths like `web-tree-sitter`,
|
|
42
|
+
* `vitest/config`) are expected — madge doesn't traverse node_modules. We flag
|
|
43
|
+
* only **local** skips (relative/absolute paths), which are the ones that could
|
|
44
|
+
* silently drop an internal edge and hide a cycle.
|
|
45
|
+
*
|
|
46
|
+
* @returns total skip count and the subset that look local.
|
|
47
|
+
*/
|
|
48
|
+
export function parseMadgeSkips(stderr) {
|
|
49
|
+
const lines = (stderr || "").split(/\r?\n/);
|
|
50
|
+
const headerIdx = lines.findIndex((l) => /Skipped\s+\d+\s+file/i.test(l));
|
|
51
|
+
if (headerIdx === -1)
|
|
52
|
+
return { total: 0, local: [] };
|
|
53
|
+
const total = Number.parseInt(lines[headerIdx].match(/Skipped\s+(\d+)/i)?.[1] ?? "0", 10) ||
|
|
54
|
+
0;
|
|
55
|
+
const specifiers = lines
|
|
56
|
+
.slice(headerIdx + 1)
|
|
57
|
+
.map((l) => l.trim())
|
|
58
|
+
.filter(Boolean);
|
|
59
|
+
const local = specifiers.filter((s) => s.startsWith(".") || s.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(s));
|
|
60
|
+
return { total, local };
|
|
61
|
+
}
|
|
14
62
|
// --- Client ---
|
|
15
63
|
export class DependencyChecker {
|
|
16
64
|
available = null;
|
|
@@ -202,14 +250,7 @@ export class DependencyChecker {
|
|
|
202
250
|
this.log(`Imports changed for ${path.basename(normalized)}, checking dependencies...`);
|
|
203
251
|
// Run madge on the specific file (fast)
|
|
204
252
|
try {
|
|
205
|
-
const result = await safeSpawnAsync("npx", [
|
|
206
|
-
"madge",
|
|
207
|
-
"--circular",
|
|
208
|
-
"--extensions",
|
|
209
|
-
"ts,tsx,js,jsx",
|
|
210
|
-
"--json",
|
|
211
|
-
normalized,
|
|
212
|
-
], {
|
|
253
|
+
const result = await safeSpawnAsync("npx", ["madge", ...buildMadgeArgs(normalized, projectRoot)], {
|
|
213
254
|
timeout: 15000,
|
|
214
255
|
cwd: projectRoot,
|
|
215
256
|
});
|
|
@@ -240,11 +281,16 @@ export class DependencyChecker {
|
|
|
240
281
|
}
|
|
241
282
|
this.lastCircular = circular;
|
|
242
283
|
this.circularFiles = circularFiles;
|
|
284
|
+
const skips = parseMadgeSkips(result.stderr || "");
|
|
285
|
+
if (skips.local.length > 0) {
|
|
286
|
+
this.log(`madge skipped ${skips.local.length} local file(s) (possible silent cycle-miss): ${skips.local.slice(0, 5).join(", ")}`);
|
|
287
|
+
}
|
|
243
288
|
return {
|
|
244
289
|
hasCircular: circular.length > 0,
|
|
245
290
|
circular: circular.filter((d) => d.file === normalized || d.path.includes(normalized)),
|
|
246
291
|
checked: true,
|
|
247
292
|
cacheHit: false,
|
|
293
|
+
localSkips: skips.local.length,
|
|
248
294
|
};
|
|
249
295
|
}
|
|
250
296
|
catch (err) {
|
|
@@ -298,14 +344,7 @@ export class DependencyChecker {
|
|
|
298
344
|
}
|
|
299
345
|
async runScanProject(projectRoot) {
|
|
300
346
|
try {
|
|
301
|
-
const result = await safeSpawnAsync("npx", [
|
|
302
|
-
"madge",
|
|
303
|
-
"--circular",
|
|
304
|
-
"--extensions",
|
|
305
|
-
"ts,tsx,js,jsx",
|
|
306
|
-
"--json",
|
|
307
|
-
projectRoot,
|
|
308
|
-
], {
|
|
347
|
+
const result = await safeSpawnAsync("npx", ["madge", ...buildMadgeArgs(projectRoot, projectRoot)], {
|
|
309
348
|
timeout: 30000,
|
|
310
349
|
cwd: projectRoot,
|
|
311
350
|
});
|