pi-lens 3.8.66 → 3.8.67

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 (33) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +0 -1
  3. package/dist/clients/actionable-warnings-logger.js +11 -36
  4. package/dist/clients/ast-grep-tool-logger.js +11 -36
  5. package/dist/clients/cascade-logger.js +7 -12
  6. package/dist/clients/dead-code-logger.js +11 -22
  7. package/dist/clients/diagnostic-logger.js +7 -31
  8. package/dist/clients/dispatch/auxiliary-lsp.js +38 -0
  9. package/dist/clients/dispatch/dispatcher.js +1 -36
  10. package/dist/clients/dispatch/inline-suppressions.js +61 -0
  11. package/dist/clients/dispatch/integration.js +75 -23
  12. package/dist/clients/dispatch/runners/lsp.js +17 -5
  13. package/dist/clients/dispatch/runners/tree-sitter.js +7 -2
  14. package/dist/clients/latency-logger.js +10 -15
  15. package/dist/clients/lsp/config.js +61 -3
  16. package/dist/clients/lsp/index.js +43 -3
  17. package/dist/clients/ndjson-logger.js +150 -0
  18. package/dist/clients/pipeline.js +25 -41
  19. package/dist/clients/project-diagnostics/scanner.js +8 -2
  20. package/dist/clients/read-guard-logger.js +11 -36
  21. package/dist/clients/review-graph/builder.js +316 -15
  22. package/dist/clients/review-graph/git-identity.js +150 -0
  23. package/dist/clients/review-graph/service.js +3 -3
  24. package/dist/clients/runtime-coordinator.js +83 -0
  25. package/dist/clients/runtime-tool-result.js +10 -2
  26. package/dist/clients/runtime-turn.js +21 -0
  27. package/dist/clients/tree-sitter-logger.js +7 -12
  28. package/dist/clients/tree-sitter-query-loader.js +1 -0
  29. package/dist/tools/lens-diagnostics.js +203 -12
  30. package/package.json +1 -1
  31. package/rules/ast-grep-rules/rule-tests/no-init-return-test.yml +15 -0
  32. package/rules/ast-grep-rules/rules/no-init-return.yml +11 -5
  33. package/rules/tree-sitter-queries/python/python-assert-production.yml +4 -0
package/CHANGELOG.md CHANGED
@@ -10,6 +10,31 @@ All notable changes to pi-lens will be documented in this file.
10
10
 
11
11
  ### Fixed
12
12
 
