@banbolee/dsh-lsp-diagnostics 0.1.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/README.md +297 -0
- package/collector.js +220 -0
- package/coordinator.js +686 -0
- package/cordis.patch.yml +4 -0
- package/framing.js +170 -0
- package/index.js +581 -0
- package/package.json +70 -0
- package/render.js +468 -0
- package/runtime.js +1613 -0
- package/tool.js +496 -0
package/README.md
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# @banbolee/dsh-lsp-diagnostics
|
|
2
|
+
|
|
3
|
+
Host-plane Cordis bundle plugin for DeepSeek Harness: after an official
|
|
4
|
+
`write`/`edit`/`str_replace_editor` mutation lands inside the session
|
|
5
|
+
workspace, the tool result still succeeds and the next model inference carries
|
|
6
|
+
a single persistent LSP diagnostics plugin notice. It also registers the
|
|
7
|
+
model-callable `lsp_diagnostics(file_path)` tool for explicit diagnosis.
|
|
8
|
+
|
|
9
|
+
The plugin is a named namespace function plugin: it exports `name`, `inject`,
|
|
10
|
+
`Config`, and `apply`, and has no default export. The real Loader reads the
|
|
11
|
+
module namespace (`exports.default ?? exports`) and Cordis reads
|
|
12
|
+
`inject`/`Config` from the plugin object, so every public metadata field
|
|
13
|
+
survives installation. The plugin never modifies `deepseek-harness`, never
|
|
14
|
+
extends `ctx.lsp`, never imports `@deepseek-ai/*/src/*`, and never
|
|
15
|
+
monkey-patches official objects.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
Install the bundle into any DSH profile from the repository root. The
|
|
20
|
+
workspace-root flag `-w` is required: without it pnpm cannot resolve the local
|
|
21
|
+
package inside the profile's generated workspace.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
dsh plugin --profile <profile> add -w ./plugins/dsh-lsp-diagnostics
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
To install into an isolated profile with a single command:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
DSH_HOME="$(mktemp -d)" scripts/sync-lsp-diagnostics-to-profile.sh <profile>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The sync helper requires `DSH_HOME` so tests and manual QA always target
|
|
34
|
+
isolated profile state, never the user's default Harness home.
|
|
35
|
+
|
|
36
|
+
The plugin registers one real three-parameter `tools/post-execute` waterfall
|
|
37
|
+
listener `(exec, _result, next)`: it calls `await next()` exactly once,
|
|
38
|
+
outside any plugin catch, and never consumes the `_result` placeholder.
|
|
39
|
+
|
|
40
|
+
## Config
|
|
41
|
+
|
|
42
|
+
All knobs ride through the plugin's `Config` schema with a strict validator.
|
|
43
|
+
Unknown keys, invalid JSON-representable values, invalid timers/caps, or any
|
|
44
|
+
deviation from the closed extension route fail loud at load time.
|
|
45
|
+
|
|
46
|
+
| Key | Default | Meaning |
|
|
47
|
+
| --- | --- | --- |
|
|
48
|
+
| `enabled` | `true` | With `enabled=false` the plugin creates zero collector, runtime, coordinator, or tool, registers zero tools/listeners/effects, and spawns zero processes. |
|
|
49
|
+
| `timeoutMs` | `5000` | Single non-extendable deadline for one post-execute aggregate. |
|
|
50
|
+
| `settleMs` | `200` | Quiet window; must be `< timeoutMs`. |
|
|
51
|
+
| `shutdownTimeoutMs` | `1000` | Graceful `shutdown`/`exit` budget. |
|
|
52
|
+
| `killGraceMs` | `500` | Process-tree TERM→KILL grace. |
|
|
53
|
+
| `maxDocumentBytes` | `2097152` | Hard UTF-8 byte cap for the document sent via `didOpen`. |
|
|
54
|
+
| `maxMessageBytes` | `4194304` | LSP message body cap. |
|
|
55
|
+
| `maxStderrBytes` | `16384` | Bounded stderr tail cap. |
|
|
56
|
+
| `maxDiagnostics` | `50` | Global cap on automatic diagnostic lines and per-call cap on the canonical direct-tool diagnostics array. |
|
|
57
|
+
| `maxResultChars` | `8000` | Unicode code-point cap on the final aggregate text. |
|
|
58
|
+
| `reportClean` | `true` | Whether explicit empty results render `Status: clean`. |
|
|
59
|
+
| `servers` | TypeScript + Go | Closed provider catalog; TypeScript and Go are enabled by default, while `clangd`, `rust`, and `python` are opt-in. |
|
|
60
|
+
|
|
61
|
+
The extension route is closed: `.ts` → typescript/typescript, `.tsx` →
|
|
62
|
+
typescript/typescriptreact, `.go` → go/go, `.c` → clangd/c,
|
|
63
|
+
`.cc`/`.cpp`/`.cxx` → clangd/cpp, `.h`/`.hh`/`.hpp`/`.hxx` → clangd/cpp,
|
|
64
|
+
`.rs` → rust/rust, and `.py`/`.pyi` → python/python; any other extension key,
|
|
65
|
+
missing canonical provider entry, cross-provider collision, or rewritten
|
|
66
|
+
language id fails loud at load time, while other extensions at runtime are
|
|
67
|
+
silently ignored.
|
|
68
|
+
|
|
69
|
+
The `servers` block is a provider-level partial overlay: omitting `servers` or
|
|
70
|
+
supplying `servers: {}` keeps exactly the TypeScript and Go defaults, while
|
|
71
|
+
supplying a known optional `clangd`, `rust`, or `python` key activates that
|
|
72
|
+
provider. For example, `servers: { go: { command: '/path/trae-gopls' } }`
|
|
73
|
+
changes only Go without requiring a TypeScript block. The legacy
|
|
74
|
+
`extensionToLanguage` field is accepted only when it exactly equals that
|
|
75
|
+
provider's canonical mapping; it never defines new routes.
|
|
76
|
+
|
|
77
|
+
Default commands are `typescript-language-server --stdio`, `gopls`, `clangd`,
|
|
78
|
+
`rust-analyzer`, and `pyright-langserver --stdio`. clangd, rust-analyzer, and
|
|
79
|
+
gopls receive no default arguments. Per-server `env`, `configuration`, and
|
|
80
|
+
`initializationOptions` may be overridden and must be JSON-representable.
|
|
81
|
+
|
|
82
|
+
## Behavior
|
|
83
|
+
|
|
84
|
+
Only a non-empty `session.header.cwd` that canonicalizes to a directory and
|
|
85
|
+
contains the target is eligible; missing or empty cwd, non-directory
|
|
86
|
+
canonicalization, and outside-workspace targets are ineligible and silently
|
|
87
|
+
ignored — never a `workspace unavailable` or `outside workspace` notice.
|
|
88
|
+
Workspace, cwd, and URI are always derived through the public `ctx.fs` API
|
|
89
|
+
(`resolve`/`stat`/`contains`/`targetKey`/`processPath`/`fileUrl`), never from
|
|
90
|
+
`displayPath` or the host `process.cwd()`.
|
|
91
|
+
|
|
92
|
+
Fail-open is plugin-owned post-processing failure only: after the downstream
|
|
93
|
+
`await next()` decision succeeded, a diagnostics error, missing or crashed
|
|
94
|
+
server, protocol failure, read error, or timeout never changes the decision or
|
|
95
|
+
result and never returns `kind: 'block'`. Caller abort is decided by the
|
|
96
|
+
ToolRuntime and downstream listeners; a caller abort is never swallowed,
|
|
97
|
+
rewritten, or promised to become a success by this plugin.
|
|
98
|
+
|
|
99
|
+
Freshness uses the post-write `FsVersion` plus an independent per-target
|
|
100
|
+
monotonic generation counter whose values are never reused within one plugin lifetime.
|
|
101
|
+
`retireIfCurrent` removes only the matching active marker (generation + version)
|
|
102
|
+
and never touches the counter. When a generation counter is exhausted the
|
|
103
|
+
plugin fails safe and every older candidate stays stale.
|
|
104
|
+
|
|
105
|
+
Eligible targets are ordered by the single shared comparator
|
|
106
|
+
`(renderPath, String(targetKey), canonicalUri)` compared by Unicode
|
|
107
|
+
code points, and the same order drives both diagnosis scheduling and
|
|
108
|
+
rendered sections — raw `displayPath` tuples, `localeCompare`, and
|
|
109
|
+
default UTF-16 sorts are never used.
|
|
110
|
+
|
|
111
|
+
One exec produces at most one aggregate notice with exactly one title
|
|
112
|
+
`[LSP diagnostics after write]`, one section per retained file, exactly one
|
|
113
|
+
blank line between sections, and no leading or trailing newline. After the
|
|
114
|
+
global `maxDiagnostics` count cap, a diagnostics file with zero retained lines
|
|
115
|
+
is omitted entirely, never leaving an empty section; clean and unavailable
|
|
116
|
+
sections do not consume the cap. Exactly one global advisory is appended,
|
|
117
|
+
separated by one blank line, only when at least one diagnostic line is
|
|
118
|
+
retained. The `maxResultChars` cap is applied only after the complete
|
|
119
|
+
canonical aggregate is built: the fixed marker `…(truncated)` replaces the
|
|
120
|
+
truncated suffix, and the output never exceeds the cap.
|
|
121
|
+
|
|
122
|
+
Documents are read with a bounded `ctx.fs.readBytes(target, signal,
|
|
123
|
+
maxDocumentBytes)` after a `stat.size` preflight, with strict UTF-8 decoding;
|
|
124
|
+
an unbounded `readText` is never used. A known size above the cap, or an unknown-size read that throws `FsError`
|
|
125
|
+
with `code === 'FS_TOO_LARGE'`, maps to `document too large`; any other read
|
|
126
|
+
error maps to `diagnostics unavailable`. The public seam guarantees a
|
|
127
|
+
successful `readBytes` is complete with `bytes.length <= maxDocumentBytes`;
|
|
128
|
+
the plugin never relies on or returns over-cap bytes.
|
|
129
|
+
|
|
130
|
+
`publishDiagnostics` is correlated exact-URI-first, then by document version;
|
|
131
|
+
only the consumed fields `range`, `severity`, `code`, `source`, and `message`
|
|
132
|
+
are validated, while standard optionals `tags`, `relatedInformation`,
|
|
133
|
+
`codeDescription`, `data` and unknown extension fields are safely ignored and
|
|
134
|
+
never enter the normalized schema or renderer. Positions are 0-based UTF-16
|
|
135
|
+
and render as 1-based start-end coordinates.
|
|
136
|
+
|
|
137
|
+
The coordinator owns both the active augment-operation registry and the
|
|
138
|
+
`retiredIo` late-final-stat registry. Unload quiesces in the exact order:
|
|
139
|
+
stop direct-tool admission → stop coordinator admission (which closes runtime
|
|
140
|
+
admission) → offTool → offPost → offObserved → abort coordinator operations →
|
|
141
|
+
abort direct-tool operations → await all active augment promises → await all
|
|
142
|
+
active direct-tool promises → await all `retiredIo` → `runtime.dispose()`.
|
|
143
|
+
Cleanup continues after individual failures and aggregates them; therefore no
|
|
144
|
+
direct call can race runtime disposal. Every operation's outermost `finally` clears the deadline timer and removes
|
|
145
|
+
the caller/cleanup relay listeners, leaving zero residue.
|
|
146
|
+
|
|
147
|
+
`stopAdmission()` only closes the gate and the runtime's own admission; it
|
|
148
|
+
never aborts operations. Active operations are aborted at the later abort
|
|
149
|
+
step, after both listener disposers have run, exactly as the documented order
|
|
150
|
+
states. A listener that already entered but is still blocked in downstream
|
|
151
|
+
`next()` has no plugin-owned cancellation seam, so cleanup tracks every such
|
|
152
|
+
invocation in a pending-invocation registry and awaits its settlement (it
|
|
153
|
+
takes and retires the exec's candidates before the downstream result or error
|
|
154
|
+
returns, including when `next()` throws synchronously). The deadline uses an
|
|
155
|
+
injected monotonic clock, never `Date.now()`, so wall-clock rollback cannot
|
|
156
|
+
extend a hard deadline.
|
|
157
|
+
|
|
158
|
+
Session teardown also owns the protocol write quiescence: every in-flight
|
|
159
|
+
server-request handler and the serialized write tail settle before cleanup
|
|
160
|
+
returns, and no new server-request handlers start while closing — so no queued
|
|
161
|
+
frame (responses, `shutdown`, `exit`) can be written after cleanup resolves.
|
|
162
|
+
A matching `publishDiagnostics` that races the `didOpen` write is buffered
|
|
163
|
+
until the open write succeeds; only a successfully written open generation
|
|
164
|
+
accepts notifications, and a failed `didOpen` write still allows the allowed
|
|
165
|
+
fresh-instance retry. The shared runtime applies a generic correctness-over-performance policy: it
|
|
166
|
+
still pools one provider/workspace session across distinct canonical URIs, but
|
|
167
|
+
before a URI already opened by that session is diagnosed again it evicts and
|
|
168
|
+
asynchronously retires the session, then opens the URI at version 1 in a fresh
|
|
169
|
+
process; this avoids provider VFS caches returning stale same-URI diagnostics,
|
|
170
|
+
and retired teardown remains tracked and drained on unload.
|
|
171
|
+
|
|
172
|
+
`timeoutMs` is a single non-extendable deadline for the whole post-execute;
|
|
173
|
+
the final freshness round is a one-shot final stat/deadline/generation gate.
|
|
174
|
+
The direct tool likewise captures one absolute deadline from its injected
|
|
175
|
+
monotonic clock, and every gate transitions to timeout when `now() >= deadlineAt`
|
|
176
|
+
even if event-loop starvation has not run the scheduled timer callback yet.
|
|
177
|
+
A deadline/caller/cleanup loss never waits for late final stats: unsettled
|
|
178
|
+
records are retired into `retiredIo` before the decision returns. Session
|
|
179
|
+
teardown is the single order: `shutdown` request → `exit` notification/
|
|
180
|
+
natural-close wait → conditional `handle.terminate()` (only while still alive)
|
|
181
|
+
→ await `handle.done` and `waitForExit()` → `processLifetimeController.abort()`;
|
|
182
|
+
`terminate()` is the only hard stop.
|
|
183
|
+
|
|
184
|
+
## Model Experience
|
|
185
|
+
|
|
186
|
+
`lsp_diagnostics(file_path)` is a model-callable, read-only tool for one
|
|
187
|
+
existing file that `ctx.fs` permits the session to read. It accepts a
|
|
188
|
+
workspace-relative or absolute path and uses every configured provider and the
|
|
189
|
+
closed extension route documented above. It has the same path authority as the
|
|
190
|
+
official `read` tool: the session cwd is only the LSP project root, so an
|
|
191
|
+
explicit direct call may diagnose a readable file outside that root. It never
|
|
192
|
+
creates, edits, or deletes a file. Invalid requests fail explicitly with
|
|
193
|
+
`file_path must be a non-empty string`, `session workspace cwd`, `session
|
|
194
|
+
workspace is not an existing directory`, `target does not exist`, `target is
|
|
195
|
+
not a regular file`, `no configured diagnostics provider`, or `target changed
|
|
196
|
+
during diagnosis`.
|
|
197
|
+
|
|
198
|
+
The direct tool has exactly three canonical outcomes: `diagnostics` with the
|
|
199
|
+
normalized list sorted by the same comparator as automatic rendering and sliced
|
|
200
|
+
to `maxDiagnostics` before ToolRuntime/PTC receives it, plus an always-present
|
|
201
|
+
nonnegative integer `omitted_diagnostics`; `no_diagnostics`, rendered as `No
|
|
202
|
+
diagnostics reported for this file snapshot.`; and `unavailable` with one closed
|
|
203
|
+
reason. The renderer preserves the omission marker from that canonical count.
|
|
204
|
+
Call it to
|
|
205
|
+
diagnose an existing file explicitly, especially after a shell command,
|
|
206
|
+
formatter, or generator bypasses automatic `fs/observed` feedback; avoid
|
|
207
|
+
redundant calls when fresh automatic feedback already exists. Automatic
|
|
208
|
+
post-write feedback remains the default after supported write/edit mutations,
|
|
209
|
+
while the direct tool is deliberate and reports path/workspace errors instead
|
|
210
|
+
of silently treating them as ineligible.
|
|
211
|
+
|
|
212
|
+
From the model's point of view each mutation tool (`write`, `edit`,
|
|
213
|
+
`str_replace_editor` create/str_replace/insert) returns its original result
|
|
214
|
+
unchanged; the plugin appends one bounded aggregate notice to
|
|
215
|
+
`decision.additionalContexts` for the next inference. A notice contains:
|
|
216
|
+
|
|
217
|
+
- one `File: <renderPath>` section per retained file — `- <severity>
|
|
218
|
+
<startLine>:<startCharacter>-<endLine>:<endCharacter> source=... code=...
|
|
219
|
+
message` lines for diagnostics, `Status: clean`, or `Status: diagnostics
|
|
220
|
+
unavailable (<reason>)` — in the shared code-point order;
|
|
221
|
+
- exactly one global advisory when at least one diagnostic line is retained;
|
|
222
|
+
- nothing for unsupported extensions, ineligible workspaces, `enabled=false`,
|
|
223
|
+
or sessions without a workspace.
|
|
224
|
+
|
|
225
|
+
Unavailable reasons are closed: `server not found`, `server crashed`,
|
|
226
|
+
`timeout`, `malformed response`, `document too large`, `diagnostics
|
|
227
|
+
unavailable`.
|
|
228
|
+
|
|
229
|
+
## Known Limitations and Deferred Work
|
|
230
|
+
|
|
231
|
+
- shell redirection, `sed -i`, scripts, formatters, generators, and external
|
|
232
|
+
editors that bypass `ctx.fs`/`fs/observed` are out of scope for automatic
|
|
233
|
+
feedback; the model may call `lsp_diagnostics(file_path)` afterward. There is no
|
|
234
|
+
filesystem watcher and no project-root probing — the session cwd is the
|
|
235
|
+
workspace root.
|
|
236
|
+
- For automatic feedback, agentless tool execution, missing/empty or
|
|
237
|
+
non-directory cwd, and outside-workspace targets are silently out of scope
|
|
238
|
+
and never produce a notice. Direct calls use official `read`-equivalent path
|
|
239
|
+
authority instead: a readable file outside the session cwd is eligible, while
|
|
240
|
+
that cwd remains the LSP project root.
|
|
241
|
+
- The plugin does not install or download `typescript-language-server`,
|
|
242
|
+
`gopls`, `clangd`, `rust-analyzer`, or `pyright-langserver`; install requested
|
|
243
|
+
executables yourself and configure their paths. A missing server fails open
|
|
244
|
+
with `diagnostics unavailable (server not found)`.
|
|
245
|
+
- Portable deterministic tests use the repository fixture server and never use
|
|
246
|
+
a real server or the network. The separate explicit real-server lane is
|
|
247
|
+
opt-in, local-filesystem/process-only, and never installs or downloads tools.
|
|
248
|
+
- Implementation and tests use the `0.1.5-rc.1` dependency family; the peers
|
|
249
|
+
`@deepseek-ai/dsh-fs`, `@deepseek-ai/dsh-llm`, `@deepseek-ai/dsh-subprocess`,
|
|
250
|
+
and `@deepseek-ai/dsh-tools` are `^0.1.5-rc.1`.
|
|
251
|
+
|
|
252
|
+
## Verification
|
|
253
|
+
|
|
254
|
+
Deterministic package-local tests (fixture server, no network, no real LSP
|
|
255
|
+
server, isolated temp workspaces/profiles):
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
pnpm exec vitest run plugins/dsh-lsp-diagnostics tests/package-shape.spec.ts
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Documentation shape test (required sections and contract strings):
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
pnpm exec vitest run tests/docs-shape-lsp-diagnostics.spec.ts
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Type check and sync script help:
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
pnpm dlx --package=typescript@6.0.3 tsc -p plugins/dsh-lsp-diagnostics/tsconfig.json --noEmit
|
|
271
|
+
bash scripts/sync-lsp-diagnostics-to-profile.sh --help
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Explicit real-server verification is separate from portable coverage. It
|
|
275
|
+
requires `RUN_REAL_LSP_SERVERS=1`, a non-empty `REAL_LSP_PROVIDERS` list, an
|
|
276
|
+
explicit executable path for every requested provider, and an evidence path:
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
RUN_REAL_LSP_SERVERS=1 \
|
|
280
|
+
REAL_LSP_PROVIDERS=rust \
|
|
281
|
+
REAL_LSP_RUST_COMMAND=/data00/home/lixingxin/.cargo/bin/rust-analyzer \
|
|
282
|
+
REAL_LSP_EVIDENCE_PATH=/tmp/dsh-real-rust.json \
|
|
283
|
+
pnpm exec vitest run tests/composition/lsp-real-servers.spec.ts
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
The operator wrapper performs the same check:
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
node scripts/qa/run-lsp-real-servers.mjs \
|
|
290
|
+
--providers rust \
|
|
291
|
+
--rust-command /data00/home/lixingxin/.cargo/bin/rust-analyzer \
|
|
292
|
+
--evidence /tmp/dsh-real-rust.json
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Each requested provider must complete bad → diagnostic → repair → clean. The
|
|
296
|
+
JSON file is machine-readable evidence; a missing requested executable records
|
|
297
|
+
`status: "blocked"` and exits nonzero rather than skipping or passing.
|
package/collector.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutation collector for `fs/observed` write/edit observations.
|
|
3
|
+
*
|
|
4
|
+
* Three responsibility-separated structures:
|
|
5
|
+
*
|
|
6
|
+
* - `pendingByExec`: `WeakMap<exec, Map<targetKey, Candidate>>` — the latest
|
|
7
|
+
* candidate observed for each exec/target pair. A replaced candidate is
|
|
8
|
+
* immediately stale.
|
|
9
|
+
* - `nextGenerationByTarget`: `Map<targetKey, number | 'exhausted'>` — the
|
|
10
|
+
* next generation to allocate for a target (missing entry means `1`). It
|
|
11
|
+
* only allocates monotonically and never regresses or reuses a generation
|
|
12
|
+
* within a plugin lifetime; it is never cleaned per target.
|
|
13
|
+
* - `latestObserved`: `Map<targetKey, { generation, version }>` — the current
|
|
14
|
+
* active marker only, never a counter. `retireIfCurrent` reclaims it.
|
|
15
|
+
*
|
|
16
|
+
* Only `write`, `edit`, and `str_replace_editor` with a mutating command
|
|
17
|
+
* (`create`/`str_replace`/`insert`) are accepted; `view`, unsupported tools
|
|
18
|
+
* and absent observations are silently ignored. The observer never throws:
|
|
19
|
+
* hostile observations and internal counter anomalies are contained.
|
|
20
|
+
*
|
|
21
|
+
* @module @banbolee/dsh-lsp-diagnostics/collector
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {object} CollectorExec
|
|
26
|
+
* @property {unknown} [name] - the registered tool name.
|
|
27
|
+
* @property {{ command?: unknown }} [arguments] - parsed tool arguments.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {object} CollectorTargetLike
|
|
32
|
+
* @property {unknown} [targetKey] - opaque stable target identity.
|
|
33
|
+
* @property {unknown} [displayPath] - model-facing path.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {object} MutationCandidate
|
|
38
|
+
* @property {CollectorTargetLike} target - the mutated target.
|
|
39
|
+
* @property {string} version - the observed `FsVersion` token.
|
|
40
|
+
* @property {number} generation - the per-target monotonic generation.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {object} CollectorOptions
|
|
45
|
+
* @property {Map<string, number | 'exhausted'>} [initialNextGenerationByTarget]
|
|
46
|
+
* Test seam: pre-seeds the per-target generation counter so the
|
|
47
|
+
* `Number.MAX_SAFE_INTEGER` boundary and the permanent exhausted fail-safe
|
|
48
|
+
* can be exercised without 2^53 observations. Production wiring never
|
|
49
|
+
* supplies it.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/** @type {ReadonlySet<string>} */
|
|
53
|
+
const SUPPORTED_TOOLS = new Set(['write', 'edit', 'str_replace_editor'])
|
|
54
|
+
|
|
55
|
+
/** @type {ReadonlySet<string>} */
|
|
56
|
+
const SUPPORTED_EDITOR_COMMANDS = new Set(['create', 'str_replace', 'insert'])
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create a mutation collector.
|
|
60
|
+
* @param {CollectorOptions} [options] - optional test seam options.
|
|
61
|
+
* @returns {{
|
|
62
|
+
* observe(exec: unknown, target: unknown, observation: unknown): boolean
|
|
63
|
+
* take(exec: unknown): MutationCandidate[]
|
|
64
|
+
* isCurrent(candidate: unknown): boolean
|
|
65
|
+
* retireIfCurrent(candidate: unknown): boolean
|
|
66
|
+
* }}
|
|
67
|
+
*/
|
|
68
|
+
export function createMutationCollector(options = {}) {
|
|
69
|
+
/** @type {Map<string, number | 'exhausted'>} */
|
|
70
|
+
const nextGenerationByTarget =
|
|
71
|
+
options.initialNextGenerationByTarget === undefined
|
|
72
|
+
? new Map()
|
|
73
|
+
: new Map(options.initialNextGenerationByTarget)
|
|
74
|
+
/** @type {Map<string, { generation: number, version: string }>} */
|
|
75
|
+
const latestObserved = new Map()
|
|
76
|
+
/** @type {WeakMap<object, Map<string, MutationCandidate>>} */
|
|
77
|
+
const pendingByExec = new WeakMap()
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check whether the observation comes from a supported mutating tool.
|
|
81
|
+
* @param {unknown} exec - the tool-execution context (actor).
|
|
82
|
+
* @returns {boolean}
|
|
83
|
+
*/
|
|
84
|
+
function isSupportedMutation(exec) {
|
|
85
|
+
if (typeof exec !== 'object' || exec === null) return false
|
|
86
|
+
const name = /** @type {CollectorExec} */ (exec).name
|
|
87
|
+
if (typeof name !== 'string' || !SUPPORTED_TOOLS.has(name)) return false
|
|
88
|
+
if (name !== 'str_replace_editor') return true
|
|
89
|
+
const command = /** @type {CollectorExec} */ (exec).arguments?.command
|
|
90
|
+
return typeof command === 'string' && SUPPORTED_EDITOR_COMMANDS.has(command)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Record a successful mutation observation for the exec/target pair.
|
|
95
|
+
*
|
|
96
|
+
* Per target: reads the next counter, allocates it as the generation
|
|
97
|
+
* (advancing to `generation + 1`, or to the permanent `'exhausted'` state
|
|
98
|
+
* at `Number.MAX_SAFE_INTEGER`), replaces the active marker, and stores the
|
|
99
|
+
* latest candidate. A later observation that reads `'exhausted'` deletes
|
|
100
|
+
* the active marker and silently suppresses the candidate, so all prior
|
|
101
|
+
* candidates (including the max-generation one) are stale forever. Any
|
|
102
|
+
* internal error is contained: never throws, never records.
|
|
103
|
+
*
|
|
104
|
+
* @param {unknown} exec - the tool-execution context (actor).
|
|
105
|
+
* @param {unknown} target - the mutated target.
|
|
106
|
+
* @param {unknown} observation - the fs/observed payload.
|
|
107
|
+
* @returns {boolean} true only when a supported present mutation was recorded.
|
|
108
|
+
*/
|
|
109
|
+
function observe(exec, target, observation) {
|
|
110
|
+
try {
|
|
111
|
+
if (!isSupportedMutation(exec)) return false
|
|
112
|
+
if (typeof observation !== 'object' || observation === null) return false
|
|
113
|
+
const kind = /** @type {{ kind?: unknown }} */ (observation).kind
|
|
114
|
+
if (kind !== 'present') return false
|
|
115
|
+
const version = /** @type {{ version?: unknown }} */ (observation).version
|
|
116
|
+
if (typeof version !== 'string') return false
|
|
117
|
+
if (typeof target !== 'object' || target === null) return false
|
|
118
|
+
const targetKey = /** @type {CollectorTargetLike} */ (target).targetKey
|
|
119
|
+
if (typeof targetKey !== 'string' || targetKey.length === 0) return false
|
|
120
|
+
|
|
121
|
+
const next = nextGenerationByTarget.get(targetKey)
|
|
122
|
+
let generation
|
|
123
|
+
if (next === undefined) {
|
|
124
|
+
generation = 1
|
|
125
|
+
} else if (next === 'exhausted') {
|
|
126
|
+
// Permanent exhaustion: retire the active marker and suppress the
|
|
127
|
+
// candidate; never wrap or reset the counter.
|
|
128
|
+
latestObserved.delete(targetKey)
|
|
129
|
+
return false
|
|
130
|
+
} else if (typeof next === 'number' && Number.isSafeInteger(next) && next > 0) {
|
|
131
|
+
generation = next
|
|
132
|
+
} else {
|
|
133
|
+
// Internal counter anomaly: contain it silently, record nothing.
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const following = generation < Number.MAX_SAFE_INTEGER ? generation + 1 : 'exhausted'
|
|
138
|
+
nextGenerationByTarget.set(targetKey, following)
|
|
139
|
+
latestObserved.set(targetKey, { generation, version })
|
|
140
|
+
|
|
141
|
+
const key = /** @type {object} */ (exec)
|
|
142
|
+
let byTarget = pendingByExec.get(key)
|
|
143
|
+
if (byTarget === undefined) {
|
|
144
|
+
byTarget = new Map()
|
|
145
|
+
pendingByExec.set(key, byTarget)
|
|
146
|
+
}
|
|
147
|
+
// Replacing the candidate for this exec/target makes any prior one stale.
|
|
148
|
+
byTarget.set(targetKey, {
|
|
149
|
+
target: /** @type {CollectorTargetLike} */ (target),
|
|
150
|
+
version,
|
|
151
|
+
generation,
|
|
152
|
+
})
|
|
153
|
+
return true
|
|
154
|
+
} catch {
|
|
155
|
+
// Containment: a hostile observation must never throw.
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Return and clear the pending candidates for one exec.
|
|
162
|
+
* @param {unknown} exec - the tool-execution context (actor).
|
|
163
|
+
* @returns {MutationCandidate[]}
|
|
164
|
+
*/
|
|
165
|
+
function take(exec) {
|
|
166
|
+
if (typeof exec !== 'object' || exec === null) return []
|
|
167
|
+
const byTarget = pendingByExec.get(exec)
|
|
168
|
+
if (byTarget === undefined) return []
|
|
169
|
+
pendingByExec.delete(exec)
|
|
170
|
+
return [...byTarget.values()]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Extract a candidate's target key defensively.
|
|
175
|
+
* @param {unknown} candidate - the candidate to inspect.
|
|
176
|
+
* @returns {string | undefined}
|
|
177
|
+
*/
|
|
178
|
+
function candidateTargetKey(candidate) {
|
|
179
|
+
if (typeof candidate !== 'object' || candidate === null) return undefined
|
|
180
|
+
const targetKey = /** @type {{ target?: { targetKey?: unknown } }} */ (candidate).target?.targetKey
|
|
181
|
+
return typeof targetKey === 'string' ? targetKey : undefined
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Whether the candidate is the current active marker: the marker must exist
|
|
186
|
+
* and both `generation` and `version` must match exactly.
|
|
187
|
+
* @param {unknown} candidate - the candidate to check.
|
|
188
|
+
* @returns {boolean}
|
|
189
|
+
*/
|
|
190
|
+
function isCurrent(candidate) {
|
|
191
|
+
const targetKey = candidateTargetKey(candidate)
|
|
192
|
+
if (targetKey === undefined) return false
|
|
193
|
+
const marker = latestObserved.get(targetKey)
|
|
194
|
+
if (marker === undefined) return false
|
|
195
|
+
if (typeof candidate !== 'object' || candidate === null) return false
|
|
196
|
+
const { generation, version } = /** @type {{ generation?: unknown, version?: unknown }} */ (candidate)
|
|
197
|
+
return marker.generation === generation && marker.version === version
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Retire the active marker only when the candidate matches it exactly
|
|
202
|
+
* (generation and version). Never touches the generation counter, so the
|
|
203
|
+
* next observation for the target allocates the next generation.
|
|
204
|
+
* @param {unknown} candidate - the candidate to retire.
|
|
205
|
+
* @returns {boolean} whether the active marker was removed.
|
|
206
|
+
*/
|
|
207
|
+
function retireIfCurrent(candidate) {
|
|
208
|
+
const targetKey = candidateTargetKey(candidate)
|
|
209
|
+
if (targetKey === undefined) return false
|
|
210
|
+
const marker = latestObserved.get(targetKey)
|
|
211
|
+
if (marker === undefined) return false
|
|
212
|
+
if (typeof candidate !== 'object' || candidate === null) return false
|
|
213
|
+
const { generation, version } = /** @type {{ generation?: unknown, version?: unknown }} */ (candidate)
|
|
214
|
+
if (marker.generation !== generation || marker.version !== version) return false
|
|
215
|
+
latestObserved.delete(targetKey)
|
|
216
|
+
return true
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { observe, take, isCurrent, retireIfCurrent }
|
|
220
|
+
}
|