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
|
@@ -7,6 +7,7 @@ import { logLatency } from "./latency-logger.js";
|
|
|
7
7
|
import { touchCoverageGap } from "./lsp/diagnostic-binding.js";
|
|
8
8
|
import { loadLspService } from "./lsp-lazy.js";
|
|
9
9
|
import { contentHash, createWarmIpcLineReader, diagnosticsIpcPathForCwd, requestWarmCodeActions, requestWarmDiagnostics, WARM_CODE_ACTION_LOOKUP_LIMIT, WARM_DIAGNOSTICS_SCHEMA_VERSION, } from "./mcp/ipc.js";
|
|
10
|
+
import { incrementDegradationCount, recordDegradationOnce, } from "./degradation-ledger.js";
|
|
10
11
|
import { normalizeFilePath } from "./path-utils.js";
|
|
11
12
|
const state = { local: true, servedDiagnosticHashes: new Map() };
|
|
12
13
|
function record(event, cwd, reason, pid, source) {
|
|
@@ -138,6 +139,33 @@ function startServer(cwd) {
|
|
|
138
139
|
}
|
|
139
140
|
const server = net.createServer((socket) => {
|
|
140
141
|
socket.setEncoding("utf8");
|
|
142
|
+
// #3389: an ACCEPTED socket with no `error` listener rethrows, and
|
|
143
|
+
// `server.on("error")` below covers only the listener. A client that walks
|
|
144
|
+
// away while this incumbent is answering — every `requestWarmDiagnostics`
|
|
145
|
+
// timeout, schema refusal and validation refusal destroys its socket
|
|
146
|
+
// (`clients/mcp/ipc.ts:312`) — therefore turned a routine
|
|
147
|
+
// `read ECONNRESET` into an uncaught exception in THIS host, the same
|
|
148
|
+
// ending #3375 closed for an unbounded `data` handler. The handler does NOT
|
|
149
|
+
// tear the connection down: a real system failure has already destroyed the
|
|
150
|
+
// stream before this runs (measured: `destroyed` is true at entry), and an
|
|
151
|
+
// `error` emitted with no errno leaves a socket that is still answering
|
|
152
|
+
// (review round 1) — destroying that one would drop a live request. So the
|
|
153
|
+
// handler exists to keep the event catchable and to leave the peer's
|
|
154
|
+
// behavior visible in the ledger.
|
|
155
|
+
socket.on("error", (failure) => {
|
|
156
|
+
incrementDegradationCount({
|
|
157
|
+
// Subject is the errno, so one peer's repeated resets stay one row.
|
|
158
|
+
// No `?? "unknown"` fallback: `normalizeForLedger`
|
|
159
|
+
// (`clients/ledger-bounds.ts:29`) already maps a missing value to
|
|
160
|
+
// `"unknown"`, and a second copy of that rule here was a branch no
|
|
161
|
+
// mutation could red (#3389 review round 1, F3395-01) — an error
|
|
162
|
+
// emitted with no `code` is pinned end-to-end in
|
|
163
|
+
// `tests/clients/warm-attach-socket-error.test.ts`.
|
|
164
|
+
kind: "warm-attach-socket-error",
|
|
165
|
+
subject: failure.code,
|
|
166
|
+
reason: String(failure),
|
|
167
|
+
});
|
|
168
|
+
});
|
|
141
169
|
// One-shot per connection (#1219 family): the same defect shape as the
|
|
142
170
|
// MCP warm socket — clients write exactly one request and read one
|
|
143
171
|
// reply, so a handler that kept re-reading the same buffered line
|
|
@@ -153,6 +181,11 @@ function startServer(cwd) {
|
|
|
153
181
|
socket.end(`${JSON.stringify({ error: String(error) })}\n`);
|
|
154
182
|
}
|
|
155
183
|
})();
|
|
184
|
+
}, {
|
|
185
|
+
label: "warm-attach-server",
|
|
186
|
+
// #3383: an unterminated request line is answered like any other
|
|
187
|
+
// unusable request rather than buffered without a ceiling.
|
|
188
|
+
onOverflow: () => socket.end(`${JSON.stringify({ error: "warm request line exceeded the framing limit" })}\n`),
|
|
156
189
|
}));
|
|
157
190
|
});
|
|
158
191
|
server.on("error", (error) => {
|
|
@@ -191,6 +224,20 @@ export async function tryWarmAttachedDiagnostics(file, content, timeoutMs, sourc
|
|
|
191
224
|
}
|
|
192
225
|
const result = await requestWarmDiagnostics(state.cwd, state.incumbentPid, file, content, timeoutMs);
|
|
193
226
|
if (!result.available) {
|
|
227
|
+
// #3255 H1: a confirmed-alive incumbent whose endpoint has no listener is
|
|
228
|
+
// the upgrade-stranded shape — narrowing the workspace-id case fold
|
|
229
|
+
// renamed the socket, so a peer that registered before the upgrade is
|
|
230
|
+
// still serving the previous name. `promoteToLocal` alone left that in the
|
|
231
|
+
// latency log only; this is the entry `/lens-perf` renders. Bounded twice
|
|
232
|
+
// over: once per kind/subject by the ledger, and once per session by
|
|
233
|
+
// `promoteToLocal`'s latch, which returns early on every later call.
|
|
234
|
+
if (result.reason === "no-listener") {
|
|
235
|
+
recordDegradationOnce({
|
|
236
|
+
kind: "warm-ipc-endpoint-missing",
|
|
237
|
+
subject: diagnosticsIpcPathForCwd(state.cwd, state.incumbentPid),
|
|
238
|
+
reason: "the registry confirms this incumbent is alive but nothing is listening on its derived endpoint — if pi-lens was upgraded while it was running, restart it",
|
|
239
|
+
});
|
|
240
|
+
}
|
|
194
241
|
promoteToLocal(result.reason);
|
|
195
242
|
}
|
|
196
243
|
else {
|
|
@@ -231,6 +278,18 @@ export function _resetWarmAttachForTests() {
|
|
|
231
278
|
state.local = true;
|
|
232
279
|
state.servedDiagnosticHashes.clear();
|
|
233
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* The live diagnostics server, exposed for tests (#3389 review round 1,
|
|
283
|
+
* F3395-01). The `error` handler on an ACCEPTED socket has exactly one input
|
|
284
|
+
* channel — an `error` event on that socket — and a test cannot reach the
|
|
285
|
+
* socket any other way: it is created by `net.createServer` inside the closure
|
|
286
|
+
* above, and Node's ESM namespace for `node:net` is not configurable, so the
|
|
287
|
+
* factory cannot be wrapped from a test either. A test observes the server's
|
|
288
|
+
* own `connection` event through this accessor; nothing in production reads it.
|
|
289
|
+
*/
|
|
290
|
+
export function _warmAttachServerForTests() {
|
|
291
|
+
return state.server;
|
|
292
|
+
}
|
|
234
293
|
export function _setWarmAttachForTests(cwd, incumbentPid) {
|
|
235
294
|
state.cwd = path.resolve(cwd);
|
|
236
295
|
state.incumbentPid = incumbentPid;
|
|
@@ -9,10 +9,11 @@ import { normalizeEphemeralMapKey, normalizeMapKey } from "./path-utils.js";
|
|
|
9
9
|
import { fitLine } from "./tui-fit.js";
|
|
10
10
|
import { WriteOrderingGuard } from "./write-ordering-guard.js";
|
|
11
11
|
import { collectForwardImportMtimes } from "./blocker-freshness.js";
|
|
12
|
+
import { normalizeMessage } from "./finding-identity.js";
|
|
12
13
|
import { freshnessFromMtime } from "./freshness.js";
|
|
13
14
|
import { PAST_EOF_STALE_MARKER } from "./diagnostic-line-freshness.js";
|
|
14
15
|
import { STALE_LINE_MARKER } from "./stale-marker.js";
|
|
15
|
-
import { anchorsForDiagnostic, applyDispositions, getDisposition, registerWidgetDispositionReconciler, } from "./diagnostic-dispositions.js";
|
|
16
|
+
import { anchorsForDiagnostic, applyDispositions, getDisposition, registerWidgetDispositionReconciler, strictAnchorSpan, strictAnchorSpansIn, } from "./diagnostic-dispositions.js";
|
|
16
17
|
import { recordDegradationOnce } from "./degradation-ledger.js";
|
|
17
18
|
/**
|
|
18
19
|
* Canonical key for the `files` map (and `diagnosticsWriteGuard`) — #1020.
|
|
@@ -49,6 +50,19 @@ function fileMapKey(filePath) {
|
|
|
49
50
|
* from what the footer counts.
|
|
50
51
|
*/
|
|
51
52
|
export function isBlocking(d) {
|
|
53
|
+
// #3158 round 2 F1: a finding the agent marked `false-positive`/`suppress` is
|
|
54
|
+
// not a hard stop — it is an explicitly dismissed one. Before this line only
|
|
55
|
+
// `countDiagnostics` knew that (it skips tagged entries), so the footer header
|
|
56
|
+
// counted zero errors while every consumer that asks THIS predicate still
|
|
57
|
+
// treated the row as blocking: `renderWidget` drew a red ● row for it,
|
|
58
|
+
// `capStoredDiagnostics` hoisted it ahead of live findings (evicting one at the
|
|
59
|
+
// display cap), and `getWidgetBlockingFilesForSweep`,
|
|
60
|
+
// `markWidgetFileBlockersStale` and `reconcileStaleWidgetDependencyBlockers`
|
|
61
|
+
// admitted it to the turn-end blocker sweep. The mismatch was latent while a
|
|
62
|
+
// tagged row lived only between a mark and the next scan; retention makes it
|
|
63
|
+
// durable, so the predicate has to answer for the tag.
|
|
64
|
+
if (d.disposition)
|
|
65
|
+
return false;
|
|
52
66
|
// #1631: a dependency-drift-demoted finding is no longer a hard stop. The gate
|
|
53
67
|
// sets `stale` rather than dropping the entry (#1419 demote-not-drop), so every
|
|
54
68
|
// tally and render that asks "is this blocking?" must answer no once demoted.
|
|
@@ -82,6 +96,20 @@ const diagnosticsWriteGuard = new WriteOrderingGuard();
|
|
|
82
96
|
*/
|
|
83
97
|
const runnerWriteGuard = new WriteOrderingGuard();
|
|
84
98
|
const MAX_STORED_DIAGNOSTICS_PER_FILE = 12;
|
|
99
|
+
/**
|
|
100
|
+
* #3158 / AGENTS.md shape 9: the per-file bound on
|
|
101
|
+
* {@link WidgetDiagnostic.suppressedRetained} rows. Retention is the only rule
|
|
102
|
+
* that lets a record hold findings the latest scan did not report, so it is the
|
|
103
|
+
* only axis of `allDiagnostics` that can grow while the live set shrinks — every
|
|
104
|
+
* other row is replaced wholesale by the next write. Its own constant rather
|
|
105
|
+
* than `MAX_STORED_DIAGNOSTICS_PER_FILE`: that one is the TUI's display cap
|
|
106
|
+
* (`capStoredDiagnostics`) and the two would drift the moment either policy
|
|
107
|
+
* moved. Twelve because the chip is a COUNT, not a list — past a dozen
|
|
108
|
+
* suppressed findings on one unchanged file the exact total stops informing the
|
|
109
|
+
* footer, and the truncation is stated once per file through the ledger rather
|
|
110
|
+
* than dropped silently.
|
|
111
|
+
*/
|
|
112
|
+
const MAX_RETAINED_SUPPRESSED_PER_FILE = 12;
|
|
85
113
|
const MAX_INACTIVE_FILE_RECORDS = 1024;
|
|
86
114
|
const ACTIVE_FILE_IDLE_MS = 30 * 60_000;
|
|
87
115
|
export const MAX_LSP_SERVER_RECORDS = 128;
|
|
@@ -140,24 +168,53 @@ export function reconcileWidgetDisposition(cwd, target, _disposition, content) {
|
|
|
140
168
|
}
|
|
141
169
|
}
|
|
142
170
|
const active = new Set(applyDispositions(current, cwd, target.filePath, source));
|
|
143
|
-
const normalized = current.
|
|
171
|
+
const normalized = current.flatMap((diagnostic) => {
|
|
144
172
|
const { strict, weak } = anchorsForDiagnostic(cwd, target.filePath, diagnostic, source);
|
|
145
173
|
const entry = getDisposition(cwd, strict) ?? getDisposition(cwd, weak);
|
|
146
174
|
if (!active.has(diagnostic) &&
|
|
147
175
|
(entry?.disposition === "false-positive" ||
|
|
148
176
|
entry?.disposition === "suppress")) {
|
|
149
|
-
|
|
177
|
+
// #3183: stamp the strict anchor's own SPAN so the row's lifetime can
|
|
178
|
+
// later ask the mark instead of the file's mtime. `source` is the content
|
|
179
|
+
// the anchors above were derived from, so the span is the one the mark
|
|
180
|
+
// itself binds to. Only the STRICT arm has a span — a weak `suppress`
|
|
181
|
+
// mark hashes no line — and re-tagging always re-derives it, so a stale
|
|
182
|
+
// span from an earlier tag can never survive into a new disposition.
|
|
183
|
+
const { anchorSpan: _previousSpan, ...base } = diagnostic;
|
|
184
|
+
const span = entry.disposition === "false-positive"
|
|
185
|
+
? strictAnchorSpan(source, diagnostic.line)
|
|
186
|
+
: undefined;
|
|
187
|
+
return [
|
|
188
|
+
{
|
|
189
|
+
...base,
|
|
190
|
+
disposition: entry.disposition,
|
|
191
|
+
flagged: false,
|
|
192
|
+
...(span === undefined ? {} : { anchorSpan: span }),
|
|
193
|
+
},
|
|
194
|
+
];
|
|
150
195
|
}
|
|
151
|
-
|
|
196
|
+
// #3158 retirement rule 3: a row the store holds ONLY because it was
|
|
197
|
+
// suppressed (`suppressedRetained`) has no live scan behind it — the last
|
|
198
|
+
// scan of this file did not report it. Once its mark stops suppressing it
|
|
199
|
+
// (a strict `false-positive` anchor whose marked line changed), retire it
|
|
200
|
+
// rather than re-arming it below as a finding the agent would read as
|
|
201
|
+
// currently present.
|
|
202
|
+
if (diagnostic.suppressedRetained)
|
|
203
|
+
return [];
|
|
204
|
+
const { disposition: _disposition, flagged: _flagged,
|
|
205
|
+
// #3183: a row that is no longer disposition-tagged has no mark to
|
|
206
|
+
// anchor, so its span goes with the tag — leaving it would exempt a LIVE
|
|
207
|
+
// row from the sweep's mtime gate.
|
|
208
|
+
anchorSpan: _anchorSpan, ...baseDiagnostic } = diagnostic;
|
|
152
209
|
return entry?.disposition === "flagged"
|
|
153
|
-
? { ...baseDiagnostic, flagged: true }
|
|
154
|
-
: baseDiagnostic;
|
|
210
|
+
? [{ ...baseDiagnostic, flagged: true }]
|
|
211
|
+
: [baseDiagnostic];
|
|
155
212
|
});
|
|
156
213
|
const rec = getOrCreate(target.filePath);
|
|
157
214
|
const writeIndex = admitWidgetDiagnosticsWrite(target.filePath);
|
|
158
215
|
if (!diagnosticsWriteGuard.shouldWrite(fileMapKey(target.filePath), writeIndex))
|
|
159
216
|
return;
|
|
160
|
-
commitDiagnostics(rec, target.filePath, normalized, Date.now());
|
|
217
|
+
commitDiagnostics(rec, target.filePath, normalized, Date.now(), fileMapKey(target.filePath), false);
|
|
161
218
|
}
|
|
162
219
|
/** Reserve a widget write for a producer that has no runtime write index. */
|
|
163
220
|
export function admitWidgetDiagnosticsWrite(filePath, writeIndex) {
|
|
@@ -473,11 +530,175 @@ function normalizeDiagnostics(filePath, diagnostics, observedTs) {
|
|
|
473
530
|
};
|
|
474
531
|
});
|
|
475
532
|
}
|
|
533
|
+
/**
|
|
534
|
+
* The identity a retained suppressed row is matched against a later scan's
|
|
535
|
+
* entries by (#3158), so a scan that reports the finding AGAIN replaces the
|
|
536
|
+
* retained row instead of standing beside it as a duplicate.
|
|
537
|
+
*
|
|
538
|
+
* These are the WEAK ANCHOR's identity parts — `computeWeakAnchor`
|
|
539
|
+
* (`clients/diagnostic-dispositions.ts`) hashes `[tool, rule,
|
|
540
|
+
* normalizeMessage(message)]` over the file — deliberately, because the weak
|
|
541
|
+
* anchor is how the disposition store itself decides which findings a mark
|
|
542
|
+
* covers. Matching at any finer granularity means the store can disagree with
|
|
543
|
+
* the mark that created the row.
|
|
544
|
+
*
|
|
545
|
+
* Round 2 F2: `line` USED to be part of it, and that was the finer granularity
|
|
546
|
+
* the paragraph above forbids. A writer that re-reports the marked finding one
|
|
547
|
+
* line down — `clients/pipeline.ts`'s per-edit `recordDiagnostics`, or
|
|
548
|
+
* `reconcileCascadeNeighborLspErrors`, neither of which runs
|
|
549
|
+
* `reconcileStaleWidgetFiles` — left the retained row standing beside the live
|
|
550
|
+
* one, so a single finding was counted live AND in `suppressed: N`.
|
|
551
|
+
*
|
|
552
|
+
* `computeWeakAnchor` itself is not called here: it needs `cwd` to build its
|
|
553
|
+
* path component, which `commitDiagnostics` does not have and could only get by
|
|
554
|
+
* widening `recordDiagnostics`, `reconcileScanDiagnostics` and
|
|
555
|
+
* `reconcileCorrelatedScanDiagnostics` up through `tools/`. That component is
|
|
556
|
+
* constant across one file record — both sides of this comparison are the same
|
|
557
|
+
* file — so it would discriminate nothing. `normalizeMessage` is the shared
|
|
558
|
+
* derivation from `clients/finding-identity.ts` that the anchor uses, imported
|
|
559
|
+
* rather than re-spelled.
|
|
560
|
+
*
|
|
561
|
+
* #3183: this identity is OCCURRENCE-BLIND, and for a STRICT (`false-positive`)
|
|
562
|
+
* row that is not enough on its own. Two findings of one rule with identical
|
|
563
|
+
* messages on different lines collapse onto it, so a scan reporting only the
|
|
564
|
+
* UNMARKED one matched the marked row and replaced it — the chip lost a mark
|
|
565
|
+
* that was still applying. A collision is therefore resolved by the second half
|
|
566
|
+
* of {@link WidgetDiagnostic.anchorSpan}'s contract, in `retainSuppressedRows`:
|
|
567
|
+
* only a colliding incoming finding sitting ON the marked line is the same
|
|
568
|
+
* finding. That test, not this one, is what keeps the round-2 F2 case above
|
|
569
|
+
* closed — the marked line's TEXT moved, so its span still matches.
|
|
570
|
+
*/
|
|
571
|
+
function retentionIdentity(d) {
|
|
572
|
+
return JSON.stringify([
|
|
573
|
+
d.tool ?? "",
|
|
574
|
+
d.rule ?? "",
|
|
575
|
+
normalizeMessage(d.message),
|
|
576
|
+
]);
|
|
577
|
+
}
|
|
578
|
+
/** `filePath`'s current content, or `undefined` when it cannot be read. */
|
|
579
|
+
function readContentOrUndefined(filePath) {
|
|
580
|
+
try {
|
|
581
|
+
return readFileSync(filePath, "utf8");
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
// Only reached once this module has established there IS a span-carrying row
|
|
585
|
+
// to ask about, so the record is bounded by that population — and worth
|
|
586
|
+
// making: with no content both callers fall back to the pre-#3183 rules (the
|
|
587
|
+
// per-entry mtime gate, the coarse retention identity), which is the
|
|
588
|
+
// conservative direction but can retire a mark that still stands.
|
|
589
|
+
recordDegradationOnce({
|
|
590
|
+
kind: "widget-mark-anchor-unreadable",
|
|
591
|
+
subject: filePath,
|
|
592
|
+
reason: "the marked file could not be read, so its suppressed rows fall back to the file-mtime retirement gate and the footer chip may under-count it",
|
|
593
|
+
});
|
|
594
|
+
return undefined;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* {@link strictAnchorSpansIn} over `filePath`'s current content (#3183), or
|
|
599
|
+
* `undefined` when it cannot be read.
|
|
600
|
+
*
|
|
601
|
+
* Synchronous in both callers, matching `applyCachedDispositions`
|
|
602
|
+
* (`tools/lens-diagnostics.ts`) — the seam that asks the very same question on
|
|
603
|
+
* the very same read path, with `statSync`/`readFileSync`. An async read here
|
|
604
|
+
* would add two unbounded awaits to a module a registered hook handler imports
|
|
605
|
+
* directly (`tests/config/hook-await-bounds.test.ts`) for no behavioural gain.
|
|
606
|
+
*/
|
|
607
|
+
function readAnchorSpans(filePath) {
|
|
608
|
+
const content = readContentOrUndefined(filePath);
|
|
609
|
+
return content === undefined ? undefined : strictAnchorSpansIn(content);
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* #3158: carry the record's disposition-tagged rows across a whole-replace
|
|
613
|
+
* write when the incoming set no longer reports them, so a `false-positive` /
|
|
614
|
+
* `suppress` mark keeps contributing to the footer's `suppressed: N` chip. See
|
|
615
|
+
* {@link WidgetDiagnostic.suppressedRetained} for the full lifetime statement.
|
|
616
|
+
*
|
|
617
|
+
* Retained rows are APPENDED after the incoming ones: `capStoredDiagnostics`
|
|
618
|
+
* fills the TUI's display list from the front, so a retained row never evicts a
|
|
619
|
+
* live finding from the rendered set.
|
|
620
|
+
*
|
|
621
|
+
* The tagged-row check comes FIRST so a project with no disposition marks — the
|
|
622
|
+
* overwhelmingly common case, and this runs on the per-edit write path — pays
|
|
623
|
+
* one predicate per stored row and never builds the identity set.
|
|
624
|
+
*/
|
|
625
|
+
function retainSuppressedRows(previous, incoming, filePath) {
|
|
626
|
+
const tagged = previous.filter((d) => d.disposition !== undefined);
|
|
627
|
+
if (tagged.length === 0)
|
|
628
|
+
return incoming;
|
|
629
|
+
const reported = new Map();
|
|
630
|
+
for (const d of incoming) {
|
|
631
|
+
const id = retentionIdentity(d);
|
|
632
|
+
const bucket = reported.get(id);
|
|
633
|
+
if (bucket)
|
|
634
|
+
bucket.push(d);
|
|
635
|
+
else
|
|
636
|
+
reported.set(id, [d]);
|
|
637
|
+
}
|
|
638
|
+
// #3183: the ONE file read on this path, and only for the state the coarse
|
|
639
|
+
// identity genuinely cannot resolve — a strict-anchored row whose identity an
|
|
640
|
+
// incoming finding collides with. Every other write (no marks at all, a mark
|
|
641
|
+
// with no collision, a weak `suppress` mark) stays zero-I/O, which is what
|
|
642
|
+
// keeps this off the per-edit `recordDiagnostics` cost.
|
|
643
|
+
const content = tagged.some((d) => d.anchorSpan !== undefined && reported.has(retentionIdentity(d)))
|
|
644
|
+
? readContentOrUndefined(filePath)
|
|
645
|
+
: undefined;
|
|
646
|
+
const spans = content === undefined ? undefined : strictAnchorSpansIn(content);
|
|
647
|
+
const retained = tagged.filter((d) => {
|
|
648
|
+
const collisions = reported.get(retentionIdentity(d));
|
|
649
|
+
// Nothing re-reported this finding, so retention rule 2 has nothing to say.
|
|
650
|
+
if (collisions === undefined)
|
|
651
|
+
return true;
|
|
652
|
+
// No span to ask (a weak `suppress` mark, or a row from an older build), or
|
|
653
|
+
// the file is unreadable: fall back to the pre-#3183 coarse rule. The safe
|
|
654
|
+
// direction is to UNDER-count, never to leave a phantom duplicate standing
|
|
655
|
+
// beside a live finding.
|
|
656
|
+
if (d.anchorSpan === undefined || spans === undefined)
|
|
657
|
+
return false;
|
|
658
|
+
// The mark stopped applying — its line is gone from the file — so the
|
|
659
|
+
// colliding finding IS this one, re-reported now that the filter no longer
|
|
660
|
+
// drops it (retirement rule 2, and the same verdict the sweep reaches).
|
|
661
|
+
if (!spans.has(d.anchorSpan))
|
|
662
|
+
return false;
|
|
663
|
+
// The mark still applies. Only a collision sitting ON the marked line is
|
|
664
|
+
// the same finding (its line number may have shifted under an edit — #3158
|
|
665
|
+
// round 2 F2); any other is a DIFFERENT occurrence this strict anchor never
|
|
666
|
+
// covered, and replacing the row with it loses a live mark (#3183).
|
|
667
|
+
return !collisions.some((i) => strictAnchorSpan(content, i.line) === d.anchorSpan);
|
|
668
|
+
});
|
|
669
|
+
if (retained.length > MAX_RETAINED_SUPPRESSED_PER_FILE) {
|
|
670
|
+
// Freshest observations win the cap. One record per FILE, never one per
|
|
671
|
+
// dropped row (AGENTS.md "bounded observability"): the chip under-counts
|
|
672
|
+
// by exactly `dropped` for this file until its content changes.
|
|
673
|
+
retained.sort((a, b) => (b.observedAt ?? 0) - (a.observedAt ?? 0));
|
|
674
|
+
recordDegradationOnce({
|
|
675
|
+
kind: "widget-suppressed-retention-capped",
|
|
676
|
+
subject: filePath,
|
|
677
|
+
reason: `${retained.length - MAX_RETAINED_SUPPRESSED_PER_FILE} suppressed row(s) beyond the ${MAX_RETAINED_SUPPRESSED_PER_FILE}-row per-file retention cap are no longer counted by the footer chip`,
|
|
678
|
+
});
|
|
679
|
+
retained.length = MAX_RETAINED_SUPPRESSED_PER_FILE;
|
|
680
|
+
}
|
|
681
|
+
return [
|
|
682
|
+
...incoming,
|
|
683
|
+
...retained.map((d) => d.suppressedRetained ? d : { ...d, suppressedRetained: true }),
|
|
684
|
+
];
|
|
685
|
+
}
|
|
476
686
|
/** Store `normalized` as the record's complete diagnostic set: recompute counts,
|
|
477
687
|
* cap the display list, stamp `touchedAt` (at `observedAt` when given, else now
|
|
478
688
|
* — #1093), persist, and re-render. The caller decides what `normalized`
|
|
479
689
|
* contains (a full replace, or a merge — see `reconcileCascadeNeighborLspErrors`). */
|
|
480
|
-
function commitDiagnostics(rec, filePath,
|
|
690
|
+
function commitDiagnostics(rec, filePath, incoming, observedAt, key = fileMapKey(filePath),
|
|
691
|
+
/**
|
|
692
|
+
* #3158: apply the suppressed-row retention rule to this write. True for
|
|
693
|
+
* every seam that hands in a fresh SCAN of the file (`recordDiagnostics` and
|
|
694
|
+
* both reconciles), false for `reconcileWidgetDisposition`, which re-maps the
|
|
695
|
+
* record's own current rows and is the seam that RETIRES a retained row —
|
|
696
|
+
* re-adding what it just dropped would make retirement rule 3 inert.
|
|
697
|
+
*/
|
|
698
|
+
retainSuppressed = true) {
|
|
699
|
+
const normalized = retainSuppressed
|
|
700
|
+
? retainSuppressedRows(rec.allDiagnostics, incoming, filePath)
|
|
701
|
+
: incoming;
|
|
481
702
|
rec.diagnosticCounts = countDiagnostics(normalized);
|
|
482
703
|
rec.diagnostics = capStoredDiagnostics(normalized);
|
|
483
704
|
rec.allDiagnostics = normalized;
|
|
@@ -765,13 +986,24 @@ export async function reconcileStaleWidgetFiles() {
|
|
|
765
986
|
// at dispatch/integration.ts). A missing per-entry stamp (a migrated
|
|
766
987
|
// pre-#1186 record) inherits the record's `touchedAt`. Tolerance matches
|
|
767
988
|
// the Windows host-clock skew rationale above.
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
989
|
+
const mtimeStale = (d) => freshnessFromMtime({
|
|
990
|
+
mtimeMs,
|
|
991
|
+
referenceMs: d.observedAt ?? rec.touchedAt,
|
|
992
|
+
}).verdict === "stale";
|
|
993
|
+
// #3183: a disposition-tagged row carrying a strict anchor span is gated
|
|
994
|
+
// on its MARK, not on the file's mtime (retirement rule 1 — see
|
|
995
|
+
// `WidgetDiagnostic.suppressedRetained`): it survives while the marked
|
|
996
|
+
// LINE is still somewhere in the file, and is retired when it is not.
|
|
997
|
+
// The content read is gated on a span-carrying row the mtime gate WOULD
|
|
998
|
+
// have dropped — the only state where reading can change an outcome — so
|
|
999
|
+
// the debounced render sweep stays off the disk both for a file with no
|
|
1000
|
+
// marks and for a marked file nothing has touched.
|
|
1001
|
+
const spans = rec.allDiagnostics.some((d) => d.anchorSpan !== undefined && mtimeStale(d))
|
|
1002
|
+
? readAnchorSpans(rec.filePath)
|
|
1003
|
+
: undefined;
|
|
1004
|
+
const survivors = rec.allDiagnostics.filter((d) => d.anchorSpan !== undefined && spans !== undefined
|
|
1005
|
+
? spans.has(d.anchorSpan)
|
|
1006
|
+
: !mtimeStale(d));
|
|
775
1007
|
if (survivors.length === rec.allDiagnostics.length) {
|
|
776
1008
|
return { mapKey, action: "keep" }; // nothing stale
|
|
777
1009
|
}
|
|
@@ -1064,21 +1296,66 @@ export function scheduleStaleReconcile() {
|
|
|
1064
1296
|
* Used by lens_diagnostics tool (mode: "all"). Exposes the FULL per-file
|
|
1065
1297
|
* diagnostic set — decoupled from the widget's display cap — so the agent sees
|
|
1066
1298
|
* everything, not just the 12 the TUI keeps for rendering.
|
|
1299
|
+
*
|
|
1300
|
+
* #3183: "everything" means every row a live scan stands behind.
|
|
1301
|
+
* `disposition`-tagged rows are the store's own bookkeeping for a mark — the
|
|
1302
|
+
* scan that produced this record did not report them — and they are excluded
|
|
1303
|
+
* here. That is the same predicate `countDiagnostics` and `isBlocking` have
|
|
1304
|
+
* always used, so this makes the projected array agree with the counts beside
|
|
1305
|
+
* it; before #3183 the array disagreed, and once retirement rule 1 let a row
|
|
1306
|
+
* outlive the file's mtime, `applyCachedDispositions` reached it in the
|
|
1307
|
+
* WEAK-fallback branch — which can never match a STRICT `false-positive`
|
|
1308
|
+
* anchor — and `mode=all` served a marked finding as a live warning (#3158 AC3).
|
|
1309
|
+
* The exclusion lives HERE, at the one projection every cache-only surface and
|
|
1310
|
+
* the mode=full merge read, rather than as a second disposition rule inside
|
|
1311
|
+
* `summarizeDiagnostics`' tally: that tally still skips `stale` only, and the
|
|
1312
|
+
* disposition filter still reaches it pre-applied through the shared
|
|
1313
|
+
* `applyCachedDispositions` seam.
|
|
1314
|
+
*
|
|
1315
|
+
* Deliberately NOT applied to {@link getFileDiagnostics}, the per-file read:
|
|
1316
|
+
* `reconcileWidgetDisposition` needs the tagged rows to enforce retirement
|
|
1317
|
+
* rule 3, and `lens_diagnostic_mark`'s cross-check re-marks through them.
|
|
1067
1318
|
*/
|
|
1068
1319
|
export function getFileDiagnosticSummaries() {
|
|
1069
1320
|
return [...files.values()].map((rec) => {
|
|
1070
1321
|
applyPastEofGate(rec);
|
|
1322
|
+
const live = rec.allDiagnostics.filter((d) => d.disposition === undefined);
|
|
1071
1323
|
return {
|
|
1072
1324
|
filePath: rec.filePath,
|
|
1073
1325
|
blocking: rec.diagnosticCounts.blocking,
|
|
1074
1326
|
errors: rec.diagnosticCounts.errors,
|
|
1075
1327
|
warnings: rec.diagnosticCounts.warnings,
|
|
1076
|
-
advisories: countAdvisories(
|
|
1328
|
+
advisories: countAdvisories(live),
|
|
1077
1329
|
hasFinalSnapshot: rec.hasFinalDiagnosticsSnapshot,
|
|
1078
|
-
diagnostics:
|
|
1330
|
+
diagnostics: live.map((d) => ({ ...d })),
|
|
1079
1331
|
};
|
|
1080
1332
|
});
|
|
1081
1333
|
}
|
|
1334
|
+
/**
|
|
1335
|
+
* How many {@link WidgetDiagnostic.suppressedRetained} rows this file's record
|
|
1336
|
+
* is currently carrying (#3158 round 2 F4).
|
|
1337
|
+
*
|
|
1338
|
+
* The success-path counterpart of `widget-suppressed-retention-capped`: the
|
|
1339
|
+
* ledger only hears about retention when the per-file cap TRUNCATES it, so
|
|
1340
|
+
* nothing observed the healthy case. `tools/lsp-diagnostics.ts` folds this into
|
|
1341
|
+
* the `lsp_probe_disposition_filter` phase it already emits once per filtered
|
|
1342
|
+
* file per scan, so the count is bounded by that record's existing cardinality
|
|
1343
|
+
* — never one row per retained finding.
|
|
1344
|
+
*
|
|
1345
|
+
* Deliberately not {@link getFileDiagnostics}: that applies the past-EOF gate,
|
|
1346
|
+
* which can `stat` the file. This is a map lookup plus a count over rows the
|
|
1347
|
+
* caller's own scan just filtered, so it adds no I/O to the probe path.
|
|
1348
|
+
*/
|
|
1349
|
+
export function countRetainedSuppressedRows(filePath) {
|
|
1350
|
+
const rec = files.get(fileMapKey(filePath));
|
|
1351
|
+
if (!rec)
|
|
1352
|
+
return 0;
|
|
1353
|
+
let n = 0;
|
|
1354
|
+
for (const d of rec.allDiagnostics)
|
|
1355
|
+
if (d.suppressedRetained)
|
|
1356
|
+
n += 1;
|
|
1357
|
+
return n;
|
|
1358
|
+
}
|
|
1082
1359
|
/**
|
|
1083
1360
|
* Return the current FULL (uncapped) diagnostic set for a single file, as
|
|
1084
1361
|
* last recorded by {@link recordDiagnostics} — the same `allDiagnostics`
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
import * as fs from "node:fs";
|
|
40
40
|
import * as os from "node:os";
|
|
41
41
|
import * as path from "node:path";
|
|
42
|
-
import { PROJECT_CONFIG_BASENAMES } from "./config-locations.js";
|
|
42
|
+
import { PROJECT_CONFIG_BASENAMES, isResolvedGlobalConfigPath, } from "./config-locations.js";
|
|
43
43
|
import { logLatency } from "./latency-logger.js";
|
|
44
44
|
import { isAtOrAboveHomeDir, nameMatchesMarkerGlob, walkUpDirs, } from "./path-utils.js";
|
|
45
45
|
/** Depth cap on any upward marker walk — mirrors `findNearestMarkerRoot`'s bound. */
|
|
@@ -214,9 +214,53 @@ export function getDirectoryMarkers(dir) {
|
|
|
214
214
|
function walkCacheKey(startDir, markerKey) {
|
|
215
215
|
return `${path.resolve(startDir)}\0${markerKey}`;
|
|
216
216
|
}
|
|
217
|
-
function
|
|
217
|
+
export function dirMtimesStillFresh(dirMtimes) {
|
|
218
218
|
return dirMtimes.every(({ dir, mtimeMs }) => safeDirMtimeMs(dir) === mtimeMs);
|
|
219
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Async arm of `dirMtimesStillFresh` — same records, same comparison, stats
|
|
222
|
+
* issued in parallel. It exists for the async marker walk in
|
|
223
|
+
* `clients/lsp/server.ts`'s `NearestRoot`, whose own marker probes are
|
|
224
|
+
* `fs/promises` and which runs on the per-file LSP touch path: a `statSync` per
|
|
225
|
+
* directory there would block the event loop the walk deliberately keeps free.
|
|
226
|
+
* Both arms live here so "is this recorded walk still current" has one owner
|
|
227
|
+
* (#3412).
|
|
228
|
+
*
|
|
229
|
+
* Written with promise combinators rather than `await`, along with the two
|
|
230
|
+
* readers below, so the ONLY place that waits on these stats is the caller's
|
|
231
|
+
* `await bounded(...)` — a hook-path await that a later editor cannot leave
|
|
232
|
+
* unbounded, instead of an inner await bounded only by a registry admission
|
|
233
|
+
* (#3412 review round 1, M-3421-02).
|
|
234
|
+
*/
|
|
235
|
+
export function dirMtimesStillFreshAsync(dirMtimes) {
|
|
236
|
+
return Promise.all(dirMtimes.map(({ dir }) => dirMtimeMsAsync(dir))).then((current) => dirMtimes.every(({ mtimeMs }, index) => current[index] === mtimeMs));
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Current mtime of `dir`, or `-1` when it cannot be stat'ed — the async arm of
|
|
240
|
+
* `safeDirMtimeMs`. A recorder and `dirMtimesStillFreshAsync` MUST read through
|
|
241
|
+
* the same function so the absent-directory sentinel is the same value on both
|
|
242
|
+
* sides: `prisma/` not existing yet is a recordable state, and the record must
|
|
243
|
+
* compare unequal once it is created (#3412).
|
|
244
|
+
*/
|
|
245
|
+
function dirMtimeMsAsync(dir) {
|
|
246
|
+
return fs.promises.stat(dir).then((stats) => stats.mtimeMs, () => -1);
|
|
247
|
+
}
|
|
248
|
+
/** One freshness record per directory, read in parallel (#3412). */
|
|
249
|
+
export function dirMtimeRecordsAsync(dirs) {
|
|
250
|
+
return Promise.all(dirs.map((dir) => dirMtimeMsAsync(dir).then((mtimeMs) => ({ dir, mtimeMs }))));
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Records for directories whose mtime could NOT be read — because the caller's
|
|
254
|
+
* bound fired, so the answer is unknown rather than absent. `NaN` compares
|
|
255
|
+
* unequal to every mtime including itself, so an entry carrying one of these
|
|
256
|
+
* can never be served: the walk's answer is still returned, it is simply not
|
|
257
|
+
* memoized. Unknown must never be spelled as a number a directory could
|
|
258
|
+
* actually have, and dropping the directory from the signature (recording
|
|
259
|
+
* nothing) would hide every later change in it (#3412 review round 1, S9).
|
|
260
|
+
*/
|
|
261
|
+
export function unknownDirMtimeRecords(dirs) {
|
|
262
|
+
return dirs.map((dir) => ({ dir, mtimeMs: Number.NaN }));
|
|
263
|
+
}
|
|
220
264
|
/**
|
|
221
265
|
* Shared core of the two nearest-marker walkers below: the cache check, the
|
|
222
266
|
* home-guarded/depth-capped upward loop, per-directory mtime recording, and
|
|
@@ -229,7 +273,7 @@ function walkStillFresh(dirMtimes) {
|
|
|
229
273
|
function walkToNearestMatch(startDir, cacheSuffix, matches, capMetadata, homeDir) {
|
|
230
274
|
const key = walkCacheKey(startDir, cacheSuffix);
|
|
231
275
|
const cached = walkCache.get(key);
|
|
232
|
-
if (cached &&
|
|
276
|
+
if (cached && dirMtimesStillFresh(cached.dirMtimes)) {
|
|
233
277
|
touchWalk(key, cached);
|
|
234
278
|
return cached.dir;
|
|
235
279
|
}
|
|
@@ -339,11 +383,21 @@ export function findNearestDirWithAnyBasename(startDir, basenames, homeDir = os.
|
|
|
339
383
|
* `.pi-lens.json`/`pi-lens.json` directly IN `dir` — no upward walk. The
|
|
340
384
|
* shared-index equivalent of `project-lens-config.ts`'s old private
|
|
341
385
|
* `findPiLensConfigInDir` probe loop.
|
|
386
|
+
*
|
|
387
|
+
* A candidate that IS the resolved global config path is refused
|
|
388
|
+
* (global-config-location PR, refs #2457): the global tier reads that file by
|
|
389
|
+
* absolute path, and discovering it here a second time made the nested ignore
|
|
390
|
+
* layering full-validate it and warn that its global-only keys are "not
|
|
391
|
+
* honored in a project config" — including the notice advising the user to
|
|
392
|
+
* set them in the very file being ignored. The refusal is observable through
|
|
393
|
+
* the marker staying absent, the same shape as a directory with no marker.
|
|
342
394
|
*/
|
|
343
395
|
export function findPiLensConfigMarkerInDir(dir) {
|
|
344
396
|
const markers = getDirectoryMarkers(dir);
|
|
345
397
|
if (!markers.piLensConfigPath)
|
|
346
398
|
return undefined;
|
|
399
|
+
if (isResolvedGlobalConfigPath(markers.piLensConfigPath))
|
|
400
|
+
return undefined;
|
|
347
401
|
const stat = (() => {
|
|
348
402
|
try {
|
|
349
403
|
return fs.statSync(markers.piLensConfigPath);
|