@henols/vice-mcp 0.2.0 → 0.2.2

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 (49) hide show
  1. package/README.md +2 -1
  2. package/THIRD-PARTY-NOTICES.md +1 -1
  3. package/anno-acme-ident.ts +97 -0
  4. package/anno-cli.ts +1465 -0
  5. package/anno-confidence.ts +233 -0
  6. package/anno-coverage.ts +2465 -0
  7. package/anno-d64.ts +310 -0
  8. package/anno-derive.ts +590 -0
  9. package/anno-details.ts +169 -0
  10. package/anno-enum-gen.ts +533 -0
  11. package/anno-export-asm.ts +1310 -0
  12. package/anno-index.ts +150 -0
  13. package/anno-memmap-render.ts +672 -0
  14. package/anno-regbits-gen.ts +421 -0
  15. package/anno-regbits.json +1370 -0
  16. package/anno-register.ts +240 -0
  17. package/anno-store.ts +3486 -0
  18. package/anno-symbols.ts +266 -0
  19. package/anno-tools.ts +2111 -0
  20. package/anno-types.ts +1636 -0
  21. package/block-class.ts +201 -0
  22. package/build.ts +1 -1
  23. package/capability-registry.ts +3 -1
  24. package/disasm-decoder.ts +14 -14
  25. package/disasm-opcodes.ts +4 -4
  26. package/disasm-renderer.ts +2 -2
  27. package/hostpath.ts +1 -1
  28. package/install-resources.ts +1 -1
  29. package/package.json +23 -3
  30. package/prg-image.ts +119 -0
  31. package/repo-root.ts +20 -5
  32. package/resources/broker-launch.mjs +8 -4
  33. package/resources/vice-launcher.sh +3 -3
  34. package/stock-address.ts +5 -5
  35. package/stock-cia.ts +2 -2
  36. package/stock-condition.ts +7 -7
  37. package/stock-connect.ts +1 -1
  38. package/stock-dispatch.ts +35 -5
  39. package/stock-execution.ts +5 -3
  40. package/stock-input.ts +9 -9
  41. package/stock-machine.ts +17 -6
  42. package/stock-protocol.ts +16 -11
  43. package/stock-registers.ts +54 -29
  44. package/stock-sprites.ts +3 -3
  45. package/stock-symbols.ts +33 -9
  46. package/stock-timing.ts +1 -1
  47. package/stock-vicii.ts +1 -1
  48. package/version.ts +1 -1
  49. package/vice-proxy.ts +168 -0
