@henols/vice-mcp 0.2.2 → 0.2.3

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.
Files changed (77) hide show
  1. package/README.md +2 -2
  2. package/THIRD-PARTY-NOTICES.md +422 -1
  3. package/anno-bank.ts +171 -0
  4. package/anno-cli.ts +1674 -99
  5. package/anno-enum-gen.ts +416 -30
  6. package/anno-export-asm.ts +1175 -89
  7. package/anno-graphics.ts +338 -0
  8. package/anno-hazard-report.ts +1367 -0
  9. package/anno-import.ts +495 -0
  10. package/anno-join.ts +480 -0
  11. package/anno-provenance-ledger.ts +472 -0
  12. package/anno-register.ts +159 -0
  13. package/anno-store-export.ts +661 -0
  14. package/anno-store.ts +518 -2
  15. package/anno-tools.ts +1169 -16
  16. package/anno-types.ts +275 -2
  17. package/backend-detect.mts +124 -312
  18. package/build.ts +3 -1
  19. package/capture-predicate.ts +597 -0
  20. package/channel-lock.ts +349 -0
  21. package/evid-ingest.ts +217 -0
  22. package/evid-reconcile.ts +316 -0
  23. package/host-tool-client.ts +430 -0
  24. package/incident-record.ts +23 -12
  25. package/install-resources.ts +29 -13
  26. package/memmap-lookup.ts +285 -0
  27. package/package.json +27 -8
  28. package/prg-image.ts +1 -2
  29. package/repo-root.ts +87 -3
  30. package/resources/backend-detect.mjs +98 -236
  31. package/resources/broker-control.mjs +189 -16
  32. package/resources/broker-epoch.mjs +1 -1
  33. package/resources/broker-kill.mjs +8 -2
  34. package/resources/broker-launch.mjs +365 -210
  35. package/resources/broker-state.mjs +64 -18
  36. package/resources/container-guard.mjs +1 -1
  37. package/resources/ghidra-project.mjs +790 -0
  38. package/resources/host-tool.mjs +2561 -0
  39. package/resources/vice-broker.mjs +330 -184
  40. package/resources/vice-launcher.sh +127 -9
  41. package/stock-address.ts +1 -1
  42. package/stock-condition.ts +1 -1
  43. package/stock-connect.ts +9 -5
  44. package/stock-derived.ts +29 -37
  45. package/stock-diagnose.ts +200 -36
  46. package/stock-dispatch.ts +179 -77
  47. package/stock-handler.ts +1 -1
  48. package/stock-paths.ts +18 -14
  49. package/stock-petscii.ts +1 -1
  50. package/stock-protocol.ts +1 -1
  51. package/stock-recycle.ts +83 -2
  52. package/stock-reproducible-run.ts +811 -0
  53. package/stock-run-until.ts +100 -1
  54. package/stock-symbols.ts +4 -4
  55. package/stock-timing.ts +1 -1
  56. package/stop-oracle.ts +167 -0
  57. package/text-capability-probe.ts +660 -0
  58. package/text-connect.ts +157 -0
  59. package/text-protocol.ts +810 -0
  60. package/text-tools.ts +778 -0
  61. package/textmon-backtrace.ts +385 -0
  62. package/textmon-cpuhistory.ts +335 -0
  63. package/textmon-memmap.ts +494 -0
  64. package/textmon-profile.ts +458 -0
  65. package/textmon-registers.ts +748 -0
  66. package/tools-manifest.stock.json +864 -3
  67. package/vice-broker-client.ts +189 -42
  68. package/vice-errors.ts +268 -0
  69. package/vice-proxy.ts +339 -2144
  70. package/vsf-slice.ts +640 -0
  71. package/anno-d64.ts +0 -310
  72. package/capability-registry.ts +0 -390
  73. package/refresh-manifest.ts +0 -124
  74. package/tools-manifest.json +0 -1223
  75. package/vice-probe.ts +0 -278
  76. package/vice-sync.ts +0 -336
  77. package/vice.ts +0 -772
