pi-lens 4.2.0 → 4.2.1
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 +45 -0
- package/dist/clients/actionable-warnings.js +21 -9
- package/dist/clients/cascade-format.js +24 -0
- package/dist/clients/extension-log.js +48 -1
- package/dist/clients/finding-delivery-gate.js +11 -12
- package/dist/clients/lsp/cascade-tier.js +3 -0
- package/dist/clients/lsp/index.js +41 -7
- package/dist/clients/mcp/analyze.js +9 -2
- package/dist/clients/path-utils.js +51 -9
- package/dist/clients/persistent-reverify.js +271 -0
- package/dist/clients/pipeline.js +15 -1
- package/dist/clients/runtime-turn.js +124 -7
- package/dist/clients/skills-resolver.js +38 -2
- package/dist/clients/widget-state.js +152 -6
- package/dist/index.js +729 -344
- package/dist/tools/lens-diagnostic-mark.js +14 -2
- package/dist/tools/lens-diagnostics.js +84 -17
- package/dist/tools/lsp-diagnostics.js +32 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -16,6 +16,51 @@ All notable changes to pi-lens will be documented in this file.
|
|
|
16
16
|
|
|
17
17
|
### Security
|
|
18
18
|
|
|
19
|
+
## [4.2.1] - 2026-09-17
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **Carried cascade advisories and demoted delta rows now render with explicit age labels (refs #3167)** — a cascade result carried across a turn boundary renders `(carried 1 turn · scanned Xm ago)` from the run's own observation stamp (#1444's `publishedAt`, threaded to `CascadeRun.observedAt`), a coverage advisory computed entirely from carried indeterminate runs is labeled the same way on its own line, and a demoted delta file group closes with one scan-age line under that file's own header through `formatCacheAgeLabel` — so an agent can tell a just-observed finding from one re-served from cache. An absent or partial stamp renders `scan age unknown`, never a fabricated number. The two `partial` delivery-gate entries naming this gap resolve to full, and each carrying turn emits one bounded `cascade_carry_rendered` record.
|
|
24
|
+
|
|
25
|
+
- **Stale carried findings are now re-verified against the live server before re-delivery (refs #3170)** — a deferred-origin finding whose file has not moved re-served turn after turn even when the root cause was fixed elsewhere (the #1093 cross-file class on the advisory lane). At turn_end, up to four such files per turn are re-observed through the probe's `touchFile` path (bounded by a wall budget and the turn's abort signal, both of which re-arm the rest for the next turn). A carried finding is dropped only when the touch itself completed its confirmation policy and no longer reports it; a silent, skipped or wedged answer keeps the finding verbatim and renders an explicit "(re-verify incomplete)" label in the turn-end advisory and in `lens_diagnostics` delta output — never a false clean. Findings sourced from an auxiliary scanner are left to their own lane rather than scored against a primary-scope answer. One `persistent_reverify` latency record per pass, carrying the candidate, touched, outcome and skip counts.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **The widget `suppressed: N` chip keeps counting a marked finding (refs #3158)** — A finding marked `false-positive` or `suppress` stopped contributing to the pi-lens footer's `suppressed: N` chip as soon as any fresh `lens_diagnostics source=lsp` probe ran, because the probe hands the widget store the post-disposition filtered set and the store replaces a file's entries wholesale. Suppressed rows the latest scan no longer reports are now retained, and a suppressed row is no longer treated as a blocker anywhere — it cannot draw a red footer row, displace a live finding, or enter the turn-end blocker sweep. Known window: the retained row is retired by the file's next edit, even one that leaves the marked line unchanged and the mark still applying, and nothing re-creates it; the chip therefore counts a mark until that file is next edited rather than for as long as the mark stands.
|
|
30
|
+
|
|
31
|
+
- **`lens_diagnostic_mark` widget cross-check missed mis-cased paths on case-insensitive filesystems (refs #3160)** — the #802 line-reanchor
|
|
32
|
+
cross-check looked up the raw, possibly mis-cased, mark target against
|
|
33
|
+
widget state, which is keyed case-preservingly on POSIX; on a
|
|
34
|
+
case-insensitive filesystem (macOS APFS, `nocase` vfat/ntfs3/cifs) this
|
|
35
|
+
silently missed the live diagnostic and fell back to the fuzzy line guess.
|
|
36
|
+
The lookup now normalizes the path to on-disk casing before checking
|
|
37
|
+
widget state. `pilens_analyze` also recorded diagnostics under the raw,
|
|
38
|
+
un-normalized spelling of its agent-supplied `file` argument, so it now
|
|
39
|
+
normalizes on write too — both sides key on-disk casing consistently. The
|
|
40
|
+
`lsp_diagnostics`/`lens_diagnostics(source: "lsp", scope: "paths")`
|
|
41
|
+
explicit-`paths` batch had the same raw-key write for a mis-cased entry
|
|
42
|
+
(refs #3182); it now normalizes on write as well.
|
|
43
|
+
|
|
44
|
+
- **`guard-bash` refuses `git worktree remove` on a tree whose `node_modules` is a symlink into another checkout (refs #3173)** — twice on 2026-09-16 a fixer removed a worktree whose `node_modules` was symlinked to the shared checkout (the fixer playbook's own speed convention), and git followed the link and emptied the shared install, breaking every other agent building or testing in that window. The PreToolUse hook now denies (exit 2) ANY `git worktree remove <tree>` — force or not — when `<tree>` is a real linked git worktree and its `node_modules` entry is a symlink whose target resolves outside `<tree>`; the stderr note names the fix (`rm <tree>/node_modules`, then retry). A path that does not exist, is not a git worktree, or whose `node_modules` is a real directory (or a symlink that stays inside the worktree) is left to git.
|
|
45
|
+
|
|
46
|
+
- **The tests-tree write guard no longer crashes the Unit tests job on a scratch-directory race (refs #3179)** — `tests/support/tests-tree-write-guard-setup.ts` armed a recursive `fs.watch` over `tests/` with no `'error'` listener. On Linux, node's recursive watch is a JS polyfill that re-scans a changed subfolder with a synchronous `readdirSync`; when `tests/index-2992-integration.test.ts` removed its own exempt scratch directory (`tests/support/.index-2992-scratch`) between the change event and that rescan, the polyfill emitted an unhandled `'error'` event and killed the whole vitest process with exit 1 and no failing test. The guard now attaches an `'error'` handler that swallows ENOENT for paths under its existing exempt prefixes and records any other error once via `console.warn`, never per event.
|
|
47
|
+
|
|
48
|
+
- **A tool path with `/../` in it no longer orphans its own diagnostics record (refs #3184)** — `normalizeMapKey`, the canonical map-key normalizer, passed dot segments through unchanged on macOS and Linux whenever the path's casing was already right, so an absolute path containing `/../` (or `/./`, or a doubled separator) keyed under a spelling no other writer or reader ever derives. A `lens_diagnostic_mark`, `pilens_analyze` or `lens_diagnostics {source:"lsp"}` call typed that way produced an orphan widget row, a missed line reanchor and a split disposition anchor. The POSIX arm now folds dot segments before it looks at casing, so every consumer inherits one key per file; the fold is pure string algebra, so a relative path is still never resolved against the process working directory. The `lsp_diagnostics` single-`path` mode, which derives its key without that normalizer, was folded onto the same expression its `paths` batch already uses.
|
|
49
|
+
|
|
50
|
+
- **`tmp-fixture-hygiene`'s governance owner attributed another test file's async-recreated directory to itself (refs #3186)** — PR #3168's CI run redded `tests/config/tmp-fixture-hygiene.test.ts` over `pi-lens-tool-policy-conventions-*` directories owned by `tests/clients/tool-policy-conventions.test.ts`. That file's `afterEach` removed its `setupTestEnvironment` directory synchronously while `saveProjectSnapshot` (called from within the test) was still mid-flight: its body persist is dispatched to a worker thread / main-thread fallback the caller never awaited, and that persist's write path recreates the just-removed directory via a recursive `mkdir` — measured directly landing ~10-50ms later, unforced, on a bare invocation of the real function. Fixed at the producer: the `afterEach` now awaits the repo's own `waitForProjectSnapshotPersistsForTests()` drain seam before cleanup, the same seam five other test files already use for this, closing the race instead of admitting its symptom in the tmp-hygiene baseline.
|
|
51
|
+
|
|
52
|
+
- **A cold diagnostics sweep no longer burns its whole warm-up budget on a silent TypeScript server (closes #3187)** — The shared pre-sweep warm-up (`LSPService.ensureWarmForSweep`) touches a representative file to prove the server can answer, and that touch collects nothing. The tsserver sync clean-confirm race was gated on a collecting touch, so on a project whose classic typescript-language-server publishes nothing for clean files the warm-up could only reach a verdict by waiting out its entire cold-start budget — measured on a real session at 6,972 ms of a 20,000 ms budget, and on a fake tier3-silent server through the real service at 4,509 ms (initial attempt plus the retry) against 308 ms after the fix. The warm-up is now eligible for the sync confirm on its own identity while still collecting nothing, so it neither primes nor erases the file's last-known diagnostics, and a server that does not offer the sync commands still certifies through the existing silent-clean fallback instead of failing warm-up and skipping the sweep group.
|
|
53
|
+
|
|
54
|
+
- **No more `🔴 STOP — 0 issue(s) must be fixed:` with nothing under it (refs [#3188](https://github.com/apmantza/pi-lens/issues/3188))** — When #2028's deleted-path gate retracted *every* blocker — the common shape being a bash command that writes a file and deletes it again (`cat > probe.py … ; rm probe.py`), whose recovered path the per-edit pipeline still analyses — the readback-failed re-render path emitted the STOP header with a count of zero and an empty body, so the agent read a "must be fixed" imperative naming no finding and no file. The external reporter measured 154 of 159 of their banners in that zero form. The blocker renderer now returns nothing when it has no blockers (the same guard `formatDiagnostics` already carries), and the re-render path no longer appends a bare separator, which would still have been delivered as a whitespace-only tool-result block. A partial retraction is unchanged: the surviving blockers render with their own count, and the coverage/auto-fix tail after the blocker section is still delivered. The existing bounded `finding_dead_path_drop` record (`store=stop-blocker`, `deliveredCount=0`) is unchanged and remains the way a total retraction is observed.
|
|
55
|
+
|
|
56
|
+
- **Tool schemas survive registration on hosts with callable schemas (closes #3195)** — On a host whose `typebox` specifier resolves to an ArkType-style builder — `@oh-my-pi/pi-coding-agent` (the `@oh-my-pi/omptype` shim it rewrites an extension's `typebox` import to), and any other host whose schema builders return callables — every pi-lens tool lost its parameter schema at registration: the console-capture seam replaced each function-valued property of a tool definition with a capture wrapper, and a schema is itself a function there. All 13 tools then rendered `type Args = unknown;` and rejected every `xd://` call with `root: schema must be an object or boolean`. Callable schemas are now handed to the host untouched, using the host's own `isArkSchema` test, while `execute`, `renderResult` and command handlers keep their console-capture window. Hosts whose schemas are plain objects (`@earendil-works/pi-coding-agent`) are unaffected.
|
|
57
|
+
|
|
58
|
+
- **`mode=delta` no longer renders a file's quality (or project-diagnostics) rows under another file's header (refs #3196)** — `formatDeltaMode`'s quality loop and `appendProjectDiagnosticsDeltaLines` both suppressed a file's header with `if (!lines.includes(rel)) lines.push(rel)`, true whenever an earlier tier already pushed that exact path — but each tier's rows were appended to the END of the shared buffer, not under that earlier header. A file present in more than one report (actionable, quality, or project) had its later tier's rows, and any age/re-verify-incomplete label describing them, land under whichever OTHER file's header happened to be last in the buffer, misdirecting an agent acting on the finding. The render is now a single pass grouped by file: every tier appends into that file's own bucket, which makes the header-membership guess (and #3168's label-prediction dedupe) unnecessary — both are removed.
|
|
59
|
+
|
|
60
|
+
- **`mutation (advisory)` no longer false-reds on `scripts/with-memory-watch.mjs` (closes #3108)** — its two source-text pins in `tests/scripts/with-memory-watch.test.ts` matched only the un-instrumented wrapper, so Stryker's in-place `// @ts-nocheck` + switch-mutant scaffolding (added even with no mutant active) failed the dry run on every PR that edited the file. The pins now tolerate that optional wrapper and still red when the pinned code is genuinely removed.
|
|
61
|
+
|
|
62
|
+
- **The skills-directory degradation record no longer drops which entry file triggered it (refs #3175)** — when `resources_discover`'s `skills/` lookup misses, the ledger's `reason` field concatenated a classification that repeats `skillsDir` (already recorded verbatim in `subject`) ahead of the entry file's directory — the one fact the record uniquely carries for diagnosing a managed-cache-relocated entry (#2587). Once the shared root was long enough that the two exceeded the ledger's 200-char cap (a real dry-roll/CI-lane TMPDIR shape), the head-preserving truncation kept the redundant path and silently dropped the entry directory. The ledger's copy of the reason now uses a short, path-free classification tag instead, leaving room for the entry directory to survive intact; the user-facing notification is unaffected.
|
|
63
|
+
|
|
19
64
|
## [4.2.0] - 2026-09-16
|
|
20
65
|
|
|
21
66
|
### Added
|
|
@@ -251,7 +251,7 @@ function lineInModifiedRanges(line, ranges) {
|
|
|
251
251
|
return true;
|
|
252
252
|
return ranges.some((range) => line >= range.start - 2 && line <= range.end + 2);
|
|
253
253
|
}
|
|
254
|
-
function recordFromLspDiagnostic(diag, filePath, cwd) {
|
|
254
|
+
export function recordFromLspDiagnostic(diag, filePath, cwd) {
|
|
255
255
|
const line = diag.range.start.line + 1;
|
|
256
256
|
const column = diag.range.start.character + 1;
|
|
257
257
|
// Keep agreement keyed to the producer that supplied the diagnostic. The
|
|
@@ -736,7 +736,7 @@ export async function buildActionableWarningsReport(args) {
|
|
|
736
736
|
* off-hook loop run byte-identical logic — the deferral must not become a
|
|
737
737
|
* second, drifting copy of the enrichment.
|
|
738
738
|
*/
|
|
739
|
-
async function enrichFileFromLsp(cwd, args, target, deps) {
|
|
739
|
+
export async function enrichFileFromLsp(cwd, args, target, deps) {
|
|
740
740
|
const { lspService } = deps;
|
|
741
741
|
const { filePath } = target;
|
|
742
742
|
const out = [];
|
|
@@ -1110,12 +1110,24 @@ export function mergeActionableWarningsReports(args) {
|
|
|
1110
1110
|
}
|
|
1111
1111
|
mergedFiles++;
|
|
1112
1112
|
const merged = incumbent
|
|
1113
|
-
?
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1113
|
+
? incumbent.reVerified || incumbent.reVerifyIncomplete
|
|
1114
|
+
? // #3170: the runtime just RE-OBSERVED this file (the bounded
|
|
1115
|
+
// persistent-reverify pass) — the fresh observation supersedes
|
|
1116
|
+
// the carried entry instead of unioning with it: a converged
|
|
1117
|
+
// finding would otherwise survive its own supersession via the
|
|
1118
|
+
// id-union in `mergeWarnings`.
|
|
1119
|
+
{
|
|
1120
|
+
...incumbent,
|
|
1121
|
+
fileSeq: incumbent.fileSeq ?? entry.fileSeq,
|
|
1122
|
+
generatedAt: olderStamp(incumbent.generatedAt ?? newerReport?.generatedAt, entry.generatedAt ?? olderReport?.generatedAt),
|
|
1123
|
+
warnings: incumbent.warnings,
|
|
1124
|
+
}
|
|
1125
|
+
: {
|
|
1126
|
+
...incumbent,
|
|
1127
|
+
fileSeq: incumbent.fileSeq ?? entry.fileSeq,
|
|
1128
|
+
generatedAt: olderStamp(incumbent.generatedAt ?? newerReport?.generatedAt, entry.generatedAt ?? olderReport?.generatedAt),
|
|
1129
|
+
warnings: mergeWarnings([...incumbent.warnings, ...entry.warnings]),
|
|
1130
|
+
}
|
|
1119
1131
|
: { ...entry };
|
|
1120
1132
|
if (deferredPublish) {
|
|
1121
1133
|
// Mark it, so the next in-band publish carries it forward across the
|
|
@@ -1541,7 +1553,7 @@ export function formatActionableWarningsAdvisory(report, cwd, host = "pi") {
|
|
|
1541
1553
|
const files = report.files.filter((file) => file.warnings.some((warning) => !warning.suppressed));
|
|
1542
1554
|
const fileList = files
|
|
1543
1555
|
.slice(0, 5)
|
|
1544
|
-
.map((file) => ` ${file.displayPath}: ${file.warnings.filter((warning) => !warning.suppressed).length}`)
|
|
1556
|
+
.map((file) => ` ${file.displayPath}: ${file.warnings.filter((warning) => !warning.suppressed).length}${file.reVerifyIncomplete ? " (re-verify incomplete)" : ""}`)
|
|
1545
1557
|
.join("\n");
|
|
1546
1558
|
const more = files.length > 5 ? `\n ... and ${files.length - 5} more file(s)` : "";
|
|
1547
1559
|
const safe = report.summary.autoFixEligible > 0
|
|
@@ -3,6 +3,7 @@ import { retagAuxiliaryDiagnostics } from "./dispatch/auxiliary-lsp.js";
|
|
|
3
3
|
import { applyFindingPolicy, loadProjectRulePolicyMap, renderedRuleIdentities, } from "./dispatch/finding-policy.js";
|
|
4
4
|
import { detectFileRole } from "./file-role.js";
|
|
5
5
|
import { logLatency } from "./latency-logger.js";
|
|
6
|
+
import { formatCacheAgeLabel } from "./finding-delivery-gate.js";
|
|
6
7
|
import { convertLspDiagnostics } from "./dispatch/utils/lsp-diagnostics.js";
|
|
7
8
|
import { toRunnerDisplayPath } from "./dispatch/runner-context.js";
|
|
8
9
|
export function formatCascadeNeighborDiagnostics(cwd, neighbors, options = {}) {
|
|
@@ -140,6 +141,12 @@ export function buildResolvedFoundCascadeRun(cwd, neighbor) {
|
|
|
140
141
|
},
|
|
141
142
|
neighborCount: 1,
|
|
142
143
|
diagnosticCount: diagnostics.length,
|
|
144
|
+
// #3168 F3: the #1444 publish stamp — the run's own observation time, so
|
|
145
|
+
// the carried-render age label states the real age instead of claiming
|
|
146
|
+
// no stamp exists.
|
|
147
|
+
...(neighbor.publishedAt !== undefined
|
|
148
|
+
? { observedAt: neighbor.publishedAt }
|
|
149
|
+
: {}),
|
|
143
150
|
};
|
|
144
151
|
}
|
|
145
152
|
/**
|
|
@@ -289,3 +296,20 @@ function readNeighborContent(filePath) {
|
|
|
289
296
|
return undefined;
|
|
290
297
|
}
|
|
291
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* Fix B (#3167/#3168): the carry label for a cascade run re-rendered at a
|
|
301
|
+
* later turn_end. The carry is bounded to ONE turn (`RuntimeCoordinator.beginTurn`
|
|
302
|
+
* drops anything that would reach 2), so the honest label names the carry
|
|
303
|
+
* count. #3168 F3 corrected this docstring's earlier claim that no stamp
|
|
304
|
+
* exists: the run DOES carry one (`observedAt`, threaded from #1444's
|
|
305
|
+
* `publishedAt` through the resolved-found plumb), so the age half renders
|
|
306
|
+
* from it via `formatCacheAgeLabel`. The deferred-compute re-park arm has no
|
|
307
|
+
* stamp and correctly falls to the neutral wording. Returns `undefined` for
|
|
308
|
+
* non-carried runs: no label noise on fresh observations.
|
|
309
|
+
*/
|
|
310
|
+
export function cascadeCarrySuffix(carriedTurns, observedAt) {
|
|
311
|
+
if (carriedTurns === undefined || carriedTurns < 1)
|
|
312
|
+
return undefined;
|
|
313
|
+
const noun = carriedTurns === 1 ? "turn" : "turns";
|
|
314
|
+
return `(carried ${carriedTurns} ${noun} · ${formatCacheAgeLabel(observedAt)})`;
|
|
315
|
+
}
|
|
@@ -211,6 +211,48 @@ function inCaptureWindow(fn) {
|
|
|
211
211
|
function isCaptureSeam(prop) {
|
|
212
212
|
return (prop === "on" || (typeof prop === "string" && prop.startsWith("register")));
|
|
213
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* True for a host SCHEMA value that happens to be callable.
|
|
216
|
+
*
|
|
217
|
+
* A tool definition's `parameters` is DATA the host reads, not a pi-lens
|
|
218
|
+
* callback — but the schema builder pi-lens imports is host-provided
|
|
219
|
+
* (`clients/deps/typebox.ts`: the host resolves the bare `typebox`
|
|
220
|
+
* specifier), and on a host whose builders are ArkType-style the schema is a
|
|
221
|
+
* FUNCTION. `@oh-my-pi/pi-coding-agent` rewrites an extension's `typebox`
|
|
222
|
+
* import to its own `src/extensibility/legacy-typebox.ts`, which re-exports
|
|
223
|
+
* `@oh-my-pi/omptype`'s builders; those return a callable whose prototype
|
|
224
|
+
* carries `toJsonSchema`/`assert`. `inCaptureWindow` returns a plain rest
|
|
225
|
+
* wrapper with neither, so the host falls off its ark branch, the wire schema
|
|
226
|
+
* degrades to `undefined`, and every xd:// device renders `type Args =
|
|
227
|
+
* unknown` and rejects every call with `root: schema must be an object or
|
|
228
|
+
* boolean` (#3195).
|
|
229
|
+
*
|
|
230
|
+
* The predicate IS the host's own, copied from `@oh-my-pi/pi-ai@18.2.4`
|
|
231
|
+
* `src/utils/schema/wire.ts:21-27` (`isArkSchema`) — the same test the host's
|
|
232
|
+
* `applyToolProxy` (`@oh-my-pi/pi-coding-agent@18.2.4`
|
|
233
|
+
* `src/extensibility/tool-proxy.ts:26`) uses to leave a callable schema
|
|
234
|
+
* unbound for exactly this hazard ("`bind()` … drops the schema surface …
|
|
235
|
+
* so a bound schema later stringifies to `undefined`"). Matching it means
|
|
236
|
+
* pi-lens declines to wrap precisely the values the host reads as schemas,
|
|
237
|
+
* and still wraps everything else.
|
|
238
|
+
* `tests/clients/pi-host-callable-schema-contract.test.ts` asserts against
|
|
239
|
+
* that upstream predicate (and against a fixture measured from a real
|
|
240
|
+
* `@oh-my-pi/omptype@18.2.4` schema), not against this copy of it.
|
|
241
|
+
*
|
|
242
|
+
* Deliberately NOT an allow-list of handler keys (`execute`, `renderResult`,
|
|
243
|
+
* `handler` — the measured set today): that is `isCaptureSeam`'s #1434 S1a
|
|
244
|
+
* mistake one level down (defect shape 34, spelling enumerator). The host's
|
|
245
|
+
* own `ToolDefinition` already declares `onSession`, `renderCall` and
|
|
246
|
+
* `shellEnv`, and `registerCommand` takes `getArgumentCompletions`; the day
|
|
247
|
+
* pi-lens adopts one of those, an allow-list drops its console capture
|
|
248
|
+
* SILENTLY — the #1333 regression this seam exists to prevent — while this
|
|
249
|
+
* predicate keeps wrapping it.
|
|
250
|
+
*/
|
|
251
|
+
function isCallableSchema(value) {
|
|
252
|
+
return (typeof value === "function" &&
|
|
253
|
+
typeof value.toJsonSchema === "function" &&
|
|
254
|
+
typeof value.assert === "function");
|
|
255
|
+
}
|
|
214
256
|
/**
|
|
215
257
|
* Wrap every function value found in `value`, in place. Handles the two
|
|
216
258
|
* shapes a `register*`/`on` call takes today: a function passed directly
|
|
@@ -218,6 +260,11 @@ function isCaptureSeam(prop) {
|
|
|
218
260
|
* function ONE level inside a plain options/definition object
|
|
219
261
|
* (`options.handler`, `tool.execute`).
|
|
220
262
|
*
|
|
263
|
+
* Only the property walk screens for a callable schema (#3195): every
|
|
264
|
+
* function pi-lens passes as a register argument DIRECTLY is a handler
|
|
265
|
+
* (`on`, `registerMessageRenderer`), measured across the whole activation,
|
|
266
|
+
* so a schema can only ever arrive as a property of a definition object.
|
|
267
|
+
*
|
|
221
268
|
* Deliberately NOT recursive past that one level (#1434 perf review): a tool
|
|
222
269
|
* definition's `parameters`/`schema` is a large, deeply nested TypeBox object
|
|
223
270
|
* that holds no functions, but walking its full tree on every `registerTool`
|
|
@@ -249,7 +296,7 @@ function wrapFunctionsInPlace(value) {
|
|
|
249
296
|
const obj = value;
|
|
250
297
|
for (const key of Object.keys(obj)) {
|
|
251
298
|
const propValue = obj[key];
|
|
252
|
-
if (typeof propValue === "function") {
|
|
299
|
+
if (typeof propValue === "function" && !isCallableSchema(propValue)) {
|
|
253
300
|
assignWrapped(obj, key, propValue);
|
|
254
301
|
}
|
|
255
302
|
}
|
|
@@ -284,20 +284,19 @@ export const DELIVERY_SURFACES = {
|
|
|
284
284
|
"runtime-turn:late-runner-findings": gated(RUNTIME_TURN_FILE, "Turn-end CLI runner findings collected after the post-write path.", ["gateFindingsByPathFreshness"], ['store: "late-runner-findings"'], { evidenceMin: 2 }),
|
|
285
285
|
"runtime-turn:cascade-blocker": labeled(RUNTIME_TURN_FILE, "Turn-end 🧪 cascade neighbor blocker.", "Cascade results settle synchronously this turn where possible " +
|
|
286
286
|
"(`settleCascadeRuns`), but an unsettled compute can carry over to a " +
|
|
287
|
-
"later turn (bounded by a carry cap) —
|
|
288
|
-
"
|
|
289
|
-
|
|
290
|
-
partialReason: "A carried-over cascade result (run.carriedTurns > 0) is rendered " +
|
|
291
|
-
"without an age label. Follow-up: surface carriedTurns as an explicit " +
|
|
292
|
-
"label when > 0, or route through formatCacheAgeLabel using the run's " +
|
|
293
|
-
"own timestamp.",
|
|
294
|
-
}),
|
|
287
|
+
"later turn (bounded by a carry cap) — a carried-over result renders " +
|
|
288
|
+
"with an explicit `(carried N turns · scanned Xm ago)` label (#3167, " +
|
|
289
|
+
"#3168 F3), so the agent can tell it from a fresh observation.", "live", ["cascadeCarrySuffix("], { evidenceMin: 1 }),
|
|
295
290
|
"runtime-turn:cascade-coverage-advisory": labeled(RUNTIME_TURN_FILE, "Turn-end cascade-coverage-gap advisories (graph/binding/budget).", "Explains what the cascade check could NOT confirm this turn — not a " +
|
|
296
291
|
"finding with a cited path, an absence-of-coverage disclosure computed " +
|
|
297
|
-
"from this turn's own indeterminate-run list.
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
292
|
+
"from this turn's own indeterminate-run list. An advisory computed from " +
|
|
293
|
+
"a carried indeterminate run is labeled `(carried N turns · <age>)` " +
|
|
294
|
+
"(#3167, #3168 F4 — dropped on mixed carried/fresh buckets). The age " +
|
|
295
|
+
"half reads `scan age unknown` on this surface today: no indeterminate-" +
|
|
296
|
+
"run producer stamps `observedAt` (only the resolved-found plumb does, " +
|
|
297
|
+
"`clients/cascade-format.ts`), and one unstamped carried run collapses " +
|
|
298
|
+
"the whole bucket's age rather than claiming the stamped runs' (#3168 " +
|
|
299
|
+
"F13). The carry COUNT is always real.", "live", ["withCarryLabel("], { evidenceMin: 3 }),
|
|
301
300
|
// #3102: the cold-neighbour cascade run is BUILT here, in the quiet-window
|
|
302
301
|
// reconcile (`onResolvedFound` in index.ts), a turn earlier than the
|
|
303
302
|
// `runtime-turn:cascade-blocker` render that finally carries it — so it is
|
|
@@ -344,6 +344,9 @@ export function registerCascadeTierReconcileTask(getLspService, options = {}) {
|
|
|
344
344
|
filePath: o.filePath,
|
|
345
345
|
serverId: o.serverId,
|
|
346
346
|
diagnostics: o.diagnostics,
|
|
347
|
+
...(o.publishedAt !== undefined
|
|
348
|
+
? { publishedAt: o.publishedAt }
|
|
349
|
+
: {}),
|
|
347
350
|
});
|
|
348
351
|
}
|
|
349
352
|
else if (o.outcome === "resolved-clean" && o.publishedAt != null) {
|
|
@@ -3955,6 +3955,12 @@ export class LSPService {
|
|
|
3955
3955
|
// (undefined = the race didn't produce an answer; the end-of-wait
|
|
3956
3956
|
// fallback below may still fill it in on a timed-out empty result).
|
|
3957
3957
|
let tsserverSyncEligible = false;
|
|
3958
|
+
// #1639/#3187: `ensureWarmForSweep`'s readiness probe (`source:
|
|
3959
|
+
// "lsp_sweep_warmup"`) is the one touch that is single-server,
|
|
3960
|
+
// `clientScope: "primary"` and NON-collecting at the same time. Both
|
|
3961
|
+
// sync-confirm gates below and the per-server wait's settle-source tag
|
|
3962
|
+
// read the same fact, so it is derived once, here.
|
|
3963
|
+
const isWarmupTouch = source === "lsp_sweep_warmup";
|
|
3958
3964
|
let tsserverSyncConfirmed;
|
|
3959
3965
|
let diagnosticsUnsupportedServerIds = [];
|
|
3960
3966
|
if (diagnosticsMode !== "none") {
|
|
@@ -4128,8 +4134,19 @@ export class LSPService {
|
|
|
4128
4134
|
// synchronous gate and pays ZERO extra work — not even the snapshot
|
|
4129
4135
|
// read; a non-typescript `silentOnClean` server instead gets the
|
|
4130
4136
|
// generic (non-racing) clean-confirm fallback further below.
|
|
4137
|
+
// #3187: the warm-up touch is eligible too, though it collects
|
|
4138
|
+
// nothing. `collectDiagnostics` is here because a touch that discards
|
|
4139
|
+
// its diagnostics has nothing to carry a confirm INTO — but the warm-up
|
|
4140
|
+
// is not after diagnostics at all, it is after a VERDICT, and the sync
|
|
4141
|
+
// commands are the only thing that can hand a tier3-silent server one
|
|
4142
|
+
// before its whole cold-start budget lapses (measured: 6,972 ms of a
|
|
4143
|
+
// 20,000 ms warm-up budget, then the sweep touched the same file again
|
|
4144
|
+
// in 113 ms). It stays non-collecting on purpose: a collecting warm-up
|
|
4145
|
+
// would write this file's `lastKnownDiagnostics` from primary-scope-only
|
|
4146
|
+
// evidence — and an empty confirm would DELETE a previously confirmed
|
|
4147
|
+
// record — which is the #1470/#1493 prime hazard from a new direction.
|
|
4131
4148
|
if (!notifyWriteTimedOut &&
|
|
4132
|
-
options.collectDiagnostics === true &&
|
|
4149
|
+
(options.collectDiagnostics === true || isWarmupTouch) &&
|
|
4133
4150
|
clientScope === "primary" &&
|
|
4134
4151
|
spawned.length === 1 &&
|
|
4135
4152
|
spawned[0].client.serverId === "typescript" &&
|
|
@@ -4279,8 +4296,8 @@ export class LSPService {
|
|
|
4279
4296
|
// consumer can tell them apart instead of double-counting. Omitted
|
|
4280
4297
|
// (rather than passed as "pull") on the common path — the client
|
|
4281
4298
|
// already defaults to "pull", and existing tests assert the exact
|
|
4282
|
-
// argument list `waitForDiagnostics` is called with.
|
|
4283
|
-
|
|
4299
|
+
// argument list `waitForDiagnostics` is called with. #3187: derived
|
|
4300
|
+
// once above, next to the sync-confirm gate that reads the same fact.
|
|
4284
4301
|
// #743: per-server — a server we DID push to still gets the
|
|
4285
4302
|
// version-baseline wait even when a sibling was debounced away.
|
|
4286
4303
|
const waitForDiagnostics = () => !notifySkippedServerIds.has(entry.info.id) &&
|
|
@@ -5127,9 +5144,9 @@ export class LSPService {
|
|
|
5127
5144
|
// ever double-deciding typescript's own touches — when the sync race
|
|
5128
5145
|
// was attempted and failed/was unavailable, typescript's existing
|
|
5129
5146
|
// "falls through to inconclusive, unchanged" contract (#707) is
|
|
5130
|
-
// preserved exactly
|
|
5131
|
-
//
|
|
5132
|
-
//
|
|
5147
|
+
// preserved exactly for a COLLECTING touch; #3187 re-admits the
|
|
5148
|
+
// non-collecting warm-up, which has no end-of-wait retry to fall
|
|
5149
|
+
// through to (see the exclusion's own comment below). Scoped
|
|
5133
5150
|
// to `clientScope === "primary"`/`spawned.length === 1` exactly like
|
|
5134
5151
|
// the sync-eligible gate above (and like `ensureWarmForSweep`'s own
|
|
5135
5152
|
// `clientScope: "primary"` warm-up touch) so a multi-server
|
|
@@ -5149,7 +5166,17 @@ export class LSPService {
|
|
|
5149
5166
|
// below means the one server IS the primary, so this is the same condition
|
|
5150
5167
|
// written in the vocabulary the rest of the merge now uses.
|
|
5151
5168
|
!primaryNotifyWriteTimedOut &&
|
|
5152
|
-
|
|
5169
|
+
// #3187: the warm-up is sync-ELIGIBLE now, and eligibility alone must
|
|
5170
|
+
// not cost it this fallback. For a collecting touch the exclusion is
|
|
5171
|
+
// #707's contract (the end-of-wait retry above is typescript's second
|
|
5172
|
+
// chance, and failing it stays inconclusive); the warm-up collects
|
|
5173
|
+
// nothing, so that retry's `collected !== undefined` precondition can
|
|
5174
|
+
// never hold for it and the exclusion would leave a server whose
|
|
5175
|
+
// `typescript.tsserverRequest` is unavailable stranded inconclusive —
|
|
5176
|
+
// a #744 warm-up failure, its retry, and a SKIPPED sweep group, where
|
|
5177
|
+
// today it is certified clean. Reaching here at all means the race
|
|
5178
|
+
// produced no answer, so this decides nothing the sync already decided.
|
|
5179
|
+
(!tsserverSyncEligible || isWarmupTouch) &&
|
|
5153
5180
|
clientScope === "primary" &&
|
|
5154
5181
|
spawned.length === 1 &&
|
|
5155
5182
|
getStrategy(spawned[0].client.serverId, spawned[0].client.getLaunchVariant?.()).silentOnClean === true) {
|
|
@@ -6824,6 +6851,13 @@ export class LSPService {
|
|
|
6824
6851
|
});
|
|
6825
6852
|
const warmupAttempt = this.touchFile(representativeFile, content, {
|
|
6826
6853
|
diagnostics: "document",
|
|
6854
|
+
// #3187: stays FALSE. This touch wants a verdict, not findings: its
|
|
6855
|
+
// `clientScope: "primary"` evidence does not speak for the auxiliaries
|
|
6856
|
+
// the sweep's own `clientScope: "all"` touch waits on, so collecting
|
|
6857
|
+
// here would prime this file's `lastKnownDiagnostics` from a narrower
|
|
6858
|
+
// scope than the result that follows (and an empty confirm would delete
|
|
6859
|
+
// a previously confirmed record). `touchFile`'s sync-confirm gates key
|
|
6860
|
+
// off `source` instead, so the verdict still arrives at ~grace+RTT.
|
|
6827
6861
|
collectDiagnostics: false,
|
|
6828
6862
|
clientScope: "primary",
|
|
6829
6863
|
source: "lsp_sweep_warmup",
|
|
@@ -273,8 +273,15 @@ export async function analyzeFile(filePath, cwd, options = {}) {
|
|
|
273
273
|
const durationMs = Date.now() - start;
|
|
274
274
|
if (options.record !== false) {
|
|
275
275
|
// Mirror pipeline.ts's recording so pilens_diagnostics (mode=all) and
|
|
276
|
-
// pilens_health see what this analysis found.
|
|
277
|
-
|
|
276
|
+
// pilens_health see what this analysis found. #3160 F1: `absPath` here
|
|
277
|
+
// is derived raw from the agent-supplied `file` arg (path.resolve above)
|
|
278
|
+
// — unlike pipeline.ts, which always records ctx.filePath, already
|
|
279
|
+
// canonicalized to on-disk casing by createDispatchContext (#2016/
|
|
280
|
+
// #3098). Normalize before recording so a mis-cased analyze call lands
|
|
281
|
+
// under the SAME widget-state key a canonical per-edit write already
|
|
282
|
+
// uses, instead of an orphaned key no reader (raw or normalized) can
|
|
283
|
+
// reach.
|
|
284
|
+
recordDiagnostics(normalizeMapKey(absPath), result.diagnostics);
|
|
278
285
|
if (result.diagnostics.length > 0) {
|
|
279
286
|
getDiagnosticTracker().trackShown(result.diagnostics);
|
|
280
287
|
}
|
|
@@ -161,6 +161,7 @@ function adoptCanonicalCasing(held, canonical) {
|
|
|
161
161
|
* (needed for new files where we haven't written yet)
|
|
162
162
|
*
|
|
163
163
|
* On POSIX:
|
|
164
|
+
* - Folds `.`, `..` and duplicate separators first (#3184, see below).
|
|
164
165
|
* - If the file exists: adopts the on-disk CASING of the trailing segments
|
|
165
166
|
* (see `adoptCanonicalCasing`) — no lowercasing, no symlink resolution.
|
|
166
167
|
* - If the file doesn't exist: returns the path as-is, case-PRESERVING. On a
|
|
@@ -169,6 +170,26 @@ function adoptCanonicalCasing(held, canonical) {
|
|
|
169
170
|
* answer for a path that EXISTS can tell the two apart, and for a path that
|
|
170
171
|
* does not exist there is no such answer to ask for.
|
|
171
172
|
*
|
|
173
|
+
* Why the POSIX arm folds dot segments (#3184): the casing arm returns the
|
|
174
|
+
* caller's own spelling whenever `adoptCanonicalCasing` changes nothing, and
|
|
175
|
+
* for `<base>/src/../src/a.ts` it always changes nothing — `realpath` answers
|
|
176
|
+
* a string with FEWER segments, which a casing-only rewrite cannot express, so
|
|
177
|
+
* it declines and the caller's un-folded spelling came back as the map key.
|
|
178
|
+
* Every canonical writer keys through `path.resolve` first (`ctx.filePath` =
|
|
179
|
+
* `normalizeMapKey(resolveAgainstAncestors(...))`, `clients/dispatch/
|
|
180
|
+
* runner-context.ts:49`), so a consumer that passes an ALREADY-absolute
|
|
181
|
+
* agent-typed path straight in (`tools/lens-diagnostic-mark.ts`,
|
|
182
|
+
* `clients/mcp/analyze.ts`) derived an orphan key that no reader could reach.
|
|
183
|
+
* Folding here is pure string algebra — no cwd, no filesystem — so a relative
|
|
184
|
+
* path stays relative (`src/../x` → `x`, `../x` → `../x`, never resolved
|
|
185
|
+
* against `process.cwd()`; refs #2490, where a cwd fold broke every monorepo)
|
|
186
|
+
* and a symlinked package still keys under the path the caller held. These
|
|
187
|
+
* are `path.resolve`'s own TEXTUAL `..` semantics, which is exactly what
|
|
188
|
+
* makes a folded reader key equal to the canonical writer's key: where a
|
|
189
|
+
* `..` sits right after a symlinked directory, textual folding and the
|
|
190
|
+
* kernel disagree, and both sides of every comparison take the textual
|
|
191
|
+
* answer because every canonical writer already resolved that way.
|
|
192
|
+
*
|
|
172
193
|
* Why POSIX canonicalizes casing at all (#3098, the #1024 defect's live half):
|
|
173
194
|
* a case-insensitive POSIX filesystem — macOS's default APFS, `nocase` vfat /
|
|
174
195
|
* ntfs3 / cifs mounts — makes `SUB/a.ts` and `sub/a.ts` ONE file, so a raw
|
|
@@ -194,16 +215,37 @@ export function normalizeFilePath(filePath) {
|
|
|
194
215
|
// Convert backslashes to forward slashes first
|
|
195
216
|
const normalized = filePath.replace(/\\/g, "/");
|
|
196
217
|
if (process.platform !== "win32" && !isWindowsPath(normalized)) {
|
|
218
|
+
// #3184. `path.posix`, not the host default: this branch is already
|
|
219
|
+
// committed to POSIX parsing of a slash-folded string (shape 2). Only
|
|
220
|
+
// the POSIX arm needs this — the win32 arm below reaches `realpath` or
|
|
221
|
+
// `win32.resolve`/`win32.normalize` on every path, all of which fold
|
|
222
|
+
// dot segments already (measured: `C:\repo\src\..\src\a.ts` →
|
|
223
|
+
// `c:/repo/src/a.ts` on this POSIX host, before this change) — and
|
|
224
|
+
// folding BEFORE the arms would also move which arm a degenerate
|
|
225
|
+
// drive-letter path selects (`path.posix.normalize("C:/repo/../..")`
|
|
226
|
+
// is `"."`, no longer Windows-shaped).
|
|
227
|
+
// Two inputs keep the caller's spelling instead:
|
|
228
|
+
// - "" is a non-path sentinel in this codebase's path-typed fields
|
|
229
|
+
// (see `normalizeLoggedPath`'s doc); `posix.normalize("")` invents
|
|
230
|
+
// ".", the process cwd.
|
|
231
|
+
// - a UNC root (`\\server\share`, slash-folded to `//server/share`)
|
|
232
|
+
// reaches THIS arm on a POSIX host, because `isWindowsPath` tests
|
|
233
|
+
// the already-folded string and sees no backslash; POSIX
|
|
234
|
+
// `normalize` collapses its leading `//` to `/`, renaming the path
|
|
235
|
+
// to an unrelated local one.
|
|
236
|
+
const folded = normalized === "" || normalized.startsWith("//")
|
|
237
|
+
? normalized
|
|
238
|
+
: path.posix.normalize(normalized);
|
|
197
239
|
try {
|
|
198
|
-
const canonical = realpathSync.native(
|
|
199
|
-
// Fast path, not a guard: both arms answer `
|
|
240
|
+
const canonical = realpathSync.native(folded);
|
|
241
|
+
// Fast path, not a guard: both arms answer `folded` when the
|
|
200
242
|
// strings match, but skipping the two `split`s there is a measured
|
|
201
243
|
// 1.9 vs 2.3 microseconds per call on the per-edit seam (#3098).
|
|
202
|
-
if (canonical ===
|
|
203
|
-
return
|
|
204
|
-
const adopted = adoptCanonicalCasing(
|
|
205
|
-
if (adopted ===
|
|
206
|
-
return
|
|
244
|
+
if (canonical === folded)
|
|
245
|
+
return folded;
|
|
246
|
+
const adopted = adoptCanonicalCasing(folded, canonical);
|
|
247
|
+
if (adopted === folded)
|
|
248
|
+
return folded;
|
|
207
249
|
// The rewrite is string algebra and can land on a DIFFERENT file
|
|
208
250
|
// (#3159 review round 2, F1 — see `adoptCanonicalCasing`). Adopt it
|
|
209
251
|
// only once the filesystem agrees it still names the file the caller
|
|
@@ -213,11 +255,11 @@ export function normalizeFilePath(filePath) {
|
|
|
213
255
|
// never on an already-canonical path. A throw here (the rewritten
|
|
214
256
|
// path does not exist, the #2490 monorepo case) lands in the catch
|
|
215
257
|
// below and keeps the caller's spelling, which is the same answer.
|
|
216
|
-
return realpathSync.native(adopted) === canonical ? adopted :
|
|
258
|
+
return realpathSync.native(adopted) === canonical ? adopted : folded;
|
|
217
259
|
}
|
|
218
260
|
catch {
|
|
219
261
|
// Does not exist (or is unreadable): case-preserving, as above.
|
|
220
|
-
return
|
|
262
|
+
return folded;
|
|
221
263
|
}
|
|
222
264
|
}
|
|
223
265
|
// Windows: try realpathSync.native() for canonical casing
|