@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,349 @@
1
+ #!/usr/bin/env node
2
+ // channel-lock.ts
3
+ //
4
+ // THE ONE PLACE the cross-channel mutex, its FIFO queue, its holder record
5
+ // and its refusal text live. Phase 39's `go` verdict (rule R15) selected an
6
+ // in-process async mutex with no narrowing as the shape that serializes
7
+ // every halt-taking operation on either monitor channel -- this is that
8
+ // primitive, built fresh: there is no async mutex anywhere in this tree, and
9
+ // the only prior single-flight queue died with the retired analyser and was
10
+ // explicitly not extracted. None of the four pieces this module owns -- the
11
+ // mutex, its FIFO queue, its holder record, its refusal text -- may be
12
+ // re-derived in stock-dispatch.ts, text-protocol.ts or stock-diagnose.ts.
13
+ //
14
+ // WHY HAND-BUILT RATHER THAN TAKEN FROM A LIBRARY: a generic mutex (e.g.
15
+ // `async-mutex`) grants and releases but exposes no holder record -- it
16
+ // cannot answer "who holds this, since when, doing what". This module's
17
+ // `ChannelLockHolder` is exactly that record, and it is what makes
18
+ // contention readable to `vice_diagnose` (plan 41-04) rather than a bare
19
+ // "something is locked". `async-mutex` was considered and rejected by this
20
+ // phase's own locked decision; it is never installed, and there is no
21
+ // install task in this plan for a package-legitimacy audit to cover.
22
+ //
23
+ // WHY `MonitorChannel` IS DECLARED HERE A SECOND TIME rather than imported
24
+ // from broker-state.mts (which already has an `InstanceRecord.monitorClient`
25
+ // concept): broker-state.mts is host-bound and compiled into
26
+ // `resources/*.mjs`, so a container-side `.ts` module -- this one -- cannot
27
+ // import it at runtime. The shared thing between the two declarations is the
28
+ // two-value contract ("binary" | "text"), not the declaration itself --
29
+ // exactly as textmon-fixtures.ts reimplements the provenance contract rather
30
+ // than importing it from binmon-fixtures.ts.
31
+ //
32
+ // WHAT NOT TO DO:
33
+ // - Never acquire this lock per wire command in a way that lets a foreign
34
+ // command land between a resume and its checkpoint observation -- the
35
+ // lock is acquired per LOGICAL OPERATION (see stock-dispatch.ts's
36
+ // withChannelLockHeld() and text-protocol.ts's withTextChannelLock()),
37
+ // spanning resume -> wait -> observe. A design that preserves the resume
38
+ // count while destroying what the count protects is a regression, not a
39
+ // variant.
40
+ // - Never import stock-run-until.ts from this module. The derivation
41
+ // comment on CHANNEL_LOCK_ACQUIRE_TIMEOUT_MS below states the
42
+ // relationship to RUN_UNTIL_MAX_TIMEOUT_MS in prose; importing it here
43
+ // would invert the dependency, with the primitive importing a consumer.
44
+ // channel-lock.test.ts cross-imports both files to assert the
45
+ // inequality instead.
46
+ // - Never resolve a waiter's timeout through a second admission path.
47
+ // Both the grant-on-free branch and the enqueue branch below run
48
+ // through one internal function (admit()) so there is exactly one place
49
+ // a caller is ever granted the lock.
50
+ // - Never hand the lock to the next waiter through a `setTimeout` on
51
+ // release -- that would let timer scheduling perturb arrival order.
52
+ // release() hands off synchronously, in the same tick.
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // MonitorChannel -- the two-value contract, declared here (see header).
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /** Exactly two channels exist and a third is not anticipated -- stock VICE
59
+ * exposes precisely the binary monitor and the `-remotemonitor` text
60
+ * channel, and this project has no plan to add a third. Frozen so a
61
+ * consumer cannot accidentally push a third value onto it at runtime. */
62
+ export const MONITOR_CHANNELS = Object.freeze(["binary", "text"] as const);
63
+
64
+ export type MonitorChannel = (typeof MONITOR_CHANNELS)[number];
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // The holder record and the lock handle.
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /** A read-only snapshot of who currently holds halt authority. `grantId` is
71
+ * `null` when the caller did not supply one (not every acquirer has a
72
+ * broker-issued grant id to report) -- `channelLockRefusalMessage()` renders
73
+ * that case as the literal `unknown`, never a fabricated id. */
74
+ export interface ChannelLockHolder {
75
+ channel: MonitorChannel;
76
+ operation: string;
77
+ grantId: string | null;
78
+ heldSince: number;
79
+ }
80
+
81
+ /** Returned by a successful acquire. `release()` is idempotent: a second
82
+ * call is a no-op, and a stale handle's release() (called after the lock has
83
+ * already been re-acquired by someone else) never releases the NEW holder's
84
+ * lock -- both are identity-checked against a private id captured at grant
85
+ * time, never against the holder record's own field values. */
86
+ export interface ChannelLockHandle {
87
+ readonly holder: ChannelLockHolder;
88
+ release(): void;
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // CHANNEL_LOCK_ACQUIRE_TIMEOUT_MS -- the bounded-wait default (D-06).
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /**
96
+ * Default bound (milliseconds) on how long acquireChannelLock() will queue
97
+ * behind a holder before rejecting. Overridable via
98
+ * VICE_CHANNEL_LOCK_TIMEOUT_MS for a test or a deliberately narrowed
99
+ * deployment.
100
+ *
101
+ * DERIVATION (stated in prose, never imported -- see header): the longest
102
+ * legitimate hold on this lock is a stock wait path bounded by
103
+ * stock-run-until.ts's `RUN_UNTIL_MAX_TIMEOUT_MS` (600000ms). This default
104
+ * is that bound plus a 30000ms margin -- 630000ms -- so the queued-wait
105
+ * bound always EXCEEDS the longest legitimate hold and never fires on
106
+ * healthy operation. A bound below the longest legitimate hold would fire
107
+ * while the machine is doing exactly what it was asked to do, which is
108
+ * precisely the self-inflicted-DoS failure this constant exists to prevent
109
+ * (T-41-06). A waiting caller's own MCP client timeout (150000ms,
110
+ * `.mcp.json`) will surface first for THAT caller regardless -- that is a
111
+ * timeout on their call, not a wedge diagnosis of the instance.
112
+ */
113
+ export const CHANNEL_LOCK_ACQUIRE_TIMEOUT_MS: number = (() => {
114
+ const raw = process.env.VICE_CHANNEL_LOCK_TIMEOUT_MS;
115
+ if (raw === undefined || raw === "") return 630000;
116
+ const n = Number(raw);
117
+ return Number.isFinite(n) && n > 0 ? n : 630000;
118
+ })();
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // The refusal wording -- the ONE place it is composed.
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /**
125
+ * The ONE refusal wording produced when a caller cannot be granted the lock
126
+ * (either an immediate `tryAcquireChannelLock()` miss reported by a caller,
127
+ * or a `ChannelLockTimeoutError`'s own message). Names the other channel,
128
+ * the operation it is running, the hold duration in whole milliseconds, and
129
+ * the holder's grant id (or the literal `unknown` when there is none --
130
+ * never a fabricated id, matching claimMonitor()'s own posture for a
131
+ * malformed holder payload).
132
+ *
133
+ * This reads as a legitimate ownership statement -- the same register
134
+ * broker-control.mts's MONITOR_OWNERSHIP_DENIAL/`monitor_owned` refusal
135
+ * already establishes for the broker's own monitor-claim conflict -- and
136
+ * must NEVER contain the words wedge, wedged, hang, hung, frozen, stuck or
137
+ * unresponsive, nor otherwise suggest the emulator itself has stopped
138
+ * answering. A hold the OTHER channel cannot see would otherwise read as
139
+ * exactly the `wedged` triage signature, whose recommended remedy is
140
+ * destructive (T-41-06's threat-register entry).
141
+ */
142
+ export function channelLockRefusalMessage(holder: ChannelLockHolder, nowMs: number): string {
143
+ const heldMs = Math.max(0, nowMs - holder.heldSince);
144
+ const grantId = holder.grantId ?? "unknown";
145
+ return (
146
+ `channel-lock: the ${holder.channel} channel currently holds halt authority ` +
147
+ `(operation "${holder.operation}", grant ${grantId}, held for ${heldMs}ms) -- ` +
148
+ `this call must wait for that channel to release before it can proceed`
149
+ );
150
+ }
151
+
152
+ /**
153
+ * Thrown by acquireChannelLock() when a queued waiter's bound
154
+ * (CHANNEL_LOCK_ACQUIRE_TIMEOUT_MS by default, or an explicit override)
155
+ * expires before the lock reaches it. Carries the holder record that was
156
+ * blocking it and the measured hold/wait durations as public fields, with
157
+ * `message` produced by channelLockRefusalMessage() -- callers must never
158
+ * re-word this message, only pass it through verbatim.
159
+ */
160
+ export class ChannelLockTimeoutError extends Error {
161
+ readonly holder: ChannelLockHolder;
162
+ readonly heldMs: number;
163
+ readonly waitedMs: number;
164
+
165
+ constructor(holder: ChannelLockHolder, waitedMs: number, nowMs: number = Date.now()) {
166
+ super(channelLockRefusalMessage(holder, nowMs));
167
+ this.name = "ChannelLockTimeoutError";
168
+ this.holder = holder;
169
+ this.heldMs = Math.max(0, nowMs - holder.heldSince);
170
+ this.waitedMs = waitedMs;
171
+ }
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // The mutex itself: one holder, one FIFO queue, one admission path.
176
+ // ---------------------------------------------------------------------------
177
+
178
+ interface QueueEntry {
179
+ readonly id: symbol;
180
+ readonly channel: MonitorChannel;
181
+ readonly operation: string;
182
+ readonly grantId: string | null;
183
+ readonly arrivedAt: number;
184
+ timer: NodeJS.Timeout | null;
185
+ readonly resolve: (handle: ChannelLockHandle) => void;
186
+ readonly reject: (err: unknown) => void;
187
+ }
188
+
189
+ let currentHolder: ChannelLockHolder | null = null;
190
+ /** Identity of the handle that currently owns `currentHolder` -- distinct
191
+ * from the holder record's own field values, so a stale handle (one whose
192
+ * release() runs after the lock has already passed to someone else) can
193
+ * never be mistaken for the live one even if the new holder happens to
194
+ * share the same channel/operation/grantId. */
195
+ let currentHolderHandleId: symbol | null = null;
196
+
197
+ /** Plain array, used strictly FIFO: push() to enqueue, shift() to wake --
198
+ * per this module's own implementation requirement, never a priority queue,
199
+ * never a LIFO stack. */
200
+ let queue: QueueEntry[] = [];
201
+
202
+ function removeFromQueueById(id: symbol): QueueEntry | null {
203
+ const idx = queue.findIndex((e) => e.id === id);
204
+ if (idx === -1) return null;
205
+ const [entry] = queue.splice(idx, 1);
206
+ return entry;
207
+ }
208
+
209
+ /** The ONE place a caller is granted the lock, whether immediately (queue
210
+ * was empty) or via wakeNext() below (queue had waiters). Builds a fresh
211
+ * holder record and a fresh handle identity every time -- there is no
212
+ * second admission path. */
213
+ function grantLock(channel: MonitorChannel, operation: string, grantId: string | null): ChannelLockHandle {
214
+ const id = Symbol("channel-lock-handle");
215
+ const holder: ChannelLockHolder = { channel, operation, grantId, heldSince: Date.now() };
216
+ currentHolder = holder;
217
+ currentHolderHandleId = id;
218
+ let released = false;
219
+ return {
220
+ holder,
221
+ release: () => {
222
+ if (released) return;
223
+ released = true;
224
+ // Identity check: a stale handle whose lock has already been handed
225
+ // to a new holder (this handle's `id` no longer matches
226
+ // currentHolderHandleId) must not clear the NEW holder's state.
227
+ if (currentHolderHandleId !== id) return;
228
+ currentHolder = null;
229
+ currentHolderHandleId = null;
230
+ wakeNext();
231
+ },
232
+ };
233
+ }
234
+
235
+ /** Hands the lock to the next queued waiter, synchronously -- never through
236
+ * a setTimeout, so release-to-grant handoff cannot be reordered by timer
237
+ * scheduling. No-op when the queue is empty. */
238
+ function wakeNext(): void {
239
+ const next = queue.shift();
240
+ if (!next) return;
241
+ if (next.timer) {
242
+ clearTimeout(next.timer);
243
+ next.timer = null;
244
+ }
245
+ const handle = grantLock(next.channel, next.operation, next.grantId);
246
+ next.resolve(handle);
247
+ }
248
+
249
+ /** The ONE internal admission function both acquireChannelLock() and its
250
+ * queueing path run through -- grants immediately when free, otherwise
251
+ * enqueues and resolves later via wakeNext(). */
252
+ function admit(channel: MonitorChannel, operation: string, grantId: string | null, timeoutMs: number): Promise<ChannelLockHandle> {
253
+ if (currentHolder === null) {
254
+ return Promise.resolve(grantLock(channel, operation, grantId));
255
+ }
256
+ return new Promise<ChannelLockHandle>((resolve, reject) => {
257
+ const id = Symbol("channel-lock-waiter");
258
+ const arrivedAt = Date.now();
259
+ const entry: QueueEntry = { id, channel, operation, grantId, arrivedAt, timer: null, resolve, reject };
260
+ entry.timer = setTimeout(() => {
261
+ const removed = removeFromQueueById(id);
262
+ if (!removed) {
263
+ // Already granted or already drained by resetChannelLockForTests()
264
+ // between the timer firing and this callback running -- nothing
265
+ // left to reject. Unreachable in the single-threaded JS event loop
266
+ // under normal operation, but defensive rather than assumed.
267
+ return;
268
+ }
269
+ // Invariant: a queued entry can only exist while the lock is held --
270
+ // every release() immediately drains the queue via wakeNext() when
271
+ // non-empty, and admit() only enqueues when currentHolder !== null.
272
+ // So currentHolder is guaranteed non-null here.
273
+ const holder = currentHolder;
274
+ if (holder === null) {
275
+ reject(
276
+ new Error(
277
+ "channel-lock: internal invariant violation -- a queued waiter's timeout fired while no holder was recorded",
278
+ ),
279
+ );
280
+ return;
281
+ }
282
+ reject(new ChannelLockTimeoutError(holder, Date.now() - arrivedAt));
283
+ }, timeoutMs);
284
+ if (typeof entry.timer.unref === "function") entry.timer.unref();
285
+ queue.push(entry);
286
+ });
287
+ }
288
+
289
+ export interface AcquireChannelLockOptions {
290
+ channel: MonitorChannel;
291
+ operation: string;
292
+ grantId?: string | null;
293
+ timeoutMs?: number;
294
+ }
295
+
296
+ /**
297
+ * Grants the lock immediately when free; otherwise appends to the FIFO
298
+ * queue and resolves when the lock reaches this caller. On expiry of
299
+ * `timeoutMs` (default CHANNEL_LOCK_ACQUIRE_TIMEOUT_MS) removes its own
300
+ * entry from the queue (by identity, so a later release() can never wake an
301
+ * already-rejected waiter) and rejects with ChannelLockTimeoutError carrying
302
+ * the holder record and the measured hold/wait durations.
303
+ */
304
+ export function acquireChannelLock(opts: AcquireChannelLockOptions): Promise<ChannelLockHandle> {
305
+ return admit(opts.channel, opts.operation, opts.grantId ?? null, opts.timeoutMs ?? CHANNEL_LOCK_ACQUIRE_TIMEOUT_MS);
306
+ }
307
+
308
+ export interface TryAcquireChannelLockOptions {
309
+ channel: MonitorChannel;
310
+ operation: string;
311
+ grantId?: string | null;
312
+ }
313
+
314
+ /**
315
+ * Fully synchronous, no `await` anywhere: grants and returns a handle when
316
+ * free, returns `null` immediately when held -- never queues. This is the
317
+ * entry point a diagnostic uses so that diagnosing contention never queues
318
+ * behind the holder it is diagnosing (consumed by vice_diagnose, plan
319
+ * 41-04).
320
+ */
321
+ export function tryAcquireChannelLock(opts: TryAcquireChannelLockOptions): ChannelLockHandle | null {
322
+ if (currentHolder !== null) return null;
323
+ return grantLock(opts.channel, opts.operation, opts.grantId ?? null);
324
+ }
325
+
326
+ /** A read-only COPY of the holder record -- never the live object, so a
327
+ * caller cannot mutate module state by holding onto what this returns.
328
+ * `null` when nothing holds the lock. */
329
+ export function currentChannelLockHolder(): ChannelLockHolder | null {
330
+ if (currentHolder === null) return null;
331
+ return { ...currentHolder };
332
+ }
333
+
334
+ /**
335
+ * Clears the holder and drains the queue by rejecting every waiter --
336
+ * exists only so a test file can start from a known state, in the register
337
+ * stock-dispatch.ts's own clearHeldStockSession() already establishes.
338
+ * Never called from production code.
339
+ */
340
+ export function resetChannelLockForTests(): void {
341
+ currentHolder = null;
342
+ currentHolderHandleId = null;
343
+ const pending = queue;
344
+ queue = [];
345
+ for (const entry of pending) {
346
+ if (entry.timer) clearTimeout(entry.timer);
347
+ entry.reject(new Error("channel-lock: resetChannelLockForTests() drained this waiter"));
348
+ }
349
+ }
package/evid-ingest.ts ADDED
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+ // evid-ingest.ts
3
+ //
4
+ // Plan 43-05 (EVID-01, EVID-04): THE ONE PLACE a parsed `memmapshow` access
5
+ // map (`textmon-memmap.ts`'s `AccessMap`) becomes durable-store-shaped
6
+ // observation rows. `anno-tools.ts`'s `anno_evid_ingest` dispatch arm calls
7
+ // this module's pure functions and then, and only then, writes what they
8
+ // return through `anno-store.ts`'s `insertExecObservations`.
9
+ //
10
+ // THE ONE POSITIVE FACT THIS LAYER IS LICENSED TO ASSERT (quoted from this
11
+ // plan's own objective, verbatim, so a later reader never has to re-derive
12
+ // it): "A row is written if and only if an address's `ram.execute`,
13
+ // `rom.execute` or `io.execute` flag is true in the parsed reply. Nothing
14
+ // else produces a row." An address `memmapshow` mentioned with read or
15
+ // write access but no execute produces no row, and an address absent from
16
+ // the sparse `entries` array produces no row either -- those are two
17
+ // DIFFERENT facts about the world, and neither is recoverable from the
18
+ // store's row set alone, only from the reply itself. That is correct and is
19
+ // the point: the store holds observed execution, and the absence of a row
20
+ // is not a fact the store asserts about the address. It is the absence of
21
+ // an assertion.
22
+ //
23
+ // Read-and-write-only observations are deliberately NOT stored here. The
24
+ // schema can gain a separate table for them later without a migration,
25
+ // because it would be a wholly new table -- the store's existing additive
26
+ // discipline.
27
+ //
28
+ // WHY THE VERB TAKES AN ARGV ARRAY AND NEVER A DIGEST: a caller-supplied
29
+ // pre-computed digest would let a caller invent a run identity, a second
30
+ // notion of sameness sitting beside EVID-01's own. `runIdentityFrom()`
31
+ // below computes `argvDigest` itself, from the exact `argv` the caller
32
+ // supplies, through the ONE shipped digest function
33
+ // (`capture-predicate.ts`'s `argvDigest()`) and nothing else -- there is no
34
+ // code path here that accepts a digest as input.
35
+ //
36
+ // ---------------------------------------------------------------------------
37
+ // WHAT NOT TO DO -- each of these is a specific, named trap
38
+ // ---------------------------------------------------------------------------
39
+ // 1. NEVER write a row from an address's ABSENCE. `execObservationsFrom()`
40
+ // only ever iterates `map.entries` -- the sparse array VICE actually
41
+ // sent -- and never pads it back out to the full address space. An
42
+ // address absent from `entries` produces nothing, silently, by
43
+ // construction: there is no branch here that could emit a row for it.
44
+ // 2. NEVER merge two banks' execute bits into one row. `ram.execute`,
45
+ // `rom.execute` and `io.execute` are three independent facts
46
+ // (`textmon-memmap.ts`'s own header states this for `AccessFlags`);
47
+ // this module emits at most one observation per (address, bank) pair,
48
+ // never a combined "any bank executed" row.
49
+ // 3. NEVER accept a pre-computed argv digest. `runIdentityFrom()`'s ONLY
50
+ // input for identity is `argv` itself; there is no `argvDigest`
51
+ // parameter anywhere in this module's exported surface.
52
+ // 4. NEVER open a store here. This module imports nothing from
53
+ // `anno-store.ts` and touches no filesystem, transport or
54
+ // child-process -- the write happens in `anno-tools.ts`'s dispatch
55
+ // arm, which is what keeps this module out of `anno-seam.test.ts`'s
56
+ // single-consumer set (`anno-store.ts` remains the one module naming
57
+ // `node:sqlite`).
58
+ // 5. NEVER read `.read` or `.write` off an `AccessFlags` value anywhere in
59
+ // this file. Only `.execute` is ever inspected -- read-and-write-only
60
+ // access is deliberately not this layer's concern (see the header
61
+ // above).
62
+ import type { AccessMap, AccessMapParseResult } from "./textmon-memmap.ts";
63
+ import { argvDigest } from "./capture-predicate.ts";
64
+ import { EVID_SOURCE_BANKS, type EvidSourceBank } from "./anno-types.ts";
65
+ import { ViceError } from "./vice-errors.ts";
66
+
67
+ /** One observed execute bit: this address, in this source bank, was seen
68
+ * executing. There is no third field -- an `ExecObservation` carries no
69
+ * opinion about whether the address is "code" or "data"; it is a single,
70
+ * narrow fact about what the emulator actually did. */
71
+ export interface ExecObservation {
72
+ readonly address: number;
73
+ readonly sourceBank: EvidSourceBank;
74
+ }
75
+
76
+ /** Exactly 64 lowercase hex characters -- the shape a sha256 digest (an
77
+ * image hash, or `argvDigest()`'s own output) always takes. This module's
78
+ * OWN copy of the check, deliberately not imported from `anno-types.ts`
79
+ * (this file's import list is closed to exactly the five named imports
80
+ * above): the caller-supplied `imageSha256` never reaches a digest
81
+ * function here, so there is nothing to route through `argvDigest`'s own
82
+ * shape, and duplicating a four-line regex check is cheaper than widening
83
+ * this module's import surface for it. */
84
+ const RUN_IDENTITY_DIGEST_RE = /^[0-9a-f]{64}$/;
85
+
86
+ /** The exact launch identity a caller must supply. `runClass` exists ONLY
87
+ * on plan 43-01's `promote` branch decision and is never read by this
88
+ * module's `no-change` implementation -- carried in the type so a future
89
+ * `promote` branch has somewhere to put it without a second interface. */
90
+ export interface IngestRunIdentity {
91
+ readonly imageSha256: string;
92
+ readonly argv: readonly string[];
93
+ readonly seed: string;
94
+ }
95
+
96
+ /** `runIdentityFrom()`'s answer: the bare `(imageSha256, argvDigest, seed)`
97
+ * triple the `no-change` run-identity decision selected (plan 43-01,
98
+ * `docs/phase43-instrumentation-perturbation-ab.md`) -- no `runClass`
99
+ * discriminator field exists on this type. */
100
+ export interface RunIdentity {
101
+ readonly imageSha256: string;
102
+ readonly argvDigest: string;
103
+ readonly seed: string;
104
+ }
105
+
106
+ /** Validates `identity.imageSha256` and `identity.seed`, then computes
107
+ * `argvDigest` from `identity.argv` through the ONE shipped digest function
108
+ * -- and nothing else. Refuses BY NAME, throwing inside the `ViceError`
109
+ * family so the anno never-throw boundary (`anno-tools.ts`'s
110
+ * `runAnnoTool()`) can name the class in its `{isError:true}` answer:
111
+ *
112
+ * - `imageSha256` that is not exactly 64 lowercase hex characters
113
+ * (the program image is named by its bytes; a malformed digest here
114
+ * would silently key a run under the wrong image identity forever).
115
+ * - `seed` that is not a non-empty string.
116
+ * - `argv` that is not an array, or an empty array -- both refusals are
117
+ * `argvDigest()`'s OWN (this function never duplicates that check; it
118
+ * lets `argvDigest()` throw and re-wraps the message as a `ViceError`
119
+ * so the caller sees one error family regardless of which check fired).
120
+ *
121
+ * `argvDigest()` is the ONLY place `argv` becomes an identity: there is no
122
+ * second hashing site here, and no parameter anywhere on this module's
123
+ * surface through which a caller could hand in an already-computed digest.
124
+ */
125
+ export function runIdentityFrom(identity: IngestRunIdentity): RunIdentity {
126
+ if (typeof identity?.imageSha256 !== "string" || !RUN_IDENTITY_DIGEST_RE.test(identity.imageSha256)) {
127
+ throw new ViceError(
128
+ `runIdentityFrom: imageSha256 ${JSON.stringify(identity?.imageSha256)} is not exactly 64 lowercase hex characters -- ` +
129
+ "expected the sha256 digest of the program image's own bytes.",
130
+ { code: "evid-ingest-bad-image-sha256" },
131
+ );
132
+ }
133
+ if (typeof identity.seed !== "string" || identity.seed.length === 0) {
134
+ throw new ViceError(`runIdentityFrom: seed ${JSON.stringify(identity.seed)} is not a non-empty string.`, {
135
+ code: "evid-ingest-bad-seed",
136
+ });
137
+ }
138
+
139
+ let digest: string;
140
+ try {
141
+ digest = argvDigest(identity.argv);
142
+ } catch (err) {
143
+ const reason = err instanceof Error ? err.message : String(err);
144
+ throw new ViceError(`runIdentityFrom: ${reason}`, { code: "evid-ingest-bad-argv" });
145
+ }
146
+
147
+ return { imageSha256: identity.imageSha256, argvDigest: digest, seed: identity.seed };
148
+ }
149
+
150
+ /**
151
+ * The pure transform (EVID-01, EVID-04): one observation per (address,
152
+ * bank) pair whose `execute` flag is `true` in `map.entries`, sorted
153
+ * ascending by address then by the bank's index in `EVID_SOURCE_BANKS`'s
154
+ * frozen order (`ram`, `rom`, `io`). One pass over `map.entries`; never
155
+ * reads `.read` or `.write`; never derives an observation from an address's
156
+ * absence, because the loop only ever visits addresses `map.entries`
157
+ * actually contains.
158
+ */
159
+ export function execObservationsFrom(map: AccessMap): ExecObservation[] {
160
+ const observations: ExecObservation[] = [];
161
+ for (const entry of map.entries) {
162
+ for (const bank of EVID_SOURCE_BANKS) {
163
+ if (entry[bank].execute) {
164
+ observations.push({ address: entry.address, sourceBank: bank });
165
+ }
166
+ }
167
+ }
168
+ observations.sort((a, b) => {
169
+ if (a.address !== b.address) return a.address - b.address;
170
+ return EVID_SOURCE_BANKS.indexOf(a.sourceBank) - EVID_SOURCE_BANKS.indexOf(b.sourceBank);
171
+ });
172
+ return observations;
173
+ }
174
+
175
+ /** `ingestAccessMap()`'s success arm: the derived run identity plus the
176
+ * sorted observation list -- exactly what `anno-tools.ts`'s dispatch arm
177
+ * hands to `insertExecObservations()` in one call. */
178
+ export interface IngestAccessMapOk {
179
+ readonly ok: true;
180
+ readonly runIdentity: RunIdentity;
181
+ readonly observations: readonly ExecObservation[];
182
+ }
183
+
184
+ /** `ingestAccessMap()`'s refusal arm: the parse refusal's own code, line
185
+ * number and offending line, carried through UNABSORBED -- a drifted or
186
+ * malformed `memmapshow` reply is never silently treated as a zero-entry
187
+ * capture. */
188
+ export interface IngestAccessMapRefusal {
189
+ readonly ok: false;
190
+ readonly message: string;
191
+ }
192
+
193
+ export type IngestAccessMapResult = IngestAccessMapOk | IngestAccessMapRefusal;
194
+
195
+ /**
196
+ * The one join of a parse result and a run identity into observations ready
197
+ * to write. THIS FUNCTION DOES NOT WRITE -- it is pure, and the store write
198
+ * happens in `anno-tools.ts`'s dispatch arm.
199
+ *
200
+ * On `parsed.ok === false`, returns the refusal form carrying the refusal's
201
+ * own code, line number and offending line, and touches nothing else --
202
+ * neither `runIdentityFrom()` nor `execObservationsFrom()` is ever called on
203
+ * a refused parse, because there is nothing in a refusal to derive either
204
+ * from.
205
+ */
206
+ export function ingestAccessMap(parsed: AccessMapParseResult, identity: IngestRunIdentity): IngestAccessMapResult {
207
+ if (!parsed.ok) {
208
+ const { refusal } = parsed;
209
+ return {
210
+ ok: false,
211
+ message: `memmapshow reply refused [${refusal.code}] at line ${refusal.lineNumber}: ${refusal.message} (offending line: ${JSON.stringify(refusal.line)})`,
212
+ };
213
+ }
214
+ const runIdentity = runIdentityFrom(identity);
215
+ const observations = execObservationsFrom(parsed.value);
216
+ return { ok: true, runIdentity, observations };
217
+ }