13
+ ## [3.8.67] - 2026-07-09
14
+
15
+ ### Added
16
+
17
+ - nightly clean-signal probe: probe-clean-signal.mjs generalized per-server (Tier 2 publishes-empty vs Tier 3 silent-on-clean among push LSPs) and wired into the tool-smoke nightly so the capability matrix's clean-behavior column self-populates (#460). The probe is phase-aware (dirty touch proves liveness; clean transitions are the discriminator) and classifies 4-way: `publishes-versioned` (tier 2 — affirmative + currency-proven, ast-grep), `publishes-unversioned` (tier 2\* — a version-less publish still early-returns the wait at runtime since the client accepts it as fresh, but currency is only temporally correlated: a staleness-risk note, not a latency cost — opengrep, yaml), `silent` (tier 3 — alive on dirty, silent on clean: the budget-wait case and #458's learned-deadline target set — typescript on a clean file), and `unknown` (no publish — conservatively unclassified). Clean fixtures (`typescript-clean`) are authoritative for a lang's row: typescript measurably re-publishes while dirty but goes silent once clean, so the dirty-fixture 2\* is overridden by the clean fixture's tier 3.
18
+ - nightly LSP-docs commit-back (#390): the tool-smoke nightly now also runs `server-capabilities.mjs` (regenerating `docs/servercapabilities.md` incl. the ws-pull column) and opens/updates a single auto-PR (`bot/lsp-docs-refresh`) with the regenerated `lsp-capability-matrix.md` + `servercapabilities.md` — previously these were generated in CI then discarded. All three generators now **merge** into their docs (keyed by lang/server), so a server the ubuntu host can't spawn keeps its prior dev-box row and the nightly can never regress a richer run. The phase-aware probe refined opengrep's hand-noted Tier 2 to 2\* (re-publishes on clean scans, so the wait early-returns — but version-lessly, so currency is unproven).
19
+ - **`serverOverrides` — per-server `initializationOptions` in project config** (#434) — `.pi-lens/lsp.json` (or `.pi-lens.json` / `pi-lsp.json`) accepts a `serverOverrides` key mapping a built-in server `id` (`"rust"`, `"nix"`, …) to an `initializationOptions` object that is deep-merged onto the server's built-in defaults at spawn time (user wins on conflicts; arrays replaced, not merged). Brings pi-lens diagnostics in line with a user's editor LSP setup (e.g. rust-analyzer `check.command: "clippy"`, nixd options expressions) without forking. Contributed by @vkarasen.
20
+ - `lens_diagnostics` (and MCP `pilens_diagnostics`) accept `paths` to scope any mode to an explicit file/directory list — enables wrappers like "check exactly the git-staged files" (#461).
21
+
22
+ ### Changed
23
+
24
+ - perf: cascade diagnostics now run concurrently after each edit instead of blocking the write pipeline (~26% median per-edit latency reduction); settled at turn_end with a bounded wait (#450)
25
+ - perf: write-path micro batch — ESLint autofix runs a single `--fix` spawn (was dry-run + fix, double cold-start), LSP quick-fix lookups for blocking diagnostics run in parallel, and the lsp runner reuses its already-read file content for nosemgrep suppression (#453)
26
+ - perf/refactor: the eight NDJSON debug loggers (latency, cascade, read-guard, tree-sitter, dead-code, actionable-warnings, ast-grep-tool, diagnostic) now share one buffered async writer (clients/ndjson-logger.ts) — no more synchronous appendFileSync on the per-edit hot path; best-effort sync flush at process exit (#454)
27
+ - perf: the review-graph freshness check now uses RuntimeCoordinator sequence state to skip the per-build O(project) walk+stat sweep when only pi-observed edits occurred (seq fast path; periodic full re-verify every 20 builds/5 min catches external changes; PI_LENS_GRAPH_SEQ_FASTPATH=0 disables) (#451)
28
+ - perf: skip the reverse-dependency index rebuild (O(graph edges)) and its project-snapshot disk write on cascade runs where the review graph didn't actually change — the index is a pure function of the graph, so a cache-hit graph build (or a seq-fastpath build that found nothing graph-relevant to re-parse) now reuses the last-built index instead of redoing both. Freshness keys on a new `ReviewGraph.buildGeneration` stamp that travels with the returned graph instance (`mode` alone can't distinguish a true seq-fastpath no-op from one that re-parsed files, and the global build-info slot can be clobbered by overlapping deferred cascades); per-workspace cache, `PI_LENS_REVERSE_DEPS_REUSE=0` disables (#459)
29
+
30
+ ### Fixed
31
+
32
+ - **`no-init-return` no longer flags factory functions** (#439) — the ast-grep rule matched `return` inside any `function_definition` whose *body text* regex-contained `def __init__`, so a factory that returns a class with an `__init__` (and its sibling methods' returns) tripped it. It now matches a `function_definition` whose **name field** is `__init__` (`has: field: name`), so only real `__init__` returns are flagged. Regression fixtures added (factory + sibling-method cases).
33
+ - **`python-assert-production` no longer fires in test files** (#440) — `assert` is the idiomatic test assertion, so flagging every `assert` in `tests/**` was pure noise that trained users to ignore the rule. Tree-sitter rules gain an opt-in `skip_test_files` field (the runner otherwise runs on test files, since structural issues matter there); `python-assert-production` sets it, so production `assert` (the `-O` strip risk) is still flagged while test asserts are skipped. Exercised through the real runner (prod fires, `tests/` skips).
34
+ - **The opengrep/Semgrep runner now honors `# nosemgrep` suppression** (#441) — the canonical Semgrep inline suppression (`# nosemgrep` and `# nosemgrep: <rule-id>[,<rule-id>]`, also `//`) was ignored, leaving only `.pi-lens.json` path globs or code restructuring as escapes. The auxiliary-LSP runner now drops opengrep findings suppressed by a `nosemgrep` comment on the finding's own line (inline) or a standalone comment on the line above — matching Semgrep's placement semantics (an inline comment doesn't leak to the next line).
35
+ - **`lens_diagnostics mode=full` now honors inline `# pi-lens-ignore` comments** (#442) — inline suppression (`// pi-lens-ignore: rule` / `# pi-lens-ignore: rule`) was applied only in the per-edit dispatch path (`mode=all`), so a site cleanly suppressed there reappeared as **blocking** in the project-wide `mode=full` sweep — making `mode=full` unusable as a "clean" gate for any project with a legitimate suppression. The suppression filter is now shared (`clients/dispatch/inline-suppressions.ts`) and applied to the merged `mode=full` summaries too: each flagged file is read and its inline ignores honored (fail-safe — a read error never hides a finding), and counts are re-summarized so a fully-suppressed file reports clean. Rule matching also normalizes `ast-grep:<id>` / `<id>-js` forms, so a bare `pi-lens-ignore: <id>` suppresses the finding in both modes. (The reporter's secondary ask — per-rule enable/disable in `.pi-lens.json` — is tracked separately as a follow-up.)
36
+ - **The review-graph snapshot is now stamped with git HEAD + worktree root, and the read-substitute path drops it on mismatch** (#300) — `git worktree remove` followed by `add` at the same path for a different branch reuses the cwd-derived data-dir slug, so a `module_report`/blast-radius read fired before the first rebuild could return the previous branch's symbols/edges. The persisted snapshot now carries an optional git stamp (HEAD commit + worktree top-level path), resolved purely by reading `.git`/`HEAD`/`refs` files — no `git` subprocess, since the persist path includes the synchronous flush-on-exit handler and spawning at teardown crashes libuv on Windows (#234). The blind read path (`getCachedReviewGraph`, which trusts disk with no other verification) drops a stamped snapshot that mismatches the current repo; the build path deliberately keeps loading it unverified, because its signature/content-hash confirm (#202) already proves file-level freshness — so a plain `git commit` (HEAD moves, files unchanged) still cold-starts as a cheap "cached" reuse, never a full rebuild. An absent stamp (older snapshot, or a non-git cwd) behaves exactly as before. Separately, a cwd that isn't the git worktree top-level is now logged once per process (observability only, no hard-fail) — the review graph's cross-worktree isolation has always rested on that assumption, and it was previously invisible.
37
+
13
38
  ## [3.8.66] - 2026-07-07
14
39
 
15
40
  ### Added
package/README.md CHANGED
@@ -57,7 +57,6 @@ pi-lens is released under the [MIT License](LICENSE).
57
57
  ## Contributors
58
58
 
59
59
  Thanks goes to these wonderful people:
60
-
61
60
  <!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
62
61
  <!-- prettier-ignore-start -->
63
62
  <!-- markdownlint-disable -->
@@ -1,51 +1,26 @@
1
- import * as fs from "node:fs";
2
1
  import * as path from "node:path";
3
2
  import { isTestMode } from "./env-utils.js";
4
3
  import { getGlobalPiLensDir } from "./file-utils.js";
4
+ import { createNdjsonLogger } from "./ndjson-logger.js";
5
5
  const AW_LOG_DIR = getGlobalPiLensDir();
6
6
  const AW_LOG_FILE = path.join(AW_LOG_DIR, "actionable-warnings.log");
7
7
  const AW_LOG_BACKUP_FILE = path.join(AW_LOG_DIR, "actionable-warnings.log.1");
8
8
  const MAX_LOG_BYTES = Math.max(128 * 1024, Number.parseInt(process.env.PI_LENS_AW_LOG_MAX_BYTES ?? "1048576", 10) || 1048576);
9
- try {
10
- if (!fs.existsSync(AW_LOG_DIR)) {
11
- fs.mkdirSync(AW_LOG_DIR, { recursive: true });
12
- }
13
- }
14
- catch (err) {
15
- void err;
16
- }
17
- function rotateIfNeeded() {
18
- try {
19
- if (!fs.existsSync(AW_LOG_FILE))
20
- return;
21
- const size = fs.statSync(AW_LOG_FILE).size;
22
- if (size < MAX_LOG_BYTES)
23
- return;
24
- try {
25
- fs.rmSync(AW_LOG_BACKUP_FILE, { force: true });
26
- }
27
- catch (err) {
28
- void err;
29
- }
30
- fs.renameSync(AW_LOG_FILE, AW_LOG_BACKUP_FILE);
31
- }
32
- catch (err) {
33
- void err;
34
- }
35
- }
9
+ const writer = createNdjsonLogger({
10
+ filePath: AW_LOG_FILE,
11
+ maxBytes: MAX_LOG_BYTES,
12
+ backupPath: AW_LOG_BACKUP_FILE,
13
+ });
36
14
  export function logActionableWarningsEvent(entry) {
37
15
  if (isTestMode()) {
38
16
  return;
39
17
  }
40
- const line = `${JSON.stringify({ ts: new Date().toISOString(), ...entry })}\n`;
41
- try {
42
- rotateIfNeeded();
43
- fs.appendFileSync(AW_LOG_FILE, line);
44
- }
45
- catch (err) {
46
- void err;
47
- }
18
+ writer.log({ ts: new Date().toISOString(), ...entry });
48
19
  }
49
20
  export function getActionableWarningsLogPath() {
50
21
  return AW_LOG_FILE;
51
22
  }
23
+ /** Resolve once all enqueued actionable-warnings writes are on disk. */
24
+ export function flushActionableWarningsLog() {
25
+ return writer.flush();
26
+ }
@@ -9,22 +9,19 @@
9
9
  *
10
10
  * Mirrors `actionable-warnings-logger.ts` for shape + rotation behaviour.
11
11
  */
12
- import * as fs from "node:fs";
13
12
  import * as path from "node:path";
14
13
  import { isTestMode } from "./env-utils.js";
15
14
  import { getGlobalPiLensDir } from "./file-utils.js";
15
+ import { createNdjsonLogger } from "./ndjson-logger.js";
16
16
  const AG_LOG_DIR = getGlobalPiLensDir();
17
17
  const AG_LOG_FILE = path.join(AG_LOG_DIR, "ast-grep-tools.log");
18
18
  const AG_LOG_BACKUP_FILE = path.join(AG_LOG_DIR, "ast-grep-tools.log.1");
19
19
  const MAX_LOG_BYTES = Math.max(128 * 1024, Number.parseInt(process.env.PI_LENS_AST_GREP_LOG_MAX_BYTES ?? "1048576", 10) || 1048576);
20
- try {
21
- if (!fs.existsSync(AG_LOG_DIR)) {
22
- fs.mkdirSync(AG_LOG_DIR, { recursive: true });
23
- }
24
- }
25
- catch (err) {
26
- void err;
27
- }
20
+ const writer = createNdjsonLogger({
21
+ filePath: AG_LOG_FILE,
22
+ maxBytes: MAX_LOG_BYTES,
23
+ backupPath: AG_LOG_BACKUP_FILE,
24
+ });
28
25
  const PATTERN_TRUNCATE_AT = 500;
29
26
  const ERROR_TRUNCATE_AT = 300;
30
27
  function truncate(value, max) {
@@ -105,25 +102,6 @@ export function astGrepRemediationHint(kind) {
105
102
  return "Hint: verify the pattern is a single valid AST node for this `lang` (use ast_grep_dump to discover node kinds), or fall back to grep for plain-text search.";
106
103
  }
107
104
  }
108
- function rotateIfNeeded() {
109
- try {
110
- if (!fs.existsSync(AG_LOG_FILE))
111
- return;
112
- const size = fs.statSync(AG_LOG_FILE).size;
113
- if (size < MAX_LOG_BYTES)
114
- return;
115
- try {
116
- fs.rmSync(AG_LOG_BACKUP_FILE, { force: true });
117
- }
118
- catch (err) {
119
- void err;
120
- }
121
- fs.renameSync(AG_LOG_FILE, AG_LOG_BACKUP_FILE);
122
- }
123
- catch (err) {
124
- void err;
125
- }
126
- }
127
105
  export function logAstGrepToolEvent(event) {
128
106
  if (isTestMode())
129
107
  return;
@@ -135,16 +113,13 @@ export function logAstGrepToolEvent(event) {
135
113
  rewriteLineCount: event.rewriteLineCount,
136
114
  errorRaw: truncate(event.errorRaw, ERROR_TRUNCATE_AT),
137
115
  };
138
- const line = `${JSON.stringify({ ts: new Date().toISOString(), ...payload })}\n`;
139
- try {
140
- rotateIfNeeded();
141
- fs.appendFileSync(AG_LOG_FILE, line);
142
- }
143
- catch (err) {
144
- void err;
145
- }
116
+ writer.log({ ts: new Date().toISOString(), ...payload });
146
117
  }
147
118
  export function getAstGrepToolLogPath() {
148
119
  return AG_LOG_FILE;
149
120
  }
121
+ /** Resolve once all enqueued ast-grep-tool writes are on disk. */
122
+ export function flushAstGrepToolLog() {
123
+ return writer.flush();
124
+ }
150
125
  export { countLines as _countLinesForTest };
@@ -1,25 +1,20 @@
1
- import * as fs from "node:fs";
2
1
  import * as path from "node:path";
3
2
  import { isTestMode } from "./env-utils.js";
4
3
  import { getGlobalPiLensDir } from "./file-utils.js";
4
+ import { createNdjsonLogger } from "./ndjson-logger.js";
5
5
  const CASCADE_LOG_DIR = getGlobalPiLensDir();
6
6
  const CASCADE_LOG_FILE = path.join(CASCADE_LOG_DIR, "cascade.log");
7
- try {
8
- if (!fs.existsSync(CASCADE_LOG_DIR)) {
9
- fs.mkdirSync(CASCADE_LOG_DIR, { recursive: true });
10
- }
11
- }
12
- catch { }
7
+ const writer = createNdjsonLogger({ filePath: CASCADE_LOG_FILE });
13
8
  export function logCascade(entry) {
14
9
  if (isTestMode()) {
15
10
  return;
16
11
  }
17
- const line = `${JSON.stringify({ ts: new Date().toISOString(), ...entry })}\n`;
18
- try {
19
- fs.appendFileSync(CASCADE_LOG_FILE, line);
20
- }
21
- catch { }
12
+ writer.log({ ts: new Date().toISOString(), ...entry });
22
13
  }
23
14
  export function getCascadeLogPath() {
24
15
  return CASCADE_LOG_FILE;
25
16
  }
17
+ /** Resolve once all enqueued cascade writes are on disk (tests/shutdown). */
18
+ export function flushCascadeLog() {
19
+ return writer.flush();
20
+ }
@@ -5,26 +5,20 @@
5
5
  * input to phasing decisions in the issue). Mirrors `ast-grep-tool-logger.ts`
6
6
  * for shape + size-based rotation.
7
7
  */
8
- import * as fs from "node:fs";
9
8
  import * as path from "node:path";
10
9
  import { isTestMode } from "./env-utils.js";
11
10
  import { getGlobalPiLensDir } from "./file-utils.js";
11
+ import { createNdjsonLogger } from "./ndjson-logger.js";
12
12
  const LOG_DIR = getGlobalPiLensDir();
13
13
  const LOG_FILE = path.join(LOG_DIR, "dead-code.log");
14
14
  const LOG_BACKUP_FILE = path.join(LOG_DIR, "dead-code.log.1");
15
15
  const MAX_LOG_BYTES = Math.max(128 * 1024, Number.parseInt(process.env.PI_LENS_DEAD_CODE_LOG_MAX_BYTES ?? "1048576", 10) ||
16
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
- }
17
+ const writer = createNdjsonLogger({
18
+ filePath: LOG_FILE,
19
+ maxBytes: MAX_LOG_BYTES,
20
+ backupPath: LOG_BACKUP_FILE,
21
+ });
28
22
  /**
29
23
  * Append one scan event. Fire-and-forget: telemetry must never break a scan, so
30
24
  * every fs error is swallowed. Skipped under test mode to keep the suite from
@@ -33,14 +27,9 @@ function rotateIfNeeded() {
33
27
  export function logDeadCodeScan(event) {
34
28
  if (isTestMode())
35
29
  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
- }
30
+ writer.log({ ts: new Date().toISOString(), ...event });
31
+ }
32
+ /** Resolve once all enqueued dead-code writes are on disk (tests/shutdown). */
33
+ export function flushDeadCodeLog() {
34
+ return writer.flush();
46
35
  }
@@ -3,17 +3,12 @@
3
3
  *
4
4
  * Log file: ~/.pi-lens/logs/{date}.jsonl
5
5
  */
6
- import * as fs from "node:fs";
7
6
  import * as os from "node:os";
8
7
  import * as path from "node:path";
9
8
  import { isTestMode } from "./env-utils.js";
9
+ import { createNdjsonLogger } from "./ndjson-logger.js";
10
10
  function getLogDir() {
11
- const home = os.homedir();
12
- const logDir = path.join(home, ".pi-lens", "logs");
13
- if (!fs.existsSync(logDir)) {
14
- fs.mkdirSync(logDir, { recursive: true });
15
- }
16
- return logDir;
11
+ return path.join(os.homedir(), ".pi-lens", "logs");
17
12
  }
18
13
  function getLogFile() {
19
14
  const date = new Date().toISOString().split("T")[0];
@@ -28,30 +23,15 @@ export function getDiagnosticLogger() {
28
23
  return _logger;
29
24
  }
30
25
  export function createDiagnosticLogger() {
31
- const pending = [];
32
- let writing = false;
33
- const writePending = async () => {
34
- if (writing || pending.length === 0)
35
- return;
36
- writing = true;
37
- const toWrite = pending.splice(0, pending.length);
38
- const lines = toWrite.map((e) => JSON.stringify(e)).join("\n") + "\n";
39
- try {
40
- await fs.promises.appendFile(getLogFile(), lines);
41
- }
42
- catch (err) {
43
- // pi-lens-ignore: missing-error-propagation — fire-and-forget log write, must not throw
44
- console.error("Failed to write diagnostic log:", err);
45
- }
46
- writing = false;
47
- };
26
+ // Lazy filePath: the log file is keyed on the current date, resolved per
27
+ // drain so a long-lived logger rolls over at midnight.
28
+ const writer = createNdjsonLogger({ filePath: () => getLogFile() });
48
29
  return {
49
30
  log(entry) {
50
31
  if (isTestMode()) {
51
32
  return;
52
33
  }
53
- pending.push(entry);
54
- writePending(); // async, non-blocking
34
+ writer.log(entry); // async, non-blocking
55
35
  },
56
36
  logCaught(d, context, shownInline = false) {
57
37
  this.log({
@@ -77,11 +57,7 @@ export function createDiagnosticLogger() {
77
57
  });
78
58
  },
79
59
  async flush() {
80
- // Drain any buffered entries, then wait for the write to finish.
81
- await writePending();
82
- while (writing) {
83
- await new Promise((resolve) => setTimeout(resolve, 10));
84
- }
60
+ await writer.flush();
85
61
  },
86
62
  };
87
63
  }
@@ -20,6 +20,42 @@ import { findLocalOpengrepConfig } from "../opengrep-config.js";
20
20
  import { findLocalTyposConfig } from "../typos-config.js";
21
21
  import { findLocalZizmorConfig } from "../zizmor-config.js";
22
22
  import { classifyDefect } from "./diagnostic-taxonomy.js";
23
+ /**
24
+ * Semgrep/opengrep `# nosemgrep` / `# nosemgrep: <rule-id>[,<rule-id>]` inline
25
+ * suppression (#441). A bare `# nosemgrep` drops every finding on its line; the
26
+ * `: <ids>` form drops only the listed rule ids. `d.code` is the semgrep rule id.
27
+ * Also accepts the `//` comment form.
28
+ *
29
+ * Matches Semgrep placement: honored on the finding's OWN line (inline or not),
30
+ * and on the line ABOVE only when that line is a STANDALONE comment (no code before
31
+ * it) — so `a() # nosemgrep` suppresses a finding on `a()` but not the next line.
32
+ */
33
+ const NOSEMGREP_RE = /(?:#|\/\/)\s*nosemgrep(?::\s*(.+))?/i;
34
+ const NOSEMGREP_STANDALONE_RE = /^\s*(?:#|\/\/)\s*nosemgrep(?::\s*(.+))?\s*$/i;
35
+ export function isNosemgrepSuppressed(d, content) {
36
+ const startLine = d.range?.start?.line; // 0-based
37
+ if (startLine == null)
38
+ return false;
39
+ const lines = content.split("\n");
40
+ const ruleId = String(d.code ?? "");
41
+ const checkLine = (text, standaloneOnly) => {
42
+ if (!text)
43
+ return false;
44
+ const m = (standaloneOnly ? NOSEMGREP_STANDALONE_RE : NOSEMGREP_RE).exec(text);
45
+ if (!m)
46
+ return false;
47
+ if (m[1] === undefined)
48
+ return true; // bare nosemgrep → suppress the line
49
+ return m[1]
50
+ .split(",")
51
+ .map((s) => s.trim())
52
+ .filter(Boolean)
53
+ .includes(ruleId);
54
+ };
55
+ // The finding's own line (inline OK), then the line above (standalone comment only).
56
+ return (checkLine(lines[startLine], false) ||
57
+ checkLine(lines[startLine - 1], true));
58
+ }
23
59
  export const AUXILIARY_LSP_PROFILES = [
24
60
  {
25
61
  serverId: "opengrep",
@@ -37,6 +73,8 @@ export const AUXILIARY_LSP_PROFILES = [
37
73
  allowBlocking: (cwd) => Boolean(findLocalOpengrepConfig(cwd)),
38
74
  semantic: (d, { blockingAllowed }) => blockingAllowed && d.severity === 1 ? "blocking" : "warning",
39
75
  defectClass: (d) => classifyDefect(String(d.code ?? ""), "opengrep", d.message ?? ""),
76
+ // Honor the canonical Semgrep suppression the user already knows (#441).
77
+ isSuppressed: isNosemgrepSuppressed,
40
78
  },
41
79
  {
42
80
  serverId: "ast-grep",
@@ -27,6 +27,7 @@ import { loadPiLensProjectConfig } from "../project-lens-config.js";
27
27
  import { RUNTIME_CONFIG, getRunnerTimeoutFloorMs } from "../runtime-config.js";
28
28
  import { safeSpawnAsync } from "../safe-spawn.js";
29
29
  import { classifyDiagnostic } from "./diagnostic-taxonomy.js";
30
+ import { applyInlineSuppressions } from "./inline-suppressions.js";
30
31
  import { getToolPlan } from "./plan.js";
31
32
  import { resolveRunnerPath } from "./runner-context.js";
32
33
  import { getToolProfile } from "./tool-profile.js";
@@ -187,42 +188,6 @@ function dedupeOverlappingDiagnostics(diagnostics) {
187
188
  }
188
189
  return [...byKey.values()];
189
190
  }
190
- /**
191
- * Apply inline suppression comments.
192
- * Syntax: `// pi-lens-ignore: rule-id` (JS/TS) or `# pi-lens-ignore: rule-id` (Python/Ruby/etc.)
193
- * Place on the same line as the diagnostic or the line immediately above it.
194
- */
195
- function applyInlineSuppressions(diagnostics, content) {
196
- if (!content || !diagnostics.length)
197
- return diagnostics;
198
- // Build a set of (line, ruleId) pairs that are suppressed.
199
- // Line numbers are 1-based to match diagnostic line numbers.
200
- const suppressed = new Set();
201
- const lines = content.split("\n");
202
- const SUPPRESS_RE = /(?:\/\/|#)\s*pi-lens-ignore:\s*(.+)/;
203
- for (let i = 0; i < lines.length; i++) {
204
- const m = SUPPRESS_RE.exec(lines[i]);
205
- if (!m)
206
- continue;
207
- const rules = m[1]
208
- .split(",")
209
- .map((r) => r.trim())
210
- .filter(Boolean);
211
- const suppressedLine = i + 1; // same line (1-based)
212
- const nextLine = i + 2; // next line (1-based)
213
- for (const ruleId of rules) {
214
- suppressed.add(`${suppressedLine}:${ruleId}`);
215
- suppressed.add(`${nextLine}:${ruleId}`);
216
- }
217
- }
218
- if (suppressed.size === 0)
219
- return diagnostics;
220
- return diagnostics.filter((d) => {
221
- const ruleId = d.rule ?? d.id ?? "";
222
- const line = d.line ?? 1;
223
- return !suppressed.has(`${line}:${ruleId}`);
224
- });
225
- }
226
191
  function suppressLintOverlapsWithLsp(diagnostics) {
227
192
  const lspBySpanClass = new Set();
228
193
  const lspByLine = new Set();
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Inline `pi-lens-ignore` suppression — shared between the per-edit dispatch
3
+ * pipeline (`lens_diagnostics mode=all`) and the project-wide `mode=full` sweep so
4
+ * BOTH honor the same comments (#442). Previously this lived privately in the
5
+ * dispatcher, so a site suppressed on the write path reappeared as blocking in the
6
+ * full scan, making `mode=full` unusable as a clean gate.
7
+ *
8
+ * Syntax: `// pi-lens-ignore: rule-id` (JS/TS) or `# pi-lens-ignore: rule-id`
9
+ * (Python/Ruby/…), comma-separated for multiple rules, on the same line as the
10
+ * diagnostic or the line immediately above it.
11
+ */
12
+ const SUPPRESS_RE = /(?:\/\/|#)\s*pi-lens-ignore:\s*(.+)/;
13
+ /**
14
+ * Normalize a rule id to the form a user writes in a `pi-lens-ignore` comment.
15
+ * The napi scan and the ast-grep LSP tag the same rule as `ast-grep:<id>` /
16
+ * `<id>-js` in some surfaces (see `normalizeRuleForDedup` in lens-diagnostics);
17
+ * a user's bare `<id>` must still suppress those, so we match the normalized form
18
+ * as well as the raw one.
19
+ */
20
+ function normalizeSuppressRule(ruleId) {
21
+ return ruleId.replace(/^ast-grep:/, "").replace(/-js$/, "");
22
+ }
23
+ /**
24
+ * Drop diagnostics suppressed by an inline `pi-lens-ignore: <rule[,rule2]>`
25
+ * comment in `content` (the file the diagnostics belong to). A diagnostic is
26
+ * suppressed when its rule id — raw OR normalized — is listed on its own line or
27
+ * the line immediately above. Returns the surviving diagnostics (same array if
28
+ * nothing is suppressed).
29
+ */
30
+ export function applyInlineSuppressions(diagnostics, content) {
31
+ if (!content || !diagnostics.length)
32
+ return diagnostics;
33
+ // Build the set of (1-based line, rule-id) pairs that are suppressed.
34
+ const suppressed = new Set();
35
+ const lines = content.split("\n");
36
+ for (let i = 0; i < lines.length; i++) {
37
+ const m = SUPPRESS_RE.exec(lines[i]);
38
+ if (!m)
39
+ continue;
40
+ const rules = m[1]
41
+ .split(",")
42
+ .map((r) => r.trim())
43
+ .filter(Boolean);
44
+ const suppressedLine = i + 1; // same line (1-based)
45
+ const nextLine = i + 2; // next line (1-based)
46
+ for (const ruleId of rules) {
47
+ suppressed.add(`${suppressedLine}:${ruleId}`);
48
+ suppressed.add(`${nextLine}:${ruleId}`);
49
+ }
50
+ }
51
+ if (suppressed.size === 0)
52
+ return diagnostics;
53
+ return diagnostics.filter((d) => {
54
+ const rawId = d.rule ?? d.id ?? "";
55
+ const line = d.line ?? 1;
56
+ if (suppressed.has(`${line}:${rawId}`))
57
+ return false;
58
+ const normId = normalizeSuppressRule(rawId);
59
+ return normId === rawId || !suppressed.has(`${line}:${normId}`);
60
+ });
61
+ }
@@ -292,6 +292,7 @@ export function resetDispatchBaselines(cwd) {
292
292
  resetSessionSlopScore();
293
293
  clearCoverageNoticeState();
294
294
  clearReviewGraphWorkspaceCache();
295
+ clearReverseDepsIndexCache();
295
296
  clearModuleGraphCache();
296
297
  neighborTouchCache.clear();
297
298
  recentlyCleanNeighborCache.clear();
@@ -337,6 +338,15 @@ function ensureCascadeTurnScope(turnSeq) {
337
338
  const CASCADE_TTL_MS = 240_000;
338
339
  const MAX_PER_FILE = RUNTIME_CONFIG.pipeline.cascadeMaxDiagnosticsPerFile;
339
340
  const MAX_FILES = RUNTIME_CONFIG.pipeline.cascadeMaxFiles;
341
+ const reverseDepsIndexCache = new Map();
342
+ function reverseDepsReuseEnabled() {
343
+ const raw = process.env.PI_LENS_REVERSE_DEPS_REUSE;
344
+ return raw !== "0" && raw !== "false";
345
+ }
346
+ /** Test-reset hook — mirrors clearReviewGraphWorkspaceCache's scope. */
347
+ export function clearReverseDepsIndexCache() {
348
+ reverseDepsIndexCache.clear();
349
+ }
340
350
  // Bounded transitive cascade (#162): expand neighbour derivation beyond the
341
351
  // one-hop importers/callers to depth-2 dependents, so an edit's blast radius
342
352
  // reaches indirect dependents — capped so the per-edit cost stays bounded. The
@@ -383,7 +393,7 @@ function isIgnoredCascadeNeighbor(filePath, cwd) {
383
393
  }
384
394
  }
385
395
  export async function computeCascadeForFile(filePath, cwd, options = {}) {
386
- const { hasBlockers = false, dbg, turnSeq = 0, writeSeq } = options;
396
+ const { hasBlockers = false, dbg, turnSeq = 0, writeSeq, seqState } = options;
387
397
  ensureCascadeTurnScope(turnSeq);
388
398
  if (hasBlockers) {
389
399
  logCascade({
@@ -429,32 +439,71 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
429
439
  let referenceCount = 0;
430
440
  if (CASCADE_GRAPH_KINDS.has(fileKind)) {
431
441
  const graphStart = Date.now();
432
- const graph = await buildOrUpdateGraph(cwd, [normalizedFile], sessionFacts);
442
+ const graph = await buildOrUpdateGraph(cwd, [normalizedFile], sessionFacts, seqState);
433
443
  const graphMs = Date.now() - graphStart;
434
- const reverseDepsIndex = buildReverseDependencyIndexFromGraph({
435
- cwd,
436
- graph,
437
- });
438
- const reverseDepsSaved = writeReverseDependencyIndexToSnapshot({
439
- cwd,
440
- index: reverseDepsIndex,
441
- dbg,
442
- });
443
- logCascade({
444
- phase: "reverse_deps_cache",
445
- filePath,
446
- durationMs: Date.now() - graphStart,
447
- metadata: {
448
- action: "refresh_from_review_graph",
444
+ // #459: the reuse decision keys on graph.buildGeneration — a stamp that
445
+ // travels WITH the graph instance this cascade holds. Deliberately NOT the
446
+ // global last-build-info slot: post-#450 cascades overlap, and another
447
+ // cascade's cache-hit build can overwrite that slot (graphChanged:false)
448
+ // between this build mutating the graph and this read — which would turn a
449
+ // changed graph into a spurious reuse of a stale index that steady-state
450
+ // cache hits then never heal. Generation equality can't be clobbered into
451
+ // a false positive: a graph-mutating build always mints a new generation.
452
+ // An unstamped graph (mode "skipped") always rebuilds.
453
+ const graphBuildInfo = getLastGraphBuildInfo();
454
+ const workspaceKey = normalizeMapKey(cwd);
455
+ const cachedReverseDeps = reverseDepsIndexCache.get(workspaceKey);
456
+ const canReuse = reverseDepsReuseEnabled() &&
457
+ cachedReverseDeps !== undefined &&
458
+ graph.buildGeneration !== undefined &&
459
+ cachedReverseDeps.generation === graph.buildGeneration;
460
+ let reverseDepsIndex;
461
+ let reverseDepsSaved;
462
+ if (canReuse && cachedReverseDeps) {
463
+ reverseDepsIndex = cachedReverseDeps.index;
464
+ reverseDepsSaved = cachedReverseDeps.savedToSnapshot;
465
+ logCascade({
466
+ phase: "reverse_deps_cache",
467
+ filePath,
468
+ durationMs: Date.now() - graphStart,
469
+ metadata: {
470
+ action: "reused_unchanged",
471
+ savedToSnapshot: reverseDepsSaved,
472
+ importsFileCount: Object.keys(reverseDepsIndex.imports).length,
473
+ importedByFileCount: Object.keys(reverseDepsIndex.importedBy).length,
474
+ },
475
+ });
476
+ }
477
+ else {
478
+ reverseDepsIndex = buildReverseDependencyIndexFromGraph({
479
+ cwd,
480
+ graph,
481
+ });
482
+ reverseDepsSaved = writeReverseDependencyIndexToSnapshot({
483
+ cwd,
484
+ index: reverseDepsIndex,
485
+ dbg,
486
+ });
487
+ reverseDepsIndexCache.set(workspaceKey, {
488
+ index: reverseDepsIndex,
449
489
  savedToSnapshot: reverseDepsSaved,
450
- importsFileCount: Object.keys(reverseDepsIndex.imports).length,
451
- importedByFileCount: Object.keys(reverseDepsIndex.importedBy).length,
452
- importEdgeCount: Object.values(reverseDepsIndex.imports).reduce((total, imports) => total + imports.length, 0),
453
- },
454
- });
490
+ generation: graph.buildGeneration,
491
+ });
492
+ logCascade({
493
+ phase: "reverse_deps_cache",
494
+ filePath,
495
+ durationMs: Date.now() - graphStart,
496
+ metadata: {
497
+ action: "refresh_from_review_graph",
498
+ savedToSnapshot: reverseDepsSaved,
499
+ importsFileCount: Object.keys(reverseDepsIndex.imports).length,
500
+ importedByFileCount: Object.keys(reverseDepsIndex.importedBy).length,
501
+ importEdgeCount: Object.values(reverseDepsIndex.imports).reduce((total, imports) => total + imports.length, 0),
502
+ },
503
+ });
504
+ }
455
505
  // Count files represented in the graph (nodes with a filePath).
456
506
  const graphFileCount = new Set([...graph.nodes.values()].flatMap((n) => n.filePath ? [n.filePath] : [])).size;
457
- const graphBuildInfo = getLastGraphBuildInfo();
458
507
  logCascade({
459
508
  phase: "graph_build",
460
509
  filePath,
@@ -468,6 +517,9 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
468
517
  skipReason: graphBuildInfo.skipReason,
469
518
  sourceFileCount: graphBuildInfo.sourceFileCount,
470
519
  maxFileCount: graphBuildInfo.maxFileCount,
520
+ // #451: when the seq fast path fell back (or was skipped), why — so
521
+ // cascade.log surfaces the fast-path hit/miss rate.
522
+ seqFastpathFallback: graphBuildInfo.seqFastpathFallback,
471
523
  },
472
524
  });
473
525
  impact = computeImpactCascade(graph, normalizedFile, cwd);