package/anno-cli.ts ADDED
@@ -0,0 +1,1465 @@
1
+ #!/usr/bin/env node
2
+ // anno-cli.ts -- the thin CLI ergonomics layer over the annotation store
3
+ // (D-06). Reached as `vice-mcp anno <verb>` because that bin is the only
4
+ // surface that resolves identically across the Claude Code plugin route and
5
+ // both npm-installer routes: `installer/bin/cli.mjs`'s `viceServerEntry()`
6
+ // always launches this server via `npx` in BOTH npm-installer modes, and
7
+ // neither route places `src/mcp/vice/*.ts` as plain files inside a
8
+ // consuming project for some other filesystem-path-resolving design to find.
9
+ //
10
+ // ---------------------------------------------------------------------------
11
+ // THREE VERBS. THAT IS THE WHOLE SURFACE (D-14, 2026-08-29; third verb landed
12
+ // 2026-08-31).
13
+ // ---------------------------------------------------------------------------
14
+ // This file used to carry eight. Six were removed in one commit because they
15
+ // were delivery paths for the retired external analyser this project used to
16
+ // rent an annotation store from: three drove its child process directly and
17
+ // three reached it through capability modules that did. Removing the analyser
18
+ // without removing them would have left six verbs that typecheck, dispatch,
19
+ // and then fail at the first call. That paragraph is kept rather than deleted:
20
+ // it records what went and why, and it stays true.
21
+ //
22
+ // What went, and where it stands now:
23
+ // - `bootstrap`, `export-asm`, `verify` -- the analyser's own routes.
24
+ // `export-asm` RETURNED on 2026-08-31 as a REBUILD OVER THE ANNOTATION
25
+ // STORE behind a real-ACME byte-diff oracle -- not as restored code, and
26
+ // not sharing a line with the deleted implementation. It is the third
27
+ // verb below. `bootstrap` and `verify` did not come back: `bootstrap`
28
+ // created the analyser's own project file, which no longer exists as a
29
+ // format this repo produces, and `verify` drove the analyser's own
30
+ // checker.
31
+ // - `gen-enums`, `export-lbl`, `import-lbl` -- the enum generator and the
32
+ // VICE-label round trip. These did NOT return with `export-asm`. No
33
+ // requirement and no success criterion of the phase that rebuilt
34
+ // `export-asm` covers any of them, and NO PHASE CURRENTLY OWNS THEM, so
35
+ // the symbol round trip still has NO route at all. That is recorded as a
36
+ // withdrawal in `.planning/PROJECT.md`'s shipped-capability list rather
37
+ // than left for a reader to discover by running it. The exact wording of
38
+ // those withdrawal notices across both skill trees is re-pointed in one
39
+ // place, by the plan that owns the tree-wide sweep (30-06); this file
40
+ // states the code fact and does not restate their text, so the two edits
41
+ // cannot contradict each other.
42
+ //
43
+ // WHAT NOT TO DO, named concretely:
44
+ // - Never auto-pick an input when the caller does not name one (D-02). A
45
+ // silent auto-pick would happily analyse a cracktro or loader stub's
46
+ // bytes instead of the actual game -- precisely the failure
47
+ // `c64-provenance-diff` exists to prevent elsewhere in this project.
48
+ // Every verb takes EXISTING inputs and refuses rather than guess:
49
+ // `render-memmap` demands its provenance sidecar by name, `coverage`
50
+ // demands its annotation store by name, and `export-asm` demands BOTH an
51
+ // existing store and an existing image. No verb derives one
52
+ // caller-supplied path from another.
53
+ // - Never grow a second path validator. Every caller-supplied path below
54
+ // goes through `storePathWithinWorkspace()` -- the ONE confinement seam,
55
+ // the same one `anno-tools.ts` puts its store and image arguments
56
+ // through. A second answer to "is this path inside the workspace" is a
57
+ // confinement escape waiting to be written.
58
+ //
59
+ // THIS PARAGRAPH WAS FALSE WHEN IT WAS FIRST WRITTEN, and that is why it
60
+ // now names the mechanism that keeps it. `29-VERIFICATION.md` gap 3 /
61
+ // `29-REVIEW.md` CR-02 and CR-03 reproduced three escapes on this very
62
+ // tree, on arguments the shipped playbooks tell an agent to compose in a
63
+ // Bash invocation: `render-memmap --out` and `coverage --out` reached
64
+ // `writeFileSync` as raw caller strings (the first silently replacing a
65
+ // pre-existing file OUTSIDE the workspace root and exiting 0), and
66
+ // `render-memmap --provenance` reached `readFileSync` raw, making it an
67
+ // arbitrary-file read oracle that then DISCLOSED the file's opening bytes
68
+ // through an interpolated parse error. Four of the six arguments were
69
+ // unconfined while this paragraph said all of them were.
70
+ //
71
+ // A header naming a maintained property is a written warrant for the next
72
+ // maintainer not to check, so when the property and the prose disagree the
73
+ // prose is the more dangerous half. What went wrong is worth stating
74
+ // precisely: the SEAM was never weak -- `anno-confinement.test.ts` proves
75
+ // the predicate fifteen ways, including the symlink and dangling-link
76
+ // classes -- but its CONSUMER SET was unenumerated, and nothing could fail
77
+ // when a new argument skipped it. `anno-cli-path-consumers.test.ts` closes
78
+ // exactly that asymmetry: it ENUMERATES every caller-supplied path
79
+ // argument this CLI accepts -- the flags derived from `VERB_OPTIONS`
80
+ // below, the positionals derived from each verb's `--help` synopsis line
81
+ // -- and fails when the inventory and the surface disagree in either
82
+ // direction, or when the number of confinement call sites in this file
83
+ // falls below the inventory's size. A new path-shaped flag or positional
84
+ // therefore joins the audit automatically instead of by a reviewer
85
+ // noticing.
86
+ //
87
+ // AND WHAT IT DOES NOT CHECK, stated in terms so the limit can be closed
88
+ // deliberately rather than discovered (WR-02): it does not associate a
89
+ // particular argument with a particular call site. "Six arguments each
90
+ // confined once" and "five confined with one of them confined twice" read
91
+ // the same to it. That association needs per-argument dataflow through
92
+ // this file -- a static-analysis job, deliberately not taken on in a
93
+ // gap-closure round -- so what this paragraph now claims is the narrower
94
+ // property the test has, not the wider one it used to be credited with.
95
+ //
96
+ // - Never use the RAW caller string after confining it.
97
+ // `storePathWithinWorkspace()` returns the REALPATH, not its input, so
98
+ // carrying the original forward reintroduces the escape one line below the
99
+ // check that refused it -- and makes every "wrote X" line name a file that
100
+ // is not the one on disk.
101
+ //
102
+ // `runAnnoCli()` returns an exit code and never terminates the process
103
+ // itself, so it is testable in-process as well as from the bin (the bin,
104
+ // `vice-proxy.ts`, is the only place that ends the process with this
105
+ // function's return value). All output goes to stdout/stderr via
106
+ // `console.log`/`console.error` -- never a thrown stack trace for an
107
+ // expected, user-facing failure (missing file, unreadable store, refused
108
+ // overwrite): each of those produces a single actionable line instead.
109
+ //
110
+ // Import nothing from `hostpath.ts` or `containerpath.ts`. Every path this
111
+ // CLI handles is already container-side, and translating any of these
112
+ // arguments would be the mirror image of the DERIV-07 screenshot-path trap,
113
+ // where a client-side-derived path was wrongly translated a second time.
114
+ // This absence is asserted structurally by `hostpath-consumers.test.ts`
115
+ // (D-08), not merely stated here.
116
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
117
+ import { basename, dirname, extname, join } from "node:path";
118
+
119
+ import { renderMemoryMap, checkRenderedMemoryMap } from "./anno-memmap-render.ts";
120
+ // The ACME source emitter (EXPORT-01). It reads the store and the image and
121
+ // returns text plus counts; it starts no assembler and knows nothing about
122
+ // one. `acme-verify.ts` -- the module that DOES spawn ACME -- is deliberately
123
+ // NOT imported here and must never be: it is test-only (it is absent from
124
+ // `package.json`'s `files[]` on purpose), so a shipped module importing it
125
+ // would drag it into the published closure `check-npm-packages.mjs` walks.
126
+ import { exportAsm } from "./anno-export-asm.ts";
127
+ import type { ExportAsmResult } from "./anno-export-asm.ts";
128
+ // The coverage instrument (COV-01/COV-02). It declares its own input shapes
129
+ // and never reads a store, a file or a tool on its own behalf -- a caller
130
+ // fetches and hands the data in, which is exactly what makes the store
131
+ // re-point below a CALLER-side change and nothing more.
132
+ import { buildCoverageReport, coverageFindings, loadProjectImage } from "./anno-coverage.ts";
133
+ import type { CoverageReport, LoadedProject, AnnoComment, AnnoCrossReference, AnnoSymbol } from "./anno-coverage.ts";
134
+ // The store's block-entry shape comes from the boundary that owns its
135
+ // vocabulary, not from the census -- see `block-class.ts`.
136
+ import type { BlockEntry } from "./block-class.ts";
137
+ import { openStore, closeStore, listLabels, listComments, listRanges } from "./anno-store.ts";
138
+ import type { AnnoStoreHandle } from "./anno-store.ts";
139
+ // The derived half of STORE-06: cross-references are DERIVED from the bytes
140
+ // plus the store's typed ranges plus the few rows that cannot be recovered
141
+ // from bytes at all. There is exactly one definition of that union and this
142
+ // file calls it rather than restating it.
143
+ import { crossReferencesTo } from "./anno-derive.ts";
144
+ import { storePathWithinWorkspace } from "./anno-types.ts";
145
+ import type { CommentRow, LabelRow, RangeRow } from "./anno-types.ts";
146
+ import { repoRoot } from "./repo-root.ts";
147
+ const NPX_INVOCATION = "npx -y @henols/vice-mcp anno <verb>";
148
+ const PLUGIN_INVOCATION = "node <plugin-root>/src/mcp/vice/vice-proxy.ts anno <verb>";
149
+
150
+ const USAGE = `usage (npm install): ${NPX_INVOCATION}
151
+ usage (plugin/in-repo): ${PLUGIN_INVOCATION}
152
+
153
+ verbs:
154
+ render-memmap <store> --provenance FILE [--out FILE] [--force] [--check]
155
+ Generates the Markdown memory map from an annotation store plus a
156
+ validated provenance sidecar (D-24: the store is canonical, this
157
+ output is a GENERATED VIEW -- never hand-edit it). Without --check,
158
+ writes --out (default: memory-map.md beside the STORE -- in the
159
+ store's own directory), refusing to overwrite an existing file there
160
+ unless --force is passed, and prints the row count, the number of
161
+ [unknown]-graded rows, and the render digest. That derived default is
162
+ put through the SAME confinement seam as a caller-supplied --out,
163
+ rather than trusted because this verb computed it.
164
+ With --check, re-renders in memory and compares against the file at
165
+ --out: prints "in sync" and exits 0 when they match, prints the first
166
+ differing line and exits non-zero on drift, or prints "missing" and
167
+ exits non-zero when --out does not exist yet. Drift is reported when,
168
+ and only when, one of these changed: this file itself (a hand edit --
169
+ which is what --check exists to catch); a store row (a range, a label,
170
+ a comment, or a comment's confidence grade); the provenance sidecar's
171
+ bytes; the location of the store or the sidecar RELATIVE TO THE
172
+ WORKSPACE ROOT; or the renderer. Relocating the checkout is NOT drift --
173
+ the same tree at a different absolute path renders these same bytes,
174
+ because the two locations the banner records are workspace-relative.
175
+ Requires an EXISTING annotation store and an EXISTING --provenance
176
+ sidecar (this verb creates neither).
177
+
178
+ coverage <image> --store FILE [--out FILE] [--force] [--sample N]
179
+ Measures how far a program has actually been reverse-engineered
180
+ (COV-01/COV-02), through anno-coverage.ts. <image> supplies the
181
+ PAYLOAD BYTES and the load origin; --store names the ANNOTATION STORE
182
+ holding the labels, comments and typed ranges. Those are two separate
183
+ files on purpose: the store holds annotations and never bytes, so a
184
+ derived measure has to be told which bytes it is measuring and this
185
+ verb refuses to guess one from the other.
186
+ <image> is dispatched IN THIS ORDER, and the order is load-bearing:
187
+ first, a .raw or .bin is read as a flat capture BY EXTENSION, before
188
+ any length check, so a truncated capture is refused BY NAME instead
189
+ of falling through to the .prg parser (WR-07: a 4096-byte .raw once
190
+ had its first two bytes read as a load address and reported a
191
+ complete-looking measurement); then any file that is NOT a .prg and
192
+ is exactly 65536 bytes is read as a flat capture, which is the one
193
+ branch that does dispatch on byte length; then a .prg, whose first
194
+ two bytes are the load address. The retired JSON project form
195
+ survives as a TRAILING LEGACY branch, reached only when none of
196
+ those matched -- its only producer was deleted (D-14) and it is kept
197
+ solely so an existing file on disk is not broken.
198
+ Prints three separately named measures -- the structural byte census,
199
+ the two label figures, and the sampled reproducibility result -- plus
200
+ the comment-vacuity measure, the indirect-dispatch scan and the
201
+ divergence sub-report, each under its own heading with its own
202
+ numbers. Writes the JSON report to --out when given, refusing to
203
+ overwrite an existing file there unless --force is passed; --sample
204
+ overrides the reproducibility sample size.
205
+ Exits non-zero for a caller error (a missing or malformed argument, a
206
+ path outside the workspace root, a named file that does not exist, or
207
+ a refused overwrite of an existing --out without --force), for a store
208
+ it could not read, for a report it could not write, and for an image
209
+ whose PAYLOAD COULD NOT BE DECODED -- that last is not a low score but
210
+ a measurement taken over nothing, and it is reported AFTER the report
211
+ so the reason is on screen. A LOW MEASUREMENT IS A RESULT, NEVER A
212
+ FAILURE, so a bad report still exits 0.
213
+ This verb deliberately reports separate numbers and never a single
214
+ combined figure: one aggregate is precisely what makes a coverage
215
+ claim unfalsifiable, because any one weak measure can be hidden by
216
+ averaging it against a strong one.
217
+
218
+ export-asm <image> --store FILE [--out FILE] [--force]
219
+ Writes ACME source for a program from its annotation store. <image>
220
+ supplies the PAYLOAD BYTES and the load origin; --store names the
221
+ ANNOTATION STORE holding the ranges, labels, comments and enums. Those
222
+ are two separate files on purpose, and NEITHER IS DERIVED FROM THE
223
+ OTHER: the store holds annotations and never bytes, so an exporter has
224
+ to be told which bytes it is describing and this verb refuses to guess
225
+ one from the other.
226
+ The default --out is the image's basename with a .a extension, in the
227
+ STORE's own directory. That derived default is put through the SAME
228
+ confinement seam as a caller-supplied --out, rather than trusted
229
+ because this verb computed it. An existing destination is refused
230
+ unless --force is passed.
231
+ Requires an EXISTING annotation store and an EXISTING image, and
232
+ creates neither.
233
+ THIS VERB DOES NOT ASSEMBLE ITS OUTPUT. It writes source text and
234
+ nothing more: it starts no assembler, reads no assembler's exit status
235
+ and compares no bytes. Whether that source reassembles to the image it
236
+ came from is settled by the byte-diff oracle in this project's own test
237
+ suite, which is deliberately test-only, so nothing this command prints
238
+ may be read as a verification result.
239
+ Refuses, by name and with exit 1, any annotation the exporter cannot
240
+ express -- a range the image does not cover, an enum bound to an
241
+ operand that cannot carry it, a comment with no line to attach to.
242
+ Such an annotation is never silently dropped while this command
243
+ reports success.
244
+
245
+ Every verb requires inputs that already exist. None creates a project, a
246
+ store or a sidecar, and none derives one path from another -- this CLI
247
+ never guesses (D-02).
248
+ `;
249
+
250
+ function errMsg(err: unknown): string {
251
+ return err instanceof Error ? err.message : String(err);
252
+ }
253
+
254
+ /**
255
+ * IN-06 (D-11.1-04): the ONE declared verb-to-accepted-options fact in this
256
+ * file. Every option every verb's own code actually reads is listed here --
257
+ * ground truth, not merely what USAGE happens to say.
258
+ *
259
+ * The defect this map exists against, kept on the record because the shape
260
+ * outlives the verb it was found on: a verb accepted a flag its own code
261
+ * never read, so a caller who passed it got no error and no effect. Listing
262
+ * only the options a verb ACTUALLY reads means `checkAcceptedOptions()` below
263
+ * refuses the rest before the verb ever runs.
264
+ *
265
+ * `coverage`'s `--store` is REQUIRED rather than optional, and it is declared
266
+ * here for the same reason as every other entry: the verb reads it. It is not
267
+ * defaulted from `<image>` -- see this file's header on never deriving one
268
+ * caller-supplied path from another. `export-asm`'s `--store` is required on
269
+ * the same terms and for the same reason.
270
+ *
271
+ * `export-asm` deliberately carries NO assembler-facing option. It writes
272
+ * source and runs no assembler, so there is no binary to name, no exit status
273
+ * to surface and no flag that could imply either.
274
+ */
275
+ export const VERB_OPTIONS: Readonly<Record<string, readonly string[]>> = Object.freeze({
276
+ "render-memmap": ["--provenance", "--out", "--force", "--check"],
277
+ coverage: ["--store", "--out", "--force", "--sample"],
278
+ "export-asm": ["--store", "--out", "--force"],
279
+ });
280
+
281
+ /**
282
+ * The one shared refusal check IN-06 generalises to every verb (WR-08's
283
+ * closed-option-set posture, applied uniformly rather than verb by verb).
284
+ * Scans `rest` for any `--flag`-shaped token not in `verb`'s accepted set
285
+ * from `VERB_OPTIONS` and returns a one-line refusal naming the flag and the
286
+ * accepted set; returns `undefined` when every flag-shaped token is
287
+ * accepted (or when `verb` is not a key in the map at all, so an unknown
288
+ * verb still falls through to `runAnnoCli()`'s own "unknown verb"
289
+ * message). Never throws -- this file's never-throw posture applies here
290
+ * too.
291
+ *
292
+ * THE LOOKUP IS AN OWN-PROPERTY READ, AND THAT IS THE WHOLE POINT (30-REVIEW
293
+ * CR-01, fixed 2026-08-31). `VERB_OPTIONS` is an object literal, so it
294
+ * inherits from `Object.prototype`; a bare `VERB_OPTIONS[verb]` resolved
295
+ * `hasOwnProperty`, `toString`, `constructor`, `valueOf` and `__proto__` to
296
+ * TRUTHY inherited FUNCTIONS. Those sailed past the `if (!accepted) return
297
+ * undefined` short-circuit and the next line crashed. Reproduced against the
298
+ * shipped table before this fix:
299
+ *
300
+ * `anno hasOwnProperty game.prg --force` -> TypeError: accepted.includes is not a function
301
+ *
302
+ * The call site at `runAnnoCli()` sits OUTSIDE that function's `try`, so the
303
+ * throw escaped the function entirely and broke the never-throw contract this
304
+ * file's header states. The identical defect was found and fixed one
305
+ * directory over in this same phase -- `scripts/lib/anno-cli-invocations.mjs`
306
+ * reads every verb-keyed table through its `own()` helper, and one of that
307
+ * file's controls quotes THIS file's variable name verbatim as
308
+ * `"accepted.includes is not a function"`. The hardening stopped at the
309
+ * checker and never reached the CLI the checker models; it reaches it now.
310
+ *
311
+ * `Array.isArray()` rather than a bare truthiness test is deliberate belt and
312
+ * braces: an own key whose value is somehow not an array falls through to
313
+ * "unknown verb" instead of reaching `.includes()`.
314
+ */
315
+ export function checkAcceptedOptions(verb: string, rest: string[]): string | undefined {
316
+ const accepted = Object.hasOwn(VERB_OPTIONS, verb) ? VERB_OPTIONS[verb] : undefined;
317
+ if (!Array.isArray(accepted)) return undefined;
318
+ for (const token of rest) {
319
+ if (token.startsWith("--") && !accepted.includes(token)) {
320
+ const acceptedList = accepted.length > 0 ? accepted.join(", ") : "none";
321
+ return `${verb}: unknown option "${token}" -- not accepted by this verb (accepted: ${acceptedList})`;
322
+ }
323
+ }
324
+ return undefined;
325
+ }
326
+
327
+ /**
328
+ * Refuses to overwrite an existing file at `outPath` unless the caller
329
+ * passed `--force`. Called by ALL THREE verbs that write an output file --
330
+ * `cmdRenderMemmap()` (non-`--check` branch only; `--check` never writes),
331
+ * `cmdCoverage()` and `cmdExportAsm()` -- so overwrite safety is uniform
332
+ * rather than one verb accreting a check the others lack (CR-01/CR-02).
333
+ *
334
+ * "SHARED BY EVERY VERB THAT WRITES AN OUTPUT FILE" IS WHAT THIS DOC USED TO
335
+ * SAY, AND IT WAS NOT TRUE. `render-memmap` wrote an output file and had
336
+ * neither `--force` in its option set nor a call to this function anywhere on
337
+ * its path; `29-REVIEW.md` CR-02 reproduced it destroying a pre-existing file
338
+ * silently, exit code 0. The claim is now stated as the THREE call sites it
339
+ * actually has, because a count is checkable where "every" is not.
340
+ *
341
+ * "THE TWO CALL SITES" IS WHAT THIS SENTENCE SAID UNTIL 2026-08-31, AFTER
342
+ * `cmdExportAsm()` BECAME THE THIRD (30-REVIEW WR-08). The paragraph directly
343
+ * above had been updated to name all three; this one, whose entire point is
344
+ * that a COUNT is checkable where "every" is not, was left carrying a stale
345
+ * count -- the failure mode it exists to argue against, reproduced in
346
+ * miniature two lines below itself. `anno-cli.test.ts` now asserts the count
347
+ * mechanically, the way `anno-cli-path-consumers.test.ts` already does for the
348
+ * confinement seam, so the next verb to write an output file cannot leave this
349
+ * number behind again.
350
+ *
351
+ * `outPath` MUST already be confined through `storePathWithinWorkspace()`.
352
+ * This function performs no confinement of its own and must never be read as
353
+ * providing any: it answers "does this file already exist", which is a
354
+ * different question from "may this process write here", and running it
355
+ * against an unconfined path produces a check that guards the wrong file.
356
+ */
357
+ function refuseOverwrite(outPath: string, force: boolean | undefined, verbLabel: string, extraHint = ""): boolean {
358
+ if (force || !existsSync(outPath)) return true;
359
+ console.error(
360
+ `${verbLabel}: refusing to overwrite the existing file ${outPath}${extraHint} -- ` +
361
+ `pass --force to overwrite it deliberately.`,
362
+ );
363
+ return false;
364
+ }
365
+
366
+ /**
367
+ * THE ONE "is this token a value, or the next flag?" TEST, shared by all THREE
368
+ * option parsers below (30-REVIEW WR-09, fixed 2026-08-31).
369
+ *
370
+ * The `*MissingValue` mechanism exists precisely to avoid "silently swallowing
371
+ * the next token" when an option is given without its value. Until this
372
+ * helper, each of the SEVEN option-with-a-value sites spelled the test inline
373
+ * as `value === undefined || value.startsWith("--")` -- which refuses a
374
+ * DOUBLE-dash token and accepts a single-dash one. So
375
+ * `anno export-asm g.prg --store -x` took `-x` as the store path, and the run
376
+ * failed downstream as a confinement or not-found error about a file called
377
+ * `-x` rather than as the `--store requires a value` refusal the parser was
378
+ * written to produce. A single-dash token is exactly the case the mechanism
379
+ * missed.
380
+ *
381
+ * ANY leading `-` is refused, including a bare `-`. No verb in this CLI reads
382
+ * stdin, so `-` has no meaning here, and a path that genuinely begins with a
383
+ * dash is addressable as `./-x` -- which is also how every other CLI a caller
384
+ * has used behaves. Refusing beats guessing which of the two a caller meant.
385
+ *
386
+ * ONE PREDICATE, SEVEN CALL SITES, deliberately: the three parsers' own docs
387
+ * each claim they are "the SAME shape ... rather than a third convention", and
388
+ * an inline copy per site is how that claim quietly stops being true. A fix
389
+ * applied to one parser would leave the finding armed in the other two.
390
+ */
391
+ function isMissingOptionValue(value: string | undefined): boolean {
392
+ return value === undefined || value.startsWith("-");
393
+ }
394
+
395
+ interface RenderMemmapParsedArgs {
396
+ positional: string[];
397
+ provenance?: string;
398
+ provenanceMissingValue?: boolean;
399
+ out?: string;
400
+ outMissingValue?: boolean;
401
+ force?: boolean;
402
+ check?: boolean;
403
+ unknownOption?: string;
404
+ }
405
+
406
+ /** Fixed, closed option set for render-memmap -- exactly `--provenance`,
407
+ * `--out`, `--force` and `--check`. Per WR-08's posture (do not silently
408
+ * accept a flag a verb does not implement, or a flag missing its value), any
409
+ * OTHER `--flag`-shaped token is refused as `unknownOption`, and
410
+ * `--provenance`/`--out` with no value (or a flag-shaped "value") is refused
411
+ * via their own `*MissingValue` fields.
412
+ *
413
+ * `--force` is parsed in the SAME boolean shape `parseCoverageArgs()` already
414
+ * uses, deliberately rather than as a second convention: it feeds the same
415
+ * `refuseOverwrite()` every writing verb shares, so a caller who learns the
416
+ * opt-in on one verb has learned it on the others. */
417
+ function parseRenderMemmapArgs(rest: string[]): RenderMemmapParsedArgs {
418
+ const positional: string[] = [];
419
+ let provenance: string | undefined;
420
+ let provenanceMissingValue = false;
421
+ let out: string | undefined;
422
+ let outMissingValue = false;
423
+ let force = false;
424
+ let check = false;
425
+ let unknownOption: string | undefined;
426
+ for (let i = 0; i < rest.length; i++) {
427
+ const a = rest[i]!;
428
+ if (a === "--provenance") {
429
+ const value = rest[i + 1];
430
+ if (isMissingOptionValue(value)) {
431
+ provenanceMissingValue = true;
432
+ } else {
433
+ provenance = value;
434
+ i++;
435
+ }
436
+ } else if (a === "--out") {
437
+ const value = rest[i + 1];
438
+ if (isMissingOptionValue(value)) {
439
+ outMissingValue = true;
440
+ } else {
441
+ out = value;
442
+ i++;
443
+ }
444
+ } else if (a === "--force") {
445
+ force = true;
446
+ } else if (a === "--check") {
447
+ check = true;
448
+ } else if (a.startsWith("--")) {
449
+ unknownOption ??= a;
450
+ } else {
451
+ positional.push(a);
452
+ }
453
+ }
454
+ return { positional, provenance, provenanceMissingValue, out, outMissingValue, force, check, unknownOption };
455
+ }
456
+
457
+ /**
458
+ * `render-memmap <store> --provenance FILE [--out FILE] [--force] [--check]`
459
+ * -- D-24's generated-view verb, via `anno-memmap-render.ts`'s
460
+ * `renderMemoryMap()`/`checkRenderedMemoryMap()`. Never writes a file when
461
+ * `--check` is given -- that mode only reads and reports.
462
+ *
463
+ * ALL THREE OF THIS VERB'S PATHS ARE CONFINED, and the reason each one is
464
+ * named here rather than left to a reader to infer is that two of them were
465
+ * NOT, and shipped that way. `29-VERIFICATION.md` gap 3 / `29-REVIEW.md`
466
+ * CR-02 and CR-03 reproduced both on this tree:
467
+ *
468
+ * - `--out` reached `writeFileSync` as the RAW caller string. Pointed
469
+ * outside the workspace root it exited 0, printed `wrote /tmp/.../
470
+ * PRECIOUS.md` and replaced that pre-existing file's bytes. `--force`
471
+ * was not in this verb's option set at all, so `refuseOverwrite()` --
472
+ * whose own doc claims the safety is uniform across every verb that
473
+ * writes an output file -- was never reached from here (CR-02).
474
+ * - `--provenance` reached `readFileSync` as the RAW caller string, making
475
+ * it an arbitrary-file read oracle; the sidecar parse failure then
476
+ * interpolated Node's own parse error, which carries a snippet of the
477
+ * file, so the oracle DISCLOSED CONTENT (CR-03). Confining it here also
478
+ * confines it for `anno-memmap-render.ts`, which reads it with no check
479
+ * of its own.
480
+ *
481
+ * Every one of them now goes through the SAME one confinement seam,
482
+ * `storePathWithinWorkspace()` against `repoRoot()` (T-29-51) -- never a
483
+ * second hand-rolled rule, and never a suffix check standing in for a
484
+ * location check. The DEFAULT output path is confined too, deliberately: a
485
+ * derived path is confined by the same rule as a caller-supplied one rather
486
+ * than trusted because it was derived.
487
+ *
488
+ * The predicate was never the weak half -- `anno-confinement.test.ts` proves
489
+ * it fifteen ways. Its CONSUMER SET was unenumerated, and that asymmetry is
490
+ * the whole mechanism by which both findings shipped past a green suite.
491
+ * `anno-cli-path-consumers.test.ts` is what closes it: it enumerates every
492
+ * caller-supplied path argument this CLI accepts -- flags from
493
+ * `VERB_OPTIONS`, positionals from each verb's `--help` synopsis line -- and
494
+ * fails when the inventory and the surface disagree in either direction, or
495
+ * when this file's confinement call sites number fewer than the inventory's
496
+ * entries. It does not associate a particular argument with a particular call
497
+ * site (WR-02), so six arguments confined once each and five confined with one
498
+ * of them confined twice read the same to it; that limit is named here rather
499
+ * than papered over. A header that asserts a property must point at the
500
+ * mechanism that keeps it, and must claim no more than the mechanism checks.
501
+ */
502
+ async function cmdRenderMemmap(rest: string[]): Promise<number> {
503
+ const {
504
+ positional,
505
+ provenance,
506
+ provenanceMissingValue,
507
+ out,
508
+ outMissingValue,
509
+ force,
510
+ check,
511
+ unknownOption,
512
+ } = parseRenderMemmapArgs(rest);
513
+
514
+ if (unknownOption) {
515
+ console.error(`render-memmap: unknown option "${unknownOption}"\n`);
516
+ console.log(USAGE);
517
+ return 1;
518
+ }
519
+ if (provenanceMissingValue) {
520
+ console.error("render-memmap: --provenance requires a value\n");
521
+ console.log(USAGE);
522
+ return 1;
523
+ }
524
+ if (outMissingValue) {
525
+ console.error("render-memmap: --out requires a value\n");
526
+ console.log(USAGE);
527
+ return 1;
528
+ }
529
+
530
+ const store = positional[0];
531
+ if (!store) {
532
+ console.error("render-memmap: usage: render-memmap <store> --provenance FILE [--out FILE] [--check]");
533
+ return 1;
534
+ }
535
+
536
+ // T-29-51 / T-19-22: the ONE confinement seam, the same one `coverage` puts
537
+ // both of its caller-supplied paths through. `openStore()` downstream is
538
+ // handed this same workspace root, so its own confinement agrees by
539
+ // construction rather than by a second rule.
540
+ const workspaceRoot = repoRoot();
541
+ let storePath: string;
542
+ try {
543
+ storePath = storePathWithinWorkspace(store, workspaceRoot);
544
+ } catch (err) {
545
+ console.error(`render-memmap: ${errMsg(err)}`);
546
+ return 1;
547
+ }
548
+ if (!existsSync(storePath)) {
549
+ console.error(
550
+ `render-memmap: annotation store not found: ${storePath} -- refusing to CREATE one, because "the annotations are ` +
551
+ 'gone" and "there are no annotations" must not read the same.',
552
+ );
553
+ return 1;
554
+ }
555
+ if (!provenance) {
556
+ console.error("render-memmap: --provenance FILE is required\n");
557
+ console.log(USAGE);
558
+ return 1;
559
+ }
560
+
561
+ // CR-03. The sidecar is confined BEFORE the existence check, so a path
562
+ // outside the workspace root never reaches the filesystem at all -- not as
563
+ // an `existsSync` probe (which is itself an oracle: it answers "does this
564
+ // file exist" for any path the process can stat) and not as the
565
+ // `readFileSync` inside `renderMemoryMap()`. From here on the RAW caller
566
+ // string is dead: `provenancePath` is the realpath the seam returned, and
567
+ // it is what every downstream call receives.
568
+ let provenancePath: string;
569
+ try {
570
+ provenancePath = storePathWithinWorkspace(provenance, workspaceRoot);
571
+ } catch (err) {
572
+ console.error(`render-memmap: ${errMsg(err)}`);
573
+ return 1;
574
+ }
575
+ if (!existsSync(provenancePath)) {
576
+ console.error(`render-memmap: provenance sidecar not found: ${provenancePath}`);
577
+ return 1;
578
+ }
579
+
580
+ // CR-02. The default is applied FIRST and the result confined AFTER, so the
581
+ // derived path and a caller-supplied one are confined by the same rule --
582
+ // rather than the default being trusted because this verb computed it.
583
+ let outPath: string;
584
+ try {
585
+ outPath = storePathWithinWorkspace(out ?? join(dirname(storePath), "memory-map.md"), workspaceRoot);
586
+ } catch (err) {
587
+ console.error(`render-memmap: ${errMsg(err)}`);
588
+ return 1;
589
+ }
590
+
591
+ if (check) {
592
+ let result: Awaited<ReturnType<typeof checkRenderedMemoryMap>>;
593
+ try {
594
+ result = await checkRenderedMemoryMap({ storePath, provenancePath, renderedPath: outPath, workspaceRoot });
595
+ } catch (err) {
596
+ console.error(`render-memmap: ${errMsg(err)}`);
597
+ return 1;
598
+ }
599
+ if (result.status === "in-sync") {
600
+ console.log(`render-memmap: in sync (${outPath})`);
601
+ return 0;
602
+ }
603
+ if (result.status === "missing") {
604
+ console.error(`render-memmap: missing -- ${outPath} does not exist yet. Run render-memmap without --check first.`);
605
+ return 1;
606
+ }
607
+ console.error(`render-memmap: drifted at line ${result.line}`);
608
+ console.error(` expected: ${result.expected}`);
609
+ console.error(` actual: ${result.actual}`);
610
+ return 1;
611
+ }
612
+
613
+ // CR-02, the second half. `--check` never writes, so the overwrite refusal
614
+ // belongs on THIS branch only -- and it runs against the CONFINED path, so
615
+ // the file it protects is the file that would actually be written.
616
+ if (!refuseOverwrite(outPath, force, "render-memmap")) {
617
+ return 1;
618
+ }
619
+
620
+ let rendered: Awaited<ReturnType<typeof renderMemoryMap>>;
621
+ try {
622
+ rendered = await renderMemoryMap({ storePath, provenancePath, workspaceRoot });
623
+ } catch (err) {
624
+ console.error(`render-memmap: ${errMsg(err)}`);
625
+ return 1;
626
+ }
627
+ try {
628
+ writeFileSync(outPath, rendered.markdown);
629
+ } catch (err) {
630
+ // WR-09 (D-11.1-04): the same shape as bootstrapProject()'s write above,
631
+ // one verb over -- an ordinary write failure (missing parent directory,
632
+ // permissions, full disk) must not throw past this verb's own
633
+ // never-throw contract.
634
+ console.error(`render-memmap: could not write ${outPath}: ${errMsg(err)}`);
635
+ return 1;
636
+ }
637
+ console.log(
638
+ `render-memmap: wrote ${outPath} (${rendered.rowCount} row(s), ${rendered.unknownCount} [unknown], digest ${rendered.renderDigest})`,
639
+ );
640
+ return 0;
641
+ }
642
+
643
+ interface CoverageParsedArgs {
644
+ positional: string[];
645
+ store?: string;
646
+ storeMissingValue?: boolean;
647
+ out?: string;
648
+ outMissingValue?: boolean;
649
+ force?: boolean;
650
+ sample?: number;
651
+ sampleRaw?: string;
652
+ sampleMissingValue?: boolean;
653
+ unknownOption?: string;
654
+ }
655
+
656
+ /** Fixed, closed option set for coverage -- exactly `--store`, `--out`,
657
+ * `--force` and `--sample`. Same WR-08 posture as `parseRenderMemmapArgs()`
658
+ * above: an unimplemented flag is refused as `unknownOption`, and
659
+ * `--store`/`--out`/`--sample` with a missing or flag-shaped value are refused
660
+ * through their own `*MissingValue` fields rather than silently swallowing the
661
+ * next token. */
662
+ function parseCoverageArgs(rest: string[]): CoverageParsedArgs {
663
+ const positional: string[] = [];
664
+ let store: string | undefined;
665
+ let storeMissingValue = false;
666
+ let out: string | undefined;
667
+ let outMissingValue = false;
668
+ let force = false;
669
+ let sample: number | undefined;
670
+ let sampleRaw: string | undefined;
671
+ let sampleMissingValue = false;
672
+ let unknownOption: string | undefined;
673
+ for (let i = 0; i < rest.length; i++) {
674
+ const a = rest[i]!;
675
+ if (a === "--store") {
676
+ const value = rest[i + 1];
677
+ if (isMissingOptionValue(value)) {
678
+ storeMissingValue = true;
679
+ } else {
680
+ store = value;
681
+ i++;
682
+ }
683
+ } else if (a === "--out") {
684
+ const value = rest[i + 1];
685
+ if (isMissingOptionValue(value)) {
686
+ outMissingValue = true;
687
+ } else {
688
+ out = value;
689
+ i++;
690
+ }
691
+ } else if (a === "--sample") {
692
+ const value = rest[i + 1];
693
+ if (isMissingOptionValue(value)) {
694
+ sampleMissingValue = true;
695
+ } else {
696
+ sampleRaw = value;
697
+ sample = Number.parseInt(value, 10);
698
+ i++;
699
+ }
700
+ } else if (a === "--force") {
701
+ force = true;
702
+ } else if (a.startsWith("--")) {
703
+ unknownOption ??= a;
704
+ } else {
705
+ positional.push(a);
706
+ }
707
+ }
708
+ return { positional, store, storeMissingValue, out, outMissingValue, force, sample, sampleRaw, sampleMissingValue, unknownOption };
709
+ }
710
+
711
+ // ---------------------------------------------------------------------------
712
+ // THE STORE-TO-CENSUS ADAPTER (Discretion 4).
713
+ //
714
+ // `anno-coverage.ts` declares four input shapes and fetches NONE of them: a
715
+ // caller hands the data in. So moving the census from the retired analyser's
716
+ // project JSON onto this project's own annotation store is a CALLER-side
717
+ // change and nothing else -- the four functions below, and no edit to the
718
+ // instrument.
719
+ //
720
+ // THE COLUMN MAPPING, stated once, here, because a vocabulary mismatch at this
721
+ // boundary changes coverage verdicts SILENTLY (T-29-29):
722
+ //
723
+ // LabelRow -> AnnoSymbol address, name, kind. `kind` needs no
724
+ // translation: the store's LABEL_KINDS are
725
+ // the same four tokens the census filters
726
+ // on ("User"/"Auto"/"System"/"Platform").
727
+ // `id` and `bank` are store-only and are
728
+ // dropped. The census never reads a
729
+ // symbol's `type`, so its absence from the
730
+ // store costs nothing.
731
+ // CommentRow -> AnnoComment address, commentType -> type, text ->
732
+ // comment. COMMENT_TYPES is "line"/"side",
733
+ // which is exactly the census's own pair.
734
+ // RangeRow -> BlockEntry start -> start_address, endInclusive ->
735
+ // end_address (both INCLUSIVE on both
736
+ // sides), dataType -> type. That last
737
+ // column is the one the census must NOT
738
+ // interpret itself: it goes through
739
+ // `block-class.ts`, the one boundary
740
+ // allowed to read a store block spelling,
741
+ // and `block-class.test.ts` pins the class
742
+ // each of the frozen twelve resolves to BY
743
+ // NAME so this mapping cannot drift
744
+ // quietly.
745
+ // derived -> AnnoCrossReference the union `crossReferencesTo()` computes
746
+ // from the bytes, the typed split tables
747
+ // and the stored rows.
748
+ // ---------------------------------------------------------------------------
749
+
750
+ /** `LabelRow[]` as the census's symbol shape. */
751
+ export function symbolsFromStore(rows: readonly LabelRow[]): AnnoSymbol[] {
752
+ return rows.map((row) => ({ address: row.address, name: row.name, kind: row.kind }));
753
+ }
754
+
755
+ /** `CommentRow[]` as the census's comment shape. */
756
+ export function commentsFromStore(rows: readonly CommentRow[]): AnnoComment[] {
757
+ return rows.map((row) => ({ address: row.address, type: row.commentType, comment: row.text }));
758
+ }
759
+
760
+ /** `RangeRow[]` as the census's block shape. The `dataType` column is copied
761
+ * VERBATIM and never compared here -- `block-class.ts` is the only place in
762
+ * this tree allowed to interpret it. */
763
+ export function blocksFromStore(rows: readonly RangeRow[]): BlockEntry[] {
764
+ return rows.map((row) => ({ start_address: row.start, end_address: row.endInclusive, type: row.dataType }));
765
+ }
766
+
767
+ /**
768
+ * The census's fourth input, derived in ONE pass over the store and the image
769
+ * rather than fetched one address at a time.
770
+ *
771
+ * WHAT THIS REPLACED, and why the replacement has no ceiling. The previous
772
+ * implementation issued one transport round trip PER LABEL through a held
773
+ * child process, and bounded that at a hard ceiling of 512 lookups, printing a
774
+ * note when the ceiling bit. Over an in-process derivation that ceiling would
775
+ * be strictly worse than the bound it used to express: it would truncate a
776
+ * COMPLETE answer and call the remainder a floor. So it is gone, and this
777
+ * function answers over the WHOLE population -- every non-System, non-Platform
778
+ * label the store holds.
779
+ *
780
+ * `System`/`Platform` labels are excluded because every label figure already
781
+ * excludes them, so deriving their callers would buy the census nothing.
782
+ */
783
+ export function crossReferencesFromStore(
784
+ handle: AnnoStoreHandle,
785
+ image: Uint8Array,
786
+ origin: number,
787
+ symbols: readonly AnnoSymbol[],
788
+ ): AnnoCrossReference[] {
789
+ const targets = [
790
+ ...new Set(
791
+ (Array.isArray(symbols) ? symbols : [])
792
+ .filter((s) => s && String(s.kind ?? "") !== "System" && String(s.kind ?? "") !== "Platform")
793
+ .map((s) => s.address),
794
+ ),
795
+ ].sort((a, b) => a - b);
796
+ return targets.map((address) => ({ address, callers: crossReferencesTo(handle, image, origin, address).callers }));
797
+ }
798
+
799
+ /**
800
+ * The payload bytes and the load origin, read from the SAME project file the
801
+ * census reads them from -- and, since 2026-08-30, through the SAME FUNCTION.
802
+ *
803
+ * NOT a second byte source, and no longer only by convention. This used to be
804
+ * a second hand-rolled decode sitting beside `buildCoverageReport()`'s own,
805
+ * with a comment asking a reader to keep the two in step; two decodes over one
806
+ * path is two answers to "which program does this report describe", and the
807
+ * comment was the only thing holding them together (`T-29-16-02`). It now
808
+ * delegates to `anno-coverage.ts`'s exported `loadProjectImage()`, so the
809
+ * derived half and the censused half of one report CANNOT describe different
810
+ * programs -- they are the same call.
811
+ *
812
+ * Returns `null` -- never a throw and never a guess -- when the payload did
813
+ * not decode or decoded to nothing. The census reports that same condition
814
+ * itself, in its own words, and the verb exits non-zero on it.
815
+ */
816
+ function projectImage(projectPath: string): { origin: number; bytes: Uint8Array } | null {
817
+ let loaded: LoadedProject;
818
+ try {
819
+ loaded = loadProjectImage(projectPath);
820
+ } catch {
821
+ // The one throw the loader has left is an unreadable PATH. This verb has
822
+ // already checked existence above and the census reports the condition in
823
+ // its own words, so a null is the right answer here rather than a second
824
+ // diagnosis of the same fact.
825
+ return null;
826
+ }
827
+ if (!loaded.payloadDecoded || loaded.bytes.length === 0) return null;
828
+ return { origin: loaded.origin, bytes: loaded.bytes };
829
+ }
830
+
831
+ function hexAddr(address: number): string {
832
+ return `$${address.toString(16).padStart(4, "0")}`;
833
+ }
834
+
835
+ function ratio(value: number | null): string {
836
+ return value === null ? "UNAVAILABLE" : value.toFixed(3);
837
+ }
838
+
839
+ function addressList(addresses: readonly number[], cap = 12): string {
840
+ if (addresses.length === 0) return "none";
841
+ const shown = addresses.slice(0, cap).map(hexAddr).join(", ");
842
+ return addresses.length > cap ? `${shown}, ... (${addresses.length} in all)` : shown;
843
+ }
844
+
845
+ /**
846
+ * Renders the report as separately-headed sections.
847
+ *
848
+ * THE ONE RULE THIS FUNCTION EXISTS TO HOLD (COV-01, and the reason the
849
+ * rendering lives here rather than being a generic pretty-printer): print
850
+ * every measure's own numbers under its own heading, and never compute a
851
+ * combined figure at the point of display. `anno-coverage.ts`'s report
852
+ * object carries no aggregate -- if one ever appears, it will be because
853
+ * somebody averaged, summed or weighted these numbers HERE. Do not. The
854
+ * ratios below measure different populations (labels, comments, sampled
855
+ * addresses); they are not commensurable and combining them would produce a
856
+ * number that means nothing while reading like a verdict.
857
+ */
858
+ function printCoverageReport(report: CoverageReport): void {
859
+ const s = report.structural;
860
+ const classSum = s.reachedAsInstruction + s.tableEntry + s.referencedAsData + s.unreached;
861
+
862
+ console.log(`coverage: ${report.project.path}`);
863
+ console.log(
864
+ ` origin ${hexAddr(report.project.origin)}, ${report.project.size} byte(s), payload ` +
865
+ (report.project.payloadDecoded ? "decoded" : `UNAVAILABLE -- ${report.project.reason ?? "reason not recorded"}`),
866
+ );
867
+ console.log(` schema version ${report.schemaVersion}, generated ${report.generatedAt}`);
868
+ console.log("");
869
+
870
+ console.log(" MEASURE 1 of 3 -- structural byte census (raw bytes plus the seed set only; the store cannot move it)");
871
+ console.log(` reached-as-instruction : ${s.reachedAsInstruction}`);
872
+ console.log(` table-entry : ${s.tableEntry}`);
873
+ console.log(` referenced-as-data : ${s.referencedAsData}`);
874
+ console.log(` unreached : ${s.unreached}`);
875
+ console.log(` the four classes sum to ${classSum} of ${s.rangeBytes} censused byte(s)`);
876
+ console.log(
877
+ ` linear-sweep decodable : ${s.linearSweepDecodable} byte(s) -- reported BESIDE the census, never added to it; ` +
878
+ "decodability is not evidence of code",
879
+ );
880
+ console.log(` seeds: ${s.seeds.length} (${addressList(s.seeds)}); descent steps ${s.steps}; truncated: ${s.truncated ? "YES" : "no"}`);
881
+ console.log("");
882
+
883
+ console.log(" MEASURE 2 of 3 -- label figures (two of them, both printed; neither is folded into the other)");
884
+ console.log(
885
+ ` kind ratio over non-System labels: ${report.labels.kindRatio.user} user / ${report.labels.kindRatio.auto} auto ` +
886
+ `-> user fraction ${ratio(report.labels.kindRatio.userFraction)}`,
887
+ );
888
+ console.log(
889
+ ` auto-prefix names remaining : ${report.labels.autoPrefixNamesRemaining} at ${addressList(report.labels.autoPrefixNameAddresses)}`,
890
+ );
891
+ console.log(` System labels excluded : ${report.labels.systemExcluded}`);
892
+ console.log(
893
+ ` disqualified by the multi-caller rule: ${report.labels.excludedByMultiCallerRule.length} at ` +
894
+ `${addressList(report.labels.excludedByMultiCallerRule)}`,
895
+ );
896
+ console.log("");
897
+
898
+ const repro = report.reproducibility;
899
+ console.log(" MEASURE 3 of 3 -- sampled reproducibility (the bytes route versus the store route; neither reads the other's input)");
900
+ console.log(
901
+ ` sampled ${repro.sampled}, agreed ${repro.agreed}, disagreed ${repro.disagreed} -> agreement rate ${ratio(repro.agreementRate)}`,
902
+ );
903
+ console.log(` sample rule: ${repro.sampleRule}`);
904
+ console.log(` sampled addresses: ${addressList(repro.addresses)}`);
905
+ for (const c of repro.comparisons) {
906
+ console.log(` ${hexAddr(c.address)} bytes=${c.fromBytes} store=${c.fromStore} ${c.agreed ? "agree" : "DISAGREE"}`);
907
+ }
908
+ console.log(
909
+ ` multi-caller labels documented without naming a caller: ${repro.multiCallerUndocumented.count} at ` +
910
+ `${addressList(repro.multiCallerUndocumented.addresses)}`,
911
+ );
912
+ if (repro.reason) console.log(` reason: ${repro.reason}`);
913
+ console.log("");
914
+
915
+ const vac = report.commentVacuity;
916
+ console.log(" comment vacuity (its own measure -- kept out of the three above, not averaged into them)");
917
+ console.log(` commented addresses : ${vac.commentedAddresses}`);
918
+ console.log(` distinct comments : ${vac.distinctComments} -> distinct-comment ratio ${ratio(vac.distinctCommentRatio)}`);
919
+ console.log(
920
+ ` graded : ${vac.gradedAddresses} graded, ${vac.unknownGradedAddresses} [unknown] -> graded fraction ${ratio(vac.gradedFraction)}`,
921
+ );
922
+ console.log(` banned-generic : ${vac.bannedGenericAddresses.length} at ${addressList(vac.bannedGenericAddresses)}`);
923
+ console.log(` near-miss grade token: ${vac.malformedGradeAddresses.length} at ${addressList(vac.malformedGradeAddresses)}`);
924
+ if (vac.reason) console.log(` reason: ${vac.reason}`);
925
+ console.log("");
926
+
927
+ const d = report.dispatch;
928
+ console.log(" indirect-dispatch scan (feeds the census its extra seeds; reported as counts, never graded)");
929
+ console.log(
930
+ ` indirect jumps ${d.indirectJumps.length}, multi-entry tables ${d.multiEntryTables.length}, ` +
931
+ `split lo/hi tables ${d.splitTables.length}, stack-return dispatch ${d.stackReturnDispatch.length}`,
932
+ );
933
+ console.log(
934
+ ` discovered targets ${d.discoveredTargets.length}, table-entry addresses ${d.tableEntryAddresses.length}, ` +
935
+ `truncated: ${d.truncated ? "YES" : "no"}`,
936
+ );
937
+ console.log("");
938
+
939
+ const div = report.divergence;
940
+ console.log(" divergence sub-report (census versus the store's own block table -- a COMPARISON, not a measure of completeness)");
941
+ if (!div.blocksSupplied) {
942
+ console.log(` UNAVAILABLE -- ${div.reason ?? "reason not recorded"}`);
943
+ } else {
944
+ console.log(` census reached as instructions but the store does not call Code : ${div.censusCodeStoreNotCode} byte(s)`);
945
+ console.log(` the store calls Code but the census never reached : ${div.storeCodeCensusUnreached} byte(s)`);
946
+ console.log(` covered by no block entry at all : ${div.uncoveredByStore} byte(s)`);
947
+ console.log(` compared over ${div.comparedBytes} byte(s)`);
948
+ }
949
+ console.log(` ${div.note}`);
950
+ console.log("");
951
+
952
+ const verdict = coverageFindings(report);
953
+ console.log(" per-measure findings (one named measure each -- this list is not a rating and carries no number)");
954
+ if (verdict.clean) {
955
+ console.log(" none -- every measure is above its own threshold");
956
+ } else {
957
+ for (const f of verdict.findings) console.log(` [${f.measure}] ${f.reason}`);
958
+ }
959
+ console.log("");
960
+ console.log(
961
+ " Read the numbers against each other, never as one figure: a high user fraction beside a large unreached count " +
962
+ "means the wrong things were named, and a large divergence means the store and the bytes disagree about what is code.",
963
+ );
964
+ }
965
+
966
+ /**
967
+ * `coverage <image> --store FILE [--out FILE] [--force] [--sample N]` --
968
+ * COV-01's delivery path: the instrument from `anno-coverage.ts`, run against
969
+ * a real program and a real annotation store.
970
+ *
971
+ * TWO PATHS, NEITHER DERIVED FROM THE OTHER. `<image>` carries the payload
972
+ * bytes and the load origin; `--store` names the annotation store holding the
973
+ * labels, comments and typed ranges. The store holds annotations and never
974
+ * bytes, so a derived measure has to be told which bytes it is measuring, and
975
+ * guessing one path from the other is exactly the auto-pick D-02 forbids.
976
+ *
977
+ * Two properties this function must keep:
978
+ * - NO SECOND PATH VALIDATOR (T-19-22 / T-29-28), over ALL THREE of this
979
+ * verb's caller-supplied paths -- the positional, `--store` and `--out`.
980
+ * The count is stated because it was WRONG: this doc said "both" and meant
981
+ * it, while `--out` reached `refuseOverwrite()` and `writeFileSync()` as
982
+ * the raw caller string. `29-REVIEW.md` CR-02 reproduced the escape --
983
+ * `coverage <project> --store <store> --out /tmp/...` wrote the report
984
+ * outside the workspace root. All three now go through
985
+ * `storePathWithinWorkspace()` against `repoRoot()` -- the one seam, the
986
+ * same one `anno-tools.ts` puts its own store and image arguments through.
987
+ * `openStore()` is then handed the same workspace root, so its own
988
+ * confinement agrees by construction rather than by a second rule. The
989
+ * enumeration is now mechanical rather than prose:
990
+ * `anno-cli-path-consumers.test.ts` inventories this verb's path
991
+ * arguments -- flags from `VERB_OPTIONS`, positionals from the `--help`
992
+ * synopsis line -- and fails when that inventory and the surface disagree
993
+ * either way, or when this file's confinement call sites number fewer
994
+ * than the inventory's entries. It does not associate a given argument
995
+ * with a given call site (WR-02), so it cannot tell six arguments
996
+ * confined once each from five confined with one confined twice.
997
+ * - THE STORE IS OPENED ONCE, read-only, for the whole verb, and closed in a
998
+ * `finally`. `mustExist` is what makes "the annotations are gone" and
999
+ * "there are no annotations" refuse differently instead of reading the
1000
+ * same: without it this verb would CREATE an empty store at the named path
1001
+ * and report a measurement of nothing.
1002
+ *
1003
+ * The exit code is 0 for any report it managed to build, however poor the
1004
+ * numbers are -- a bad score is a result, not a failure. Non-zero is reserved
1005
+ * for a caller error (bad path, bad option, refused overwrite) and for a store
1006
+ * it could not read or a payload it could not decode.
1007
+ */
1008
+ async function cmdCoverage(rest: string[]): Promise<number> {
1009
+ const { positional, store, storeMissingValue, out, outMissingValue, force, sample, sampleRaw, sampleMissingValue, unknownOption } =
1010
+ parseCoverageArgs(rest);
1011
+
1012
+ if (unknownOption) {
1013
+ console.error(`coverage: unknown option "${unknownOption}"\n`);
1014
+ console.log(USAGE);
1015
+ return 1;
1016
+ }
1017
+ if (storeMissingValue) {
1018
+ console.error("coverage: --store requires a value\n");
1019
+ console.log(USAGE);
1020
+ return 1;
1021
+ }
1022
+ if (outMissingValue) {
1023
+ console.error("coverage: --out requires a value\n");
1024
+ console.log(USAGE);
1025
+ return 1;
1026
+ }
1027
+ if (sampleMissingValue) {
1028
+ console.error("coverage: --sample requires a value\n");
1029
+ console.log(USAGE);
1030
+ return 1;
1031
+ }
1032
+
1033
+ const project = positional[0];
1034
+ if (!project) {
1035
+ console.error("coverage: usage: coverage <image> --store FILE [--out FILE] [--force] [--sample N]");
1036
+ return 1;
1037
+ }
1038
+ if (!store) {
1039
+ console.error(
1040
+ "coverage: --store FILE is required -- the annotation store holds the labels, comments and typed ranges, " +
1041
+ "and this verb will not derive its path from <project>.\n",
1042
+ );
1043
+ console.log(USAGE);
1044
+ return 1;
1045
+ }
1046
+ if (sample !== undefined && (!Number.isInteger(sample) || sample <= 0)) {
1047
+ console.error(`coverage: --sample must be a positive integer, got "${sampleRaw}"`);
1048
+ return 1;
1049
+ }
1050
+
1051
+ // T-19-22 / T-29-28 / CR-02: the ONE confinement seam, for ALL THREE
1052
+ // caller-supplied paths. Never a second hand-rolled one, and never a
1053
+ // different rule for the store than for the program it annotates -- or, as
1054
+ // CR-02 found, no rule at all for the report this verb writes.
1055
+ const workspaceRoot = repoRoot();
1056
+ let projectPath: string;
1057
+ let storePath: string;
1058
+ let outPath: string | undefined;
1059
+ try {
1060
+ projectPath = storePathWithinWorkspace(project, workspaceRoot);
1061
+ storePath = storePathWithinWorkspace(store, workspaceRoot);
1062
+ outPath = out === undefined ? undefined : storePathWithinWorkspace(out, workspaceRoot);
1063
+ } catch (err) {
1064
+ console.error(`coverage: ${errMsg(err)}`);
1065
+ return 1;
1066
+ }
1067
+ if (!existsSync(projectPath)) {
1068
+ console.error(`coverage: project file not found: ${projectPath}`);
1069
+ return 1;
1070
+ }
1071
+ if (!existsSync(storePath)) {
1072
+ console.error(
1073
+ `coverage: annotation store not found: ${storePath} -- refusing to CREATE one, because "the annotations are ` +
1074
+ 'gone" and "there are no annotations" must not read the same.',
1075
+ );
1076
+ return 1;
1077
+ }
1078
+
1079
+ // Against the CONFINED path, so the file this check protects is the file
1080
+ // that would actually be written.
1081
+ if (outPath !== undefined && !refuseOverwrite(outPath, force, "coverage")) {
1082
+ return 1;
1083
+ }
1084
+
1085
+ let symbols: AnnoSymbol[];
1086
+ let comments: AnnoComment[];
1087
+ let blocks: BlockEntry[];
1088
+ let crossReferences: AnnoCrossReference[];
1089
+ let handle: AnnoStoreHandle;
1090
+ try {
1091
+ handle = openStore(storePath, { workspaceRoot, mustExist: true });
1092
+ } catch (err) {
1093
+ console.error(`coverage: ${errMsg(err)}`);
1094
+ return 1;
1095
+ }
1096
+ try {
1097
+ symbols = symbolsFromStore(listLabels(handle));
1098
+ comments = commentsFromStore(listComments(handle));
1099
+ blocks = blocksFromStore(listRanges(handle));
1100
+ // The bytes come from the SAME file the census decodes, so the derived
1101
+ // half and the censused half can never describe different programs. A
1102
+ // payload that will not decode yields no cross-references at all rather
1103
+ // than a partial answer -- the census reports that condition itself and
1104
+ // this verb exits non-zero on it below.
1105
+ const image = projectImage(projectPath);
1106
+ crossReferences = image === null ? [] : crossReferencesFromStore(handle, image.bytes, image.origin, symbols);
1107
+ } catch (err) {
1108
+ console.error(`coverage: ${errMsg(err)}`);
1109
+ return 1;
1110
+ } finally {
1111
+ closeStore(handle);
1112
+ }
1113
+
1114
+ let report: CoverageReport;
1115
+ try {
1116
+ report = buildCoverageReport({
1117
+ projectPath,
1118
+ symbols,
1119
+ comments,
1120
+ blocks,
1121
+ crossReferences,
1122
+ ...(sample !== undefined ? { sampleSize: sample } : {}),
1123
+ });
1124
+ } catch (err) {
1125
+ console.error(`coverage: ${errMsg(err)}`);
1126
+ return 1;
1127
+ }
1128
+
1129
+ printCoverageReport(report);
1130
+
1131
+ if (outPath !== undefined) {
1132
+ try {
1133
+ writeFileSync(outPath, JSON.stringify(report, null, 2) + "\n");
1134
+ } catch (err) {
1135
+ console.error(`coverage: could not write ${outPath}: ${errMsg(err)}`);
1136
+ return 1;
1137
+ }
1138
+ // The CONFINED path, so the line names the file that was actually written
1139
+ // rather than whatever the caller typed.
1140
+ console.log(`coverage: wrote ${outPath} (schema version ${report.schemaVersion})`);
1141
+ }
1142
+
1143
+ if (!report.project.payloadDecoded) {
1144
+ // Not a low measurement -- an unreadable payload means every byte-side
1145
+ // measure above was computed over nothing. Reported as the caller-facing
1146
+ // failure it is, AFTER the report, so the reason is on screen (COV-02).
1147
+ console.error(`coverage: the project's payload was UNAVAILABLE -- ${report.project.reason ?? "reason not recorded"}`);
1148
+ return 1;
1149
+ }
1150
+ return 0;
1151
+ }
1152
+
1153
+ interface ExportAsmParsedArgs {
1154
+ positional: string[];
1155
+ store?: string;
1156
+ storeMissingValue?: boolean;
1157
+ out?: string;
1158
+ outMissingValue?: boolean;
1159
+ force?: boolean;
1160
+ unknownOption?: string;
1161
+ }
1162
+
1163
+ /** Fixed, closed option set for export-asm -- exactly `--store`, `--out` and
1164
+ * `--force`. The SAME WR-08 posture, and deliberately the same SHAPE, as
1165
+ * `parseRenderMemmapArgs()` and `parseCoverageArgs()` above rather than a
1166
+ * third convention: an unimplemented flag is refused as `unknownOption`, and
1167
+ * `--store`/`--out` with a missing or flag-shaped value are refused through
1168
+ * their own `*MissingValue` fields rather than silently swallowing the next
1169
+ * token. */
1170
+ function parseExportAsmArgs(rest: string[]): ExportAsmParsedArgs {
1171
+ const positional: string[] = [];
1172
+ let store: string | undefined;
1173
+ let storeMissingValue = false;
1174
+ let out: string | undefined;
1175
+ let outMissingValue = false;
1176
+ let force = false;
1177
+ let unknownOption: string | undefined;
1178
+ for (let i = 0; i < rest.length; i++) {
1179
+ const a = rest[i]!;
1180
+ if (a === "--store") {
1181
+ const value = rest[i + 1];
1182
+ if (isMissingOptionValue(value)) {
1183
+ storeMissingValue = true;
1184
+ } else {
1185
+ store = value;
1186
+ i++;
1187
+ }
1188
+ } else if (a === "--out") {
1189
+ const value = rest[i + 1];
1190
+ if (isMissingOptionValue(value)) {
1191
+ outMissingValue = true;
1192
+ } else {
1193
+ out = value;
1194
+ i++;
1195
+ }
1196
+ } else if (a === "--force") {
1197
+ force = true;
1198
+ } else if (a.startsWith("--")) {
1199
+ unknownOption ??= a;
1200
+ } else {
1201
+ positional.push(a);
1202
+ }
1203
+ }
1204
+ return { positional, store, storeMissingValue, out, outMissingValue, force, unknownOption };
1205
+ }
1206
+
1207
+ /**
1208
+ * The destination `export-asm` writes to when the caller names none: the
1209
+ * IMAGE's basename with its extension replaced by `.a`, in the STORE's own
1210
+ * directory.
1211
+ *
1212
+ * The store's directory rather than the image's, deliberately and for the
1213
+ * reason `render-memmap`'s `memory-map.md` default already gives: the output
1214
+ * is a GENERATED VIEW of the annotations, so it belongs beside the artefact it
1215
+ * was generated from. The image is an input this verb only reads.
1216
+ *
1217
+ * A name with no extension keeps its whole basename and gains `.a`; a name
1218
+ * that already ends in `.a` is unchanged in spelling, which is correct -- the
1219
+ * caller then gets the overwrite refusal rather than a silently-different
1220
+ * destination.
1221
+ */
1222
+ function defaultExportAsmOut(imagePath: string, storeDir: string): string {
1223
+ const base = basename(imagePath);
1224
+ const ext = extname(base);
1225
+ const stem = ext === "" ? base : base.slice(0, -ext.length);
1226
+ return join(storeDir, `${stem}.a`);
1227
+ }
1228
+
1229
+ /**
1230
+ * `export-asm <image> --store FILE [--out FILE] [--force]` -- ACME source for
1231
+ * a program, emitted from its annotation store by `anno-export-asm.ts`'s
1232
+ * `exportAsm()`.
1233
+ *
1234
+ * ALL THREE OF THIS VERB'S PATHS ARE CONFINED, and the ORDER each step happens
1235
+ * in is the load-bearing part rather than the mere presence of the calls. It
1236
+ * follows `cmdRenderMemmap()`'s chain deliberately, because that chain is the
1237
+ * corrected shape of three reproduced escapes (`29-VERIFICATION.md` gap 3 /
1238
+ * `29-REVIEW.md` CR-02 and CR-03) on exactly the argument shapes this verb
1239
+ * has:
1240
+ *
1241
+ * - `<image>` and `--store` go through `storePathWithinWorkspace()` BEFORE
1242
+ * any `existsSync` probe. A stat is itself an oracle -- it answers "does
1243
+ * this file exist" for any path this process can reach -- so probing first
1244
+ * and confining second would leak that answer for a path the seam is about
1245
+ * to refuse.
1246
+ * - `--out`'s DEFAULT is applied FIRST and the result confined AFTER, so a
1247
+ * path this verb computed is confined by the same rule as one a caller
1248
+ * supplied, rather than trusted because this verb computed it (CR-02).
1249
+ * - From each seam call onwards the RAW CALLER STRING IS DEAD.
1250
+ * `storePathWithinWorkspace()` returns the REALPATH, and it is the
1251
+ * realpath that reaches `readFileSync`, `openStore()`, `refuseOverwrite()`
1252
+ * and `writeFileSync` -- so every printed line names the file that is
1253
+ * actually on disk.
1254
+ * - `refuseOverwrite()` runs against the CONFINED destination, so the file
1255
+ * it protects is the file that would actually be written.
1256
+ *
1257
+ * WHAT THIS VERB DOES NOT DO, stated here as well as in `USAGE` because a
1258
+ * reader of the code must not have to infer it: it does not assemble. It
1259
+ * spawns nothing, reads no assembler's exit status and compares no bytes. The
1260
+ * byte-diff oracle that settles whether this source reassembles to the image
1261
+ * it came from is test-only and is not importable from here -- a shipped
1262
+ * module importing it would drag a test-only module into `package.json`'s
1263
+ * `files[]` closure. Nothing this function prints may therefore read as a
1264
+ * verification result, and the summary says so in as many words.
1265
+ */
1266
+ async function cmdExportAsm(rest: string[]): Promise<number> {
1267
+ const { positional, store, storeMissingValue, out, outMissingValue, force, unknownOption } = parseExportAsmArgs(rest);
1268
+
1269
+ if (unknownOption) {
1270
+ console.error(`export-asm: unknown option "${unknownOption}"\n`);
1271
+ console.log(USAGE);
1272
+ return 1;
1273
+ }
1274
+ if (storeMissingValue) {
1275
+ console.error("export-asm: --store requires a value\n");
1276
+ console.log(USAGE);
1277
+ return 1;
1278
+ }
1279
+ if (outMissingValue) {
1280
+ console.error("export-asm: --out requires a value\n");
1281
+ console.log(USAGE);
1282
+ return 1;
1283
+ }
1284
+
1285
+ if (positional.length !== 1) {
1286
+ console.error("export-asm: usage: export-asm <image> --store FILE [--out FILE] [--force]");
1287
+ return 1;
1288
+ }
1289
+ const image = positional[0]!;
1290
+ if (!store) {
1291
+ console.error(
1292
+ "export-asm: --store FILE is required -- the annotation store holds the ranges, labels, comments and enums, " +
1293
+ "and this verb will not derive its path from <image>.\n",
1294
+ );
1295
+ console.log(USAGE);
1296
+ return 1;
1297
+ }
1298
+
1299
+ // T-30-15 / CR-03: the ONE confinement seam, on both input paths, BEFORE any
1300
+ // filesystem probe. `openStore()` downstream is handed this same workspace
1301
+ // root, so its own confinement agrees by construction rather than by a
1302
+ // second rule.
1303
+ const workspaceRoot = repoRoot();
1304
+ let imagePath: string;
1305
+ let storePath: string;
1306
+ try {
1307
+ imagePath = storePathWithinWorkspace(image, workspaceRoot);
1308
+ storePath = storePathWithinWorkspace(store, workspaceRoot);
1309
+ } catch (err) {
1310
+ console.error(`export-asm: ${errMsg(err)}`);
1311
+ return 1;
1312
+ }
1313
+ if (!existsSync(storePath)) {
1314
+ console.error(
1315
+ `export-asm: annotation store not found: ${storePath} -- refusing to CREATE one, because "the annotations are ` +
1316
+ 'gone" and "there are no annotations" must not read the same.',
1317
+ );
1318
+ return 1;
1319
+ }
1320
+ if (!existsSync(imagePath)) {
1321
+ console.error(`export-asm: image not found: ${imagePath}`);
1322
+ return 1;
1323
+ }
1324
+
1325
+ // T-30-02 / CR-02. The default is applied FIRST and the RESULT confined,
1326
+ // so the derived path and a caller-supplied one are confined by the same
1327
+ // rule.
1328
+ let outPath: string;
1329
+ try {
1330
+ outPath = storePathWithinWorkspace(out ?? defaultExportAsmOut(imagePath, dirname(storePath)), workspaceRoot);
1331
+ } catch (err) {
1332
+ console.error(`export-asm: ${errMsg(err)}`);
1333
+ return 1;
1334
+ }
1335
+
1336
+ // THE OUTPUT MAY NOT LAND ON AN INPUT, AND `--force` DOES NOT OVERRIDE THIS
1337
+ // (30-REVIEW WR-05, fixed 2026-08-31). `outPath` was confined and
1338
+ // overwrite-checked but never COMPARED to the two inputs, so
1339
+ // `anno export-asm game.raw --store g.annostore --out g.annostore --force`
1340
+ // overwrote the annotation store with ACME text, and `--out game.raw
1341
+ // --force` overwrote the image. `refuseOverwrite()` blocks both without
1342
+ // `--force` and `exportAsm()` has fully read both inputs before the write
1343
+ // below, so this was user-directed rather than silent -- but a CLI whose
1344
+ // header says "Every verb takes EXISTING inputs and refuses rather than
1345
+ // guess" should not let its own output destination land on its own input.
1346
+ //
1347
+ // SEPARATE FROM `refuseOverwrite()` AND UNCONDITIONAL, deliberately.
1348
+ // `--force` means "yes, replace the file I named"; it cannot mean "yes,
1349
+ // destroy the annotations I spent a month writing", because nobody types it
1350
+ // for that reason. This is the one write refusal in this file `--force`
1351
+ // does not lift.
1352
+ //
1353
+ // All three paths are confined realpaths by this point, so the comparison
1354
+ // is exact rather than a string-shape guess about `..` and symlinks.
1355
+ if (outPath === storePath || outPath === imagePath) {
1356
+ const which = outPath === storePath ? "annotation store (--store)" : "image (<image>)";
1357
+ console.error(
1358
+ `export-asm: refusing to write the exported source to ${outPath} -- that is this run's own ${which}. ` +
1359
+ `The export would destroy the input it was generated from, and --force does not lift this refusal. ` +
1360
+ `Pass a different --out.`,
1361
+ );
1362
+ return 1;
1363
+ }
1364
+
1365
+ // Against the CONFINED path, so the file this check protects is the file
1366
+ // that would actually be written.
1367
+ if (!refuseOverwrite(outPath, force, "export-asm")) {
1368
+ return 1;
1369
+ }
1370
+
1371
+ let result: ExportAsmResult;
1372
+ try {
1373
+ result = exportAsm({ storePath, imagePath, workspaceRoot });
1374
+ } catch (err) {
1375
+ // Every refusal the exporter raises -- an uncovered range, an
1376
+ // inexpressible enum binding, a comment with no line to attach to --
1377
+ // arrives here already named. It is reported as this verb's own
1378
+ // single actionable line and never as a thrown stack trace, and the verb
1379
+ // exits non-zero rather than reporting success over a dropped annotation.
1380
+ console.error(`export-asm: ${errMsg(err)}`);
1381
+ return 1;
1382
+ }
1383
+ try {
1384
+ writeFileSync(outPath, result.source);
1385
+ } catch (err) {
1386
+ // Same shape as `cmdRenderMemmap()`'s write failure one verb over (WR-09):
1387
+ // an ordinary write failure -- missing parent directory, permissions, full
1388
+ // disk -- must not throw past this verb's own never-throw contract.
1389
+ console.error(`export-asm: could not write ${outPath}: ${errMsg(err)}`);
1390
+ return 1;
1391
+ }
1392
+ console.log(
1393
+ `export-asm: wrote ${outPath} (${result.blocks.length} block(s), ${result.symbolCount} symbol(s), ` +
1394
+ `${result.autoNamedSymbolCount} auto-named, ${result.unexpressibleCount} unexpressible instruction(s), ` +
1395
+ `${result.midInstructionLabelCount} mid-instruction label(s), ${result.enumSubstitutionCount} enum substitution(s))`,
1396
+ );
1397
+ console.log("export-asm: this file has NOT been assembled -- this command writes source text and runs no assembler.");
1398
+ return 0;
1399
+ }
1400
+
1401
+ /**
1402
+ * Entry point for the `anno` subcommand. Returns an exit code; never calls
1403
+ * exit the process directly (the bin does that). Handles `--help`/no verb/unknown
1404
+ * verb per `acme.mjs`'s own dispatch convention (`src/skills/acme-build/
1405
+ * scripts/acme.mjs`), with one deliberate difference: an explicit `--help`
1406
+ * returns 0 (a no-op invocation with no verb also returns 0), while an
1407
+ * unrecognised verb returns 1.
1408
+ */
1409
+ export async function runAnnoCli(argv: string[]): Promise<number> {
1410
+ const [verb, ...rest] = argv;
1411
+
1412
+ if (!verb || verb === "--help" || verb === "-h") {
1413
+ console.log(USAGE);
1414
+ return 0;
1415
+ }
1416
+
1417
+ try {
1418
+ // IN-06 (D-11.1-04): the single call site for the shared verb-options
1419
+ // check, run BEFORE dispatch so a refused option never reaches any cmd*
1420
+ // function -- one place enforces the closed option set for every verb,
1421
+ // rather than seven places each doing (or, as `verify` proved, NOT doing)
1422
+ // it themselves.
1423
+ //
1424
+ // INSIDE the try since 2026-08-31 (30-REVIEW CR-01, defence in depth).
1425
+ // It used to sit above this block, so a throw from it escaped
1426
+ // `runAnnoCli()` entirely -- which is exactly what a prototype-key verb
1427
+ // did. `checkAcceptedOptions()` is now own-property-safe and cannot
1428
+ // throw for that reason, but the never-throw contract this file's header
1429
+ // states should not depend on one callee staying careful: every
1430
+ // pre-dispatch check belongs under the last-resort net below.
1431
+ const optionError = checkAcceptedOptions(verb, rest);
1432
+ if (optionError) {
1433
+ console.error(optionError);
1434
+ console.log(USAGE);
1435
+ return 1;
1436
+ }
1437
+
1438
+ switch (verb) {
1439
+ case "render-memmap":
1440
+ return await cmdRenderMemmap(rest);
1441
+ case "coverage":
1442
+ return await cmdCoverage(rest);
1443
+ case "export-asm":
1444
+ return await cmdExportAsm(rest);
1445
+ default:
1446
+ // WR-14 site 2, corrected 2026-08-30 (plan 29-16). This prefix read
1447
+ // `anno:` -- the subcommand renamed to `anno` on 2026-08-29 (29-09)
1448
+ // -- so a user who mistyped a verb was answered by a subcommand that
1449
+ // no longer dispatches. Only the STRING moved: the enclosing function
1450
+ // keeps its current name, so no consumer, test or record entry moves
1451
+ // with it (see the plan's <wr14_scope_decision>).
1452
+ console.error(`anno: unknown verb "${verb}" -- this CLI has exactly three: render-memmap, coverage and export-asm\n`);
1453
+ console.log(USAGE);
1454
+ return 1;
1455
+ }
1456
+ } catch (err) {
1457
+ // A last-resort net: every expected failure path above already returns its
1458
+ // own code with its own message, so anything arriving here is unexpected
1459
+ // and is reported verbatim rather than swallowed. The loud failure is the
1460
+ // point (D-07).
1461
+ // WR-14 site 2, second half -- same correction, same reason.
1462
+ console.error(`anno: ${errMsg(err)}`);
1463
+ return 1;
1464
+ }
1465
+ }