@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.
- package/README.md +2 -2
- package/THIRD-PARTY-NOTICES.md +422 -1
- package/anno-bank.ts +171 -0
- package/anno-cli.ts +1674 -99
- package/anno-enum-gen.ts +416 -30
- package/anno-export-asm.ts +1175 -89
- package/anno-graphics.ts +338 -0
- package/anno-hazard-report.ts +1367 -0
- package/anno-import.ts +495 -0
- package/anno-join.ts +480 -0
- package/anno-provenance-ledger.ts +472 -0
- package/anno-register.ts +159 -0
- package/anno-store-export.ts +661 -0
- package/anno-store.ts +518 -2
- package/anno-tools.ts +1169 -16
- package/anno-types.ts +275 -2
- package/backend-detect.mts +124 -312
- package/build.ts +3 -1
- package/capture-predicate.ts +597 -0
- package/channel-lock.ts +349 -0
- package/evid-ingest.ts +217 -0
- package/evid-reconcile.ts +316 -0
- package/host-tool-client.ts +430 -0
- package/incident-record.ts +23 -12
- package/install-resources.ts +29 -13
- package/memmap-lookup.ts +285 -0
- package/package.json +27 -8
- package/prg-image.ts +1 -2
- package/repo-root.ts +87 -3
- package/resources/backend-detect.mjs +98 -236
- package/resources/broker-control.mjs +189 -16
- package/resources/broker-epoch.mjs +1 -1
- package/resources/broker-kill.mjs +8 -2
- package/resources/broker-launch.mjs +365 -210
- package/resources/broker-state.mjs +64 -18
- package/resources/container-guard.mjs +1 -1
- package/resources/ghidra-project.mjs +790 -0
- package/resources/host-tool.mjs +2561 -0
- package/resources/vice-broker.mjs +330 -184
- package/resources/vice-launcher.sh +127 -9
- package/stock-address.ts +1 -1
- package/stock-condition.ts +1 -1
- package/stock-connect.ts +9 -5
- package/stock-derived.ts +29 -37
- package/stock-diagnose.ts +200 -36
- package/stock-dispatch.ts +179 -77
- package/stock-handler.ts +1 -1
- package/stock-paths.ts +18 -14
- package/stock-petscii.ts +1 -1
- package/stock-protocol.ts +1 -1
- package/stock-recycle.ts +83 -2
- package/stock-reproducible-run.ts +811 -0
- package/stock-run-until.ts +100 -1
- package/stock-symbols.ts +4 -4
- package/stock-timing.ts +1 -1
- package/stop-oracle.ts +167 -0
- package/text-capability-probe.ts +660 -0
- package/text-connect.ts +157 -0
- package/text-protocol.ts +810 -0
- package/text-tools.ts +778 -0
- package/textmon-backtrace.ts +385 -0
- package/textmon-cpuhistory.ts +335 -0
- package/textmon-memmap.ts +494 -0
- package/textmon-profile.ts +458 -0
- package/textmon-registers.ts +748 -0
- package/tools-manifest.stock.json +864 -3
- package/vice-broker-client.ts +189 -42
- package/vice-errors.ts +268 -0
- package/vice-proxy.ts +339 -2144
- package/vsf-slice.ts +640 -0
- package/anno-d64.ts +0 -310
- package/capability-registry.ts +0 -390
- package/refresh-manifest.ts +0 -124
- package/tools-manifest.json +0 -1223
- package/vice-probe.ts +0 -278
- package/vice-sync.ts +0 -336
- 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
|
+
}
|