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.
Files changed (77) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +5 -4
  3. package/banner.png +0 -0
  4. package/banner.svg +54 -46
  5. package/dist/clients/ast-grep-client.js +160 -3
  6. package/dist/clients/ast-grep-tool-logger.js +1 -1
  7. package/dist/clients/ast-grep-yaml-synth.js +30 -6
  8. package/dist/clients/bootstrap.js +99 -33
  9. package/dist/clients/complexity-client.js +1 -1
  10. package/dist/clients/dead-code-client.js +304 -0
  11. package/dist/clients/dead-code-logger.js +46 -0
  12. package/dist/clients/deadline-utils.js +70 -0
  13. package/dist/clients/dependency-checker.js +55 -16
  14. package/dist/clients/deps/ast-grep-napi.js +3 -0
  15. package/dist/clients/deps/js-yaml.js +9 -0
  16. package/dist/clients/deps/minimatch.js +4 -0
  17. package/dist/clients/deps/pi-tui.js +5 -0
  18. package/dist/clients/deps/typebox.js +6 -0
  19. package/dist/clients/deps/typescript.js +15 -0
  20. package/dist/clients/deps/vscode-jsonrpc.js +1 -0
  21. package/dist/clients/deps/web-tree-sitter.js +3 -0
  22. package/dist/clients/dispatch/fact-runner.js +59 -0
  23. package/dist/clients/dispatch/facts/comment-facts.js +1 -1
  24. package/dist/clients/dispatch/facts/function-facts.js +1 -1
  25. package/dist/clients/dispatch/facts/import-facts.js +1 -1
  26. package/dist/clients/dispatch/facts/try-catch-facts.js +1 -1
  27. package/dist/clients/dispatch/integration.js +7 -20
  28. package/dist/clients/dispatch/rules/quality-rules.js +1 -1
  29. package/dist/clients/dispatch/rules/sonar-rules.js +1 -1
  30. package/dist/clients/dispatch/runners/ast-grep-napi.js +2 -1
  31. package/dist/clients/dispatch/runners/yaml-rule-parser.js +1 -1
  32. package/dist/clients/event-loop-monitor.js +18 -3
  33. package/dist/clients/file-utils.js +1 -1
  34. package/dist/clients/grammar-source.js +71 -0
  35. package/dist/clients/install-diagnostics.js +141 -0
  36. package/dist/clients/installer/index.js +114 -9
  37. package/dist/clients/knip-client.js +62 -43
  38. package/dist/clients/lsp/client.js +113 -62
  39. package/dist/clients/lsp/index.js +35 -8
  40. package/dist/clients/lsp/interactive-install.js +26 -24
  41. package/dist/clients/lsp/launch.js +2 -2
  42. package/dist/clients/lsp/server.js +30 -23
  43. package/dist/clients/mcp/session.js +2 -0
  44. package/dist/clients/module-report-lsp.js +1 -16
  45. package/dist/clients/module-report.js +1121 -59
  46. package/dist/clients/pipeline.js +58 -37
  47. package/dist/clients/project-diagnostics/runner-adapters/knip.js +3 -0
  48. package/dist/clients/project-diagnostics/scanner.js +23 -6
  49. package/dist/clients/read-expansion.js +1 -14
  50. package/dist/clients/read-guard-tool-lines.js +5 -1
  51. package/dist/clients/review-graph/builder.js +15 -4
  52. package/dist/clients/runtime-session.js +44 -4
  53. package/dist/clients/runtime-turn.js +75 -13
  54. package/dist/clients/tree-sitter-client.js +80 -37
  55. package/dist/clients/tree-sitter-symbol-extractor.js +86 -1
  56. package/dist/clients/ts-service.js +1 -1
  57. package/dist/clients/typescript-client.js +1 -1
  58. package/dist/clients/widget-state.js +1 -1
  59. package/dist/index.js +41 -11
  60. package/dist/tools/ast-dump.js +76 -29
  61. package/dist/tools/ast-grep-outline.js +148 -0
  62. package/dist/tools/ast-grep-replace.js +13 -1
  63. package/dist/tools/ast-grep-search.js +405 -112
  64. package/dist/tools/lens-diagnostics.js +57 -9
  65. package/dist/tools/lsp-diagnostics.js +17 -1
  66. package/dist/tools/lsp-navigation.js +14 -1
  67. package/dist/tools/module-report.js +257 -27
  68. package/dist/tools/render-compact.js +95 -0
  69. package/package.json +5 -1
  70. package/rules/ast-grep-rules/rule-tests/incomplete-string-escaping-js-test.yml +21 -0
  71. package/rules/ast-grep-rules/rule-tests/incomplete-string-escaping-test.yml +21 -0
  72. package/rules/ast-grep-rules/rules/incomplete-string-escaping-js.yml +19 -0
  73. package/rules/ast-grep-rules/rules/incomplete-string-escaping.yml +19 -0
  74. package/scripts/install-selftest.mjs +173 -0
  75. package/scripts/rpc-load-check.mjs +80 -0
  76. package/skills/ast-grep/SKILL.md +26 -6
  77. 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 [ruffMod, biomeMod, knipMod, todoMod, jscpdMod, typeCoverageMod, depCheckerMod, testRunnerMod, metricsMod, complexityMod, goMod, govulncheckMod, gitleaksMod, trivyMod, rustMod, agentBehaviorMod,] = await Promise.all([
5
- import("./ruff-client.js"),
6
- import("./biome-client.js"),
7
- import("./knip-client.js"),
8
- import("./todo-scanner.js"),
9
- import("./jscpd-client.js"),
10
- import("./type-coverage-client.js"),
11
- import("./dependency-checker.js"),
12
- import("./test-runner-client.js"),
13
- import("./metrics-client.js"),
14
- import("./complexity-client.js"),
15
- import("./go-client.js"),
16
- import("./govulncheck-client.js"),
17
- import("./gitleaks-client.js"),
18
- import("./trivy-client.js"),
19
- import("./rust-client.js"),
20
- import("./agent-behavior-client.js"),
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: new ruffMod.RuffClient(),
24
- biomeClient: new biomeMod.BiomeClient(),
25
- knipClient: new knipMod.KnipClient(),
26
- todoScanner: new todoMod.TodoScanner(),
27
- jscpdClient: new jscpdMod.JscpdClient(),
28
- typeCoverageClient: new typeCoverageMod.TypeCoverageClient(),
29
- depChecker: new depCheckerMod.DependencyChecker(),
30
- testRunnerClient: new testRunnerMod.TestRunnerClient(),
31
- metricsClient: new metricsMod.MetricsClient(),
32
- complexityClient: new complexityMod.ComplexityClient(),
33
- goClient: new goMod.GoClient(),
34
- govulncheckClient: new govulncheckMod.GovulncheckClient(),
35
- gitleaksClient: new gitleaksMod.GitleaksClient(),
36
- trivyClient: new trivyMod.TrivyClient(),
37
- rustClient: new rustMod.RustClient(),
38
- agentBehaviorClient: new agentBehaviorMod.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 * as ts from "typescript";
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
  });
@@ -0,0 +1,3 @@
1
+ export function loadAstGrepNapi() {
2
+ return import("@ast-grep/napi");
3
+ }