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.
- package/CHANGELOG.md +25 -0
- package/README.md +0 -1
- package/dist/clients/actionable-warnings-logger.js +11 -36
- package/dist/clients/ast-grep-tool-logger.js +11 -36
- package/dist/clients/cascade-logger.js +7 -12
- package/dist/clients/dead-code-logger.js +11 -22
- package/dist/clients/diagnostic-logger.js +7 -31
- package/dist/clients/dispatch/auxiliary-lsp.js +38 -0
- package/dist/clients/dispatch/dispatcher.js +1 -36
- package/dist/clients/dispatch/inline-suppressions.js +61 -0
- package/dist/clients/dispatch/integration.js +75 -23
- package/dist/clients/dispatch/runners/lsp.js +17 -5
- package/dist/clients/dispatch/runners/tree-sitter.js +7 -2
- package/dist/clients/latency-logger.js +10 -15
- package/dist/clients/lsp/config.js +61 -3
- package/dist/clients/lsp/index.js +43 -3
- package/dist/clients/ndjson-logger.js +150 -0
- package/dist/clients/pipeline.js +25 -41
- package/dist/clients/project-diagnostics/scanner.js +8 -2
- package/dist/clients/read-guard-logger.js +11 -36
- package/dist/clients/review-graph/builder.js +316 -15
- package/dist/clients/review-graph/git-identity.js +150 -0
- package/dist/clients/review-graph/service.js +3 -3
- package/dist/clients/runtime-coordinator.js +83 -0
- package/dist/clients/runtime-tool-result.js +10 -2
- package/dist/clients/runtime-turn.js +21 -0
- package/dist/clients/tree-sitter-logger.js +7 -12
- package/dist/clients/tree-sitter-query-loader.js +1 -0
- package/dist/tools/lens-diagnostics.js +203 -12
- package/package.json +1 -1
- package/rules/ast-grep-rules/rule-tests/no-init-return-test.yml +15 -0
- package/rules/ast-grep-rules/rules/no-init-return.yml +11 -5
- 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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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);
|