pi-lens 4.2.0 → 4.3.0
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 +543 -0
- package/README.md +45 -2
- package/dist/clients/actionable-warnings.js +44 -16
- package/dist/clients/advisory-provenance.js +187 -51
- package/dist/clients/cache-manager.js +64 -5
- package/dist/clients/cascade-format.js +24 -0
- package/dist/clients/child-unref.js +17 -8
- package/dist/clients/config-diagnostic-codes.js +2 -0
- package/dist/clients/config-locations.js +237 -9
- package/dist/clients/config-resolve.js +7 -1
- package/dist/clients/degradation-ledger.js +33 -3
- package/dist/clients/dependency-checker.js +87 -64
- package/dist/clients/diagnostic-dispositions.js +39 -3
- package/dist/clients/dispatch/finding-policy.js +118 -1
- package/dist/clients/dispatch/indent-detect.js +8 -2
- package/dist/clients/dispatch/inline-suppressions.js +23 -4
- package/dist/clients/dispatch/rule-ignores.js +14 -7
- package/dist/clients/dispatch/runners/actionlint.js +26 -28
- package/dist/clients/dispatch/runners/biome-check.js +38 -25
- package/dist/clients/dispatch/runners/cpp-check.js +32 -49
- package/dist/clients/dispatch/runners/credo.js +16 -14
- package/dist/clients/dispatch/runners/cue-vet.js +25 -7
- package/dist/clients/dispatch/runners/dart-analyze.js +28 -47
- package/dist/clients/dispatch/runners/detekt.js +11 -4
- package/dist/clients/dispatch/runners/dotnet-build.js +27 -22
- package/dist/clients/dispatch/runners/elixir-check.js +31 -46
- package/dist/clients/dispatch/runners/eslint.js +29 -46
- package/dist/clients/dispatch/runners/gleam-check.js +91 -11
- package/dist/clients/dispatch/runners/go-vet.js +16 -2
- package/dist/clients/dispatch/runners/golangci-lint.js +38 -23
- package/dist/clients/dispatch/runners/hadolint.js +27 -15
- package/dist/clients/dispatch/runners/htmlhint.js +17 -8
- package/dist/clients/dispatch/runners/javac.js +31 -21
- package/dist/clients/dispatch/runners/ktlint.js +34 -60
- package/dist/clients/dispatch/runners/lsp.js +6 -5
- package/dist/clients/dispatch/runners/markdownlint.js +12 -5
- package/dist/clients/dispatch/runners/mypy.js +2 -1
- package/dist/clients/dispatch/runners/oxlint.js +22 -6
- package/dist/clients/dispatch/runners/php-lint.js +39 -16
- package/dist/clients/dispatch/runners/phpstan.js +1 -1
- package/dist/clients/dispatch/runners/rubocop.js +27 -18
- package/dist/clients/dispatch/runners/ruff.js +18 -19
- package/dist/clients/dispatch/runners/rust-clippy.js +6 -1
- package/dist/clients/dispatch/runners/shellcheck.js +6 -2
- package/dist/clients/dispatch/runners/spellcheck.js +10 -3
- package/dist/clients/dispatch/runners/sqlfluff.js +11 -2
- package/dist/clients/dispatch/runners/stylelint.js +10 -2
- package/dist/clients/dispatch/runners/swiftlint.js +11 -2
- package/dist/clients/dispatch/runners/taplo.js +12 -6
- package/dist/clients/dispatch/runners/tflint.js +30 -15
- package/dist/clients/dispatch/runners/tree-sitter.js +23 -0
- package/dist/clients/dispatch/runners/trivy-config.js +8 -2
- package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +11 -1
- package/dist/clients/dispatch/runners/vale.js +15 -3
- package/dist/clients/dispatch/runners/yamllint.js +12 -2
- package/dist/clients/dispatch/runners/zig-check.js +27 -39
- package/dist/clients/dispatch/suppress-writer.js +13 -3
- package/dist/clients/effective-config.js +3 -1
- package/dist/clients/extension-log.js +48 -1
- package/dist/clients/finding-delivery-gate.js +53 -23
- package/dist/clients/formatters.js +49 -11
- package/dist/clients/git-guard.js +247 -34
- package/dist/clients/gradle-ktfmt-style.js +121 -25
- package/dist/clients/inline-blocker-dispositions.js +186 -0
- package/dist/clients/install-diagnostics.js +19 -6
- package/dist/clients/installer/index.js +281 -32
- package/dist/clients/jscpd-client.js +8 -0
- package/dist/clients/latency-logger.js +8 -0
- package/dist/clients/lens-config.js +66 -8
- package/dist/clients/lsp/cascade-tier.js +3 -0
- package/dist/clients/lsp/client.js +139 -11
- package/dist/clients/lsp/config.js +9 -7
- package/dist/clients/lsp/content-limits.js +41 -0
- package/dist/clients/lsp/index.js +42 -8
- package/dist/clients/lsp/launch.js +23 -2
- package/dist/clients/lsp/server.js +106 -25
- package/dist/clients/lsp/sync-kind.js +14 -0
- package/dist/clients/lsp/wait-policy/strategies.js +105 -16
- package/dist/clients/mcp/analyze.js +9 -2
- package/dist/clients/mcp/ipc.js +232 -44
- package/dist/clients/mcp/review.js +53 -7
- package/dist/clients/opengrep-client.js +11 -6
- package/dist/clients/path-utils.js +51 -9
- package/dist/clients/persistent-reverify.js +271 -0
- package/dist/clients/pipeline.js +136 -67
- package/dist/clients/project-diagnostics/fresh-fetch.js +21 -3
- package/dist/clients/project-lens-config.js +11 -3
- package/dist/clients/review-graph/builder.js +34 -26
- package/dist/clients/review-graph/git-identity.js +16 -10
- package/dist/clients/ruff-client.js +31 -4
- package/dist/clients/runtime-coordinator.js +38 -4
- package/dist/clients/runtime-tool-call.js +16 -5
- package/dist/clients/runtime-tool-result.js +27 -12
- package/dist/clients/runtime-turn.js +571 -287
- package/dist/clients/safe-spawn.js +256 -65
- package/dist/clients/skills-resolver.js +38 -2
- package/dist/clients/spawn-output-cap.js +84 -1
- package/dist/clients/tree-sitter-client.js +119 -58
- package/dist/clients/turn-end/lane.js +51 -0
- package/dist/clients/turn-end/lanes/govulncheck.js +112 -0
- package/dist/clients/turn-end/lanes/secrets.js +204 -0
- package/dist/clients/warm-attach.js +59 -0
- package/dist/clients/widget-state.js +294 -17
- package/dist/clients/workspace-topology.js +57 -3
- package/dist/clients/zizmor-config.js +26 -13
- package/dist/index.js +6342 -3995
- package/dist/mcp/analyze-cli.js +4 -0
- package/dist/mcp/server.js +33 -21
- package/dist/scripts/lib/web-tree-sitter-dir.mjs +123 -0
- package/dist/tools/lens-diagnostic-mark.js +14 -2
- package/dist/tools/lens-diagnostics.js +127 -33
- package/dist/tools/lsp-diagnostics.js +150 -12
- package/docs/adr/0001-stale-advisory-live-arm.md +29 -0
- package/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
- package/docs/adr/0003-git-guard-latch-writer.md +203 -0
- package/docs/adr/0004-disposition-policy-seam.md +38 -0
- package/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
- package/docs/adr/0006-derived-state-benchmark-first.md +31 -0
- package/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
- package/docs/adr/0008-turn-end-lane-interface.md +165 -0
- package/docs/adr/0009-reported-path-attribution.md +114 -0
- package/docs/adr/README.md +26 -0
- package/docs/agent-tools.md +18 -1
- package/docs/ast-grep_rules_catalog.md +4 -3
- package/docs/configuration.md +55 -1
- package/docs/engineering-principles.md +202 -0
- package/docs/environment-variables.md +61 -4
- package/docs/features.md +2 -2
- package/docs/globalconfig.md +6 -1
- package/docs/language-coverage.md +17 -0
- package/docs/lsp-capability-matrix.md +98 -31
- package/docs/pi-lens-fixer.md +57 -3
- package/docs/pi-lens-investigator.md +9 -0
- package/docs/pi-lens-reviewer.md +16 -0
- package/docs/pi-lens-subagent.md +9 -0
- package/docs/public-api-stability.md +1 -0
- package/docs/release-qa-baseline.md +2 -0
- package/docs/servercapabilities.md +21 -11
- package/docs/settings.md +12 -3
- package/docs/subagent-compat.md +1 -1
- package/package.json +5 -3
- package/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
- package/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
- package/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
- package/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
- package/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
- package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
- package/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
- package/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
- package/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
- package/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
- package/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
- package/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
- package/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
- package/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
- package/scripts/install-selftest.mjs +21 -17
- package/scripts/lib/web-tree-sitter-dir.mjs +123 -0
package/dist/mcp/analyze-cli.js
CHANGED
|
@@ -165,6 +165,10 @@ function formatTurnEnd(response) {
|
|
|
165
165
|
*/
|
|
166
166
|
const TURN_END_SKIP_REASONS = {
|
|
167
167
|
"ipc-error": "no warm pi-lens MCP server answered for this workspace, or its build is stale — start the MCP server, or run `npm run build` if you have edited pi-lens sources",
|
|
168
|
+
// #3255: split out of `ipc-error` because its remedy is different and no
|
|
169
|
+
// rebuild reaches it. A server that was already running when pi-lens was
|
|
170
|
+
// upgraded still owns the endpoint name the previous build derived.
|
|
171
|
+
"no-listener": "nothing is listening on this workspace's warm endpoint — start the MCP server; if pi-lens was upgraded while a server was running, that server still owns the endpoint name of the previous build, so restart it",
|
|
168
172
|
timeout: "the warm pi-lens MCP server did not finish the pass within the hook budget — the turn-end findings are kept and will be delivered on a later Stop",
|
|
169
173
|
"schema-mismatch": "the warm pi-lens MCP server speaks a different turn-end schema than this hook binary — rebuild and restart the server so both halves match",
|
|
170
174
|
"stale-answer": "the warm pi-lens MCP server replied about a superseded state — the findings are kept for a later Stop",
|
package/dist/mcp/server.js
CHANGED
|
@@ -321,8 +321,16 @@ function startIpcServer() {
|
|
|
321
321
|
socket.end(`${JSON.stringify({ error: String(err) })}\n`);
|
|
322
322
|
}
|
|
323
323
|
});
|
|
324
|
+
}, {
|
|
325
|
+
label: "mcp-warm-server",
|
|
326
|
+
// #3383: a peer that never terminates its request line gets the same
|
|
327
|
+
// error reply every other unusable request gets, instead of this
|
|
328
|
+
// process holding its bytes until the client's timeout.
|
|
329
|
+
onOverflow: () => socket.end(`${JSON.stringify({ error: "warm request line exceeded the framing limit" })}\n`),
|
|
324
330
|
}));
|
|
325
|
-
|
|
331
|
+
// The stream is already destroyed when this handler runs; the listener
|
|
332
|
+
// remains the surviving teardown witness, while destroy() was a no-op.
|
|
333
|
+
socket.on("error", () => { });
|
|
326
334
|
});
|
|
327
335
|
ipc.on("error", (err) => {
|
|
328
336
|
// Listener failure must not take down the MCP server — warm channel is an
|
|
@@ -1623,28 +1631,32 @@ async function handleRequest(request) {
|
|
|
1623
1631
|
}
|
|
1624
1632
|
}
|
|
1625
1633
|
// --- stdio read loop (newline-delimited JSON) --------------------------------
|
|
1626
|
-
let buffer = "";
|
|
1627
1634
|
process.stdin.setEncoding("utf8");
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
if (request)
|
|
1643
|
-
void handleRequest(request);
|
|
1644
|
-
}
|
|
1645
|
-
newlineIndex = buffer.indexOf("\n");
|
|
1635
|
+
// #3383: framed by the shared reader, so the partial line this loop holds
|
|
1636
|
+
// between chunks has a ceiling. It used to be `buffer += chunk` with none: a
|
|
1637
|
+
// host that never sent a newline grew one JS string until V8 refused the next
|
|
1638
|
+
// concatenation with `RangeError: Invalid string length` — raised inside this
|
|
1639
|
+
// `data` handler, where nothing can catch it, which is the ending #3375 fixed
|
|
1640
|
+
// for child output. `continuous` because this is a stream of requests, not one
|
|
1641
|
+
// request per connection like every socket reader.
|
|
1642
|
+
process.stdin.on("data", createWarmIpcLineReader((raw) => {
|
|
1643
|
+
const line = raw.trim();
|
|
1644
|
+
if (line.length === 0)
|
|
1645
|
+
return;
|
|
1646
|
+
let request;
|
|
1647
|
+
try {
|
|
1648
|
+
request = JSON.parse(line);
|
|
1646
1649
|
}
|
|
1647
|
-
|
|
1650
|
+
catch {
|
|
1651
|
+
sendError(null, -32700, "Parse error");
|
|
1652
|
+
}
|
|
1653
|
+
if (request)
|
|
1654
|
+
void handleRequest(request);
|
|
1655
|
+
}, {
|
|
1656
|
+
label: "mcp-stdio",
|
|
1657
|
+
continuous: true,
|
|
1658
|
+
onOverflow: () => sendError(null, -32700, "Parse error: request line exceeded the framing limit"),
|
|
1659
|
+
}));
|
|
1648
1660
|
process.stdin.on("end", () => {
|
|
1649
1661
|
endSituationalToolTelemetry();
|
|
1650
1662
|
void flushExtensionLog().finally(() => process.exit(0));
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where web-tree-sitter is installed — ONE ladder, for every caller that needs
|
|
3
|
+
* the answer (#3409).
|
|
4
|
+
*
|
|
5
|
+
* Callers: `clients/tree-sitter-client.ts` (the grammar read path and the
|
|
6
|
+
* lazy-fetch write target), `clients/install-diagnostics.ts` (the pasted
|
|
7
|
+
* environment fingerprint) and `scripts/install-selftest.mjs` (the installed
|
|
8
|
+
* host's own diagnostic). Each used to spell its own rungs, and the two that led
|
|
9
|
+
* with a BARE specifier were unconditionally broken on the host pi ships: inside
|
|
10
|
+
* a `bun build --compile` binary `require.resolve("web-tree-sitter")` throws
|
|
11
|
+
* MODULE_NOT_FOUND while `require.resolve("web-tree-sitter/tree-sitter.wasm")` —
|
|
12
|
+
* an explicit file subpath — still resolves.
|
|
13
|
+
*
|
|
14
|
+
* It lives in `scripts/lib/` rather than `clients/` because the installed
|
|
15
|
+
* selftest is packaged (`files[]`) without `clients/`, and this is the house
|
|
16
|
+
* pattern for logic a packaged .mjs script and the TypeScript runtime share
|
|
17
|
+
* (`scripts/lib/skills-predicate.mjs`, `scripts/lib/process-scan.mjs`). It
|
|
18
|
+
* imports nothing but `node:fs` and `node:path`, so it loads even on the hosts
|
|
19
|
+
* whose broken dependency graph the selftest exists to report.
|
|
20
|
+
*/
|
|
21
|
+
import * as fs from "node:fs";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
/** The package this module is about. Also the name its manifest must carry. */
|
|
24
|
+
const WEB_TREE_SITTER = "web-tree-sitter";
|
|
25
|
+
/**
|
|
26
|
+
* The wasm the runtime loads, at the package root — where 0.25.10's `exports`
|
|
27
|
+
* map puts it (`"./tree-sitter.wasm": "./tree-sitter.wasm"`). If a later release
|
|
28
|
+
* moves it (#381's 0.26 migration), the two FALLBACK rungs below stop matching
|
|
29
|
+
* and the resolver rungs, which ask Node for that same subpath, keep working;
|
|
30
|
+
* this constant is then the one line to update.
|
|
31
|
+
*/
|
|
32
|
+
const RUNTIME_WASM = "tree-sitter.wasm";
|
|
33
|
+
/**
|
|
34
|
+
* True when `dir` IS an installed web-tree-sitter package: its own manifest
|
|
35
|
+
* names it, and the wasm the runtime loads is in it.
|
|
36
|
+
*
|
|
37
|
+
* Round 1 review of #3409 (R3418-1): the constructed rungs used to accept any
|
|
38
|
+
* directory that merely EXISTED at `<base>/node_modules/web-tree-sitter`, so an
|
|
39
|
+
* empty or foreign directory of that name became the runtime fetch's write
|
|
40
|
+
* target and a downloaded grammar would land in an unrelated tree. Existence is
|
|
41
|
+
* not identity. The manifest name is the identity; the wasm is what makes the
|
|
42
|
+
* directory the right place to put grammars, since a grammar is only loadable by
|
|
43
|
+
* the web-tree-sitter runtime sitting next to it (the ABI drift #1564 is about).
|
|
44
|
+
*/
|
|
45
|
+
export function isWebTreeSitterPackageDir(dir) {
|
|
46
|
+
try {
|
|
47
|
+
if (!fs.existsSync(path.join(dir, RUNTIME_WASM)))
|
|
48
|
+
return false;
|
|
49
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
50
|
+
return manifest?.name === WEB_TREE_SITTER;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The package directory owning `resolvedFile`: walk up until a directory passes
|
|
58
|
+
* the identity check, or the filesystem root is reached. The walk exists because
|
|
59
|
+
* an `exports` map may point into a subdirectory, so the file's own parent is
|
|
60
|
+
* not necessarily the package; the identity check is what stops the walk, so a
|
|
61
|
+
* resolver that answers outside any web-tree-sitter package yields nothing
|
|
62
|
+
* rather than an unrelated ancestor.
|
|
63
|
+
*/
|
|
64
|
+
function packageDirOf(resolvedFile) {
|
|
65
|
+
let dir = path.dirname(resolvedFile);
|
|
66
|
+
for (;;) {
|
|
67
|
+
if (isWebTreeSitterPackageDir(dir))
|
|
68
|
+
return dir;
|
|
69
|
+
const parent = path.dirname(dir);
|
|
70
|
+
if (parent === dir)
|
|
71
|
+
return undefined;
|
|
72
|
+
dir = parent;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* web-tree-sitter's installed package directory, or undefined.
|
|
77
|
+
*
|
|
78
|
+
* Rung order, most authoritative first:
|
|
79
|
+
* 1. the `tree-sitter.wasm` SUBPATH, which resolves on a compiled host as well
|
|
80
|
+
* as a plain one. This is the rung that makes the non-core grammars
|
|
81
|
+
* fetchable there at all.
|
|
82
|
+
* 2. the BARE specifier, for a future web-tree-sitter whose exports map no
|
|
83
|
+
* longer carries `./tree-sitter.wasm`.
|
|
84
|
+
* 3. pi-lens's own package root, for the temp-dir compile layout (#20) where
|
|
85
|
+
* the resolver's context is a temp directory but the package root still has
|
|
86
|
+
* the right `node_modules`.
|
|
87
|
+
* 4. the working directory, the pre-#3409 last resort.
|
|
88
|
+
*
|
|
89
|
+
* Rungs 1-2 are the resolver's answer and outrank the constructed guesses: the
|
|
90
|
+
* grammars must come from the SAME package whose wasm the runtime loaded. Every
|
|
91
|
+
* rung's candidate passes `isWebTreeSitterPackageDir` before it is returned.
|
|
92
|
+
*
|
|
93
|
+
* `resolve`, `packageRoot` and `cwd` are injected, never defaulted: the caller's
|
|
94
|
+
* resolver context and package root are what differ on a compiled host, so they
|
|
95
|
+
* must not be silently replaced by this module's — and a rung nothing can vary
|
|
96
|
+
* is a rung no test can drive.
|
|
97
|
+
*/
|
|
98
|
+
export function resolveWebTreeSitterPackageDir(deps) {
|
|
99
|
+
for (const specifier of [
|
|
100
|
+
`${WEB_TREE_SITTER}/${RUNTIME_WASM}`,
|
|
101
|
+
WEB_TREE_SITTER,
|
|
102
|
+
]) {
|
|
103
|
+
try {
|
|
104
|
+
const dir = packageDirOf(deps.resolve(specifier));
|
|
105
|
+
if (dir)
|
|
106
|
+
return dir;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
/* next rung */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
for (const base of [deps.packageRoot, deps.cwd]) {
|
|
113
|
+
try {
|
|
114
|
+
const candidate = path.join(base(), "node_modules", WEB_TREE_SITTER);
|
|
115
|
+
if (isWebTreeSitterPackageDir(candidate))
|
|
116
|
+
return candidate;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
/* next rung */
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
@@ -45,8 +45,9 @@ import * as path from "node:path";
|
|
|
45
45
|
import { Type } from "../clients/deps/typebox.js";
|
|
46
46
|
import { markDisposition, normalizeMessage, } from "../clients/diagnostic-dispositions.js";
|
|
47
47
|
import { insertSuppressComment } from "../clients/dispatch/suppress-writer.js";
|
|
48
|
-
import {
|
|
48
|
+
import { normalizeMapKey } from "../clients/path-utils.js";
|
|
49
49
|
import { resolveLensToolName } from "../clients/tool-config.js";
|
|
50
|
+
import { getFileDiagnostics, } from "../clients/widget-state.js";
|
|
50
51
|
const DISPOSITIONS = [
|
|
51
52
|
"false-positive",
|
|
52
53
|
"suppress",
|
|
@@ -70,11 +71,22 @@ function isBlank(text) {
|
|
|
70
71
|
* violation) are ambiguous; per #802 this picks the match CLOSEST to the
|
|
71
72
|
* caller's line rather than guessing — the conservative choice for suppress,
|
|
72
73
|
* which must never place a comment above the wrong site.
|
|
74
|
+
*
|
|
75
|
+
* #3160: `getFileDiagnostics` keys with `normalizeEphemeralMapKey` — case-
|
|
76
|
+
* preserving on POSIX by design (no filesystem I/O on that hot path) — but
|
|
77
|
+
* the widget's own writers key from `ctx.filePath`, which
|
|
78
|
+
* `createDispatchContext` already canonicalizes to the on-disk casing
|
|
79
|
+
* (#2016/#3098). `absPath` here is the caller's RAW spelling, so on a
|
|
80
|
+
* case-insensitive filesystem a mis-cased mark call must derive the same
|
|
81
|
+
* canonical key the writers used or it silently misses the live diagnostic
|
|
82
|
+
* and falls through to the fuzzy fallback below — do NOT case-fold
|
|
83
|
+
* `normalizeEphemeralMapKey` itself for this: on a case-sensitive filesystem
|
|
84
|
+
* that would merge two genuinely different files.
|
|
73
85
|
*/
|
|
74
86
|
function widgetCrossCheck(absPath, tool, rule, message, callerLine) {
|
|
75
87
|
let diagnostics;
|
|
76
88
|
try {
|
|
77
|
-
diagnostics = getFileDiagnostics(absPath);
|
|
89
|
+
diagnostics = getFileDiagnostics(normalizeMapKey(absPath));
|
|
78
90
|
}
|
|
79
91
|
catch {
|
|
80
92
|
return undefined;
|
|
@@ -18,13 +18,13 @@ import { DEPENDENCY_DRIFT_MAX_DELIVERIES } from "../clients/blocker-freshness.js
|
|
|
18
18
|
import { freshnessFromMtime } from "../clients/freshness.js";
|
|
19
19
|
import { applyFindingPolicy, loadProjectRulePolicyMap, } from "../clients/dispatch/finding-policy.js";
|
|
20
20
|
import { gateFindingsByPathFreshness } from "../clients/advisory-provenance.js";
|
|
21
|
-
import { markUnreconciledFindings } from "../clients/finding-delivery-gate.js";
|
|
21
|
+
import { formatCacheAgeLabel, markUnreconciledFindings, } from "../clients/finding-delivery-gate.js";
|
|
22
22
|
import { normalizeRuleId } from "../clients/dispatch/rule-id-normalize.js";
|
|
23
23
|
import { applyRulePolicy, } from "../clients/dispatch/rule-policy.js";
|
|
24
24
|
import { compactRenderResult } from "./render-compact.js";
|
|
25
25
|
import { combineAbortSignals } from "../clients/deadline-utils.js";
|
|
26
26
|
import { getProjectIgnoreMatcher } from "../clients/file-utils.js";
|
|
27
|
-
import { isAtOrAboveHomeDir, normalizeEphemeralMapKey, normalizeFilePath, realpathOrResolve, } from "../clients/path-utils.js";
|
|
27
|
+
import { isAtOrAboveHomeDir, normalizeEphemeralMapKey, normalizeFilePath, normalizeMapKey, realpathOrResolve, } from "../clients/path-utils.js";
|
|
28
28
|
import { getLSPService } from "../clients/lsp/index.js";
|
|
29
29
|
import { retireInlineBlockerAndResyncGuard } from "../clients/git-guard.js";
|
|
30
30
|
import { primaryServerId, resolveLspCwdForFile, } from "../clients/lsp/config.js";
|
|
@@ -47,7 +47,7 @@ import { findAuxiliaryProfileForSource, retagAuxiliaryDiagnostics, } from "../cl
|
|
|
47
47
|
import { detectFileRole } from "../clients/file-role.js";
|
|
48
48
|
import { STALE_LINE_MARKER } from "../clients/stale-marker.js";
|
|
49
49
|
import { makeProgressReporter, scanningSummaryLine } from "./scan-progress.js";
|
|
50
|
-
import { createLspDiagnosticsTool, LSP_SEVERITY_FILTERS, MAX_BATCH_FILES, } from "./lsp-diagnostics.js";
|
|
50
|
+
import { createLspDiagnosticsTool, LSP_SEVERITY_FILTERS, MAX_BATCH_FILES, renderBatchOutcomeLines, } from "./lsp-diagnostics.js";
|
|
51
51
|
import { demotePastEofDiagnostics, PAST_EOF_STALE_MARKER, resyncDocumentOnPastEof, } from "../clients/diagnostic-line-freshness.js";
|
|
52
52
|
// The widget state exposes the full per-file diagnostic set; this is the tool's
|
|
53
53
|
// own generous display budget per file (independent of the TUI's 12 cap), to
|
|
@@ -197,11 +197,15 @@ captureLspStatusRepaint, getRuntime, isLensGuardEnabled = () => true) {
|
|
|
197
197
|
if ((details.unconfirmedFiles ?? 0) > 0)
|
|
198
198
|
return `lens_diagnostics${scope} — ${count} ${noun} · ${details.cleanFiles ?? 0} clean · ${details.unconfirmedFiles} unconfirmed${details.timedOutFiles ? ` (${details.timedOutFiles} timed out)` : ""}`;
|
|
199
199
|
const outcomeCounts = details.outcomeCounts;
|
|
200
|
+
const tooLargeLines = renderBatchOutcomeLines(details.outcomes ?? []);
|
|
201
|
+
if (tooLargeLines.length > 0)
|
|
202
|
+
return `lens_diagnostics${scope} — too large: ${tooLargeLines.join("; ")}`;
|
|
200
203
|
const notConfirmed = outcomeCounts
|
|
201
204
|
? (outcomeCounts.inconclusive ?? 0) +
|
|
202
205
|
(outcomeCounts.unavailable ?? 0) +
|
|
203
206
|
(outcomeCounts.unsupported ?? 0) +
|
|
204
|
-
(outcomeCounts.failed ?? 0)
|
|
207
|
+
(outcomeCounts.failed ?? 0) +
|
|
208
|
+
(outcomeCounts.too_large ?? 0)
|
|
205
209
|
: 0;
|
|
206
210
|
if (notConfirmed > 0)
|
|
207
211
|
return `lens_diagnostics${scope} — ${count} ${noun} · ${notConfirmed} checks not confirmed`;
|
|
@@ -508,16 +512,19 @@ function formatProjectDeltaDiagnostic(diagnostic, stale) {
|
|
|
508
512
|
* rule/message), live -> unchanged. See `clients/finding-delivery-gate.ts`
|
|
509
513
|
* surface `lens-diagnostics:mode-delta`.
|
|
510
514
|
*/
|
|
511
|
-
function appendProjectDiagnosticsDeltaLines(
|
|
515
|
+
function appendProjectDiagnosticsDeltaLines(groups, cwd, report, severity, includeFile) {
|
|
512
516
|
const scoped = (report?.diagnostics ?? []).filter((diagnostic) => includeFile(diagnostic.filePath) &&
|
|
513
517
|
matchesSeverity(projectDiagnosticToWidget(diagnostic, diagnostic.filePath), severity));
|
|
514
|
-
const gated = gateFindingsByPathFreshness({
|
|
515
|
-
store: "lens-diagnostics-delta-project",
|
|
516
|
-
findings: scoped,
|
|
518
|
+
const { "lens-diagnostics-delta-project": gated } = gateFindingsByPathFreshness({
|
|
517
519
|
cwd,
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
520
|
+
sources: {
|
|
521
|
+
"lens-diagnostics-delta-project": {
|
|
522
|
+
findings: scoped,
|
|
523
|
+
scannedAt: report?.generatedAt,
|
|
524
|
+
citedPath: (d) => d.filePath,
|
|
525
|
+
onMissing: "drop",
|
|
526
|
+
},
|
|
527
|
+
},
|
|
521
528
|
});
|
|
522
529
|
const staleSet = new Set(gated.stale);
|
|
523
530
|
// Concatenating live-then-stale reorders a file's demoted rows to the end
|
|
@@ -532,11 +539,9 @@ function appendProjectDiagnosticsDeltaLines(lines, cwd, report, severity, includ
|
|
|
532
539
|
byFile.set(filePath, bucket);
|
|
533
540
|
}
|
|
534
541
|
for (const [filePath, fileDiagnostics] of byFile) {
|
|
535
|
-
const
|
|
536
|
-
if (!lines.includes(rel))
|
|
537
|
-
lines.push(rel);
|
|
542
|
+
const group = getDeltaFileGroup(groups, cwd, filePath);
|
|
538
543
|
for (const diagnostic of fileDiagnostics) {
|
|
539
|
-
|
|
544
|
+
group.projectLines.push(formatProjectDeltaDiagnostic(diagnostic, staleSet.has(diagnostic)));
|
|
540
545
|
}
|
|
541
546
|
}
|
|
542
547
|
return diagnostics.length;
|
|
@@ -704,13 +709,16 @@ function applyDeltaFreshnessGate(files, cwd, generatedAt) {
|
|
|
704
709
|
}
|
|
705
710
|
if (flat.length === 0)
|
|
706
711
|
return files;
|
|
707
|
-
const gated = gateFindingsByPathFreshness({
|
|
708
|
-
store: "lens-diagnostics-delta",
|
|
709
|
-
findings: flat,
|
|
712
|
+
const { "lens-diagnostics-delta": gated } = gateFindingsByPathFreshness({
|
|
710
713
|
cwd,
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
+
sources: {
|
|
715
|
+
"lens-diagnostics-delta": {
|
|
716
|
+
findings: flat,
|
|
717
|
+
scannedAt: effectiveAt,
|
|
718
|
+
citedPath: (f) => f.filePath,
|
|
719
|
+
onMissing: "drop",
|
|
720
|
+
},
|
|
721
|
+
},
|
|
714
722
|
});
|
|
715
723
|
// Two passes (live, then stale) reorder a file's demoted rows to the end
|
|
716
724
|
// of its warnings array rather than the original report order — cosmetic
|
|
@@ -723,7 +731,15 @@ function applyDeltaFreshnessGate(files, cwd, generatedAt) {
|
|
|
723
731
|
}
|
|
724
732
|
for (const f of gated.stale) {
|
|
725
733
|
const arr = byFile.get(f.filePath) ?? [];
|
|
726
|
-
|
|
734
|
+
// Fix B (#3167): carry the stamp the row was judged against so the render
|
|
735
|
+
// can emit one age label per file group — the row's own observation stamp,
|
|
736
|
+
// not the report-level one (the #2504 r4 multi-stamp case).
|
|
737
|
+
arr.push({
|
|
738
|
+
...f.warning,
|
|
739
|
+
stale: true,
|
|
740
|
+
line: undefined,
|
|
741
|
+
staleAsOf: effectiveAt,
|
|
742
|
+
});
|
|
727
743
|
byFile.set(f.filePath, arr);
|
|
728
744
|
}
|
|
729
745
|
return files
|
|
@@ -733,10 +749,34 @@ function applyDeltaFreshnessGate(files, cwd, generatedAt) {
|
|
|
733
749
|
}))
|
|
734
750
|
.filter((file) => file.warnings.length > 0);
|
|
735
751
|
}
|
|
752
|
+
function getDeltaFileGroup(groups, cwd, filePath) {
|
|
753
|
+
const rel = path.relative(cwd, filePath);
|
|
754
|
+
let group = groups.get(rel);
|
|
755
|
+
if (!group) {
|
|
756
|
+
group = {
|
|
757
|
+
rel,
|
|
758
|
+
actionableLines: [],
|
|
759
|
+
qualityLines: [],
|
|
760
|
+
projectLines: [],
|
|
761
|
+
incomplete: false,
|
|
762
|
+
};
|
|
763
|
+
groups.set(rel, group);
|
|
764
|
+
}
|
|
765
|
+
return group;
|
|
766
|
+
}
|
|
736
767
|
// @delivery-surface: lens-diagnostics:mode-delta
|
|
737
768
|
function formatDeltaMode(cacheManager, cwd, severity, pathsScope) {
|
|
738
769
|
const actionableEntry = cacheManager.readCache("actionable-warnings", cwd);
|
|
739
770
|
const qualityEntry = cacheManager.readCache("code-quality-warnings", cwd);
|
|
771
|
+
// #3170: files whose re-verify pass could not complete inside its budget —
|
|
772
|
+
// their rows render as carried, plus this explicit gap label (never a
|
|
773
|
+
// false clean). Computed from the RAW actionable-warnings cache entries
|
|
774
|
+
// because the freshness pipeline below rebuilds the file shape. The
|
|
775
|
+
// re-verify only ever writes the actionable-warnings cache — the quality
|
|
776
|
+
// report's file shape carries no such marker.
|
|
777
|
+
const reverifyIncompletePaths = new Set((actionableEntry?.data?.files ?? [])
|
|
778
|
+
.filter((file) => file.reVerifyIncomplete)
|
|
779
|
+
.map((file) => normalizeMapKey(file.filePath)));
|
|
740
780
|
const actionable = actionableEntry?.data;
|
|
741
781
|
const quality = qualityEntry?.data;
|
|
742
782
|
// Project rule policy (`.pi-lens.json` `rules.<id>.disable` /
|
|
@@ -778,32 +818,69 @@ function formatDeltaMode(cacheManager, cwd, severity, pathsScope) {
|
|
|
778
818
|
warnings: matchingWarnings(file.warnings),
|
|
779
819
|
}))
|
|
780
820
|
.filter((file) => file.warnings.length > 0);
|
|
781
|
-
|
|
821
|
+
// #3196: one grouping pass keyed by file — every tier below appends into
|
|
822
|
+
// the SAME per-file group (see `DeltaFileGroup`/`getDeltaFileGroup`), so a
|
|
823
|
+
// file present in more than one report always renders its rows under its
|
|
824
|
+
// own header, wherever that header ends up in the final buffer.
|
|
825
|
+
const groups = new Map();
|
|
782
826
|
// Fixable warnings from actionable-warnings and quality cache entries retain
|
|
783
827
|
// their own severity tier. Apply the same threshold semantics as the LSP path.
|
|
828
|
+
// F5/F10 (#3168): at most ONE label group (age and/or #3170's re-verify
|
|
829
|
+
// gap) per file. The actionable tier is folded first, so its stale row (if
|
|
830
|
+
// any) wins the group's age label; the quality tier only supplies one when
|
|
831
|
+
// the actionable tier had none for that file (#3168 F10(c)'s quality-only
|
|
832
|
+
// shape). The `(re-verify incomplete)` flag is a fact about the file (from
|
|
833
|
+
// the raw actionable cache, independent of which tier's rows happen to
|
|
834
|
+
// render it), so it is recorded on the group the first time either tier
|
|
835
|
+
// touches that file and never predicted.
|
|
784
836
|
if (filteredActionableFiles.length > 0) {
|
|
785
837
|
for (const file of filteredActionableFiles) {
|
|
786
|
-
const
|
|
787
|
-
lines.push(`${rel}`);
|
|
838
|
+
const group = getDeltaFileGroup(groups, cwd, file.filePath);
|
|
788
839
|
for (const w of file.warnings) {
|
|
789
840
|
const where = w.stale ? STALE_LINE_MARKER : `L${w.line ?? "?"}`;
|
|
790
|
-
|
|
841
|
+
group.actionableLines.push(` ⚠ ${where} ${w.rule ?? w.code ?? w.tool} ${w.message}`);
|
|
842
|
+
}
|
|
843
|
+
const key = normalizeMapKey(file.filePath);
|
|
844
|
+
if (reverifyIncompletePaths.has(key))
|
|
845
|
+
group.incomplete = true;
|
|
846
|
+
if (!group.staleRow) {
|
|
847
|
+
const staleWarning = file.warnings.find((w) => w.stale);
|
|
848
|
+
if (staleWarning)
|
|
849
|
+
group.staleRow = { staleAsOf: staleWarning.staleAsOf };
|
|
791
850
|
}
|
|
792
851
|
}
|
|
793
852
|
}
|
|
794
853
|
// Quality issues
|
|
795
854
|
if (filteredQualityFiles.length > 0) {
|
|
796
855
|
for (const file of filteredQualityFiles) {
|
|
797
|
-
const
|
|
798
|
-
if (!lines.includes(rel))
|
|
799
|
-
lines.push(rel);
|
|
856
|
+
const group = getDeltaFileGroup(groups, cwd, file.filePath);
|
|
800
857
|
for (const w of file.warnings) {
|
|
801
858
|
const where = w.stale ? STALE_LINE_MARKER : `L${w.line ?? "?"}`;
|
|
802
|
-
|
|
859
|
+
group.qualityLines.push(` ℹ ${where} ${w.rule ?? w.code ?? w.tool} ${w.message}`);
|
|
803
860
|
}
|
|
861
|
+
const key = normalizeMapKey(file.filePath);
|
|
862
|
+
if (reverifyIncompletePaths.has(key))
|
|
863
|
+
group.incomplete = true;
|
|
864
|
+
if (!group.staleRow) {
|
|
865
|
+
const staleWarning = file.warnings.find((w) => w.stale);
|
|
866
|
+
if (staleWarning)
|
|
867
|
+
group.staleRow = { staleAsOf: staleWarning.staleAsOf };
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
const projectDeltaCount = appendProjectDiagnosticsDeltaLines(groups, cwd, projectDelta, severity, includeFile);
|
|
872
|
+
const lines = [];
|
|
873
|
+
for (const group of groups.values()) {
|
|
874
|
+
lines.push(group.rel);
|
|
875
|
+
lines.push(...group.actionableLines);
|
|
876
|
+
lines.push(...group.qualityLines);
|
|
877
|
+
lines.push(...group.projectLines);
|
|
878
|
+
if (group.staleRow) {
|
|
879
|
+
lines.push(` (${formatCacheAgeLabel(group.staleRow.staleAsOf)})`);
|
|
804
880
|
}
|
|
881
|
+
if (group.incomplete)
|
|
882
|
+
lines.push(" (re-verify incomplete)");
|
|
805
883
|
}
|
|
806
|
-
const projectDeltaCount = appendProjectDiagnosticsDeltaLines(lines, cwd, projectDelta, severity, includeFile);
|
|
807
884
|
const selectedActionableFiles = filteredActionableFiles;
|
|
808
885
|
const selectedQualityFiles = filteredQualityFiles;
|
|
809
886
|
const aw = selectedActionableFiles.reduce((count, file) => count + file.warnings.length, 0);
|
|
@@ -1093,11 +1170,20 @@ function runnerIdOf(diagnostic) {
|
|
|
1093
1170
|
}
|
|
1094
1171
|
/**
|
|
1095
1172
|
* The one project-runner retirement decision. Coverage is authoritative when
|
|
1096
|
-
* present for the runner; the older id-only arm is used only when
|
|
1173
|
+
* present for the runner; the older id-only arm is used only when the runner
|
|
1174
|
+
* declared no coverage at all.
|
|
1175
|
+
*
|
|
1176
|
+
* #2962: an entry with an EMPTY file set is a declaration, not a missing one —
|
|
1177
|
+
* "this producer analysed the root and proved zero files". Selecting it out by
|
|
1178
|
+
* its own size (the pre-#2962 `entry.files.size > 0` filter) dropped it into the
|
|
1179
|
+
* whole-root id-only arm, so one complete opengrep report with
|
|
1180
|
+
* `paths.scanned: []` retired every retained opengrep finding in the tree even
|
|
1181
|
+
* though no file had been scanned. The id-only arm is unchanged for the eight
|
|
1182
|
+
* runners that declare no coverage.
|
|
1097
1183
|
*/
|
|
1098
1184
|
export function runnerRetirementDecision(diagnostic, filePath, authoritativeRunnerIds, authoritativeCoverage) {
|
|
1099
1185
|
const runnerId = runnerIdOf(diagnostic);
|
|
1100
|
-
const coverage = (authoritativeCoverage ?? []).filter((entry) => entry.runnerId === runnerId
|
|
1186
|
+
const coverage = (authoritativeCoverage ?? []).filter((entry) => entry.runnerId === runnerId);
|
|
1101
1187
|
if (coverage.length === 0) {
|
|
1102
1188
|
return authoritativeRunnerIds?.has(runnerId) ? "retire" : "keep";
|
|
1103
1189
|
}
|
|
@@ -1792,6 +1878,14 @@ async function formatFullMode(cwd, severity, lspService, cacheManager, options =
|
|
|
1792
1878
|
});
|
|
1793
1879
|
}
|
|
1794
1880
|
for (const entry of authoritativeRunnerCoverage) {
|
|
1881
|
+
// #2962: a zero-file declaration retires nothing, so it must neither
|
|
1882
|
+
// write this row (a `runner_coverage_retired` row with count 0 would
|
|
1883
|
+
// report the opposite of what happened) nor BURN the once-per-session
|
|
1884
|
+
// claim — the session's first real coverage row would then be suppressed
|
|
1885
|
+
// by the call that proved no coverage. Its own observation is the
|
|
1886
|
+
// `runner-coverage-empty` ledger row raised at the fresh-fetch seam.
|
|
1887
|
+
if (entry.files.size === 0)
|
|
1888
|
+
continue;
|
|
1795
1889
|
if (!claimPhaseOncePerSession("runner_coverage_retired", entry.runnerId)) {
|
|
1796
1890
|
continue;
|
|
1797
1891
|
}
|