pi-lens 3.8.69 → 3.8.71
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 +99 -0
- package/README.md +31 -13
- package/dist/clients/bootstrap.js +3 -1
- package/dist/clients/bus-events-logger.js +53 -0
- package/dist/clients/bus-publish.js +45 -2
- package/dist/clients/cache/rule-cache.js +9 -5
- package/dist/clients/cache-manager.js +19 -13
- package/dist/clients/code-quality-warnings.js +27 -0
- package/dist/clients/dead-code-client.js +5 -23
- package/dist/clients/diagnostic-dispositions.js +298 -0
- package/dist/clients/diagnostics-publish.js +38 -2
- package/dist/clients/dispatch/auxiliary-lsp.js +144 -6
- package/dist/clients/dispatch/dispatcher.js +6 -1
- package/dist/clients/dispatch/facts/function-facts.js +83 -0
- package/dist/clients/dispatch/integration.js +19 -4
- package/dist/clients/dispatch/rules/async-noise.js +1 -1
- package/dist/clients/dispatch/rules/async-unnecessary-wrapper.js +1 -1
- package/dist/clients/dispatch/rules/cors-wildcard.js +1 -1
- package/dist/clients/dispatch/rules/error-obscuring.js +2 -1
- package/dist/clients/dispatch/rules/error-swallowing.js +2 -1
- package/dist/clients/dispatch/rules/framework-call-noise.js +67 -0
- package/dist/clients/dispatch/rules/high-complexity.js +12 -1
- package/dist/clients/dispatch/rules/high-fan-out.js +14 -3
- package/dist/clients/dispatch/rules/high-import-coupling.js +1 -1
- package/dist/clients/dispatch/rules/missing-error-propagation.js +1 -1
- package/dist/clients/dispatch/rules/no-commented-credentials.js +1 -1
- package/dist/clients/dispatch/rules/pass-through-wrappers.js +1 -1
- package/dist/clients/dispatch/rules/placeholder-comments.js +1 -1
- package/dist/clients/dispatch/rules/unsafe-boundary.js +1 -1
- package/dist/clients/dispatch/runners/ast-grep-napi.js +75 -18
- package/dist/clients/dispatch/runners/lsp.js +23 -34
- package/dist/clients/dispatch/runners/oxlint.js +1 -1
- package/dist/clients/dispatch/suppress-writer.js +68 -0
- package/dist/clients/dispatch/utils/lsp-diagnostics.js +4 -1
- package/dist/clients/disposition-logger.js +39 -0
- package/dist/clients/disposition-publish.js +109 -0
- package/dist/clients/file-utils.js +94 -33
- package/dist/clients/format-events-publish.js +299 -0
- package/dist/clients/git-tracked-ignore.js +208 -0
- package/dist/clients/gitleaks-client.js +40 -5
- package/dist/clients/installer/index.js +123 -6
- package/dist/clients/instance-reaper.js +229 -2
- package/dist/clients/instance-registry.js +141 -2
- package/dist/clients/json-cache-read.js +65 -0
- package/dist/clients/knip-client.js +35 -38
- package/dist/clients/language-profile.js +11 -11
- package/dist/clients/lens-engine.js +12 -0
- package/dist/clients/lens-map.js +1079 -0
- package/dist/clients/log-cleanup.js +61 -19
- package/dist/clients/lsp/aggregation.js +69 -2
- package/dist/clients/lsp/cascade-tier.js +31 -19
- package/dist/clients/lsp/client.js +124 -14
- package/dist/clients/lsp/config.js +27 -1
- package/dist/clients/lsp/index.js +1028 -68
- package/dist/clients/lsp/server-strategies.js +17 -8
- package/dist/clients/lsp/server.js +80 -28
- package/dist/clients/lsp/tsserver-sync.js +145 -0
- package/dist/clients/lsp/workspace-diagnostics-cache.js +172 -0
- package/dist/clients/lsp-budget.js +148 -0
- package/dist/clients/mcp/session.js +9 -0
- package/dist/clients/middle-man-analysis.js +260 -0
- package/dist/clients/module-report.js +173 -11
- package/dist/clients/ndjson-logger.js +24 -0
- package/dist/clients/opengrep-client.js +217 -0
- package/dist/clients/opengrep-config.js +2 -10
- package/dist/clients/path-utils.js +66 -0
- package/dist/clients/pipeline.js +5 -1
- package/dist/clients/project-diagnostics/cache.js +5 -12
- package/dist/clients/project-diagnostics/extractors.js +21 -1
- package/dist/clients/project-diagnostics/fresh-fetch.js +275 -0
- package/dist/clients/project-diagnostics/runner-adapters/opengrep.js +40 -0
- package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +46 -0
- package/dist/clients/project-snapshot.js +3 -6
- package/dist/clients/quiet-window.js +57 -2
- package/dist/clients/read-expansion.js +2 -3
- package/dist/clients/read-guard-tool-lines.js +10 -0
- package/dist/clients/read-guard.js +1 -1
- package/dist/clients/resource-sampler.js +254 -0
- package/dist/clients/review-graph/builder.js +322 -42
- package/dist/clients/review-graph/import-resolvers.js +53 -19
- package/dist/clients/review-graph/symbol-id.js +57 -0
- package/dist/clients/runtime-agent-end.js +26 -0
- package/dist/clients/runtime-coordinator.js +9 -1
- package/dist/clients/runtime-session.js +93 -17
- package/dist/clients/runtime-tool-call.js +789 -0
- package/dist/clients/runtime-tool-result.js +15 -2
- package/dist/clients/runtime-turn.js +62 -13
- package/dist/clients/safe-spawn.js +48 -2
- package/dist/clients/session-state-store.js +6 -9
- package/dist/clients/sgconfig.js +2 -9
- package/dist/clients/source-filter.js +17 -25
- package/dist/clients/source-walker.js +66 -0
- package/dist/clients/startup-scan.js +105 -43
- package/dist/clients/symbol-containment.js +24 -0
- package/dist/clients/test-runner-client.js +544 -29
- package/dist/clients/tree-sitter-symbol-extractor.js +12 -5
- package/dist/clients/typos-config.js +2 -11
- package/dist/clients/widget-state.js +83 -1
- package/dist/clients/write-ordering-guard.js +58 -0
- package/dist/clients/zizmor-config.js +29 -10
- package/dist/index.js +25511 -19414
- package/dist/mcp/server.js +95 -19
- package/dist/tools/activate-tools.js +72 -0
- package/dist/tools/ast-dump.js +2 -8
- package/dist/tools/lens-diagnostic-mark.js +158 -0
- package/dist/tools/lens-diagnostics.js +327 -33
- package/dist/tools/lsp-diagnostics.js +525 -105
- package/dist/tools/module-report.js +18 -5
- package/docs/agent-tools.md +136 -0
- package/docs/ast-grep_rules_catalog.md +564 -0
- package/docs/astplayground.md +178 -0
- package/docs/audit1.md +177 -0
- package/docs/custom-rules.md +231 -0
- package/docs/dependencies.md +54 -0
- package/docs/dispositions.md +85 -0
- package/docs/env_variables.md +10 -0
- package/docs/environment-variables.md +83 -0
- package/docs/fable.md +140 -0
- package/docs/features.md +338 -0
- package/docs/globalconfig.md +79 -0
- package/docs/language-coverage.md +48 -0
- package/docs/lsp-capability-matrix.md +157 -0
- package/docs/lsp-latency-benchmark.md +169 -0
- package/docs/mcp.md +438 -0
- package/docs/module-report-read-symbol.md +258 -0
- package/docs/servercapabilities.md +118 -0
- package/docs/subagent-compat.md +281 -0
- package/docs/tools.md +16 -0
- package/docs/tools_improvement2.md +345 -0
- package/docs/tree-sitter_rules_catalog.md +296 -0
- package/docs/usage.md +129 -0
- package/docs/word-index.md +150 -0
- package/package.json +10 -1
- package/rules/ast-grep-rules/rule-tests/find-import-file-without-extension-test.yml +4 -0
- package/rules/ast-grep-rules/rule-tests/no-javascript-url-js-test.yml +5 -0
- package/rules/ast-grep-rules/rule-tests/no-javascript-url-test.yml +9 -0
- package/rules/ast-grep-rules/rules/array-callback-return-js.yml +1 -1
- package/rules/ast-grep-rules/rules/find-import-file-without-extension.yml +3 -1
- package/rules/ast-grep-rules/rules/jwt-no-verify-js.yml +1 -1
- package/rules/ast-grep-rules/rules/no-extra-boolean-cast-js.yml +1 -1
- package/rules/ast-grep-rules/rules/no-implied-eval-js.yml +1 -1
- package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +13 -1
- package/rules/ast-grep-rules/rules/no-javascript-url.yml +12 -0
- package/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +1 -1
- package/rules/ast-grep-rules/rules/weak-rsa-key-js.yml +1 -1
- package/skills/pi-lens-ast-grep/SKILL.md +3 -1
- package/dist/clients/production-readiness.js +0 -493
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent+user disposition layer over dispatch diagnostics (#690, unifying #181/
|
|
3
|
+
* #503/#504's discussion). Four dispositions:
|
|
4
|
+
*
|
|
5
|
+
* false-positive — the rule misfired. Project-persistent, routed nowhere
|
|
6
|
+
* special yet (telemetry hookup is a fast-follow).
|
|
7
|
+
* suppress — real finding, deliberate policy not to fix. Persistent,
|
|
8
|
+
* but the mechanism is an inline `pi-lens-ignore` comment
|
|
9
|
+
* written into the source (see suppress-writer.ts), not
|
|
10
|
+
* just a store entry — portable, git-visible, discoverable
|
|
11
|
+
* without pi-lens's own store. The store entry here is an
|
|
12
|
+
* audit-trail mirror, not the enforcement point.
|
|
13
|
+
* defer — fix later, not now. Session-ephemeral: held in memory
|
|
14
|
+
* only, so it naturally resurfaces on process restart —
|
|
15
|
+
* never persisted, never needs pruning.
|
|
16
|
+
* flagged — user wants the agent to fix this. Persistent until
|
|
17
|
+
* resolved; surfaced through the existing lens_diagnostics
|
|
18
|
+
* query (tagged), not a separate file/tool the agent has
|
|
19
|
+
* to separately poll.
|
|
20
|
+
*
|
|
21
|
+
* Anchoring: TWO flavors, chosen per-disposition because each one binds to a
|
|
22
|
+
* different thing conceptually:
|
|
23
|
+
*
|
|
24
|
+
* STRICT ("dd:" prefix) — relativeFile|tool|rule|normalizedMessage|
|
|
25
|
+
* lineContentHash(diagnostic's own line). Used ONLY for false-positive: a
|
|
26
|
+
* false-positive judgment is about THIS specific piece of code — if the
|
|
27
|
+
* line is rewritten, the rule earned a fresh chance to fire on the new
|
|
28
|
+
* content, so the mark should NOT follow it. Reuses read-guard's
|
|
29
|
+
* lineContentHash so a no-op formatter/whitespace pass doesn't rot the
|
|
30
|
+
* anchor, while a semantic edit to the flagged line correctly invalidates
|
|
31
|
+
* it.
|
|
32
|
+
* WEAK ("ddw:" prefix) — relativeFile|tool|rule|normalizedMessage, no line
|
|
33
|
+
* hash at all. Used for defer, flagged, and suppress: these are
|
|
34
|
+
* intent-level judgments ("I'll get to this", "fix this", "policy says
|
|
35
|
+
* don't") about a finding identity, not about one exact line's bytes —
|
|
36
|
+
* they must survive incidental edits elsewhere on the flagged line
|
|
37
|
+
* (reformatting, a nearby rename) without silently dropping the mark.
|
|
38
|
+
* suppress's real enforcement is the inline comment (see
|
|
39
|
+
* suppress-writer.ts) which travels with the code by construction; the
|
|
40
|
+
* weak-anchored store entry is just an audit mirror plus a second,
|
|
41
|
+
* belt-and-braces filter.
|
|
42
|
+
*
|
|
43
|
+
* Distinct prefixes ("dd:" vs "ddw:") keep the two id spaces from ever
|
|
44
|
+
* colliding in the same store.
|
|
45
|
+
*
|
|
46
|
+
* Content is hashed only from the diagnostic's own line (for the strict
|
|
47
|
+
* anchor), not a surrounding window as #181's original sketch considered.
|
|
48
|
+
* Two diagnostics on the same file/tool/rule/message whose flagged line
|
|
49
|
+
* happens to have identical content collide on the SAME strict anchor —
|
|
50
|
+
* deliberately: identical content at the same rule/message is a semantically
|
|
51
|
+
* equivalent finding, so marking one intentionally marks all of them (e.g. a
|
|
52
|
+
* copy-pasted line repeated a few times in the same file). If that
|
|
53
|
+
* assumption proves wrong in practice, a surrounding-window hash can be
|
|
54
|
+
* layered on later without changing the store shape.
|
|
55
|
+
*/
|
|
56
|
+
import { createHash } from "node:crypto";
|
|
57
|
+
import * as fs from "node:fs";
|
|
58
|
+
import * as path from "node:path";
|
|
59
|
+
import { logDispositionEvent } from "./disposition-logger.js";
|
|
60
|
+
import { publishDisposition } from "./disposition-publish.js";
|
|
61
|
+
import { getProjectDataDir } from "./file-utils.js";
|
|
62
|
+
import { normalizeMapKey } from "./path-utils.js";
|
|
63
|
+
import { lineContentHash } from "./read-guard.js";
|
|
64
|
+
// "defer" is session-ephemeral by design (#690) — held only in memory so it
|
|
65
|
+
// resurfaces for free on the next process run, with no expiry/pruning logic
|
|
66
|
+
// needed. Stores WEAK anchors (see module doc) so a deferred finding stays
|
|
67
|
+
// hidden all session even if the flagged line itself is edited.
|
|
68
|
+
const deferredThisSession = new Set();
|
|
69
|
+
function normalizeMessage(message) {
|
|
70
|
+
return message.replace(/\s+/g, " ").trim().toLowerCase();
|
|
71
|
+
}
|
|
72
|
+
function relativeFile(filePath, cwd) {
|
|
73
|
+
const rel = path.relative(cwd, filePath).replace(/\\/g, "/");
|
|
74
|
+
return rel && !rel.startsWith("..") ? rel : normalizeMapKey(filePath);
|
|
75
|
+
}
|
|
76
|
+
/** Site-specific anchor — see module doc. Used only for false-positive. */
|
|
77
|
+
export function computeStrictAnchor(args) {
|
|
78
|
+
const lines = args.content?.split(/\r?\n/);
|
|
79
|
+
const lineText = args.line !== undefined && lines ? (lines[args.line - 1] ?? "") : "";
|
|
80
|
+
const parts = [
|
|
81
|
+
relativeFile(args.filePath, args.cwd),
|
|
82
|
+
args.tool ?? "",
|
|
83
|
+
args.rule ?? "",
|
|
84
|
+
normalizeMessage(args.message),
|
|
85
|
+
lineContentHash(lineText),
|
|
86
|
+
];
|
|
87
|
+
return `dd:${createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 12)}`;
|
|
88
|
+
}
|
|
89
|
+
/** Intent-level anchor — see module doc. Used for defer/flagged/suppress. */
|
|
90
|
+
export function computeWeakAnchor(args) {
|
|
91
|
+
const parts = [
|
|
92
|
+
relativeFile(args.filePath, args.cwd),
|
|
93
|
+
args.tool ?? "",
|
|
94
|
+
args.rule ?? "",
|
|
95
|
+
normalizeMessage(args.message),
|
|
96
|
+
];
|
|
97
|
+
return `ddw:${createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 12)}`;
|
|
98
|
+
}
|
|
99
|
+
/** Both anchors a stored/filtered diagnostic would compute — the one shared
|
|
100
|
+
* derivation both the dispatch-pipeline filter and lens-diagnostics' flagged
|
|
101
|
+
* tag lookup must use so a mark and a fresh diagnostic converge on the same
|
|
102
|
+
* ids. */
|
|
103
|
+
export function anchorsForDiagnostic(cwd, filePath, diagnostic, content) {
|
|
104
|
+
const args = {
|
|
105
|
+
cwd,
|
|
106
|
+
filePath,
|
|
107
|
+
tool: diagnostic.tool,
|
|
108
|
+
rule: diagnostic.rule,
|
|
109
|
+
message: diagnostic.message,
|
|
110
|
+
line: diagnostic.line,
|
|
111
|
+
content,
|
|
112
|
+
};
|
|
113
|
+
return { strict: computeStrictAnchor(args), weak: computeWeakAnchor(args) };
|
|
114
|
+
}
|
|
115
|
+
function statePath(cwd) {
|
|
116
|
+
return path.join(getProjectDataDir(cwd), "cache", "diagnostic-dispositions.json");
|
|
117
|
+
}
|
|
118
|
+
let stateCache = null;
|
|
119
|
+
function readState(cwd) {
|
|
120
|
+
const p = statePath(cwd);
|
|
121
|
+
let stat;
|
|
122
|
+
try {
|
|
123
|
+
stat = fs.statSync(p);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
if (stateCache && stateCache.path === p && stateCache.missing) {
|
|
127
|
+
return stateCache.state;
|
|
128
|
+
}
|
|
129
|
+
const empty = {};
|
|
130
|
+
stateCache = { path: p, missing: true, mtimeMs: -1, size: -1, state: empty };
|
|
131
|
+
return empty;
|
|
132
|
+
}
|
|
133
|
+
if (stateCache &&
|
|
134
|
+
stateCache.path === p &&
|
|
135
|
+
!stateCache.missing &&
|
|
136
|
+
stateCache.mtimeMs === stat.mtimeMs &&
|
|
137
|
+
stateCache.size === stat.size) {
|
|
138
|
+
return stateCache.state;
|
|
139
|
+
}
|
|
140
|
+
let state;
|
|
141
|
+
try {
|
|
142
|
+
const parsed = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
143
|
+
state =
|
|
144
|
+
parsed && typeof parsed === "object" ? parsed : {};
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
state = {};
|
|
148
|
+
}
|
|
149
|
+
stateCache = {
|
|
150
|
+
path: p,
|
|
151
|
+
missing: false,
|
|
152
|
+
mtimeMs: stat.mtimeMs,
|
|
153
|
+
size: stat.size,
|
|
154
|
+
state,
|
|
155
|
+
};
|
|
156
|
+
return state;
|
|
157
|
+
}
|
|
158
|
+
function writeState(cwd, state) {
|
|
159
|
+
const p = statePath(cwd);
|
|
160
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
161
|
+
fs.writeFileSync(p, JSON.stringify(state, null, 2));
|
|
162
|
+
// Refresh the cache from the write we just did instead of invalidating it —
|
|
163
|
+
// avoids an immediate re-stat+re-parse of the file we already have in hand,
|
|
164
|
+
// and guards against coarse filesystem mtime granularity making a
|
|
165
|
+
// read-immediately-after-write look like a cache hit on stale data.
|
|
166
|
+
const stat = fs.statSync(p);
|
|
167
|
+
stateCache = {
|
|
168
|
+
path: p,
|
|
169
|
+
missing: false,
|
|
170
|
+
mtimeMs: stat.mtimeMs,
|
|
171
|
+
size: stat.size,
|
|
172
|
+
state,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/** Test-only escape hatch — the state cache is module-level, so tests that
|
|
176
|
+
* write the store file out-of-band (or across separate cwds sharing a stat
|
|
177
|
+
* coincidence) need to reset it between cases. */
|
|
178
|
+
export function _resetStateCacheForTests() {
|
|
179
|
+
stateCache = null;
|
|
180
|
+
}
|
|
181
|
+
/** Fire-and-forget mark telemetry (see markDisposition's doc): the NDJSON log
|
|
182
|
+
* entry (project-relative path — rule-tuning data, not machine layout) and
|
|
183
|
+
* the bus event (absolute normalized path — in-process consumers navigate).
|
|
184
|
+
* Neither can throw into the mark path; both are already internally
|
|
185
|
+
* fail-safe, but the try/catch keeps a future regression in either from
|
|
186
|
+
* breaking a mark. */
|
|
187
|
+
function emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing) {
|
|
188
|
+
try {
|
|
189
|
+
logDispositionEvent({
|
|
190
|
+
event: "mark",
|
|
191
|
+
disposition,
|
|
192
|
+
tool: target.tool,
|
|
193
|
+
rule: target.rule,
|
|
194
|
+
filePath: relativeFile(target.filePath, cwd),
|
|
195
|
+
line: target.line,
|
|
196
|
+
reason,
|
|
197
|
+
anchor,
|
|
198
|
+
previousDisposition: existing?.disposition,
|
|
199
|
+
});
|
|
200
|
+
publishDisposition({
|
|
201
|
+
cwd,
|
|
202
|
+
filePath: target.filePath,
|
|
203
|
+
disposition,
|
|
204
|
+
tool: target.tool,
|
|
205
|
+
rule: target.rule,
|
|
206
|
+
line: target.line,
|
|
207
|
+
anchor,
|
|
208
|
+
reason,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// never let telemetry break a mark
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Record a disposition. Picks the anchor flavor per-disposition (see module
|
|
217
|
+
* doc): strict for false-positive, weak for everything else. Returns the
|
|
218
|
+
* anchor actually used, so callers (the mark tool) can report/verify it.
|
|
219
|
+
*
|
|
220
|
+
* This is THE single choke point for mark telemetry — the NDJSON log
|
|
221
|
+
* (disposition-logger.ts, #181's FP-rule-tuning signal) and the
|
|
222
|
+
* `pilens:diagnostic:disposition` bus event (disposition-publish.ts) both
|
|
223
|
+
* hang off it, so the agent tool and any future UI caller are covered without
|
|
224
|
+
* per-caller wiring.
|
|
225
|
+
*/
|
|
226
|
+
export function markDisposition(cwd, target, disposition, reason) {
|
|
227
|
+
const anchor = disposition === "false-positive"
|
|
228
|
+
? computeStrictAnchor(target)
|
|
229
|
+
: computeWeakAnchor(target);
|
|
230
|
+
// Captured for BOTH branches: a defer never writes the store, but a store
|
|
231
|
+
// entry can already exist at the same weak anchor (a prior flagged/suppress
|
|
232
|
+
// mark) — the log should record what this mark shadowed either way.
|
|
233
|
+
const existing = readState(cwd).dispositions?.[anchor];
|
|
234
|
+
emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing);
|
|
235
|
+
if (disposition === "defer") {
|
|
236
|
+
deferredThisSession.add(anchor);
|
|
237
|
+
return anchor;
|
|
238
|
+
}
|
|
239
|
+
const state = readState(cwd);
|
|
240
|
+
state.dispositions ??= {};
|
|
241
|
+
const now = new Date().toISOString();
|
|
242
|
+
const capturesFixContext = disposition === "flagged";
|
|
243
|
+
const lineText = capturesFixContext
|
|
244
|
+
? (target.content?.split(/\r?\n/)[target.line !== undefined ? target.line - 1 : -1] ?? existing?.lineText)?.trim()
|
|
245
|
+
: existing?.lineText;
|
|
246
|
+
state.dispositions[anchor] = {
|
|
247
|
+
disposition,
|
|
248
|
+
reason: reason ?? existing?.reason,
|
|
249
|
+
createdAt: existing?.createdAt ?? now,
|
|
250
|
+
lastSeenAt: now,
|
|
251
|
+
line: capturesFixContext ? (target.line ?? existing?.line) : existing?.line,
|
|
252
|
+
lineText,
|
|
253
|
+
};
|
|
254
|
+
writeState(cwd, state);
|
|
255
|
+
return anchor;
|
|
256
|
+
}
|
|
257
|
+
export function getDisposition(cwd, anchor) {
|
|
258
|
+
return readState(cwd).dispositions?.[anchor];
|
|
259
|
+
}
|
|
260
|
+
export function isDeferredThisSession(anchor) {
|
|
261
|
+
return deferredThisSession.has(anchor);
|
|
262
|
+
}
|
|
263
|
+
/** Test-only escape hatch — defer state is module-level (one process = one
|
|
264
|
+
* session), so tests need to reset it between cases. */
|
|
265
|
+
export function _resetDeferredForTests() {
|
|
266
|
+
deferredThisSession.clear();
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Drop diagnostics disposed false-positive/suppress, or deferred this session,
|
|
270
|
+
* from `diagnostics`. `flagged` diagnostics are kept as-is — callers that want
|
|
271
|
+
* to surface the flag (e.g. lens_diagnostics' rendering) look it up separately
|
|
272
|
+
* via getDisposition on the WEAK anchor (anchorsForDiagnostic(...).weak).
|
|
273
|
+
*
|
|
274
|
+
* Computes both anchors per diagnostic (cheap — same hash primitive, twice)
|
|
275
|
+
* since false-positive is keyed strict while defer/suppress are keyed weak;
|
|
276
|
+
* see module doc for why each disposition binds the way it does.
|
|
277
|
+
*/
|
|
278
|
+
export function applyDispositions(diagnostics, cwd, filePath, content) {
|
|
279
|
+
if (!diagnostics.length)
|
|
280
|
+
return diagnostics;
|
|
281
|
+
const dispositions = readState(cwd).dispositions;
|
|
282
|
+
if (!dispositions && deferredThisSession.size === 0)
|
|
283
|
+
return diagnostics;
|
|
284
|
+
return diagnostics.filter((d) => {
|
|
285
|
+
const { strict, weak } = anchorsForDiagnostic(cwd, filePath, d, content);
|
|
286
|
+
if (deferredThisSession.has(weak))
|
|
287
|
+
return false;
|
|
288
|
+
if (dispositions?.[strict]?.disposition === "false-positive")
|
|
289
|
+
return false;
|
|
290
|
+
// Belt-and-braces: the inline `pi-lens-ignore` comment is the real
|
|
291
|
+
// suppress enforcement (see suppress-writer.ts) and normally already
|
|
292
|
+
// dropped this finding upstream via applyInlineSuppressions. This is a
|
|
293
|
+
// harmless second cover for the store-only audit trail case.
|
|
294
|
+
if (dispositions?.[weak]?.disposition === "suppress")
|
|
295
|
+
return false;
|
|
296
|
+
return true;
|
|
297
|
+
});
|
|
298
|
+
}
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
* fields may be added under `v: 1`; a breaking change to an existing field's
|
|
60
60
|
* meaning must bump `v`.
|
|
61
61
|
*/
|
|
62
|
+
import { logBusEvent } from "./bus-events-logger.js";
|
|
62
63
|
import { normalizeFilePath } from "./path-utils.js";
|
|
63
64
|
import { isBusPublishEnabled } from "./bus-publish.js";
|
|
64
65
|
export const BUS_DIAGNOSTICS_EVENT = "pilens:diagnostics";
|
|
@@ -67,6 +68,8 @@ export const BUS_DIAGNOSTICS_VERSION = 1;
|
|
|
67
68
|
export const MAX_DIAGNOSTICS_PER_FILE_EVENT = 12;
|
|
68
69
|
let busEmit;
|
|
69
70
|
let hasLoggedFailure = false;
|
|
71
|
+
let hasLoggedUnwired = false;
|
|
72
|
+
let hasLoggedDisabled = false;
|
|
70
73
|
let seqCounter = 0;
|
|
71
74
|
/** Paths this producer has reported with at least one non-empty diagnostics array, so we know when to fire the one-time clean-transition event. */
|
|
72
75
|
const reportedDirtyPaths = new Set();
|
|
@@ -82,6 +85,8 @@ export function wireDiagnosticsBusEmitter(emitFn) {
|
|
|
82
85
|
export function _resetDiagnosticsPublishForTests() {
|
|
83
86
|
busEmit = undefined;
|
|
84
87
|
hasLoggedFailure = false;
|
|
88
|
+
hasLoggedUnwired = false;
|
|
89
|
+
hasLoggedDisabled = false;
|
|
85
90
|
seqCounter = 0;
|
|
86
91
|
reportedDirtyPaths.clear();
|
|
87
92
|
}
|
|
@@ -129,10 +134,28 @@ export function publishDiagnostics(args) {
|
|
|
129
134
|
return;
|
|
130
135
|
if (args.files.length === 0)
|
|
131
136
|
return;
|
|
132
|
-
if (!isBusPublishEnabled())
|
|
137
|
+
if (!isBusPublishEnabled()) {
|
|
138
|
+
if (!hasLoggedDisabled) {
|
|
139
|
+
hasLoggedDisabled = true;
|
|
140
|
+
logBusEvent({
|
|
141
|
+
event: BUS_DIAGNOSTICS_EVENT,
|
|
142
|
+
outcome: "skipped_disabled",
|
|
143
|
+
cwd: normalizeFilePath(args.cwd),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
133
146
|
return;
|
|
134
|
-
|
|
147
|
+
}
|
|
148
|
+
if (!busEmit) {
|
|
149
|
+
if (!hasLoggedUnwired) {
|
|
150
|
+
hasLoggedUnwired = true;
|
|
151
|
+
logBusEvent({
|
|
152
|
+
event: BUS_DIAGNOSTICS_EVENT,
|
|
153
|
+
outcome: "skipped_unwired",
|
|
154
|
+
cwd: normalizeFilePath(args.cwd),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
135
157
|
return;
|
|
158
|
+
}
|
|
136
159
|
try {
|
|
137
160
|
const fileEntries = args.files.map((f) => {
|
|
138
161
|
const normPath = normalizeFilePath(f.path);
|
|
@@ -161,8 +184,21 @@ export function publishDiagnostics(args) {
|
|
|
161
184
|
files: fileEntries,
|
|
162
185
|
};
|
|
163
186
|
busEmit(BUS_DIAGNOSTICS_EVENT, payload);
|
|
187
|
+
logBusEvent({
|
|
188
|
+
event: BUS_DIAGNOSTICS_EVENT,
|
|
189
|
+
outcome: "emitted",
|
|
190
|
+
cwd: payload.cwd,
|
|
191
|
+
fileCount: payload.files.length,
|
|
192
|
+
seq: payload.seq,
|
|
193
|
+
});
|
|
164
194
|
}
|
|
165
195
|
catch (err) {
|
|
196
|
+
logBusEvent({
|
|
197
|
+
event: BUS_DIAGNOSTICS_EVENT,
|
|
198
|
+
outcome: "emit_failed",
|
|
199
|
+
cwd: normalizeFilePath(args.cwd),
|
|
200
|
+
error: String(err),
|
|
201
|
+
});
|
|
166
202
|
if (!hasLoggedFailure) {
|
|
167
203
|
hasLoggedFailure = true;
|
|
168
204
|
args.dbg?.(`diagnostics-publish: pilens:diagnostics emit failed (further failures suppressed): ${err}`);
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* Adding a new cross-cutting tool = register an `LSPServerInfo` with
|
|
17
17
|
* `role:"auxiliary"` (clients/lsp/server.ts) + one profile entry here.
|
|
18
18
|
*/
|
|
19
|
+
import { shouldDegradeAuxiliaryLsp } from "../lsp-budget.js";
|
|
20
|
+
import { isSubagentSession } from "../subagent-mode.js";
|
|
19
21
|
import { findLocalOpengrepConfig } from "../opengrep-config.js";
|
|
20
22
|
import { findLocalTyposConfig } from "../typos-config.js";
|
|
21
23
|
import { findLocalZizmorConfig } from "../zizmor-config.js";
|
|
@@ -56,6 +58,11 @@ export function isNosemgrepSuppressed(d, content) {
|
|
|
56
58
|
return (checkLine(lines[startLine], false) ||
|
|
57
59
|
checkLine(lines[startLine - 1], true));
|
|
58
60
|
}
|
|
61
|
+
// #277 R7: every profile below used this exact same rule (ERROR-severity
|
|
62
|
+
// findings block only when the workspace opted into curated/authored rules;
|
|
63
|
+
// everything else stays advisory) — shared here instead of copy-pasted per
|
|
64
|
+
// profile so a future policy change (e.g. a WARNING-blocking tier) is one edit.
|
|
65
|
+
const blockOnErrorWhenAllowed = (d, { blockingAllowed }) => (blockingAllowed && d.severity === 1 ? "blocking" : "warning");
|
|
59
66
|
export const AUXILIARY_LSP_PROFILES = [
|
|
60
67
|
{
|
|
61
68
|
serverId: "opengrep",
|
|
@@ -71,7 +78,7 @@ export const AUXILIARY_LSP_PROFILES = [
|
|
|
71
78
|
// supplies its own curated rules (the author's deliberate severity); the
|
|
72
79
|
// auto set is advisory. Either way, findings surface via lens_diagnostics.
|
|
73
80
|
allowBlocking: (cwd) => Boolean(findLocalOpengrepConfig(cwd)),
|
|
74
|
-
semantic:
|
|
81
|
+
semantic: blockOnErrorWhenAllowed,
|
|
75
82
|
defectClass: (d) => classifyDefect(String(d.code ?? ""), "opengrep", d.message ?? ""),
|
|
76
83
|
// Honor the canonical Semgrep suppression the user already knows (#441).
|
|
77
84
|
isSuppressed: isNosemgrepSuppressed,
|
|
@@ -83,12 +90,17 @@ export const AUXILIARY_LSP_PROFILES = [
|
|
|
83
90
|
sourceMatch: /ast[-_]?grep/i,
|
|
84
91
|
killSwitchFlag: "no-ast-grep",
|
|
85
92
|
enabledByDefault: true,
|
|
93
|
+
// Matches the in-process ast-grep-napi runner's own `skipTestFiles: true`
|
|
94
|
+
// (#687) — that flag stops applying the moment the ast-grep binary is
|
|
95
|
+
// present, since the napi runner then skips entirely in favor of this
|
|
96
|
+
// LSP surface, which had no test-file gating of its own.
|
|
97
|
+
skipTestFiles: true,
|
|
86
98
|
// The ast-grep LSP runs either the repo's own sgconfig (when present) or
|
|
87
99
|
// pi-lens's shipped baseline sgconfig. In both cases the rule severity is
|
|
88
100
|
// deliberate, so preserve ast-grep's severity semantics: ERROR can block,
|
|
89
101
|
// WARNING/INFO stay advisory.
|
|
90
102
|
allowBlocking: () => true,
|
|
91
|
-
semantic:
|
|
103
|
+
semantic: blockOnErrorWhenAllowed,
|
|
92
104
|
defectClass: (d) => classifyDefect(String(d.code ?? ""), "ast-grep", d.message ?? ""),
|
|
93
105
|
},
|
|
94
106
|
{
|
|
@@ -104,7 +116,7 @@ export const AUXILIARY_LSP_PROFILES = [
|
|
|
104
116
|
// ignores). Advisory otherwise — findings still surface via lens_diagnostics.
|
|
105
117
|
// zizmor maps High→ERROR(1), Medium/Low→WARNING(2), Informational→INFO(3).
|
|
106
118
|
allowBlocking: (cwd) => Boolean(findLocalZizmorConfig(cwd)),
|
|
107
|
-
semantic:
|
|
119
|
+
semantic: blockOnErrorWhenAllowed,
|
|
108
120
|
defectClass: (d) => classifyDefect(String(d.code ?? ""), "zizmor", d.message ?? ""),
|
|
109
121
|
},
|
|
110
122
|
{
|
|
@@ -122,23 +134,149 @@ export const AUXILIARY_LSP_PROFILES = [
|
|
|
122
134
|
// severity is WARNING, so even with a config it stays advisory unless the
|
|
123
135
|
// repo raises `diagnostic-severity` to Error.
|
|
124
136
|
allowBlocking: (cwd) => Boolean(findLocalTyposConfig(cwd)),
|
|
125
|
-
semantic:
|
|
137
|
+
semantic: blockOnErrorWhenAllowed,
|
|
126
138
|
// A misspelling is a documentation/quality defect — not security or
|
|
127
139
|
// correctness. "style" is the closest taxonomy class.
|
|
128
140
|
defectClass: () => "style",
|
|
129
141
|
},
|
|
130
142
|
];
|
|
131
143
|
/** The auxiliary server ids enabled for this turn (the lsp runner passes these
|
|
132
|
-
* to `touchFile` since it — not the LSP service — owns flag access).
|
|
144
|
+
* to `touchFile` since it — not the LSP service — owns flag access).
|
|
145
|
+
*
|
|
146
|
+
* #449 slice 2 (prototype): when this process decided at `session_start` that
|
|
147
|
+
* the machine-wide LSP budget is exceeded (`clients/lsp-budget.ts`), auxiliary
|
|
148
|
+
* servers are skipped entirely for the rest of the session — the primary
|
|
149
|
+
* language server per file is unaffected. This is a per-SESSION degrade, not
|
|
150
|
+
* per-file: once over budget, this session never spawns its auxiliary fleet,
|
|
151
|
+
* rather than flip-flopping file to file. */
|
|
133
152
|
export function enabledAuxiliaryLspServerIds(getFlag) {
|
|
153
|
+
// #449 slice 2 (budget): skip auxiliaries when machine-wide LSP budget is
|
|
154
|
+
// exceeded. #713 (subagent light mode): reuse the same seam — a subagent
|
|
155
|
+
// session also skips auxiliaries; the parent session already runs them on
|
|
156
|
+
// the same cwd. PI_LENS_SUBAGENT_FULL=1 restores full behavior via
|
|
157
|
+
// isSubagentSession() returning false.
|
|
158
|
+
if (shouldDegradeAuxiliaryLsp() || isSubagentSession())
|
|
159
|
+
return [];
|
|
134
160
|
return AUXILIARY_LSP_PROFILES.flatMap((p) => p.enabledByDefault &&
|
|
135
161
|
!(p.killSwitchFlag && getFlag(p.killSwitchFlag) === true)
|
|
136
162
|
? [p.serverId]
|
|
137
163
|
: []);
|
|
138
164
|
}
|
|
165
|
+
// #277 R7: `findAuxiliaryProfileForSource` is called once per diagnostic, and a
|
|
166
|
+
// file's diagnostics are typically dominated by a handful of distinct `source`
|
|
167
|
+
// strings (one per tool that fired). Memoizing by exact `source` turns an
|
|
168
|
+
// O(profiles) regex scan per diagnostic into one scan per distinct source seen
|
|
169
|
+
// — safe because `AUXILIARY_LSP_PROFILES` is a fixed module-level const, never
|
|
170
|
+
// mutated at runtime, so a source's matching profile never changes.
|
|
171
|
+
const profileForSourceCache = new Map();
|
|
139
172
|
/** Find the profile whose server emitted a diagnostic with this `source`. */
|
|
140
173
|
export function findAuxiliaryProfileForSource(source) {
|
|
141
174
|
if (!source)
|
|
142
175
|
return undefined;
|
|
143
|
-
|
|
176
|
+
const cached = profileForSourceCache.get(source);
|
|
177
|
+
if (cached !== undefined || profileForSourceCache.has(source))
|
|
178
|
+
return cached;
|
|
179
|
+
const found = AUXILIARY_LSP_PROFILES.find((p) => p.sourceMatch.test(source));
|
|
180
|
+
profileForSourceCache.set(source, found);
|
|
181
|
+
return found;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Single-diagnostic suppression check (#586): look up the diagnostic's
|
|
185
|
+
* auxiliary profile by `source` and, if that profile declares an
|
|
186
|
+
* `isSuppressed` callback (currently only opengrep's `# nosemgrep`, #441),
|
|
187
|
+
* apply it. Returns false for diagnostics with no matching profile or whose
|
|
188
|
+
* profile has no native suppression syntax — the common case for plain
|
|
189
|
+
* language-server diagnostics.
|
|
190
|
+
*
|
|
191
|
+
* This is the ONE lookup+apply implementation; every call site that decides
|
|
192
|
+
* whether to drop a diagnostic for its tool's own inline suppression comment
|
|
193
|
+
* should go through this (or `applyAuxiliarySuppressions` below) rather than
|
|
194
|
+
* re-deriving the profile lookup.
|
|
195
|
+
*/
|
|
196
|
+
export function isAuxiliaryDiagnosticSuppressed(d, content) {
|
|
197
|
+
const profile = findAuxiliaryProfileForSource(d.source);
|
|
198
|
+
return Boolean(profile?.isSuppressed?.(d, content));
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Filter a diagnostic list down to the ones NOT suppressed by their
|
|
202
|
+
* auxiliary profile's native inline-comment syntax (#586). This is the
|
|
203
|
+
* shared helper `tools/lsp-diagnostics.ts` and `clients/lsp/index.ts`'s
|
|
204
|
+
* `runWorkspaceDiagnostics` use so a `// nosemgrep` (or any future profile's
|
|
205
|
+
* equivalent) suppresses a finding identically whether it's seen via the
|
|
206
|
+
* per-edit dispatch runner or a standalone diagnostics query — previously
|
|
207
|
+
* only the former honored it (#586).
|
|
208
|
+
*
|
|
209
|
+
* #692: `opts.fileRole` additionally drops a diagnostic whose auxiliary
|
|
210
|
+
* profile declares `skipTestFiles` (e.g. ast-grep, #687) when the file is a
|
|
211
|
+
* test file — the per-edit dispatch runner (`clients/dispatch/runners/lsp.ts`)
|
|
212
|
+
* has applied this gate since #687/#688 via its own inline check; the
|
|
213
|
+
* `runWorkspaceDiagnostics` workspace sweep (`clients/lsp/index.ts`) called
|
|
214
|
+
* this function WITHOUT ever passing a fileRole, so ast-grep findings on test
|
|
215
|
+
* files that are suppressed per-edit reappeared wholesale in every
|
|
216
|
+
* `mode=full` sweep. Omitting `opts` (or `opts.fileRole`) keeps every existing
|
|
217
|
+
* 2-arg call site's behavior byte-for-byte unchanged.
|
|
218
|
+
*/
|
|
219
|
+
export function applyAuxiliarySuppressions(diagnostics, content, opts) {
|
|
220
|
+
return diagnostics.filter((d) => {
|
|
221
|
+
if (isAuxiliaryDiagnosticSuppressed(d, content))
|
|
222
|
+
return false;
|
|
223
|
+
if (opts?.fileRole === "test") {
|
|
224
|
+
const profile = findAuxiliaryProfileForSource(d.source);
|
|
225
|
+
if (profile?.skipTestFiles)
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
return true;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* #692: shared aux re-tag implementation — extracted from the per-edit
|
|
233
|
+
* dispatch runner (`clients/dispatch/runners/lsp.ts`) so a scan/sweep path
|
|
234
|
+
* that reconciles LSP diagnostics into widget state (`lens_diagnostics
|
|
235
|
+
* mode=full`, `lsp_diagnostics`) gives its auxiliary-sourced findings
|
|
236
|
+
* (ast-grep, opengrep, zizmor, typos) the SAME tool re-tag, semantic policy,
|
|
237
|
+
* and defect-class classification the per-edit path always has — previously
|
|
238
|
+
* only the per-edit runner ran this loop, so a scan-reconciled aux finding
|
|
239
|
+
* kept `tool: "lsp"` and lost its curated-repo-rules `blockingAllowed`
|
|
240
|
+
* context entirely (#692).
|
|
241
|
+
*
|
|
242
|
+
* `diagnostics` and `rawLspDiags` must be the SAME length and index-aligned —
|
|
243
|
+
* `convertLspDiagnostics` maps its input 1:1, so this holds for every caller
|
|
244
|
+
* that passes it the same array it just converted. Mutates and returns the
|
|
245
|
+
* SURVIVING subset of `diagnostics` (native-inline-suppressed and
|
|
246
|
+
* `skipTestFiles`-dropped entries removed), mirroring the per-edit runner's
|
|
247
|
+
* prior inline behavior exactly.
|
|
248
|
+
*/
|
|
249
|
+
export function retagAuxiliaryDiagnostics(diagnostics, rawLspDiags, content, ctx) {
|
|
250
|
+
const blockingAllowedByProfile = new Map();
|
|
251
|
+
const suppressedIndices = new Set();
|
|
252
|
+
for (let i = 0; i < diagnostics.length; i++) {
|
|
253
|
+
const profile = findAuxiliaryProfileForSource(rawLspDiags[i]?.source);
|
|
254
|
+
if (!profile)
|
|
255
|
+
continue;
|
|
256
|
+
if (profile.skipTestFiles && ctx.fileRole === "test") {
|
|
257
|
+
suppressedIndices.add(i);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (isAuxiliaryDiagnosticSuppressed(rawLspDiags[i], content)) {
|
|
261
|
+
suppressedIndices.add(i);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
let blockingAllowed = blockingAllowedByProfile.get(profile);
|
|
265
|
+
if (blockingAllowed === undefined) {
|
|
266
|
+
blockingAllowed = profile.allowBlocking?.(ctx.cwd) ?? false;
|
|
267
|
+
blockingAllowedByProfile.set(profile, blockingAllowed);
|
|
268
|
+
}
|
|
269
|
+
const d = diagnostics[i];
|
|
270
|
+
d.tool = profile.tool;
|
|
271
|
+
d.semantic = profile.semantic(rawLspDiags[i], { blockingAllowed });
|
|
272
|
+
if (d.semantic !== "blocking" && d.severity === "error") {
|
|
273
|
+
d.severity = "warning";
|
|
274
|
+
}
|
|
275
|
+
const defectClass = profile.defectClass?.(rawLspDiags[i]);
|
|
276
|
+
if (defectClass)
|
|
277
|
+
d.defectClass = defectClass;
|
|
278
|
+
}
|
|
279
|
+
return suppressedIndices.size
|
|
280
|
+
? diagnostics.filter((_, i) => !suppressedIndices.has(i))
|
|
281
|
+
: diagnostics;
|
|
144
282
|
}
|
|
@@ -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 { applyDispositions } from "../diagnostic-dispositions.js";
|
|
30
31
|
import { applyInlineSuppressions } from "./inline-suppressions.js";
|
|
31
32
|
import { getToolPlan } from "./plan.js";
|
|
32
33
|
import { resolveRunnerPath } from "./runner-context.js";
|
|
@@ -559,7 +560,11 @@ export async function dispatchForFile(ctx, groups, registry, onRunnerResult) {
|
|
|
559
560
|
const overlapSuppressed = suppressLintOverlapsWithLsp(dockerOverlapSuppressed);
|
|
560
561
|
const fileContent = ctx.facts.getFileFact(ctx.filePath, "file.content") ?? "";
|
|
561
562
|
const inlineSuppressed = applyInlineSuppressions(overlapSuppressed, fileContent);
|
|
562
|
-
|
|
563
|
+
// #690: agent/user disposition layer — drop false-positive/suppress marks
|
|
564
|
+
// and anything deferred this session. flagged marks are left in place;
|
|
565
|
+
// lens_diagnostics tags them at render time via the same anchor.
|
|
566
|
+
const dispositionFiltered = applyDispositions(inlineSuppressed, ctx.cwd, ctx.filePath, fileContent);
|
|
567
|
+
let visibleDiagnostics = dispositionFiltered;
|
|
563
568
|
let resolvedCount = 0;
|
|
564
569
|
if (ctx.deltaMode && previousBaseline) {
|
|
565
570
|
const filtered = filterDelta(visibleDiagnostics, previousBaseline, (d) => d.id);
|