@@ -0,0 +1,660 @@
1
+ // text-capability-probe.ts
2
+ //
3
+ // THE ONE owning module for PARSE-04: probing whether a connected VICE
4
+ // binary was BUILT with the tracing/profiling support a given text-monitor
5
+ // command needs, and answering the gap by name -- capability, command,
6
+ // binary, remedy -- never a silent empty result and never a parse error
7
+ // that reads like a defect in this project.
8
+ //
9
+ // WHY PER-COMMAND, NOT ONE GUARD FOR ALL FIVE: VICE gates `memmapshow` and
10
+ // `chis` behind the SAME build-time C macro (FEATURE_CPUMEMHISTORY) and both
11
+ // print the IDENTICAL disabled-stub string when it is compiled out. `bt` and
12
+ // `prof flat` carry NO build-time guard at all -- a probe for them can only
13
+ // ever come back capable or indeterminate. `io` (register decode) has no
14
+ // build-time guard either, but degrades gracefully per-chip with its own two
15
+ // runtime strings ("No details available." / "No I/O regs available") when a
16
+ // register has no dump function -- a CHIP-LEVEL degradation, not a missing
17
+ // build capability, and reported with different wording so a reader is never
18
+ // told the wrong reason shape for the same command. Probing per command, per
19
+ // binary is what stays correct under all three shapes; one guard per command
20
+ // or one guard for all five would both be wrong.
21
+ //
22
+ // D-42-2 (locked): the cache is IN-PROCESS ONLY, keyed by the backend kind
23
+ // plus a RESOLVED absolute binary path, and the key is never operator-
24
+ // supplied -- it is built from the identity StockDispatchDeps already
25
+ // threads down from the dispatch layer's single resolvedBackend() call
26
+ // (stock-dispatch.ts's own header comment), cross-checked against the
27
+ // broker's own hostState() report when the broker reports one. A build
28
+ // capability is a property of a BINARY FILE; a persisted answer would
29
+ // outlive the VICE rebuild that invalidated it, which is worse than no
30
+ // answer at all. This module therefore contains no filesystem write and no
31
+ // reference to the tool-written root -- asserted mechanically by this
32
+ // module's own test, not by review.
33
+ //
34
+ // D-42-2, CONTINUED (CR-02): the cache holds facts that are properties of a
35
+ // BINARY FILE -- and a fact that varies with a caller's OWN ARGUMENT is a
36
+ // different kind of fact that must never enter it. `io`'s chip-level
37
+ // degradation (IO_CHIP_DEGRADATION_STRINGS below) is decided by the
38
+ // specific address the caller dialed, not by how the binary was built --
39
+ // filing it under the binary-wide cache key let one call's answer to one
40
+ // address decide every later call's answer to every other address. A
41
+ // caller with a per-call, per-argument question takes
42
+ // `textCapabilityVerdictFor()` instead: it builds exactly the verdict
43
+ // `runProbe()` would build over a response the caller ALREADY HAS, with no
44
+ // dial, no cache read and no cache write. `io` is that caller, and is
45
+ // additionally removed from the cache's domain structurally (see
46
+ // `NEVER_CACHED_COMMANDS`) so no future caller can put it back by routing
47
+ // it back through the memoised entry point.
48
+ //
49
+ // D-42-3 (inherited from textmon-memmap.ts, decided once for all five text
50
+ // parsers this phase adds): refuse by RETURNING, never by throwing. This
51
+ // module never throws for a malformed, empty, unresolved, or disagreeing
52
+ // input -- every failure mode reports through TextCapabilityVerdict's
53
+ // three-state outcome instead.
54
+ //
55
+ // A DIFFERENT, ALREADY-PERSISTED FACT THIS MODULE MUST NOT TOUCH: the
56
+ // supervisor's backend record (backend-detect.mts's readCapabilityRecord())
57
+ // already carries a `cpuHistoryAvailable` field. That is the BINARY
58
+ // MONITOR's own opcode availability (CPUHISTORY_GET, 0x86) -- a protocol-
59
+ // VERSION question, gated by VICE >= 3.10 on the wire opcode itself. This
60
+ // module answers a build-FLAG question about the TEXT monitor, which the
61
+ // project's own committed fixtures prove is a genuinely different fact:
62
+ // `chis` returns real CPU-history entries with per-entry cycle counts over
63
+ // the text channel on genuine stock VICE 3.9 (the fixture batch's own
64
+ // cpu-history-stock.txt), the exact binary the binary-monitor opcode cannot
65
+ // reach at all. This module neither reads, writes, nor overwrites
66
+ // `cpuHistoryAvailable`; collapsing the two answers would produce a
67
+ // confidently wrong verdict on the same binary.
68
+ //
69
+ // SOURCE-TRACED, NOT LIVE-OBSERVED: CPUHISTORY_DISABLED_STUB and the two
70
+ // `io` degradation strings below were read from VICE's own C source
71
+ // (mon_memmap.c, monitor.c) during this phase's research pass, not observed
72
+ // over a live socket against a binary genuinely built without the feature --
73
+ // no such binary exists on this host and building one is out of scope for
74
+ // this plan (see 42-VALIDATION.md's manual-only entry). Do not upgrade this
75
+ // module's own header comment, or any string it renders, to imply a binary
76
+ // was ever seen refusing.
77
+ //
78
+ // WHAT NOT TO DO:
79
+ // - Never import a transport module, a filesystem module, or a host-bound
80
+ // resolver here. This module takes its dial function and its identity
81
+ // as ARGUMENTS (`ProbeTextCapabilityOptions`); the only import anywhere
82
+ // in this file is a type-only import of ViceBackend, erased entirely by
83
+ // Node's type-stripping at runtime -- this module imports nothing at
84
+ // runtime.
85
+ // - Never write a capability verdict to disk, in any form -- not under
86
+ // the tool-written root directory, not in the supervisor's backend
87
+ // record, not in a temp file. See the D-42-2 paragraph above.
88
+ // - Never key the cache on an unresolved binary path (a bare name like
89
+ // "x64sc" is not a binary identity) or on an identity the broker's own
90
+ // report definitely disagrees with. Attributing a capability answer to
91
+ // the wrong binary is precisely the mislabelling failure the fixture
92
+ // batch's own README records having paid for once (see
93
+ // fixtures/textmon/README.md's "capturedFrom kind token is DERIVED, not
94
+ // operator-supplied" paragraph).
95
+ // - Never cache an indeterminate outcome, a rejected dial, or an unkeyable
96
+ // identity -- a transient failure must not poison this process's answer
97
+ // for its whole lifetime.
98
+ // - Never conflate `cpuHistoryAvailable` (backend-detect.mts) with this
99
+ // module's own verdicts -- see the paragraph above.
100
+ // - Never route a per-call, per-argument classification (a fact that
101
+ // varies with the caller's OWN argument, not with how the binary was
102
+ // built) through `probeTextCapability()`'s memoised entry point -- use
103
+ // `textCapabilityVerdictFor()` over the response the caller already has
104
+ // instead (CR-02, D-42-2 CONTINUED above).
105
+
106
+ /** FORKRM-01 (plan 52-06): deliberately NOT `backend-detect.mts`'s
107
+ * `ViceBackend` -- that type now has exactly one member ("stock"), since it
108
+ * answers "which backend did THIS PROCESS just detect/launch". This
109
+ * module's identity/cache-keying logic below answers a different, PERMANENT
110
+ * question: whether the identity a probe is keyed on agrees with what the
111
+ * broker independently reports -- a cross-check that stays two-valued on
112
+ * purpose so a genuine disagreement (this process resolved one binary, the
113
+ * broker reports another) is still detectable even though only "stock" is
114
+ * ever resolved today (plan 52-07 confirmed this decoupling; there was
115
+ * nothing importing a shared registry here to remove). */
116
+ type LegacyViceBackend = "fork" | "stock";
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // The canonical command set and report order.
120
+ // ---------------------------------------------------------------------------
121
+
122
+ /** The five text-monitor commands PARSE-04 probes, in the module's declared
123
+ * canonical report order -- `textCapabilityRefusalMessage()` always renders
124
+ * in this order, independent of probe completion order or the order
125
+ * verdicts are handed to it in. */
126
+ export const TEXT_CAPABILITY_COMMANDS = Object.freeze([
127
+ "memmapshow",
128
+ "prof flat",
129
+ "chis",
130
+ "bt",
131
+ "io",
132
+ ] as const);
133
+
134
+ export type TextCapabilityCommand = (typeof TEXT_CAPABILITY_COMMANDS)[number];
135
+
136
+ /** The two commands sharing FEATURE_CPUMEMHISTORY, VICE's build-time C
137
+ * macro -- the only two commands a probe can legitimately classify
138
+ * "missing" for. `bt` and `prof flat` carry no build-time guard at all
139
+ * (only ever capable or indeterminate); `io` carries no build-time guard
140
+ * either, but degrades per-chip with its own two runtime strings (see
141
+ * IO_CHIP_DEGRADATION_STRINGS below) -- a different fact, reported with
142
+ * different wording, never a build-capability claim. */
143
+ export const CPUHISTORY_GATED_COMMANDS: readonly TextCapabilityCommand[] = Object.freeze(["memmapshow", "chis"]);
144
+
145
+ /** CR-02: commands whose outcome is decided by the CALLER'S OWN ARGUMENT --
146
+ * not by how the binary was built -- and therefore have no binary-wide
147
+ * answer to memoise. Excluded from the cache's domain in all three places a
148
+ * cache can act: `runProbe()`'s `cacheable` predicate never writes a
149
+ * verdict for one, and `probeTextCapability()` consults neither the
150
+ * cache-read early return nor the `inFlightProbes` in-flight memo for one --
151
+ * it dials fresh, unconditionally, every single call.
152
+ *
153
+ * `io` is the sole member: its chip-level runtime degradation
154
+ * (IO_CHIP_DEGRADATION_STRINGS above) is decided by the specific `address`
155
+ * the caller dialed, so the SAME binary answers differently for two
156
+ * different addresses in the same process lifetime -- the opposite of
157
+ * `CPUHISTORY_GATED_COMMANDS`, whose members' outcome IS a build-time
158
+ * property of the binary file and stays constant for the binary's whole
159
+ * process lifetime. A future command belongs here if, and only if, its
160
+ * outcome can differ between two calls to the SAME binary under the SAME
161
+ * identity -- `text-capability-probe.test.ts`'s own cacheability-invariant
162
+ * case fails by name for any `TEXT_CAPABILITY_COMMANDS` member in neither
163
+ * this set nor provably cacheable. */
164
+ export const NEVER_CACHED_COMMANDS: readonly TextCapabilityCommand[] = Object.freeze(["io"]);
165
+
166
+ /** The exact disabled-stub TEXT (VICE's own C source prints this line
167
+ * followed by a trailing newline; this constant omits the newline because
168
+ * classification always compares against a trimmed, already-split line).
169
+ * SOURCE-TRACED, not live-observed -- see this module's header comment. */
170
+ export const CPUHISTORY_DISABLED_STUB = "Disabled. configure with --enable-cpuhistory and recompile.";
171
+
172
+ const CPUHISTORY_CAPABILITY_NAME = "CPU-and-memory-history build support (shared by memmapshow and chis)";
173
+
174
+ const CPUHISTORY_REMEDY =
175
+ "rebuild the binary with --enable-cpuhistory (VICE compiles this in by default; only an explicit " +
176
+ "--disable-cpuhistory at configure time removes it)";
177
+
178
+ /** `io`'s own two chip-level degradation strings (monitor.c's dump-path
179
+ * iterator) -- printed per-chip when a specific register has no dump
180
+ * function, or when the register list itself is empty for the current
181
+ * bank. Neither is a build-time refusal; both are reported as a chip
182
+ * reporting it has nothing to show, worded distinctly from a missing build
183
+ * capability. SOURCE-TRACED, not live-observed -- see this module's header
184
+ * comment. */
185
+ const IO_CHIP_DEGRADATION_STRINGS: readonly string[] = Object.freeze(["No details available.", "No I/O regs available"]);
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Identity and the cache key (D-42-2).
189
+ // ---------------------------------------------------------------------------
190
+
191
+ /** The identity a probe is keyed on: the backend kind plus a binary path,
192
+ * with `resolved` stating whether that path is a real resolved absolute
193
+ * path (as opposed to a bare configured name like "x64sc" that resolution
194
+ * failed on -- WR-05's own distinction, threaded down unchanged from
195
+ * `StockDispatchDeps.resolvedBinaryPath`/`resolvedBinaryPathIsResolved`). */
196
+ export interface TextCapabilityIdentity {
197
+ readonly backend: LegacyViceBackend;
198
+ readonly binPath: string;
199
+ readonly resolved: boolean;
200
+ }
201
+
202
+ /** The broker's OWN reported identity (`BrokerControlSession.hostState()`'s
203
+ * `vice_bin`/`backend` fields), used only for the cross-check against
204
+ * `TextCapabilityIdentity` above. `backend: null` and `binPath: ""` are
205
+ * both ABSENT EVIDENCE, never disagreement -- mirroring vice-proxy.ts's own
206
+ * backend-mismatch refusal discipline ("Absent evidence is NOT
207
+ * disagreement: a broker that does not report a backend ... leaves
208
+ * `backend: null`, and ... is not allowed to block an acquire. Only a
209
+ * definite, named mismatch refuses."). */
210
+ export interface TextCapabilityBrokerIdentity {
211
+ readonly backend: LegacyViceBackend | null;
212
+ readonly binPath: string;
213
+ }
214
+
215
+ /** Renders the cache key `"<backend>:<binPath>"` for a resolved identity,
216
+ * or `null` when `identity.resolved` is false -- a bare binary name is not
217
+ * a binary identity, and a capability answer filed under one would be
218
+ * attributed to whatever that name resolves to NEXT time, not this time. */
219
+ export function textCapabilityCacheKey(identity: TextCapabilityIdentity): string | null {
220
+ if (!identity.resolved) return null;
221
+ return `${identity.backend}:${identity.binPath}`;
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // The verdict.
226
+ // ---------------------------------------------------------------------------
227
+
228
+ export type TextCapabilityOutcome = "capable" | "missing" | "indeterminate";
229
+
230
+ /** One command's answer. `capability`/`remedy` are present only when
231
+ * `outcome === "missing"`. `dialError` is present only when the dial itself
232
+ * rejected (a transport failure, never a parse result). `identityDisagreement`
233
+ * is present only when the cross-check against a supplied broker identity
234
+ * found a DEFINITE mismatch -- and when present, the verdict was never
235
+ * cached (see `probeTextCapability`'s own doc comment). `fromCache` is
236
+ * `true` only when this verdict was served from a prior probe's cached
237
+ * answer rather than a fresh dial. */
238
+ export interface TextCapabilityVerdict {
239
+ readonly command: TextCapabilityCommand;
240
+ readonly outcome: TextCapabilityOutcome;
241
+ readonly response: string;
242
+ readonly capability?: string;
243
+ readonly remedy?: string;
244
+ readonly identity: TextCapabilityIdentity;
245
+ readonly fromCache: boolean;
246
+ readonly dialError?: string;
247
+ readonly identityDisagreement?: string;
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // The classifier -- pure, no dial, no identity, no cache.
252
+ // ---------------------------------------------------------------------------
253
+
254
+ export interface TextCapabilityClassification {
255
+ readonly outcome: TextCapabilityOutcome;
256
+ readonly capability?: string;
257
+ readonly remedy?: string;
258
+ }
259
+
260
+ /** The first non-empty line of `text`, UNTRIMMED (callers trim it
261
+ * themselves) -- `""` when every line is empty or whitespace-only. Bounded
262
+ * by construction: one pass over `text.split("\n")`, no recursion. */
263
+ function firstNonEmptyLine(text: string): string {
264
+ for (const line of text.split("\n")) {
265
+ if (line.trim() !== "") return line;
266
+ }
267
+ return "";
268
+ }
269
+
270
+ /**
271
+ * Classifies one text-monitor reply for one command. Equality against the
272
+ * FIRST NON-EMPTY LINE, trimmed, only -- never a substring search over the
273
+ * whole payload -- because a large legitimate reply must never be misread
274
+ * as a refusal because the disabled-stub words happen to appear somewhere
275
+ * inside it (e.g. `memmapshow`'s own real output could in principle carry
276
+ * arbitrary bytes on a later line; only the first line is the command's own
277
+ * answer to "did this even run").
278
+ *
279
+ * A missing verdict is possible ONLY for `command` in CPUHISTORY_GATED_COMMANDS
280
+ * (`memmapshow`, `chis`) -- VICE never gates any other command behind
281
+ * FEATURE_CPUMEMHISTORY, so this classifier never renders a build-capability
282
+ * claim for `bt`, `prof flat`, or `io` even if their first line happened to
283
+ * coincidentally equal the stub text.
284
+ *
285
+ * An empty or whitespace-only reply is `indeterminate`, never `capable`:
286
+ * PARSE-04's whole point is that a user is never handed a silent empty
287
+ * result, so "nothing came back" is a named state, not an absence of one.
288
+ */
289
+ export function classifyTextCapabilityResponse(
290
+ command: TextCapabilityCommand,
291
+ response: string,
292
+ ): TextCapabilityClassification {
293
+ if (typeof response !== "string" || response.trim() === "") {
294
+ return { outcome: "indeterminate" };
295
+ }
296
+ const firstLine = firstNonEmptyLine(response).trim();
297
+ if (CPUHISTORY_GATED_COMMANDS.includes(command) && firstLine === CPUHISTORY_DISABLED_STUB) {
298
+ return { outcome: "missing", capability: CPUHISTORY_CAPABILITY_NAME, remedy: CPUHISTORY_REMEDY };
299
+ }
300
+ return { outcome: "capable" };
301
+ }
302
+
303
+ /** `io`'s own chip-level degradation text, when `verdict`'s first line
304
+ * matches one of `IO_CHIP_DEGRADATION_STRINGS` -- `null` for every other
305
+ * command, and `null` when `io`'s reply does not match either string
306
+ * (a genuine register dump). Never a `TextCapabilityOutcome` of its own
307
+ * (the three-state outcome stays exactly capable/missing/indeterminate,
308
+ * per Task 1's own locked shape) -- this is a render-time distinction over
309
+ * an otherwise-capable `io` verdict. */
310
+ function ioChipDegradationText(command: TextCapabilityCommand, response: string): string | null {
311
+ if (command !== "io") return null;
312
+ const firstLine = firstNonEmptyLine(response).trim();
313
+ return IO_CHIP_DEGRADATION_STRINGS.includes(firstLine) ? firstLine : null;
314
+ }
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // The identity cross-check.
318
+ // ---------------------------------------------------------------------------
319
+
320
+ /** Renders a binary-identity disagreement for the caller of the ANSWER being
321
+ * returned right now -- empty string (`""`) when there is nothing to
322
+ * report: no broker identity supplied, or every field it reports is absent
323
+ * evidence (a null backend, an empty path). Only a DEFINITE, named mismatch
324
+ * ever renders; absent evidence is never disagreement, mirroring this
325
+ * module's own caching discipline (D-42-2) for the identical two-value
326
+ * comparison.
327
+ *
328
+ * Deliberately separate from {@link textCapabilityRefusalMessage}: that
329
+ * function answers "is there something that blocks this call" -- a
330
+ * non-empty return makes a handler refuse. This one answers "is there
331
+ * something the caller must know about an answer that is otherwise fine" --
332
+ * an advisory, not a refusal. Merging the two would silently convert a mere
333
+ * identity disagreement into a hard refusal of an otherwise-working call,
334
+ * which is a heavier behaviour change than a disagreement warrants and is
335
+ * not what this function is for. Callers append this string's own line to
336
+ * an answer that already succeeded or already refused for an unrelated
337
+ * reason -- never construct a NEW refusal purely because this returned
338
+ * non-empty. */
339
+ export function textCapabilityIdentityWarning(
340
+ identity: TextCapabilityIdentity,
341
+ brokerIdentity: TextCapabilityBrokerIdentity | undefined,
342
+ ): string {
343
+ if (!brokerIdentity) return "";
344
+ const backendAbsent = brokerIdentity.backend === null;
345
+ const pathAbsent = brokerIdentity.binPath === "";
346
+ const backendDisagrees = !backendAbsent && brokerIdentity.backend !== identity.backend;
347
+ const pathDisagrees = !pathAbsent && brokerIdentity.binPath !== identity.binPath;
348
+ if (!backendDisagrees && !pathDisagrees) return "";
349
+ return (
350
+ `text-capability-probe: this answer's binary identity could not be confirmed -- identity disagreement -- the ` +
351
+ `dispatch-resolved identity is "${identity.backend}:${identity.binPath}" but the broker reports ` +
352
+ `"${brokerIdentity.backend ?? "(none)"}:${brokerIdentity.binPath || "(empty)"}" -- refusing to cache an ` +
353
+ `answer that may not be attributable to either binary with confidence`
354
+ );
355
+ }
356
+
357
+ /** `null` when there is nothing to disagree about, otherwise a
358
+ * human-readable sentence naming BOTH observed identities, for embedding in
359
+ * a verdict and, from there, a rendered message -- never silently keyed to
360
+ * one of the two without saying so. A thin wrapper over
361
+ * {@link textCapabilityIdentityWarning} so there is exactly one wording in
362
+ * exactly one place; `runProbe()`'s caching behaviour (D-42-2) is
363
+ * unaffected by this reimplementation. */
364
+ function identityDisagreementText(
365
+ identity: TextCapabilityIdentity,
366
+ brokerIdentity: TextCapabilityBrokerIdentity | undefined,
367
+ ): string | null {
368
+ const warning = textCapabilityIdentityWarning(identity, brokerIdentity);
369
+ return warning === "" ? null : warning;
370
+ }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // The cache -- in-process only (D-42-2), never persisted to disk.
374
+ // ---------------------------------------------------------------------------
375
+
376
+ const capabilityCache = new Map<string, Map<TextCapabilityCommand, TextCapabilityVerdict>>();
377
+ const inFlightProbes = new Map<string, Promise<TextCapabilityVerdict>>();
378
+
379
+ /** Test-only: clears the in-process cache and the in-flight dial memo. Never
380
+ * called by any production code path -- production never needs "start
381
+ * over," only a test driving many independent scenarios in one process. */
382
+ export function resetTextCapabilityCache(): void {
383
+ capabilityCache.clear();
384
+ inFlightProbes.clear();
385
+ }
386
+
387
+ export interface ProbeTextCapabilityOptions {
388
+ readonly command: TextCapabilityCommand;
389
+ readonly identity: TextCapabilityIdentity;
390
+ /** The broker's own reported identity, for the D-42-2 cross-check.
391
+ * Omitted entirely is the same as an all-absent broker report -- absent
392
+ * evidence, never disagreement. */
393
+ readonly brokerIdentity?: TextCapabilityBrokerIdentity;
394
+ /** Issues `command` over the text-monitor channel and resolves to the
395
+ * framed reply string, or rejects on a transport failure. The ONLY
396
+ * transport-touching thing this module ever calls, and it is always
397
+ * INJECTED -- this module has no socket, no channel lock, and no lease of
398
+ * its own. */
399
+ readonly dial: (command: TextCapabilityCommand) => Promise<string>;
400
+ }
401
+
402
+ /** Inputs to {@link textCapabilityVerdictFor}: everything a caller who has
403
+ * ALREADY OBSERVED a response (because it dialed the command itself, for
404
+ * its own reasons) needs to get the same classification `runProbe()` would
405
+ * have produced, without a dial and without touching the cache. */
406
+ export interface TextCapabilityVerdictForOptions {
407
+ readonly command: TextCapabilityCommand;
408
+ readonly response: string;
409
+ readonly identity: TextCapabilityIdentity;
410
+ readonly brokerIdentity?: TextCapabilityBrokerIdentity;
411
+ /** Present only when the CALLER's own dial rejected -- this function never
412
+ * dials, so it cannot observe a dial failure itself; a caller that wants
413
+ * the `dialError` shape represented in the returned verdict supplies it
414
+ * here. */
415
+ readonly dialError?: string;
416
+ }
417
+
418
+ /**
419
+ * Builds exactly the verdict `runProbe()` builds today, over a response the
420
+ * caller has ALREADY RECEIVED -- NO dial, NO cache read, and NO cache write.
421
+ * That is this function's whole contract.
422
+ *
423
+ * WHY THIS EXISTS (CR-02): a caller whose outcome is decided by ITS OWN
424
+ * ARGUMENT -- not by a property of the binary -- must never have that
425
+ * per-call fact memoised under the binary-wide capability cache. `io`'s
426
+ * chip-level degradation is exactly this: it is decided by the `address`
427
+ * the caller dialed, so `handleIoRegisters` calls this function directly
428
+ * over the response it already dialed for its own reasons, instead of
429
+ * reaching `probeTextCapability()`'s memoised entry point at all.
430
+ *
431
+ * Every optional field is spread in only when it has a value, exactly
432
+ * mirroring `runProbe()`'s own object-literal construction -- there is
433
+ * exactly ONE verdict-construction site in this module, reached by both
434
+ * `runProbe()` (which additionally dials and may cache) and this function
435
+ * (which never does either).
436
+ */
437
+ export function textCapabilityVerdictFor(options: TextCapabilityVerdictForOptions): TextCapabilityVerdict {
438
+ const { command, response, identity, brokerIdentity, dialError } = options;
439
+ const disagreement = identityDisagreementText(identity, brokerIdentity);
440
+ const classification: TextCapabilityClassification =
441
+ dialError !== undefined ? { outcome: "indeterminate" } : classifyTextCapabilityResponse(command, response);
442
+
443
+ return {
444
+ command,
445
+ outcome: classification.outcome,
446
+ response,
447
+ identity,
448
+ fromCache: false,
449
+ ...(classification.capability !== undefined ? { capability: classification.capability } : {}),
450
+ ...(classification.remedy !== undefined ? { remedy: classification.remedy } : {}),
451
+ ...(dialError !== undefined ? { dialError } : {}),
452
+ ...(disagreement !== null ? { identityDisagreement: disagreement } : {}),
453
+ };
454
+ }
455
+
456
+ async function runProbe(
457
+ command: TextCapabilityCommand,
458
+ identity: TextCapabilityIdentity,
459
+ brokerIdentity: TextCapabilityBrokerIdentity | undefined,
460
+ dial: (command: TextCapabilityCommand) => Promise<string>,
461
+ key: string | null,
462
+ ): Promise<TextCapabilityVerdict> {
463
+ let response = "";
464
+ let dialError: string | undefined;
465
+ try {
466
+ response = await dial(command);
467
+ } catch (err) {
468
+ dialError = err instanceof Error ? err.message : String(err);
469
+ }
470
+
471
+ const verdict = textCapabilityVerdictFor({
472
+ command,
473
+ response,
474
+ identity,
475
+ ...(brokerIdentity !== undefined ? { brokerIdentity } : {}),
476
+ ...(dialError !== undefined ? { dialError } : {}),
477
+ });
478
+
479
+ const cacheable =
480
+ key !== null &&
481
+ verdict.identityDisagreement === undefined &&
482
+ dialError === undefined &&
483
+ verdict.outcome !== "indeterminate" &&
484
+ !NEVER_CACHED_COMMANDS.includes(command);
485
+ if (cacheable) {
486
+ let forKey = capabilityCache.get(key!);
487
+ if (!forKey) {
488
+ forKey = new Map();
489
+ capabilityCache.set(key!, forKey);
490
+ }
491
+ forKey.set(command, verdict);
492
+ }
493
+
494
+ return verdict;
495
+ }
496
+
497
+ /**
498
+ * Probes `options.command` against `options.identity`, using the in-process
499
+ * cache (D-42-2) when a definitive answer is already on file.
500
+ *
501
+ * Caching rules, exactly:
502
+ * - `capable` or `missing`, from an identity that produced a cache key
503
+ * (`textCapabilityCacheKey(identity) !== null`) and whose cross-check
504
+ * against `options.brokerIdentity` did not definitely disagree: CACHED.
505
+ * - `indeterminate` (empty/whitespace reply), a rejected dial, an
506
+ * unresolved identity (no key), or a definite identity disagreement:
507
+ * NEVER cached -- a transient failure must not poison this process's
508
+ * answer for its whole lifetime, and an unproven identity must never be
509
+ * asserted as fact to a later caller.
510
+ *
511
+ * Concurrency: two concurrent probes of the SAME key and command dial
512
+ * exactly once -- the in-flight promise is memoised and handed to the
513
+ * second caller, mirroring the broker's own single-owner check-and-set
514
+ * launch guard (`broker-launch.mts`) for the same reason.
515
+ */
516
+ export async function probeTextCapability(options: ProbeTextCapabilityOptions): Promise<TextCapabilityVerdict> {
517
+ const { command, identity, brokerIdentity, dial } = options;
518
+ const key = textCapabilityCacheKey(identity);
519
+
520
+ if (key === null) {
521
+ // Unkeyable identity: never cached, never memoised in-flight -- there is
522
+ // no key to memoise against.
523
+ return runProbe(command, identity, brokerIdentity, dial, null);
524
+ }
525
+
526
+ if (NEVER_CACHED_COMMANDS.includes(command)) {
527
+ // CR-02: a command in this set has no binary-wide answer to memoise --
528
+ // go straight to runProbe() with the key already computed (runProbe's
529
+ // own `cacheable` predicate refuses to write it back, above). The
530
+ // in-flight memo is skipped too, not only the persistent cache: two
531
+ // concurrent probes for a per-call command sharing one memoised promise
532
+ // is the SAME defect in a narrower window -- one caller's in-flight
533
+ // dial would otherwise hand its response to a second caller asking a
534
+ // DIFFERENT question (a different address), which a fix that closed
535
+ // only the persistent cache would leave wide open.
536
+ return runProbe(command, identity, brokerIdentity, dial, key);
537
+ }
538
+
539
+ const cachedForKey = capabilityCache.get(key);
540
+ const cached = cachedForKey?.get(command);
541
+ if (cached) {
542
+ return { ...cached, fromCache: true };
543
+ }
544
+
545
+ const inFlightKey = `${key} ${command}`;
546
+ const existing = inFlightProbes.get(inFlightKey);
547
+ if (existing) {
548
+ return existing;
549
+ }
550
+
551
+ const promise = runProbe(command, identity, brokerIdentity, dial, key);
552
+ inFlightProbes.set(inFlightKey, promise);
553
+ try {
554
+ return await promise;
555
+ } finally {
556
+ inFlightProbes.delete(inFlightKey);
557
+ }
558
+ }
559
+
560
+ // ---------------------------------------------------------------------------
561
+ // The user-facing answer.
562
+ // ---------------------------------------------------------------------------
563
+
564
+ interface MissingGroup {
565
+ readonly kind: "missing-group";
566
+ readonly capability: string;
567
+ readonly remedy: string;
568
+ readonly binPath: string;
569
+ readonly commands: TextCapabilityCommand[];
570
+ }
571
+
572
+ interface DirectLine {
573
+ readonly kind: "line";
574
+ readonly text: string;
575
+ }
576
+
577
+ function buildMissingGroupText(group: MissingGroup): string {
578
+ return (
579
+ `${group.commands.join(", ")}: ${group.capability} is missing on ${group.binPath} -- ${group.remedy}. ` +
580
+ (group.commands.length > 1
581
+ ? `Both commands are compiled behind the same VICE build macro, so this is one gap, not two.`
582
+ : ``)
583
+ ).trim();
584
+ }
585
+
586
+ function indeterminateLine(verdict: TextCapabilityVerdict): string {
587
+ const observed =
588
+ verdict.dialError !== undefined ? `a dial failure (${verdict.dialError})` : "an empty or unframeable reply";
589
+ return (
590
+ `${verdict.command}: whether this text-monitor build capability is present on ${verdict.identity.binPath} is ` +
591
+ `unknown, not negative -- the probe observed ${observed}.`
592
+ );
593
+ }
594
+
595
+ function chipDegradationLine(verdict: TextCapabilityVerdict, observedLine: string): string {
596
+ return (
597
+ `${verdict.command}: ${verdict.identity.binPath} reports ${JSON.stringify(observedLine)} -- the chip has ` +
598
+ `nothing to report here, not a missing build capability.`
599
+ );
600
+ }
601
+
602
+ /**
603
+ * Renders one message over `verdicts`, in `TEXT_CAPABILITY_COMMANDS` order
604
+ * (independent of the order `verdicts` was handed in), with three
605
+ * distinguishable shapes -- never one wording reused for all three, so a
606
+ * reader can tell which of the three failure classes they actually hit:
607
+ *
608
+ * 1. A MISSING build capability: command(s), capability name, binary
609
+ * path, one remedy sentence. Two or more verdicts sharing the same
610
+ * `capability`/`remedy` (memmapshow + chis, always) are merged into
611
+ * ONE line naming both commands and stating the remedy once -- telling
612
+ * a user about two independent gaps when one flag fixes both is a
613
+ * worse answer than telling them about one.
614
+ * 2. An INDETERMINATE probe: command, binary, what was observed (a dial
615
+ * failure or an empty reply), and that the answer is unknown rather
616
+ * than negative.
617
+ * 3. A CHIP-LEVEL DEGRADATION (`io`'s own two runtime strings): command,
618
+ * binary, the observed string, and that this is the chip reporting it
619
+ * has nothing to show, not a build gap.
620
+ *
621
+ * A fully `capable` verdict for any other command renders nothing -- this
622
+ * is a REFUSAL/gap message, not a general-purpose status report.
623
+ *
624
+ * No line this function renders names a phase number (own test asserts
625
+ * this against the same `/\bPhase\s+\d/i` pattern `docs-dangling-refs.test.ts`
626
+ * enforces tree-wide once this module joins `files[]`).
627
+ */
628
+ export function textCapabilityRefusalMessage(verdicts: readonly TextCapabilityVerdict[]): string {
629
+ const byCommand = new Map<TextCapabilityCommand, TextCapabilityVerdict>();
630
+ for (const v of verdicts) byCommand.set(v.command, v);
631
+
632
+ const ordered = TEXT_CAPABILITY_COMMANDS.filter((c) => byCommand.has(c)).map((c) => byCommand.get(c)!);
633
+
634
+ const groupsByKey = new Map<string, MissingGroup>();
635
+ const queue: (MissingGroup | DirectLine)[] = [];
636
+
637
+ for (const verdict of ordered) {
638
+ if (verdict.outcome === "missing" && verdict.capability !== undefined && verdict.remedy !== undefined) {
639
+ const groupKey = `${verdict.capability} ${verdict.remedy}`;
640
+ let group = groupsByKey.get(groupKey);
641
+ if (!group) {
642
+ group = { kind: "missing-group", capability: verdict.capability, remedy: verdict.remedy, binPath: verdict.identity.binPath, commands: [] };
643
+ groupsByKey.set(groupKey, group);
644
+ queue.push(group);
645
+ }
646
+ group.commands.push(verdict.command);
647
+ continue;
648
+ }
649
+ if (verdict.outcome === "indeterminate") {
650
+ queue.push({ kind: "line", text: indeterminateLine(verdict) });
651
+ continue;
652
+ }
653
+ const degraded = ioChipDegradationText(verdict.command, verdict.response);
654
+ if (degraded !== null) {
655
+ queue.push({ kind: "line", text: chipDegradationLine(verdict, degraded) });
656
+ }
657
+ }
658
+
659
+ return queue.map((item) => (item.kind === "missing-group" ? buildMissingGroupText(item) : item.text)).join("\n");
660
+ }