@henols/vice-mcp 0.2.1 → 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 (108) hide show
  1. package/README.md +4 -3
  2. package/THIRD-PARTY-NOTICES.md +423 -25
  3. package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
  4. package/anno-bank.ts +171 -0
  5. package/anno-cli.ts +3040 -0
  6. package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
  7. package/anno-coverage.ts +2465 -0
  8. package/anno-derive.ts +590 -0
  9. package/anno-details.ts +169 -0
  10. package/anno-enum-gen.ts +919 -0
  11. package/anno-export-asm.ts +2396 -0
  12. package/anno-graphics.ts +338 -0
  13. package/anno-hazard-report.ts +1367 -0
  14. package/anno-import.ts +495 -0
  15. package/anno-index.ts +150 -0
  16. package/anno-join.ts +480 -0
  17. package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
  18. package/anno-provenance-ledger.ts +472 -0
  19. package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
  20. package/{r2000-regbits.json → anno-regbits.json} +2 -2
  21. package/anno-register.ts +399 -0
  22. package/anno-store-export.ts +661 -0
  23. package/anno-store.ts +4002 -0
  24. package/anno-symbols.ts +266 -0
  25. package/anno-tools.ts +3264 -0
  26. package/anno-types.ts +1909 -0
  27. package/backend-detect.mts +124 -312
  28. package/block-class.ts +201 -0
  29. package/build.ts +4 -2
  30. package/capture-predicate.ts +597 -0
  31. package/channel-lock.ts +349 -0
  32. package/disasm-decoder.ts +14 -14
  33. package/disasm-opcodes.ts +4 -4
  34. package/disasm-renderer.ts +2 -2
  35. package/evid-ingest.ts +217 -0
  36. package/evid-reconcile.ts +316 -0
  37. package/host-tool-client.ts +430 -0
  38. package/hostpath.ts +1 -1
  39. package/incident-record.ts +23 -12
  40. package/install-resources.ts +30 -14
  41. package/memmap-lookup.ts +285 -0
  42. package/package.json +48 -23
  43. package/prg-image.ts +118 -0
  44. package/repo-root.ts +107 -8
  45. package/resources/backend-detect.mjs +98 -236
  46. package/resources/broker-control.mjs +189 -16
  47. package/resources/broker-epoch.mjs +1 -1
  48. package/resources/broker-kill.mjs +8 -2
  49. package/resources/broker-launch.mjs +373 -214
  50. package/resources/broker-state.mjs +64 -18
  51. package/resources/container-guard.mjs +1 -1
  52. package/resources/ghidra-project.mjs +790 -0
  53. package/resources/host-tool.mjs +2561 -0
  54. package/resources/vice-broker.mjs +330 -184
  55. package/resources/vice-launcher.sh +130 -12
  56. package/stock-address.ts +6 -6
  57. package/stock-cia.ts +2 -2
  58. package/stock-condition.ts +8 -8
  59. package/stock-connect.ts +10 -6
  60. package/stock-derived.ts +29 -37
  61. package/stock-diagnose.ts +200 -36
  62. package/stock-dispatch.ts +200 -68
  63. package/stock-execution.ts +5 -3
  64. package/stock-handler.ts +1 -1
  65. package/stock-input.ts +9 -9
  66. package/stock-machine.ts +17 -6
  67. package/stock-paths.ts +18 -14
  68. package/stock-petscii.ts +1 -1
  69. package/stock-protocol.ts +17 -12
  70. package/stock-recycle.ts +83 -2
  71. package/stock-registers.ts +54 -29
  72. package/stock-reproducible-run.ts +811 -0
  73. package/stock-run-until.ts +100 -1
  74. package/stock-sprites.ts +3 -3
  75. package/stock-symbols.ts +13 -13
  76. package/stock-timing.ts +2 -2
  77. package/stock-vicii.ts +1 -1
  78. package/stop-oracle.ts +167 -0
  79. package/text-capability-probe.ts +660 -0
  80. package/text-connect.ts +157 -0
  81. package/text-protocol.ts +810 -0
  82. package/text-tools.ts +778 -0
  83. package/textmon-backtrace.ts +385 -0
  84. package/textmon-cpuhistory.ts +335 -0
  85. package/textmon-memmap.ts +494 -0
  86. package/textmon-profile.ts +458 -0
  87. package/textmon-registers.ts +748 -0
  88. package/tools-manifest.stock.json +864 -3
  89. package/version.ts +1 -1
  90. package/vice-broker-client.ts +189 -42
  91. package/vice-errors.ts +268 -0
  92. package/vice-proxy.ts +392 -2175
  93. package/vsf-slice.ts +640 -0
  94. package/capability-registry.ts +0 -388
  95. package/r2000-cli.ts +0 -1103
  96. package/r2000-d64.ts +0 -310
  97. package/r2000-enum-gen.ts +0 -574
  98. package/r2000-launch.ts +0 -357
  99. package/r2000-mcp-client.ts +0 -596
  100. package/r2000-project.ts +0 -190
  101. package/r2000-symbols.ts +0 -388
  102. package/r2000-tools.ts +0 -914
  103. package/r2000-verify.ts +0 -184
  104. package/refresh-manifest.ts +0 -124
  105. package/tools-manifest.json +0 -1223
  106. package/vice-probe.ts +0 -278
  107. package/vice-sync.ts +0 -336
  108. package/vice.ts +0 -772
package/anno-cli.ts ADDED
@@ -0,0 +1,3040 @@
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
+ // FIVE VERBS. THAT IS THE WHOLE SURFACE (D-14, 2026-08-29 -- FOUR at the
12
+ // time; third verb landed 2026-08-31; fourth verb, `evid-disagreements`,
13
+ // landed by phase 43 plan 43-06 -- the CLI route for EVID-03's disagreement
14
+ // query, so a planted test has three RENDERED, textually-distinguishable
15
+ // states to compare rather than only the MCP tool's JSON, which a test can
16
+ // only inspect structurally; fifth verb, `decomp-completeness`, landed by
17
+ // phase 45 plan 45-01 -- the CLI route for the decomposition-completeness
18
+ // report's ONLY data path into a real store (D-07). D-14's original
19
+ // "FOUR VERBS. THAT IS THE WHOLE SURFACE" framing is DELIBERATELY
20
+ // SUPERSEDED by D-07, exactly as plan 43-06 (three -> four) already
21
+ // superseded it once before -- this is the second raise over the same
22
+ // decision, not a silent reopening of it).
23
+ // ---------------------------------------------------------------------------
24
+ // This file used to carry eight. Six were removed in one commit because they
25
+ // were delivery paths for the retired external analyser this project used to
26
+ // rent an annotation store from: three drove its child process directly and
27
+ // three reached it through capability modules that did. Removing the analyser
28
+ // without removing them would have left six verbs that typecheck, dispatch,
29
+ // and then fail at the first call. That paragraph is kept rather than deleted:
30
+ // it records what went and why, and it stays true.
31
+ //
32
+ // What went, and where it stands now:
33
+ // - `bootstrap`, `export-asm`, `verify` -- the analyser's own routes.
34
+ // `export-asm` RETURNED on 2026-08-31 as a REBUILD OVER THE ANNOTATION
35
+ // STORE behind a real-ACME byte-diff oracle -- not as restored code, and
36
+ // not sharing a line with the deleted implementation. It is the third
37
+ // verb below. `bootstrap` and `verify` did not come back: `bootstrap`
38
+ // created the analyser's own project file, which no longer exists as a
39
+ // format this repo produces, and `verify` drove the analyser's own
40
+ // checker.
41
+ // - `gen-enums`, `export-lbl`, `import-lbl` -- the enum generator and the
42
+ // VICE-label round trip. These did NOT return with `export-asm`. No
43
+ // requirement and no success criterion of the phase that rebuilt
44
+ // `export-asm` covers any of them, and NO PHASE CURRENTLY OWNS THEM, so
45
+ // the symbol round trip still has NO route at all. That is recorded as a
46
+ // withdrawal in `.planning/PROJECT.md`'s shipped-capability list rather
47
+ // than left for a reader to discover by running it. The exact wording of
48
+ // those withdrawal notices across both skill trees is re-pointed in one
49
+ // place, by the plan that owns the tree-wide sweep (30-06); this file
50
+ // states the code fact and does not restate their text, so the two edits
51
+ // cannot contradict each other.
52
+ //
53
+ // WHAT NOT TO DO, named concretely:
54
+ // - Never auto-pick an input when the caller does not name one (D-02). A
55
+ // silent auto-pick would happily analyse a cracktro or loader stub's
56
+ // bytes instead of the actual game -- precisely the failure
57
+ // `c64-provenance-diff` exists to prevent elsewhere in this project.
58
+ // Every verb takes EXISTING inputs and refuses rather than guess:
59
+ // `render-memmap` demands its provenance sidecar by name, `coverage`
60
+ // demands its annotation store by name, and `export-asm` demands BOTH an
61
+ // existing store and an existing image. No verb derives one
62
+ // caller-supplied path from another.
63
+ // - Never grow a second path validator. Every caller-supplied path below
64
+ // goes through `storePathWithinWorkspace()` -- the ONE confinement seam,
65
+ // the same one `anno-tools.ts` puts its store and image arguments
66
+ // through. A second answer to "is this path inside the workspace" is a
67
+ // confinement escape waiting to be written.
68
+ //
69
+ // THIS PARAGRAPH WAS FALSE WHEN IT WAS FIRST WRITTEN, and that is why it
70
+ // now names the mechanism that keeps it. `29-VERIFICATION.md` gap 3 /
71
+ // `29-REVIEW.md` CR-02 and CR-03 reproduced three escapes on this very
72
+ // tree, on arguments the shipped playbooks tell an agent to compose in a
73
+ // Bash invocation: `render-memmap --out` and `coverage --out` reached
74
+ // `writeFileSync` as raw caller strings (the first silently replacing a
75
+ // pre-existing file OUTSIDE the workspace root and exiting 0), and
76
+ // `render-memmap --provenance` reached `readFileSync` raw, making it an
77
+ // arbitrary-file read oracle that then DISCLOSED the file's opening bytes
78
+ // through an interpolated parse error. Four of the six arguments were
79
+ // unconfined while this paragraph said all of them were.
80
+ //
81
+ // A header naming a maintained property is a written warrant for the next
82
+ // maintainer not to check, so when the property and the prose disagree the
83
+ // prose is the more dangerous half. What went wrong is worth stating
84
+ // precisely: the SEAM was never weak -- `anno-confinement.test.ts` proves
85
+ // the predicate fifteen ways, including the symlink and dangling-link
86
+ // classes -- but its CONSUMER SET was unenumerated, and nothing could fail
87
+ // when a new argument skipped it. `anno-cli-path-consumers.test.ts` closes
88
+ // exactly that asymmetry: it ENUMERATES every caller-supplied path
89
+ // argument this CLI accepts -- the flags derived from `VERB_OPTIONS`
90
+ // below, the positionals derived from each verb's `--help` synopsis line
91
+ // -- and fails when the inventory and the surface disagree in either
92
+ // direction, or when the number of confinement call sites in this file
93
+ // falls below the inventory's size. A new path-shaped flag or positional
94
+ // therefore joins the audit automatically instead of by a reviewer
95
+ // noticing.
96
+ //
97
+ // AND WHAT IT DOES NOT CHECK, stated in terms so the limit can be closed
98
+ // deliberately rather than discovered (WR-02): it does not associate a
99
+ // particular argument with a particular call site. "Six arguments each
100
+ // confined once" and "five confined with one of them confined twice" read
101
+ // the same to it. That association needs per-argument dataflow through
102
+ // this file -- a static-analysis job, deliberately not taken on in a
103
+ // gap-closure round -- so what this paragraph now claims is the narrower
104
+ // property the test has, not the wider one it used to be credited with.
105
+ //
106
+ // - Never use the RAW caller string after confining it.
107
+ // `storePathWithinWorkspace()` returns the REALPATH, not its input, so
108
+ // carrying the original forward reintroduces the escape one line below the
109
+ // check that refused it -- and makes every "wrote X" line name a file that
110
+ // is not the one on disk.
111
+ //
112
+ // `runAnnoCli()` returns an exit code and never terminates the process
113
+ // itself, so it is testable in-process as well as from the bin (the bin,
114
+ // `vice-proxy.ts`, is the only place that ends the process with this
115
+ // function's return value). All output goes to stdout/stderr via
116
+ // `console.log`/`console.error` -- never a thrown stack trace for an
117
+ // expected, user-facing failure (missing file, unreadable store, refused
118
+ // overwrite): each of those produces a single actionable line instead.
119
+ //
120
+ // Import nothing from `hostpath.ts` or `containerpath.ts`. Every path this
121
+ // CLI handles is already container-side, and translating any of these
122
+ // arguments would be the mirror image of the DERIV-07 screenshot-path trap,
123
+ // where a client-side-derived path was wrongly translated a second time.
124
+ // This absence is asserted structurally by `hostpath-consumers.test.ts`
125
+ // (D-08), not merely stated here.
126
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
127
+ import { basename, dirname, extname, join, sep } from "node:path";
128
+ import { fileURLToPath } from "node:url";
129
+
130
+ import { renderMemoryMap, checkRenderedMemoryMap } from "./anno-memmap-render.ts";
131
+ // The ACME source emitter (EXPORT-01). It reads the store and the image and
132
+ // returns text plus counts; it starts no assembler and knows nothing about
133
+ // one. `acme-verify.ts` -- the module that DOES spawn ACME -- is deliberately
134
+ // NOT imported here and must never be: it is test-only (it is absent from
135
+ // `package.json`'s `files[]` on purpose), so a shipped module importing it
136
+ // would drag it into the published closure `check-npm-packages.mjs` walks.
137
+ import { exportAsmTree } from "./anno-export-asm.ts";
138
+ import type { ExportAsmTreeResult } from "./anno-export-asm.ts";
139
+ // The coverage instrument (COV-01/COV-02). It declares its own input shapes
140
+ // and never reads a store, a file or a tool on its own behalf -- a caller
141
+ // fetches and hands the data in, which is exactly what makes the store
142
+ // re-point below a CALLER-side change and nothing more.
143
+ import { buildCoverageReport, coverageFindings, loadProjectImage, AUTO_NAME_PREFIX_RE } from "./anno-coverage.ts";
144
+ import type { CoverageReport, LoadedProject, AnnoComment, AnnoCrossReference, AnnoSymbol } from "./anno-coverage.ts";
145
+ // The store's block-entry shape comes from the boundary that owns its
146
+ // vocabulary, not from the census -- see `block-class.ts`.
147
+ import type { BlockEntry } from "./block-class.ts";
148
+ import { openStore, closeStore, listLabels, listComments, listRanges, listExecObservations, listObservedRuns, listXrefs } from "./anno-store.ts";
149
+ import type { AnnoStoreHandle } from "./anno-store.ts";
150
+ // The shared 6502/6510 decoder (DISASM-01 et al.). `decomp-completeness`'s
151
+ // entry-point and referenced-address censuses (phase 45 plan 45-04) walk the
152
+ // SAME code-range decode `anno_disassemble` and `anno-enum-gen.ts`'s
153
+ // `fetchRegisterSearchRows()` already use -- never a second decoder, never a
154
+ // regex over rendered text.
155
+ import { decode } from "./disasm-decoder.ts";
156
+ import type { Instruction } from "./disasm-decoder.ts";
157
+ // The disagreement query's own pure join (EVID-03/EVID-04, plan 43-06). This
158
+ // is the SAME reconcileObservedExecution() the anno_evid_disagreements MCP
159
+ // tool calls -- reached here directly (a static import, never lazy) because
160
+ // this module IS the CLI, not a startup-cost-sensitive MCP server entry
161
+ // point.
162
+ import { reconcileObservedExecution } from "./evid-reconcile.ts";
163
+ // The pure, read-only movement-hazard report. The SAME buildHazardReport()
164
+ // the anno_hazard_report MCP tool calls -- reached here directly (a static
165
+ // import, never lazy) because this module IS the CLI.
166
+ import { buildHazardReport } from "./anno-hazard-report.ts";
167
+ import type { HazardReport } from "./anno-hazard-report.ts";
168
+ import type { EvidReconciliation } from "./evid-reconcile.ts";
169
+ // The derived half of STORE-06: cross-references are DERIVED from the bytes
170
+ // plus the store's typed ranges plus the few rows that cannot be recovered
171
+ // from bytes at all. There is exactly one definition of that union and this
172
+ // file calls it rather than restating it.
173
+ import { crossReferencesTo } from "./anno-derive.ts";
174
+ import { storePathWithinWorkspace, isSplitDataType } from "./anno-types.ts";
175
+ import type { CommentRow, LabelRow, RangeRow, DataType } from "./anno-types.ts";
176
+ import { repoRoot } from "./repo-root.ts";
177
+ // D-03's three comment-text conventions (plan 45-02), declared once in
178
+ // anno-store-export.ts and imported everywhere they are matched -- never
179
+ // restated as a second literal (T-45-15's own mitigation).
180
+ import { DECLINE_COMMENT_PREFIX, DISAGREEMENT_ACCEPTED_COMMENT_PREFIX, AUTHORED_PROVENANCE_COMMENT_PREFIX } from "./anno-store-export.ts";
181
+
182
+ const HERE = dirname(fileURLToPath(import.meta.url));
183
+ const NPX_INVOCATION = "npx -y @henols/vice-mcp anno <verb>";
184
+ const PLUGIN_INVOCATION = "node <plugin-root>/src/mcp/vice/vice-proxy.ts anno <verb>";
185
+
186
+ const USAGE = `usage (npm install): ${NPX_INVOCATION}
187
+ usage (plugin/in-repo): ${PLUGIN_INVOCATION}
188
+
189
+ verbs:
190
+ render-memmap <store> --provenance FILE [--out FILE] [--force] [--check]
191
+ Generates the Markdown memory map from an annotation store plus a
192
+ validated provenance sidecar (D-24: the store is canonical, this
193
+ output is a GENERATED VIEW -- never hand-edit it). Without --check,
194
+ writes --out (default: memory-map.md beside the STORE -- in the
195
+ store's own directory), refusing to overwrite an existing file there
196
+ unless --force is passed, and prints the row count, the number of
197
+ [unknown]-graded rows, and the render digest. That derived default is
198
+ put through the SAME confinement seam as a caller-supplied --out,
199
+ rather than trusted because this verb computed it.
200
+ With --check, re-renders in memory and compares against the file at
201
+ --out: prints "in sync" and exits 0 when they match, prints the first
202
+ differing line and exits non-zero on drift, or prints "missing" and
203
+ exits non-zero when --out does not exist yet. Drift is reported when,
204
+ and only when, one of these changed: this file itself (a hand edit --
205
+ which is what --check exists to catch); a store row (a range, a label,
206
+ a comment, or a comment's confidence grade); the provenance sidecar's
207
+ bytes; the location of the store or the sidecar RELATIVE TO THE
208
+ WORKSPACE ROOT; or the renderer. Relocating the checkout is NOT drift --
209
+ the same tree at a different absolute path renders these same bytes,
210
+ because the two locations the banner records are workspace-relative.
211
+ Requires an EXISTING annotation store and an EXISTING --provenance
212
+ sidecar (this verb creates neither).
213
+
214
+ coverage <image> --store FILE [--out FILE] [--force] [--sample N]
215
+ Measures how far a program has actually been reverse-engineered
216
+ (COV-01/COV-02), through anno-coverage.ts. <image> supplies the
217
+ PAYLOAD BYTES and the load origin; --store names the ANNOTATION STORE
218
+ holding the labels, comments and typed ranges. Those are two separate
219
+ files on purpose: the store holds annotations and never bytes, so a
220
+ derived measure has to be told which bytes it is measuring and this
221
+ verb refuses to guess one from the other.
222
+ <image> is dispatched IN THIS ORDER, and the order is load-bearing:
223
+ first, a .raw or .bin is read as a flat capture BY EXTENSION, before
224
+ any length check, so a truncated capture is refused BY NAME instead
225
+ of falling through to the .prg parser (WR-07: a 4096-byte .raw once
226
+ had its first two bytes read as a load address and reported a
227
+ complete-looking measurement); then any file that is NOT a .prg and
228
+ is exactly 65536 bytes is read as a flat capture, which is the one
229
+ branch that does dispatch on byte length; then a .prg, whose first
230
+ two bytes are the load address. The retired JSON project form
231
+ survives as a TRAILING LEGACY branch, reached only when none of
232
+ those matched -- its only producer was deleted (D-14) and it is kept
233
+ solely so an existing file on disk is not broken.
234
+ Prints three separately named measures -- the structural byte census,
235
+ the two label figures, and the sampled reproducibility result -- plus
236
+ the comment-vacuity measure, the indirect-dispatch scan and the
237
+ divergence sub-report, each under its own heading with its own
238
+ numbers. Writes the JSON report to --out when given, refusing to
239
+ overwrite an existing file there unless --force is passed; --sample
240
+ overrides the reproducibility sample size.
241
+ Exits non-zero for a caller error (a missing or malformed argument, a
242
+ path outside the workspace root, a named file that does not exist, or
243
+ a refused overwrite of an existing --out without --force), for a store
244
+ it could not read, for a report it could not write, and for an image
245
+ whose PAYLOAD COULD NOT BE DECODED -- that last is not a low score but
246
+ a measurement taken over nothing, and it is reported AFTER the report
247
+ so the reason is on screen. A LOW MEASUREMENT IS A RESULT, NEVER A
248
+ FAILURE, so a bad report still exits 0.
249
+ This verb deliberately reports separate numbers and never a single
250
+ combined figure: one aggregate is precisely what makes a coverage
251
+ claim unfalsifiable, because any one weak measure can be hidden by
252
+ averaging it against a strong one.
253
+
254
+ export-asm <image> --store FILE [--out DIR] [--ledger FILE] [--force]
255
+ Writes a TREE of ACME source files for a program from its annotation
256
+ store into --out, a DIRECTORY (D47-A: one output shape at every layer,
257
+ never a second one for a store with no scopes). <image> supplies the
258
+ PAYLOAD BYTES and the load origin; --store names the ANNOTATION STORE
259
+ holding the ranges, labels, comments and enums. Those are two separate
260
+ files on purpose, and NEITHER IS DERIVED FROM THE OTHER: the store
261
+ holds annotations and never bytes, so an exporter has to be told which
262
+ bytes it is describing and this verb refuses to guess one from the
263
+ other.
264
+ The tree's entry point is root.a, which !sources symbols.a (every
265
+ symbol definition) first, then one file per annotation scope, then
266
+ unscoped.a last for any block that lies inside no scope. A store with
267
+ no scopes yet still writes this same three-file shape -- root.a,
268
+ symbols.a, unscoped.a -- rather than a second, single-file output.
269
+ The default --out is a DIRECTORY beside the STORE: the image's basename
270
+ stem plus a fixed, extension-free suffix (no --out DIR should ever read
271
+ as a file). That derived default is put through the SAME confinement
272
+ seam as a caller-supplied --out, rather than trusted because this verb
273
+ computed it. The directory may not BE, and may not CONTAIN, the store,
274
+ the image or the ledger -- --force does not lift that refusal any more
275
+ than it lifts the single-file version of it did. A non-empty
276
+ destination is otherwise refused unless --force is passed, and --force
277
+ replaces only the names this export itself produces -- any other entry
278
+ already in the directory is refused by name, never deleted to make
279
+ room.
280
+ --ledger names c64-provenance-diff's generated recovery/PROVENANCE.md.
281
+ Supplying it makes the export carry each covered range's recorded
282
+ Verdict and Confidence as inline comments (BUILD-05). It is OPTIONAL:
283
+ omitting it exports exactly as before. The flag changes COMMENT TEXT
284
+ ONLY -- it never changes which bytes or which blocks are emitted, and a
285
+ range the supplied ledger does not cover is refused by name rather than
286
+ emitted unannotated.
287
+ Requires an EXISTING annotation store and an EXISTING image, and
288
+ creates neither.
289
+ THIS VERB DOES NOT ASSEMBLE ITS OUTPUT. It writes source text and
290
+ nothing more: it starts no assembler, reads no assembler's exit status
291
+ and compares no bytes. Whether that source reassembles to the image it
292
+ came from is settled by the byte-diff oracle in this project's own test
293
+ suite, which is deliberately test-only, so nothing this command prints
294
+ may be read as a verification result.
295
+ Refuses, by name and with exit 1, any annotation the exporter cannot
296
+ express -- a range the image does not cover, an enum bound to an
297
+ operand that cannot carry it, a comment with no line to attach to.
298
+ Such an annotation is never silently dropped while this command
299
+ reports success.
300
+
301
+ evid-disagreements --store FILE [--json]
302
+ Answers where the store's byte-derived block classification (its own
303
+ typed ranges) and the observed-execution evidence (anno_evid_exec rows,
304
+ written by anno_evid_ingest) DISAGREE -- the SAME reconciliation join
305
+ the anno_evid_disagreements MCP tool calls, run here against a real
306
+ store and rendered as three distinguishable states. Disagreements
307
+ print FIRST, as rows; agreement prints as a single count line, never
308
+ as rows; an address the block table covers with no observation
309
+ anywhere prints as its own count line stating plainly that absence
310
+ proves nothing -- never evidence that the address holds data. Two
311
+ further count lines name evidence about addresses the block table
312
+ does not classify as code or data at all, so a reader summing every
313
+ line gets what the block table covers, never what the program is.
314
+ No percentage, rate or coverage figure is ever printed. --json prints
315
+ the raw JSON answer instead of the rendered report.
316
+ Requires an EXISTING annotation store; creates none and writes
317
+ nothing.
318
+
319
+ decomp-completeness --store FILE --disagreements FILE --manifest FILE [--json]
320
+ The decomposition-closure completeness answer for ONE
321
+ per-fixture store (D-07). Three REQUIRED arguments, none defaulted from
322
+ another: --store names the annotation store; --disagreements names the
323
+ JSON "anno evid-disagreements --store <same store> --json" wrote for
324
+ THIS store's own run; --manifest names the execution manifest (D-13)
325
+ recording which committed fixtures were actually run. Omitting ANY of
326
+ the three refuses BY NAME with exit 1 -- there is no default and no
327
+ empty-array substitute for a missing disagreement input, because an
328
+ omitted query and a query that found nothing must never render the
329
+ same report (D-09 mechanism 1).
330
+ The supplied --disagreements document is refused, by name, when it is
331
+ missing any EvidReconciliation field, and when its own recorded
332
+ runIdentity (image_sha256/argv_digest/seed) matches no row in the
333
+ SAME store's own evid-runs table -- a fabricated or foreign empty
334
+ document is refused, never rendered as "no disagreements" (RESEARCH.md
335
+ Pitfall 9, anti-vacuity). The supplied --manifest is refused, by name,
336
+ when it does not list the fixture this store belongs to -- an unlisted
337
+ fixture is never defaulted to "executed".
338
+ Reports the store's byte census (per data type, with an explicit
339
+ denominator and an undefined-byte count that must read zero), the
340
+ survivor search (auto-named labels still sitting in a code region,
341
+ matched by the SAME frozen prefix set routine-queue-walker's own
342
+ candidate queue uses), the fixture's own execution disposition read
343
+ from --manifest (a NOT EXECUTED fixture renders that fact by name,
344
+ never a clean bill of health), and the disagreement input verbatim.
345
+ Never prints a percentage, rate or combined figure (D-05's own rule,
346
+ applied here too). --json prints the raw JSON answer instead of the
347
+ rendered report.
348
+ Requires an EXISTING annotation store, an EXISTING --disagreements
349
+ document and an EXISTING --manifest file; creates none and writes
350
+ nothing.
351
+
352
+ hazard-report --store FILE --image FILE [--json]
353
+ Enumerates what blocks a program's code or data from being MOVED,
354
+ relocated, rebased or stripped, across the movement-hazard
355
+ constructions this surface can detect from decoded bytes alone. It
356
+ REPORTS and changes NOTHING: it never removes, strips, relocates or
357
+ rebases any part of the image, and never prints anything a caller
358
+ could act on as an automatic relocation -- the operator decides.
359
+ Findings print first, under their own heading, each carrying its own
360
+ detection mechanism and detection-strength token. Region dispositions
361
+ print next, grouped under three separate headings by outcome
362
+ (hazard-reported, no-signal, unclassified) -- the no-signal heading's
363
+ own text states plainly that no detection is not evidence that a
364
+ region is safe to move. The named limits print last, verbatim. No
365
+ percentage, rate or combined verdict is ever printed; each heading
366
+ prints its own count against the report's own denominator. --json
367
+ prints the raw JSON answer instead of the rendered report.
368
+ Requires an EXISTING annotation store and an EXISTING image; creates
369
+ neither and writes nothing.
370
+
371
+ Every verb requires inputs that already exist. None creates a project, a
372
+ store or a sidecar, and none derives one path from another -- this CLI
373
+ never guesses (D-02).
374
+ `;
375
+
376
+ function errMsg(err: unknown): string {
377
+ return err instanceof Error ? err.message : String(err);
378
+ }
379
+
380
+ /**
381
+ * IN-06 (D-11.1-04): the ONE declared verb-to-accepted-options fact in this
382
+ * file. Every option every verb's own code actually reads is listed here --
383
+ * ground truth, not merely what USAGE happens to say.
384
+ *
385
+ * The defect this map exists against, kept on the record because the shape
386
+ * outlives the verb it was found on: a verb accepted a flag its own code
387
+ * never read, so a caller who passed it got no error and no effect. Listing
388
+ * only the options a verb ACTUALLY reads means `checkAcceptedOptions()` below
389
+ * refuses the rest before the verb ever runs.
390
+ *
391
+ * `coverage`'s `--store` is REQUIRED rather than optional, and it is declared
392
+ * here for the same reason as every other entry: the verb reads it. It is not
393
+ * defaulted from `<image>` -- see this file's header on never deriving one
394
+ * caller-supplied path from another. `export-asm`'s `--store` is required on
395
+ * the same terms and for the same reason.
396
+ *
397
+ * `export-asm` deliberately carries NO assembler-facing option. It writes
398
+ * source and runs no assembler, so there is no binary to name, no exit status
399
+ * to surface and no flag that could imply either. `--ledger` (BUILD-05,
400
+ * phase 46 plan 01) does not weaken that claim: it is an EVIDENCE-CARRYING
401
+ * INPUT, exactly like `--store`, never an assembler-facing option -- it names
402
+ * a file to READ, not a way to run or configure an assembler.
403
+ */
404
+ export const VERB_OPTIONS: Readonly<Record<string, readonly string[]>> = Object.freeze({
405
+ "render-memmap": ["--provenance", "--out", "--force", "--check"],
406
+ coverage: ["--store", "--out", "--force", "--sample"],
407
+ "export-asm": ["--store", "--out", "--ledger", "--force"],
408
+ "evid-disagreements": ["--store", "--json"],
409
+ "decomp-completeness": ["--store", "--disagreements", "--manifest", "--json"],
410
+ "hazard-report": ["--store", "--image", "--json"],
411
+ });
412
+
413
+ /**
414
+ * The one shared refusal check IN-06 generalises to every verb (WR-08's
415
+ * closed-option-set posture, applied uniformly rather than verb by verb).
416
+ * Scans `rest` for any `--flag`-shaped token not in `verb`'s accepted set
417
+ * from `VERB_OPTIONS` and returns a one-line refusal naming the flag and the
418
+ * accepted set; returns `undefined` when every flag-shaped token is
419
+ * accepted (or when `verb` is not a key in the map at all, so an unknown
420
+ * verb still falls through to `runAnnoCli()`'s own "unknown verb"
421
+ * message). Never throws -- this file's never-throw posture applies here
422
+ * too.
423
+ *
424
+ * THE LOOKUP IS AN OWN-PROPERTY READ, AND THAT IS THE WHOLE POINT (30-REVIEW
425
+ * CR-01, fixed 2026-08-31). `VERB_OPTIONS` is an object literal, so it
426
+ * inherits from `Object.prototype`; a bare `VERB_OPTIONS[verb]` resolved
427
+ * `hasOwnProperty`, `toString`, `constructor`, `valueOf` and `__proto__` to
428
+ * TRUTHY inherited FUNCTIONS. Those sailed past the `if (!accepted) return
429
+ * undefined` short-circuit and the next line crashed. Reproduced against the
430
+ * shipped table before this fix:
431
+ *
432
+ * `anno hasOwnProperty game.prg --force` -> TypeError: accepted.includes is not a function
433
+ *
434
+ * The call site at `runAnnoCli()` sits OUTSIDE that function's `try`, so the
435
+ * throw escaped the function entirely and broke the never-throw contract this
436
+ * file's header states. The identical defect was found and fixed one
437
+ * directory over in this same phase -- `scripts/lib/anno-cli-invocations.mjs`
438
+ * reads every verb-keyed table through its `own()` helper, and one of that
439
+ * file's controls quotes THIS file's variable name verbatim as
440
+ * `"accepted.includes is not a function"`. The hardening stopped at the
441
+ * checker and never reached the CLI the checker models; it reaches it now.
442
+ *
443
+ * `Array.isArray()` rather than a bare truthiness test is deliberate belt and
444
+ * braces: an own key whose value is somehow not an array falls through to
445
+ * "unknown verb" instead of reaching `.includes()`.
446
+ */
447
+ export function checkAcceptedOptions(verb: string, rest: string[]): string | undefined {
448
+ const accepted = Object.hasOwn(VERB_OPTIONS, verb) ? VERB_OPTIONS[verb] : undefined;
449
+ if (!Array.isArray(accepted)) return undefined;
450
+ for (const token of rest) {
451
+ if (token.startsWith("--") && !accepted.includes(token)) {
452
+ const acceptedList = accepted.length > 0 ? accepted.join(", ") : "none";
453
+ return `${verb}: unknown option "${token}" -- not accepted by this verb (accepted: ${acceptedList})`;
454
+ }
455
+ }
456
+ return undefined;
457
+ }
458
+
459
+ /**
460
+ * Refuses to overwrite an existing file at `outPath` unless the caller
461
+ * passed `--force`. Called by the TWO verbs that write a single output FILE
462
+ * -- `cmdRenderMemmap()` (non-`--check` branch only; `--check` never writes)
463
+ * and `cmdCoverage()` -- so overwrite safety is uniform across both rather
464
+ * than one verb accreting a check the other lacks (CR-01/CR-02).
465
+ *
466
+ * "SHARED BY EVERY VERB THAT WRITES AN OUTPUT FILE" IS WHAT THIS DOC USED TO
467
+ * SAY, AND IT WAS NOT TRUE. `render-memmap` wrote an output file and had
468
+ * neither `--force` in its option set nor a call to this function anywhere on
469
+ * its path; `29-REVIEW.md` CR-02 reproduced it destroying a pre-existing file
470
+ * silently, exit code 0. The claim is now stated as the TWO call sites it
471
+ * actually has, because a count is checkable where "every" is not.
472
+ *
473
+ * "THE TWO CALL SITES" IS WHAT THIS SENTENCE SAID UNTIL 2026-08-31, AFTER
474
+ * `cmdExportAsm()` BECAME THE THIRD (30-REVIEW WR-08). The paragraph directly
475
+ * above had been updated to name all three; this one, whose entire point is
476
+ * that a COUNT is checkable where "every" is not, was left carrying a stale
477
+ * count -- the failure mode it exists to argue against, reproduced in
478
+ * miniature two lines below itself. `anno-cli.test.ts` now asserts the count
479
+ * mechanically, the way `anno-cli-path-consumers.test.ts` already does for the
480
+ * confinement seam, so the next verb to write an output file cannot leave this
481
+ * number behind again.
482
+ *
483
+ * BACK DOWN TO TWO, phase 47 plan 47-05: `export-asm`'s `--out` was promoted
484
+ * from a FILE to a DIRECTORY (D47-A). A directory's overwrite question --
485
+ * does this directory already hold something, and may `force` replace it --
486
+ * is `exportAsmTree()`'s own output-directory contract (phase 47 plan 47-02),
487
+ * never this function's single-file question, so `cmdExportAsm()` dropped its
488
+ * call here rather than reshaping a file-shaped check to fit a directory. The
489
+ * count this doc states, and the count `anno-cli.test.ts` checks mechanically,
490
+ * moved back down to two with it.
491
+ *
492
+ * `outPath` MUST already be confined through `storePathWithinWorkspace()`.
493
+ * This function performs no confinement of its own and must never be read as
494
+ * providing any: it answers "does this file already exist", which is a
495
+ * different question from "may this process write here", and running it
496
+ * against an unconfined path produces a check that guards the wrong file.
497
+ */
498
+ function refuseOverwrite(outPath: string, force: boolean | undefined, verbLabel: string, extraHint = ""): boolean {
499
+ if (force || !existsSync(outPath)) return true;
500
+ console.error(
501
+ `${verbLabel}: refusing to overwrite the existing file ${outPath}${extraHint} -- ` +
502
+ `pass --force to overwrite it deliberately.`,
503
+ );
504
+ return false;
505
+ }
506
+
507
+ /**
508
+ * THE ONE "is this token a value, or the next flag?" TEST, shared by all THREE
509
+ * option parsers below (30-REVIEW WR-09, fixed 2026-08-31).
510
+ *
511
+ * The `*MissingValue` mechanism exists precisely to avoid "silently swallowing
512
+ * the next token" when an option is given without its value. Until this
513
+ * helper, each of the SEVEN option-with-a-value sites spelled the test inline
514
+ * as `value === undefined || value.startsWith("--")` -- which refuses a
515
+ * DOUBLE-dash token and accepts a single-dash one. So
516
+ * `anno export-asm g.prg --store -x` took `-x` as the store path, and the run
517
+ * failed downstream as a confinement or not-found error about a file called
518
+ * `-x` rather than as the `--store requires a value` refusal the parser was
519
+ * written to produce. A single-dash token is exactly the case the mechanism
520
+ * missed.
521
+ *
522
+ * ANY leading `-` is refused, including a bare `-`. No verb in this CLI reads
523
+ * stdin, so `-` has no meaning here, and a path that genuinely begins with a
524
+ * dash is addressable as `./-x` -- which is also how every other CLI a caller
525
+ * has used behaves. Refusing beats guessing which of the two a caller meant.
526
+ *
527
+ * ONE PREDICATE, SEVEN CALL SITES, deliberately: the three parsers' own docs
528
+ * each claim they are "the SAME shape ... rather than a third convention", and
529
+ * an inline copy per site is how that claim quietly stops being true. A fix
530
+ * applied to one parser would leave the finding armed in the other two.
531
+ */
532
+ function isMissingOptionValue(value: string | undefined): boolean {
533
+ return value === undefined || value.startsWith("-");
534
+ }
535
+
536
+ interface RenderMemmapParsedArgs {
537
+ positional: string[];
538
+ provenance?: string;
539
+ provenanceMissingValue?: boolean;
540
+ out?: string;
541
+ outMissingValue?: boolean;
542
+ force?: boolean;
543
+ check?: boolean;
544
+ unknownOption?: string;
545
+ }
546
+
547
+ /** Fixed, closed option set for render-memmap -- exactly `--provenance`,
548
+ * `--out`, `--force` and `--check`. Per WR-08's posture (do not silently
549
+ * accept a flag a verb does not implement, or a flag missing its value), any
550
+ * OTHER `--flag`-shaped token is refused as `unknownOption`, and
551
+ * `--provenance`/`--out` with no value (or a flag-shaped "value") is refused
552
+ * via their own `*MissingValue` fields.
553
+ *
554
+ * `--force` is parsed in the SAME boolean shape `parseCoverageArgs()` already
555
+ * uses, deliberately rather than as a second convention: it feeds the same
556
+ * `refuseOverwrite()` every writing verb shares, so a caller who learns the
557
+ * opt-in on one verb has learned it on the others. */
558
+ function parseRenderMemmapArgs(rest: string[]): RenderMemmapParsedArgs {
559
+ const positional: string[] = [];
560
+ let provenance: string | undefined;
561
+ let provenanceMissingValue = false;
562
+ let out: string | undefined;
563
+ let outMissingValue = false;
564
+ let force = false;
565
+ let check = false;
566
+ let unknownOption: string | undefined;
567
+ for (let i = 0; i < rest.length; i++) {
568
+ const a = rest[i]!;
569
+ if (a === "--provenance") {
570
+ const value = rest[i + 1];
571
+ if (isMissingOptionValue(value)) {
572
+ provenanceMissingValue = true;
573
+ } else {
574
+ provenance = value;
575
+ i++;
576
+ }
577
+ } else if (a === "--out") {
578
+ const value = rest[i + 1];
579
+ if (isMissingOptionValue(value)) {
580
+ outMissingValue = true;
581
+ } else {
582
+ out = value;
583
+ i++;
584
+ }
585
+ } else if (a === "--force") {
586
+ force = true;
587
+ } else if (a === "--check") {
588
+ check = true;
589
+ } else if (a.startsWith("--")) {
590
+ unknownOption ??= a;
591
+ } else {
592
+ positional.push(a);
593
+ }
594
+ }
595
+ return { positional, provenance, provenanceMissingValue, out, outMissingValue, force, check, unknownOption };
596
+ }
597
+
598
+ /**
599
+ * `render-memmap <store> --provenance FILE [--out FILE] [--force] [--check]`
600
+ * -- D-24's generated-view verb, via `anno-memmap-render.ts`'s
601
+ * `renderMemoryMap()`/`checkRenderedMemoryMap()`. Never writes a file when
602
+ * `--check` is given -- that mode only reads and reports.
603
+ *
604
+ * ALL THREE OF THIS VERB'S PATHS ARE CONFINED, and the reason each one is
605
+ * named here rather than left to a reader to infer is that two of them were
606
+ * NOT, and shipped that way. `29-VERIFICATION.md` gap 3 / `29-REVIEW.md`
607
+ * CR-02 and CR-03 reproduced both on this tree:
608
+ *
609
+ * - `--out` reached `writeFileSync` as the RAW caller string. Pointed
610
+ * outside the workspace root it exited 0, printed `wrote /tmp/.../
611
+ * PRECIOUS.md` and replaced that pre-existing file's bytes. `--force`
612
+ * was not in this verb's option set at all, so `refuseOverwrite()` --
613
+ * whose own doc claims the safety is uniform across every verb that
614
+ * writes an output file -- was never reached from here (CR-02).
615
+ * - `--provenance` reached `readFileSync` as the RAW caller string, making
616
+ * it an arbitrary-file read oracle; the sidecar parse failure then
617
+ * interpolated Node's own parse error, which carries a snippet of the
618
+ * file, so the oracle DISCLOSED CONTENT (CR-03). Confining it here also
619
+ * confines it for `anno-memmap-render.ts`, which reads it with no check
620
+ * of its own.
621
+ *
622
+ * Every one of them now goes through the SAME one confinement seam,
623
+ * `storePathWithinWorkspace()` against `repoRoot()` (T-29-51) -- never a
624
+ * second hand-rolled rule, and never a suffix check standing in for a
625
+ * location check. The DEFAULT output path is confined too, deliberately: a
626
+ * derived path is confined by the same rule as a caller-supplied one rather
627
+ * than trusted because it was derived.
628
+ *
629
+ * The predicate was never the weak half -- `anno-confinement.test.ts` proves
630
+ * it fifteen ways. Its CONSUMER SET was unenumerated, and that asymmetry is
631
+ * the whole mechanism by which both findings shipped past a green suite.
632
+ * `anno-cli-path-consumers.test.ts` is what closes it: it enumerates every
633
+ * caller-supplied path argument this CLI accepts -- flags from
634
+ * `VERB_OPTIONS`, positionals from each verb's `--help` synopsis line -- and
635
+ * fails when the inventory and the surface disagree in either direction, or
636
+ * when this file's confinement call sites number fewer than the inventory's
637
+ * entries. It does not associate a particular argument with a particular call
638
+ * site (WR-02), so six arguments confined once each and five confined with one
639
+ * of them confined twice read the same to it; that limit is named here rather
640
+ * than papered over. A header that asserts a property must point at the
641
+ * mechanism that keeps it, and must claim no more than the mechanism checks.
642
+ */
643
+ async function cmdRenderMemmap(rest: string[]): Promise<number> {
644
+ const {
645
+ positional,
646
+ provenance,
647
+ provenanceMissingValue,
648
+ out,
649
+ outMissingValue,
650
+ force,
651
+ check,
652
+ unknownOption,
653
+ } = parseRenderMemmapArgs(rest);
654
+
655
+ if (unknownOption) {
656
+ console.error(`render-memmap: unknown option "${unknownOption}"\n`);
657
+ console.log(USAGE);
658
+ return 1;
659
+ }
660
+ if (provenanceMissingValue) {
661
+ console.error("render-memmap: --provenance requires a value\n");
662
+ console.log(USAGE);
663
+ return 1;
664
+ }
665
+ if (outMissingValue) {
666
+ console.error("render-memmap: --out requires a value\n");
667
+ console.log(USAGE);
668
+ return 1;
669
+ }
670
+
671
+ const store = positional[0];
672
+ if (!store) {
673
+ console.error("render-memmap: usage: render-memmap <store> --provenance FILE [--out FILE] [--check]");
674
+ return 1;
675
+ }
676
+
677
+ // T-29-51 / T-19-22: the ONE confinement seam, the same one `coverage` puts
678
+ // both of its caller-supplied paths through. `openStore()` downstream is
679
+ // handed this same workspace root, so its own confinement agrees by
680
+ // construction rather than by a second rule.
681
+ const workspaceRoot = repoRoot();
682
+ let storePath: string;
683
+ try {
684
+ storePath = storePathWithinWorkspace(store, workspaceRoot);
685
+ } catch (err) {
686
+ console.error(`render-memmap: ${errMsg(err)}`);
687
+ return 1;
688
+ }
689
+ if (!existsSync(storePath)) {
690
+ console.error(
691
+ `render-memmap: annotation store not found: ${storePath} -- refusing to CREATE one, because "the annotations are ` +
692
+ 'gone" and "there are no annotations" must not read the same.',
693
+ );
694
+ return 1;
695
+ }
696
+ if (!provenance) {
697
+ console.error("render-memmap: --provenance FILE is required\n");
698
+ console.log(USAGE);
699
+ return 1;
700
+ }
701
+
702
+ // CR-03. The sidecar is confined BEFORE the existence check, so a path
703
+ // outside the workspace root never reaches the filesystem at all -- not as
704
+ // an `existsSync` probe (which is itself an oracle: it answers "does this
705
+ // file exist" for any path the process can stat) and not as the
706
+ // `readFileSync` inside `renderMemoryMap()`. From here on the RAW caller
707
+ // string is dead: `provenancePath` is the realpath the seam returned, and
708
+ // it is what every downstream call receives.
709
+ let provenancePath: string;
710
+ try {
711
+ provenancePath = storePathWithinWorkspace(provenance, workspaceRoot);
712
+ } catch (err) {
713
+ console.error(`render-memmap: ${errMsg(err)}`);
714
+ return 1;
715
+ }
716
+ if (!existsSync(provenancePath)) {
717
+ console.error(`render-memmap: provenance sidecar not found: ${provenancePath}`);
718
+ return 1;
719
+ }
720
+
721
+ // CR-02. The default is applied FIRST and the result confined AFTER, so the
722
+ // derived path and a caller-supplied one are confined by the same rule --
723
+ // rather than the default being trusted because this verb computed it.
724
+ let outPath: string;
725
+ try {
726
+ outPath = storePathWithinWorkspace(out ?? join(dirname(storePath), "memory-map.md"), workspaceRoot);
727
+ } catch (err) {
728
+ console.error(`render-memmap: ${errMsg(err)}`);
729
+ return 1;
730
+ }
731
+
732
+ if (check) {
733
+ let result: Awaited<ReturnType<typeof checkRenderedMemoryMap>>;
734
+ try {
735
+ result = await checkRenderedMemoryMap({ storePath, provenancePath, renderedPath: outPath, workspaceRoot });
736
+ } catch (err) {
737
+ console.error(`render-memmap: ${errMsg(err)}`);
738
+ return 1;
739
+ }
740
+ if (result.status === "in-sync") {
741
+ console.log(`render-memmap: in sync (${outPath})`);
742
+ return 0;
743
+ }
744
+ if (result.status === "missing") {
745
+ console.error(`render-memmap: missing -- ${outPath} does not exist yet. Run render-memmap without --check first.`);
746
+ return 1;
747
+ }
748
+ console.error(`render-memmap: drifted at line ${result.line}`);
749
+ console.error(` expected: ${result.expected}`);
750
+ console.error(` actual: ${result.actual}`);
751
+ return 1;
752
+ }
753
+
754
+ // CR-02, the second half. `--check` never writes, so the overwrite refusal
755
+ // belongs on THIS branch only -- and it runs against the CONFINED path, so
756
+ // the file it protects is the file that would actually be written.
757
+ if (!refuseOverwrite(outPath, force, "render-memmap")) {
758
+ return 1;
759
+ }
760
+
761
+ let rendered: Awaited<ReturnType<typeof renderMemoryMap>>;
762
+ try {
763
+ rendered = await renderMemoryMap({ storePath, provenancePath, workspaceRoot });
764
+ } catch (err) {
765
+ console.error(`render-memmap: ${errMsg(err)}`);
766
+ return 1;
767
+ }
768
+ try {
769
+ writeFileSync(outPath, rendered.markdown);
770
+ } catch (err) {
771
+ // WR-09 (D-11.1-04): the same shape as bootstrapProject()'s write above,
772
+ // one verb over -- an ordinary write failure (missing parent directory,
773
+ // permissions, full disk) must not throw past this verb's own
774
+ // never-throw contract.
775
+ console.error(`render-memmap: could not write ${outPath}: ${errMsg(err)}`);
776
+ return 1;
777
+ }
778
+ console.log(
779
+ `render-memmap: wrote ${outPath} (${rendered.rowCount} row(s), ${rendered.unknownCount} [unknown], digest ${rendered.renderDigest})`,
780
+ );
781
+ return 0;
782
+ }
783
+
784
+ interface CoverageParsedArgs {
785
+ positional: string[];
786
+ store?: string;
787
+ storeMissingValue?: boolean;
788
+ out?: string;
789
+ outMissingValue?: boolean;
790
+ force?: boolean;
791
+ sample?: number;
792
+ sampleRaw?: string;
793
+ sampleMissingValue?: boolean;
794
+ unknownOption?: string;
795
+ }
796
+
797
+ /** Fixed, closed option set for coverage -- exactly `--store`, `--out`,
798
+ * `--force` and `--sample`. Same WR-08 posture as `parseRenderMemmapArgs()`
799
+ * above: an unimplemented flag is refused as `unknownOption`, and
800
+ * `--store`/`--out`/`--sample` with a missing or flag-shaped value are refused
801
+ * through their own `*MissingValue` fields rather than silently swallowing the
802
+ * next token. */
803
+ function parseCoverageArgs(rest: string[]): CoverageParsedArgs {
804
+ const positional: string[] = [];
805
+ let store: string | undefined;
806
+ let storeMissingValue = false;
807
+ let out: string | undefined;
808
+ let outMissingValue = false;
809
+ let force = false;
810
+ let sample: number | undefined;
811
+ let sampleRaw: string | undefined;
812
+ let sampleMissingValue = false;
813
+ let unknownOption: string | undefined;
814
+ for (let i = 0; i < rest.length; i++) {
815
+ const a = rest[i]!;
816
+ if (a === "--store") {
817
+ const value = rest[i + 1];
818
+ if (isMissingOptionValue(value)) {
819
+ storeMissingValue = true;
820
+ } else {
821
+ store = value;
822
+ i++;
823
+ }
824
+ } else if (a === "--out") {
825
+ const value = rest[i + 1];
826
+ if (isMissingOptionValue(value)) {
827
+ outMissingValue = true;
828
+ } else {
829
+ out = value;
830
+ i++;
831
+ }
832
+ } else if (a === "--sample") {
833
+ const value = rest[i + 1];
834
+ if (isMissingOptionValue(value)) {
835
+ sampleMissingValue = true;
836
+ } else {
837
+ sampleRaw = value;
838
+ sample = Number.parseInt(value, 10);
839
+ i++;
840
+ }
841
+ } else if (a === "--force") {
842
+ force = true;
843
+ } else if (a.startsWith("--")) {
844
+ unknownOption ??= a;
845
+ } else {
846
+ positional.push(a);
847
+ }
848
+ }
849
+ return { positional, store, storeMissingValue, out, outMissingValue, force, sample, sampleRaw, sampleMissingValue, unknownOption };
850
+ }
851
+
852
+ // ---------------------------------------------------------------------------
853
+ // THE STORE-TO-CENSUS ADAPTER (Discretion 4).
854
+ //
855
+ // `anno-coverage.ts` declares four input shapes and fetches NONE of them: a
856
+ // caller hands the data in. So moving the census from the retired analyser's
857
+ // project JSON onto this project's own annotation store is a CALLER-side
858
+ // change and nothing else -- the four functions below, and no edit to the
859
+ // instrument.
860
+ //
861
+ // THE COLUMN MAPPING, stated once, here, because a vocabulary mismatch at this
862
+ // boundary changes coverage verdicts SILENTLY (T-29-29):
863
+ //
864
+ // LabelRow -> AnnoSymbol address, name, kind. `kind` needs no
865
+ // translation: the store's LABEL_KINDS are
866
+ // the same four tokens the census filters
867
+ // on ("User"/"Auto"/"System"/"Platform").
868
+ // `id` and `bank` are store-only and are
869
+ // dropped. The census never reads a
870
+ // symbol's `type`, so its absence from the
871
+ // store costs nothing.
872
+ // CommentRow -> AnnoComment address, commentType -> type, text ->
873
+ // comment. COMMENT_TYPES is "line"/"side",
874
+ // which is exactly the census's own pair.
875
+ // RangeRow -> BlockEntry start -> start_address, endInclusive ->
876
+ // end_address (both INCLUSIVE on both
877
+ // sides), dataType -> type. That last
878
+ // column is the one the census must NOT
879
+ // interpret itself: it goes through
880
+ // `block-class.ts`, the one boundary
881
+ // allowed to read a store block spelling,
882
+ // and `block-class.test.ts` pins the class
883
+ // each of the frozen twelve resolves to BY
884
+ // NAME so this mapping cannot drift
885
+ // quietly.
886
+ // derived -> AnnoCrossReference the union `crossReferencesTo()` computes
887
+ // from the bytes, the typed split tables
888
+ // and the stored rows.
889
+ // ---------------------------------------------------------------------------
890
+
891
+ /** `LabelRow[]` as the census's symbol shape. */
892
+ export function symbolsFromStore(rows: readonly LabelRow[]): AnnoSymbol[] {
893
+ return rows.map((row) => ({ address: row.address, name: row.name, kind: row.kind }));
894
+ }
895
+
896
+ /** `CommentRow[]` as the census's comment shape. */
897
+ export function commentsFromStore(rows: readonly CommentRow[]): AnnoComment[] {
898
+ return rows.map((row) => ({ address: row.address, type: row.commentType, comment: row.text }));
899
+ }
900
+
901
+ /** `RangeRow[]` as the census's block shape. The `dataType` column is copied
902
+ * VERBATIM and never compared here -- `block-class.ts` is the only place in
903
+ * this tree allowed to interpret it. */
904
+ export function blocksFromStore(rows: readonly RangeRow[]): BlockEntry[] {
905
+ return rows.map((row) => ({ start_address: row.start, end_address: row.endInclusive, type: row.dataType }));
906
+ }
907
+
908
+ /**
909
+ * The census's fourth input, derived in ONE pass over the store and the image
910
+ * rather than fetched one address at a time.
911
+ *
912
+ * WHAT THIS REPLACED, and why the replacement has no ceiling. The previous
913
+ * implementation issued one transport round trip PER LABEL through a held
914
+ * child process, and bounded that at a hard ceiling of 512 lookups, printing a
915
+ * note when the ceiling bit. Over an in-process derivation that ceiling would
916
+ * be strictly worse than the bound it used to express: it would truncate a
917
+ * COMPLETE answer and call the remainder a floor. So it is gone, and this
918
+ * function answers over the WHOLE population -- every non-System, non-Platform
919
+ * label the store holds.
920
+ *
921
+ * `System`/`Platform` labels are excluded because every label figure already
922
+ * excludes them, so deriving their callers would buy the census nothing.
923
+ */
924
+ export function crossReferencesFromStore(
925
+ handle: AnnoStoreHandle,
926
+ image: Uint8Array,
927
+ origin: number,
928
+ symbols: readonly AnnoSymbol[],
929
+ ): AnnoCrossReference[] {
930
+ const targets = [
931
+ ...new Set(
932
+ (Array.isArray(symbols) ? symbols : [])
933
+ .filter((s) => s && String(s.kind ?? "") !== "System" && String(s.kind ?? "") !== "Platform")
934
+ .map((s) => s.address),
935
+ ),
936
+ ].sort((a, b) => a - b);
937
+ return targets.map((address) => ({ address, callers: crossReferencesTo(handle, image, origin, address).callers }));
938
+ }
939
+
940
+ /**
941
+ * The payload bytes and the load origin, read from the SAME project file the
942
+ * census reads them from -- and, since 2026-08-30, through the SAME FUNCTION.
943
+ *
944
+ * NOT a second byte source, and no longer only by convention. This used to be
945
+ * a second hand-rolled decode sitting beside `buildCoverageReport()`'s own,
946
+ * with a comment asking a reader to keep the two in step; two decodes over one
947
+ * path is two answers to "which program does this report describe", and the
948
+ * comment was the only thing holding them together (`T-29-16-02`). It now
949
+ * delegates to `anno-coverage.ts`'s exported `loadProjectImage()`, so the
950
+ * derived half and the censused half of one report CANNOT describe different
951
+ * programs -- they are the same call.
952
+ *
953
+ * Returns `null` -- never a throw and never a guess -- when the payload did
954
+ * not decode or decoded to nothing. The census reports that same condition
955
+ * itself, in its own words, and the verb exits non-zero on it.
956
+ */
957
+ function projectImage(projectPath: string): { origin: number; bytes: Uint8Array } | null {
958
+ let loaded: LoadedProject;
959
+ try {
960
+ loaded = loadProjectImage(projectPath);
961
+ } catch {
962
+ // The one throw the loader has left is an unreadable PATH. This verb has
963
+ // already checked existence above and the census reports the condition in
964
+ // its own words, so a null is the right answer here rather than a second
965
+ // diagnosis of the same fact.
966
+ return null;
967
+ }
968
+ if (!loaded.payloadDecoded || loaded.bytes.length === 0) return null;
969
+ return { origin: loaded.origin, bytes: loaded.bytes };
970
+ }
971
+
972
+ function hexAddr(address: number): string {
973
+ return `$${address.toString(16).padStart(4, "0")}`;
974
+ }
975
+
976
+ function ratio(value: number | null): string {
977
+ return value === null ? "UNAVAILABLE" : value.toFixed(3);
978
+ }
979
+
980
+ function addressList(addresses: readonly number[], cap = 12): string {
981
+ if (addresses.length === 0) return "none";
982
+ const shown = addresses.slice(0, cap).map(hexAddr).join(", ");
983
+ return addresses.length > cap ? `${shown}, ... (${addresses.length} in all)` : shown;
984
+ }
985
+
986
+ /**
987
+ * Renders the report as separately-headed sections.
988
+ *
989
+ * THE ONE RULE THIS FUNCTION EXISTS TO HOLD (COV-01, and the reason the
990
+ * rendering lives here rather than being a generic pretty-printer): print
991
+ * every measure's own numbers under its own heading, and never compute a
992
+ * combined figure at the point of display. `anno-coverage.ts`'s report
993
+ * object carries no aggregate -- if one ever appears, it will be because
994
+ * somebody averaged, summed or weighted these numbers HERE. Do not. The
995
+ * ratios below measure different populations (labels, comments, sampled
996
+ * addresses); they are not commensurable and combining them would produce a
997
+ * number that means nothing while reading like a verdict.
998
+ */
999
+ function printCoverageReport(report: CoverageReport): void {
1000
+ const s = report.structural;
1001
+ const classSum = s.reachedAsInstruction + s.tableEntry + s.referencedAsData + s.unreached;
1002
+
1003
+ console.log(`coverage: ${report.project.path}`);
1004
+ console.log(
1005
+ ` origin ${hexAddr(report.project.origin)}, ${report.project.size} byte(s), payload ` +
1006
+ (report.project.payloadDecoded ? "decoded" : `UNAVAILABLE -- ${report.project.reason ?? "reason not recorded"}`),
1007
+ );
1008
+ console.log(` schema version ${report.schemaVersion}, generated ${report.generatedAt}`);
1009
+ console.log("");
1010
+
1011
+ console.log(" MEASURE 1 of 3 -- structural byte census (raw bytes plus the seed set only; the store cannot move it)");
1012
+ console.log(` reached-as-instruction : ${s.reachedAsInstruction}`);
1013
+ console.log(` table-entry : ${s.tableEntry}`);
1014
+ console.log(` referenced-as-data : ${s.referencedAsData}`);
1015
+ console.log(` unreached : ${s.unreached}`);
1016
+ console.log(` the four classes sum to ${classSum} of ${s.rangeBytes} censused byte(s)`);
1017
+ console.log(
1018
+ ` linear-sweep decodable : ${s.linearSweepDecodable} byte(s) -- reported BESIDE the census, never added to it; ` +
1019
+ "decodability is not evidence of code",
1020
+ );
1021
+ console.log(` seeds: ${s.seeds.length} (${addressList(s.seeds)}); descent steps ${s.steps}; truncated: ${s.truncated ? "YES" : "no"}`);
1022
+ console.log("");
1023
+
1024
+ console.log(" MEASURE 2 of 3 -- label figures (two of them, both printed; neither is folded into the other)");
1025
+ console.log(
1026
+ ` kind ratio over non-System labels: ${report.labels.kindRatio.user} user / ${report.labels.kindRatio.auto} auto ` +
1027
+ `-> user fraction ${ratio(report.labels.kindRatio.userFraction)}`,
1028
+ );
1029
+ console.log(
1030
+ ` auto-prefix names remaining : ${report.labels.autoPrefixNamesRemaining} at ${addressList(report.labels.autoPrefixNameAddresses)}`,
1031
+ );
1032
+ console.log(` System labels excluded : ${report.labels.systemExcluded}`);
1033
+ console.log(
1034
+ ` disqualified by the multi-caller rule: ${report.labels.excludedByMultiCallerRule.length} at ` +
1035
+ `${addressList(report.labels.excludedByMultiCallerRule)}`,
1036
+ );
1037
+ console.log("");
1038
+
1039
+ const repro = report.reproducibility;
1040
+ console.log(" MEASURE 3 of 3 -- sampled reproducibility (the bytes route versus the store route; neither reads the other's input)");
1041
+ console.log(
1042
+ ` sampled ${repro.sampled}, agreed ${repro.agreed}, disagreed ${repro.disagreed} -> agreement rate ${ratio(repro.agreementRate)}`,
1043
+ );
1044
+ console.log(` sample rule: ${repro.sampleRule}`);
1045
+ console.log(` sampled addresses: ${addressList(repro.addresses)}`);
1046
+ for (const c of repro.comparisons) {
1047
+ console.log(` ${hexAddr(c.address)} bytes=${c.fromBytes} store=${c.fromStore} ${c.agreed ? "agree" : "DISAGREE"}`);
1048
+ }
1049
+ console.log(
1050
+ ` multi-caller labels documented without naming a caller: ${repro.multiCallerUndocumented.count} at ` +
1051
+ `${addressList(repro.multiCallerUndocumented.addresses)}`,
1052
+ );
1053
+ if (repro.reason) console.log(` reason: ${repro.reason}`);
1054
+ console.log("");
1055
+
1056
+ const vac = report.commentVacuity;
1057
+ console.log(" comment vacuity (its own measure -- kept out of the three above, not averaged into them)");
1058
+ console.log(` commented addresses : ${vac.commentedAddresses}`);
1059
+ console.log(` distinct comments : ${vac.distinctComments} -> distinct-comment ratio ${ratio(vac.distinctCommentRatio)}`);
1060
+ console.log(
1061
+ ` graded : ${vac.gradedAddresses} graded, ${vac.unknownGradedAddresses} [unknown] -> graded fraction ${ratio(vac.gradedFraction)}`,
1062
+ );
1063
+ console.log(` banned-generic : ${vac.bannedGenericAddresses.length} at ${addressList(vac.bannedGenericAddresses)}`);
1064
+ console.log(` near-miss grade token: ${vac.malformedGradeAddresses.length} at ${addressList(vac.malformedGradeAddresses)}`);
1065
+ if (vac.reason) console.log(` reason: ${vac.reason}`);
1066
+ console.log("");
1067
+
1068
+ const d = report.dispatch;
1069
+ console.log(" indirect-dispatch scan (feeds the census its extra seeds; reported as counts, never graded)");
1070
+ console.log(
1071
+ ` indirect jumps ${d.indirectJumps.length}, multi-entry tables ${d.multiEntryTables.length}, ` +
1072
+ `split lo/hi tables ${d.splitTables.length}, stack-return dispatch ${d.stackReturnDispatch.length}`,
1073
+ );
1074
+ console.log(
1075
+ ` discovered targets ${d.discoveredTargets.length}, table-entry addresses ${d.tableEntryAddresses.length}, ` +
1076
+ `truncated: ${d.truncated ? "YES" : "no"}`,
1077
+ );
1078
+ console.log("");
1079
+
1080
+ const div = report.divergence;
1081
+ console.log(" divergence sub-report (census versus the store's own block table -- a COMPARISON, not a measure of completeness)");
1082
+ if (!div.blocksSupplied) {
1083
+ console.log(` UNAVAILABLE -- ${div.reason ?? "reason not recorded"}`);
1084
+ } else {
1085
+ console.log(` census reached as instructions but the store does not call Code : ${div.censusCodeStoreNotCode} byte(s)`);
1086
+ console.log(` the store calls Code but the census never reached : ${div.storeCodeCensusUnreached} byte(s)`);
1087
+ console.log(` covered by no block entry at all : ${div.uncoveredByStore} byte(s)`);
1088
+ console.log(` compared over ${div.comparedBytes} byte(s)`);
1089
+ }
1090
+ console.log(` ${div.note}`);
1091
+ console.log("");
1092
+
1093
+ const verdict = coverageFindings(report);
1094
+ console.log(" per-measure findings (one named measure each -- this list is not a rating and carries no number)");
1095
+ if (verdict.clean) {
1096
+ console.log(" none -- every measure is above its own threshold");
1097
+ } else {
1098
+ for (const f of verdict.findings) console.log(` [${f.measure}] ${f.reason}`);
1099
+ }
1100
+ console.log("");
1101
+ console.log(
1102
+ " Read the numbers against each other, never as one figure: a high user fraction beside a large unreached count " +
1103
+ "means the wrong things were named, and a large divergence means the store and the bytes disagree about what is code.",
1104
+ );
1105
+ }
1106
+
1107
+ /**
1108
+ * `coverage <image> --store FILE [--out FILE] [--force] [--sample N]` --
1109
+ * COV-01's delivery path: the instrument from `anno-coverage.ts`, run against
1110
+ * a real program and a real annotation store.
1111
+ *
1112
+ * TWO PATHS, NEITHER DERIVED FROM THE OTHER. `<image>` carries the payload
1113
+ * bytes and the load origin; `--store` names the annotation store holding the
1114
+ * labels, comments and typed ranges. The store holds annotations and never
1115
+ * bytes, so a derived measure has to be told which bytes it is measuring, and
1116
+ * guessing one path from the other is exactly the auto-pick D-02 forbids.
1117
+ *
1118
+ * Two properties this function must keep:
1119
+ * - NO SECOND PATH VALIDATOR (T-19-22 / T-29-28), over ALL THREE of this
1120
+ * verb's caller-supplied paths -- the positional, `--store` and `--out`.
1121
+ * The count is stated because it was WRONG: this doc said "both" and meant
1122
+ * it, while `--out` reached `refuseOverwrite()` and `writeFileSync()` as
1123
+ * the raw caller string. `29-REVIEW.md` CR-02 reproduced the escape --
1124
+ * `coverage <project> --store <store> --out /tmp/...` wrote the report
1125
+ * outside the workspace root. All three now go through
1126
+ * `storePathWithinWorkspace()` against `repoRoot()` -- the one seam, the
1127
+ * same one `anno-tools.ts` puts its own store and image arguments through.
1128
+ * `openStore()` is then handed the same workspace root, so its own
1129
+ * confinement agrees by construction rather than by a second rule. The
1130
+ * enumeration is now mechanical rather than prose:
1131
+ * `anno-cli-path-consumers.test.ts` inventories this verb's path
1132
+ * arguments -- flags from `VERB_OPTIONS`, positionals from the `--help`
1133
+ * synopsis line -- and fails when that inventory and the surface disagree
1134
+ * either way, or when this file's confinement call sites number fewer
1135
+ * than the inventory's entries. It does not associate a given argument
1136
+ * with a given call site (WR-02), so it cannot tell six arguments
1137
+ * confined once each from five confined with one confined twice.
1138
+ * - THE STORE IS OPENED ONCE, read-only, for the whole verb, and closed in a
1139
+ * `finally`. `mustExist` is what makes "the annotations are gone" and
1140
+ * "there are no annotations" refuse differently instead of reading the
1141
+ * same: without it this verb would CREATE an empty store at the named path
1142
+ * and report a measurement of nothing.
1143
+ *
1144
+ * The exit code is 0 for any report it managed to build, however poor the
1145
+ * numbers are -- a bad score is a result, not a failure. Non-zero is reserved
1146
+ * for a caller error (bad path, bad option, refused overwrite) and for a store
1147
+ * it could not read or a payload it could not decode.
1148
+ */
1149
+ async function cmdCoverage(rest: string[]): Promise<number> {
1150
+ const { positional, store, storeMissingValue, out, outMissingValue, force, sample, sampleRaw, sampleMissingValue, unknownOption } =
1151
+ parseCoverageArgs(rest);
1152
+
1153
+ if (unknownOption) {
1154
+ console.error(`coverage: unknown option "${unknownOption}"\n`);
1155
+ console.log(USAGE);
1156
+ return 1;
1157
+ }
1158
+ if (storeMissingValue) {
1159
+ console.error("coverage: --store requires a value\n");
1160
+ console.log(USAGE);
1161
+ return 1;
1162
+ }
1163
+ if (outMissingValue) {
1164
+ console.error("coverage: --out requires a value\n");
1165
+ console.log(USAGE);
1166
+ return 1;
1167
+ }
1168
+ if (sampleMissingValue) {
1169
+ console.error("coverage: --sample requires a value\n");
1170
+ console.log(USAGE);
1171
+ return 1;
1172
+ }
1173
+
1174
+ const project = positional[0];
1175
+ if (!project) {
1176
+ console.error("coverage: usage: coverage <image> --store FILE [--out FILE] [--force] [--sample N]");
1177
+ return 1;
1178
+ }
1179
+ if (!store) {
1180
+ console.error(
1181
+ "coverage: --store FILE is required -- the annotation store holds the labels, comments and typed ranges, " +
1182
+ "and this verb will not derive its path from <project>.\n",
1183
+ );
1184
+ console.log(USAGE);
1185
+ return 1;
1186
+ }
1187
+ if (sample !== undefined && (!Number.isInteger(sample) || sample <= 0)) {
1188
+ console.error(`coverage: --sample must be a positive integer, got "${sampleRaw}"`);
1189
+ return 1;
1190
+ }
1191
+
1192
+ // T-19-22 / T-29-28 / CR-02: the ONE confinement seam, for ALL THREE
1193
+ // caller-supplied paths. Never a second hand-rolled one, and never a
1194
+ // different rule for the store than for the program it annotates -- or, as
1195
+ // CR-02 found, no rule at all for the report this verb writes.
1196
+ const workspaceRoot = repoRoot();
1197
+ let projectPath: string;
1198
+ let storePath: string;
1199
+ let outPath: string | undefined;
1200
+ try {
1201
+ projectPath = storePathWithinWorkspace(project, workspaceRoot);
1202
+ storePath = storePathWithinWorkspace(store, workspaceRoot);
1203
+ outPath = out === undefined ? undefined : storePathWithinWorkspace(out, workspaceRoot);
1204
+ } catch (err) {
1205
+ console.error(`coverage: ${errMsg(err)}`);
1206
+ return 1;
1207
+ }
1208
+ if (!existsSync(projectPath)) {
1209
+ console.error(`coverage: project file not found: ${projectPath}`);
1210
+ return 1;
1211
+ }
1212
+ if (!existsSync(storePath)) {
1213
+ console.error(
1214
+ `coverage: annotation store not found: ${storePath} -- refusing to CREATE one, because "the annotations are ` +
1215
+ 'gone" and "there are no annotations" must not read the same.',
1216
+ );
1217
+ return 1;
1218
+ }
1219
+
1220
+ // Against the CONFINED path, so the file this check protects is the file
1221
+ // that would actually be written.
1222
+ if (outPath !== undefined && !refuseOverwrite(outPath, force, "coverage")) {
1223
+ return 1;
1224
+ }
1225
+
1226
+ let symbols: AnnoSymbol[];
1227
+ let comments: AnnoComment[];
1228
+ let blocks: BlockEntry[];
1229
+ let crossReferences: AnnoCrossReference[];
1230
+ let handle: AnnoStoreHandle;
1231
+ try {
1232
+ handle = openStore(storePath, { workspaceRoot, mustExist: true });
1233
+ } catch (err) {
1234
+ console.error(`coverage: ${errMsg(err)}`);
1235
+ return 1;
1236
+ }
1237
+ try {
1238
+ symbols = symbolsFromStore(listLabels(handle));
1239
+ comments = commentsFromStore(listComments(handle));
1240
+ blocks = blocksFromStore(listRanges(handle));
1241
+ // The bytes come from the SAME file the census decodes, so the derived
1242
+ // half and the censused half can never describe different programs. A
1243
+ // payload that will not decode yields no cross-references at all rather
1244
+ // than a partial answer -- the census reports that condition itself and
1245
+ // this verb exits non-zero on it below.
1246
+ const image = projectImage(projectPath);
1247
+ crossReferences = image === null ? [] : crossReferencesFromStore(handle, image.bytes, image.origin, symbols);
1248
+ } catch (err) {
1249
+ console.error(`coverage: ${errMsg(err)}`);
1250
+ return 1;
1251
+ } finally {
1252
+ closeStore(handle);
1253
+ }
1254
+
1255
+ let report: CoverageReport;
1256
+ try {
1257
+ report = buildCoverageReport({
1258
+ projectPath,
1259
+ symbols,
1260
+ comments,
1261
+ blocks,
1262
+ crossReferences,
1263
+ ...(sample !== undefined ? { sampleSize: sample } : {}),
1264
+ });
1265
+ } catch (err) {
1266
+ console.error(`coverage: ${errMsg(err)}`);
1267
+ return 1;
1268
+ }
1269
+
1270
+ printCoverageReport(report);
1271
+
1272
+ if (outPath !== undefined) {
1273
+ try {
1274
+ writeFileSync(outPath, JSON.stringify(report, null, 2) + "\n");
1275
+ } catch (err) {
1276
+ console.error(`coverage: could not write ${outPath}: ${errMsg(err)}`);
1277
+ return 1;
1278
+ }
1279
+ // The CONFINED path, so the line names the file that was actually written
1280
+ // rather than whatever the caller typed.
1281
+ console.log(`coverage: wrote ${outPath} (schema version ${report.schemaVersion})`);
1282
+ }
1283
+
1284
+ if (!report.project.payloadDecoded) {
1285
+ // Not a low measurement -- an unreadable payload means every byte-side
1286
+ // measure above was computed over nothing. Reported as the caller-facing
1287
+ // failure it is, AFTER the report, so the reason is on screen (COV-02).
1288
+ console.error(`coverage: the project's payload was UNAVAILABLE -- ${report.project.reason ?? "reason not recorded"}`);
1289
+ return 1;
1290
+ }
1291
+ return 0;
1292
+ }
1293
+
1294
+ interface ExportAsmParsedArgs {
1295
+ positional: string[];
1296
+ store?: string;
1297
+ storeMissingValue?: boolean;
1298
+ out?: string;
1299
+ outMissingValue?: boolean;
1300
+ /** BUILD-05 (phase 46 plan 01): the ledger `c64-provenance-diff` generates
1301
+ * (`recovery/PROVENANCE.md`). OPTIONAL -- see `ExportAsmOptions.ledgerPath`
1302
+ * in `anno-export-asm.ts` for why. */
1303
+ ledger?: string;
1304
+ ledgerMissingValue?: boolean;
1305
+ force?: boolean;
1306
+ unknownOption?: string;
1307
+ }
1308
+
1309
+ /** Fixed, closed option set for export-asm -- exactly `--store`, `--out`,
1310
+ * `--ledger` and `--force`. The SAME WR-08 posture, and deliberately the same
1311
+ * SHAPE, as `parseRenderMemmapArgs()` and `parseCoverageArgs()` above rather
1312
+ * than a third convention: an unimplemented flag is refused as
1313
+ * `unknownOption`, and `--store`/`--out`/`--ledger` with a missing or
1314
+ * flag-shaped value are refused through their own `*MissingValue` fields
1315
+ * rather than silently swallowing the next token. */
1316
+ function parseExportAsmArgs(rest: string[]): ExportAsmParsedArgs {
1317
+ const positional: string[] = [];
1318
+ let store: string | undefined;
1319
+ let storeMissingValue = false;
1320
+ let out: string | undefined;
1321
+ let outMissingValue = false;
1322
+ let ledger: string | undefined;
1323
+ let ledgerMissingValue = false;
1324
+ let force = false;
1325
+ let unknownOption: string | undefined;
1326
+ for (let i = 0; i < rest.length; i++) {
1327
+ const a = rest[i]!;
1328
+ if (a === "--store") {
1329
+ const value = rest[i + 1];
1330
+ if (isMissingOptionValue(value)) {
1331
+ storeMissingValue = true;
1332
+ } else {
1333
+ store = value;
1334
+ i++;
1335
+ }
1336
+ } else if (a === "--out") {
1337
+ const value = rest[i + 1];
1338
+ if (isMissingOptionValue(value)) {
1339
+ outMissingValue = true;
1340
+ } else {
1341
+ out = value;
1342
+ i++;
1343
+ }
1344
+ } else if (a === "--ledger") {
1345
+ const value = rest[i + 1];
1346
+ if (isMissingOptionValue(value)) {
1347
+ ledgerMissingValue = true;
1348
+ } else {
1349
+ ledger = value;
1350
+ i++;
1351
+ }
1352
+ } else if (a === "--force") {
1353
+ force = true;
1354
+ } else if (a.startsWith("--")) {
1355
+ unknownOption ??= a;
1356
+ } else {
1357
+ positional.push(a);
1358
+ }
1359
+ }
1360
+ return { positional, store, storeMissingValue, out, outMissingValue, ledger, ledgerMissingValue, force, unknownOption };
1361
+ }
1362
+
1363
+ /**
1364
+ * The destination `export-asm` writes to when the caller names none: the
1365
+ * IMAGE's basename STEM plus a fixed, extension-free suffix, in the STORE's
1366
+ * own directory.
1367
+ *
1368
+ * The store's directory rather than the image's, deliberately and for the
1369
+ * reason `render-memmap`'s `memory-map.md` default already gives: the output
1370
+ * is a GENERATED VIEW of the annotations, so it belongs beside the artefact it
1371
+ * was generated from. The image is an input this verb only reads.
1372
+ *
1373
+ * NO EXTENSION, on purpose (phase 47 plan 47-05: `--out` was promoted from a
1374
+ * FILE to a DIRECTORY, D47-A). This names a directory the tree is written
1375
+ * INTO, never a file -- a name ending in `.a` would read as a file to every
1376
+ * human and every tool that inspects it, and the tree this verb writes is
1377
+ * not one. The stem is derived the same way it always was (whatever
1378
+ * extension the image happens to carry is stripped, so `game.prg` and
1379
+ * `game.raw` derive the same default), the suffix is fixed text this
1380
+ * function owns rather than anything read off the image, and a name with no
1381
+ * extension at all keeps its whole basename.
1382
+ */
1383
+ function defaultExportAsmOut(imagePath: string, storeDir: string): string {
1384
+ const base = basename(imagePath);
1385
+ const ext = extname(base);
1386
+ const stem = ext === "" ? base : base.slice(0, -ext.length);
1387
+ return join(storeDir, `${stem}-src`);
1388
+ }
1389
+
1390
+ /**
1391
+ * Whether `containerPath` (a directory `--out` is about to become, or
1392
+ * already is) either equals `candidate` exactly, or genuinely CONTAINS it.
1393
+ * Compared by PATH SEGMENT via a trailing separator, never by string prefix
1394
+ * (T-47-14) -- `candidate.startsWith(containerPath)` alone would also match a
1395
+ * SIBLING whose name merely starts with the same characters (`game-src2`
1396
+ * beside `game-src`), which is exactly the false positive a segment boundary
1397
+ * rules out.
1398
+ *
1399
+ * Both arguments MUST already be confined, realpath-resolved strings (this
1400
+ * verb's inputs and `--out` all go through `storePathWithinWorkspace()`
1401
+ * before either ever reaches here); this function performs no confinement of
1402
+ * its own and compares the two strings it is given.
1403
+ */
1404
+ function pathIsOrContains(containerPath: string, candidate: string): boolean {
1405
+ if (candidate === containerPath) return true;
1406
+ const withTrailingSep = containerPath.endsWith(sep) ? containerPath : containerPath + sep;
1407
+ return candidate.startsWith(withTrailingSep);
1408
+ }
1409
+
1410
+ /**
1411
+ * `export-asm <image> --store FILE [--out DIR] [--ledger FILE] [--force]` --
1412
+ * a TREE of ACME source files for a program, emitted from its annotation
1413
+ * store by `anno-export-asm.ts`'s `exportAsmTree()` (phase 47 plan 47-05,
1414
+ * D47-A: `--out` promoted from a FILE to a DIRECTORY, decided at this plan's
1415
+ * own checkpoint rather than left to fall out of implementation).
1416
+ *
1417
+ * EVERY ONE OF THIS VERB'S PATHS IS CONFINED, and the ORDER each step happens
1418
+ * in is the load-bearing part rather than the mere presence of the calls. It
1419
+ * follows `cmdRenderMemmap()`'s chain deliberately, because that chain is the
1420
+ * corrected shape of three reproduced escapes (`29-VERIFICATION.md` gap 3 /
1421
+ * `29-REVIEW.md` CR-02 and CR-03) on exactly the argument shapes this verb
1422
+ * has:
1423
+ *
1424
+ * - `<image>` and `--store` go through `storePathWithinWorkspace()` BEFORE
1425
+ * any `existsSync` probe. A stat is itself an oracle -- it answers "does
1426
+ * this file exist" for any path this process can reach -- so probing first
1427
+ * and confining second would leak that answer for a path the seam is about
1428
+ * to refuse.
1429
+ * - `--ledger` (BUILD-05, phase 46 plan 01) joins that SAME confinement
1430
+ * block, on the SAME terms, WHEN SUPPLIED -- it is a third input this run
1431
+ * reads, not a second-class one confined later or not at all.
1432
+ * - `--out`'s DEFAULT is applied FIRST and the result confined AFTER, so a
1433
+ * path this verb computed is confined by the same rule as one a caller
1434
+ * supplied, rather than trusted because this verb computed it (CR-02).
1435
+ * This is unchanged by the file-to-directory promotion: the confined
1436
+ * result now NAMES A DIRECTORY rather than a file, but it is confined by
1437
+ * the exact same call.
1438
+ * - From each seam call onwards the RAW CALLER STRING IS DEAD.
1439
+ * `storePathWithinWorkspace()` returns the REALPATH, and it is the
1440
+ * realpath that reaches `readFileSync`, `openStore()`, `pathIsOrContains()`
1441
+ * and `exportAsmTree()` -- so every printed line names the file or
1442
+ * directory that is actually on disk.
1443
+ * - The output directory may not BE, and may not CONTAIN, any of the three
1444
+ * inputs (T-47-14, generalised from the single-file version's plain
1445
+ * equality check, 30-REVIEW WR-05). `pathIsOrContains()` runs against
1446
+ * the CONFINED destination and each CONFINED input, so what it protects
1447
+ * is the input that would actually be read and the directory that would
1448
+ * actually be written into -- and `--force` does not lift this refusal,
1449
+ * for the same reason the single-file version never let it: nobody
1450
+ * types `--force` meaning "destroy the annotations I spent a month
1451
+ * writing".
1452
+ * - The output-directory's own overwrite question -- does it already hold
1453
+ * something, and may `--force` replace it -- is `exportAsmTree()`'s own
1454
+ * contract (phase 47 plan 47-02), not a second check grown here. This
1455
+ * verb adds no overwrite rule of its own for the directory as a whole.
1456
+ *
1457
+ * WHAT THIS VERB DOES NOT DO, stated here as well as in `USAGE` because a
1458
+ * reader of the code must not have to infer it: it does not assemble. It
1459
+ * spawns nothing, reads no assembler's exit status and compares no bytes. The
1460
+ * byte-diff oracle that settles whether this source reassembles to the image
1461
+ * it came from is test-only and is not importable from here -- a shipped
1462
+ * module importing it would drag a test-only module into `package.json`'s
1463
+ * `files[]` closure. Nothing this function prints may therefore read as a
1464
+ * verification result, and the summary says so in as many words.
1465
+ */
1466
+ async function cmdExportAsm(rest: string[]): Promise<number> {
1467
+ const { positional, store, storeMissingValue, out, outMissingValue, ledger, ledgerMissingValue, force, unknownOption } =
1468
+ parseExportAsmArgs(rest);
1469
+
1470
+ if (unknownOption) {
1471
+ console.error(`export-asm: unknown option "${unknownOption}"\n`);
1472
+ console.log(USAGE);
1473
+ return 1;
1474
+ }
1475
+ if (storeMissingValue) {
1476
+ console.error("export-asm: --store requires a value\n");
1477
+ console.log(USAGE);
1478
+ return 1;
1479
+ }
1480
+ if (outMissingValue) {
1481
+ console.error("export-asm: --out requires a value\n");
1482
+ console.log(USAGE);
1483
+ return 1;
1484
+ }
1485
+ if (ledgerMissingValue) {
1486
+ console.error("export-asm: --ledger requires a value\n");
1487
+ console.log(USAGE);
1488
+ return 1;
1489
+ }
1490
+
1491
+ if (positional.length !== 1) {
1492
+ console.error("export-asm: usage: export-asm <image> --store FILE [--out DIR] [--ledger FILE] [--force]");
1493
+ return 1;
1494
+ }
1495
+ const image = positional[0]!;
1496
+ if (!store) {
1497
+ console.error(
1498
+ "export-asm: --store FILE is required -- the annotation store holds the ranges, labels, comments and enums, " +
1499
+ "and this verb will not derive its path from <image>.\n",
1500
+ );
1501
+ console.log(USAGE);
1502
+ return 1;
1503
+ }
1504
+
1505
+ // T-30-15 / CR-03: the ONE confinement seam, on both input paths, BEFORE any
1506
+ // filesystem probe. `openStore()` downstream is handed this same workspace
1507
+ // root, so its own confinement agrees by construction rather than by a
1508
+ // second rule. `--ledger` joins this SAME block, WHEN SUPPLIED -- confined
1509
+ // before any probe on the same terms as `<image>` and `--store`, never
1510
+ // confined later or by a second rule.
1511
+ const workspaceRoot = repoRoot();
1512
+ let imagePath: string;
1513
+ let storePath: string;
1514
+ let ledgerPath: string | undefined;
1515
+ try {
1516
+ imagePath = storePathWithinWorkspace(image, workspaceRoot);
1517
+ storePath = storePathWithinWorkspace(store, workspaceRoot);
1518
+ if (ledger !== undefined) {
1519
+ ledgerPath = storePathWithinWorkspace(ledger, workspaceRoot);
1520
+ }
1521
+ } catch (err) {
1522
+ console.error(`export-asm: ${errMsg(err)}`);
1523
+ return 1;
1524
+ }
1525
+ if (!existsSync(storePath)) {
1526
+ console.error(
1527
+ `export-asm: annotation store not found: ${storePath} -- refusing to CREATE one, because "the annotations are ` +
1528
+ 'gone" and "there are no annotations" must not read the same.',
1529
+ );
1530
+ return 1;
1531
+ }
1532
+ if (!existsSync(imagePath)) {
1533
+ console.error(`export-asm: image not found: ${imagePath}`);
1534
+ return 1;
1535
+ }
1536
+ if (ledgerPath !== undefined && !existsSync(ledgerPath)) {
1537
+ console.error(
1538
+ `export-asm: ledger not found: ${ledgerPath} -- regenerate it with c64-provenance-diff's "ledger" verb, or omit ` +
1539
+ "--ledger to export without provenance annotation.",
1540
+ );
1541
+ return 1;
1542
+ }
1543
+
1544
+ // T-30-02 / CR-02. The default is applied FIRST and the RESULT confined,
1545
+ // so the derived path and a caller-supplied one are confined by the same
1546
+ // rule.
1547
+ let outPath: string;
1548
+ try {
1549
+ outPath = storePathWithinWorkspace(out ?? defaultExportAsmOut(imagePath, dirname(storePath)), workspaceRoot);
1550
+ } catch (err) {
1551
+ console.error(`export-asm: ${errMsg(err)}`);
1552
+ return 1;
1553
+ }
1554
+
1555
+ // THE OUTPUT DIRECTORY MAY NOT BE, AND MAY NOT CONTAIN, AN INPUT, AND
1556
+ // `--force` DOES NOT OVERRIDE THIS (T-47-14, generalising 30-REVIEW WR-05's
1557
+ // plain-equality refusal to containment now that `--out` names a directory
1558
+ // a whole tree is written into). The single-file version of this refusal
1559
+ // existed because `outPath` was confined and overwrite-checked but never
1560
+ // COMPARED to the inputs, so `anno export-asm game.raw --store g.annostore
1561
+ // --out g.annostore --force` overwrote the annotation store with ACME
1562
+ // text. Promoting `--out` to a directory widens the blast radius of the
1563
+ // same mistake from one file to everything the directory would hold, so the
1564
+ // check widens from equality to containment with it: the directory may not
1565
+ // itself BE an input's own path, and no input may live INSIDE it.
1566
+ // `--ledger` (BUILD-05, phase 46 plan 01) joins this SAME check: it is a
1567
+ // THIRD input this run reads, and `--force` must not lift the refusal for
1568
+ // it any more than it lifts it for the store or the image.
1569
+ //
1570
+ // SEPARATE FROM `exportAsmTree()`'s OWN output-directory contract AND
1571
+ // UNCONDITIONAL, deliberately. `--force` means "yes, replace the tree I
1572
+ // exported here before"; it cannot mean "yes, destroy the annotations I
1573
+ // spent a month writing", because nobody types it for that reason. This is
1574
+ // the one refusal in this file `--force` does not lift.
1575
+ //
1576
+ // Every path compared here is a confined realpath by this point
1577
+ // (`pathIsOrContains()`), so the comparison is exact and segment-bounded
1578
+ // rather than a string-shape guess about `..`, symlinks or a sibling
1579
+ // directory name that merely starts the same.
1580
+ for (const { path: inputPath, which } of [
1581
+ { path: storePath, which: "annotation store (--store)" },
1582
+ { path: imagePath, which: "image (<image>)" },
1583
+ { path: ledgerPath, which: "ledger (--ledger)" },
1584
+ ]) {
1585
+ if (inputPath !== undefined && pathIsOrContains(outPath, inputPath)) {
1586
+ console.error(
1587
+ `export-asm: refusing to write the exported tree to ${outPath} -- that directory is, or contains, this run's own ${which}. ` +
1588
+ `The export would destroy the input it was generated from, and --force does not lift this refusal. ` +
1589
+ `Pass a different --out.`,
1590
+ );
1591
+ return 1;
1592
+ }
1593
+ }
1594
+
1595
+ // The output-directory contract itself -- create when missing, refuse a
1596
+ // non-empty directory without `--force`, and with `--force` replace only
1597
+ // the names this export produces -- lives entirely in `exportAsmTree()`
1598
+ // (phase 47 plan 47-02). This verb adds no second overwrite rule of its
1599
+ // own: it forwards the request and reports the library's own refusal
1600
+ // through this same single-line error path every other exporter refusal
1601
+ // already takes.
1602
+ let result: ExportAsmTreeResult;
1603
+ try {
1604
+ result = exportAsmTree({ storePath, imagePath, workspaceRoot, ledgerPath, outDir: outPath, force });
1605
+ } catch (err) {
1606
+ // Every refusal `exportAsmTree()` raises -- an uncovered range, an
1607
+ // inexpressible enum binding, a comment with no line to attach to, a
1608
+ // range crossing a scope boundary, or the output-directory contract's
1609
+ // own refusal -- arrives here already named. It is reported as this
1610
+ // verb's own single actionable line and never as a thrown stack trace,
1611
+ // and the verb exits non-zero rather than reporting success over a
1612
+ // dropped annotation or a scribbled-into directory.
1613
+ console.error(`export-asm: ${errMsg(err)}`);
1614
+ return 1;
1615
+ }
1616
+ // Every file this call wrote MINUS the two structural files that are
1617
+ // ALWAYS written (symbols.a, root.a) -- the scope files and the optional
1618
+ // unscoped.a, i.e. the files that actually carry this store's own content
1619
+ // rather than glue. `result.files.length` is never less than 2 (both are
1620
+ // unconditional), so this can never go negative.
1621
+ const dataFileCount = result.files.length - 2;
1622
+ console.log(
1623
+ `export-asm: wrote ${outPath} (${result.files.length} file(s), ${dataFileCount} data file(s), ${result.blocks.length} block(s), ` +
1624
+ `${result.symbolCount} symbol(s), ${result.autoNamedSymbolCount} auto-named, ${result.unexpressibleCount} unexpressible instruction(s), ` +
1625
+ `${result.midInstructionLabelCount} mid-instruction label(s), ${result.enumSubstitutionCount} enum substitution(s), ` +
1626
+ `${result.excludedRangeCount} exclusion(s) marked)`,
1627
+ );
1628
+ console.log("export-asm: this tree has NOT been assembled -- this command writes source text and runs no assembler.");
1629
+ return 0;
1630
+ }
1631
+
1632
+ interface EvidDisagreementsParsedArgs {
1633
+ positional: string[];
1634
+ store?: string;
1635
+ storeMissingValue?: boolean;
1636
+ json?: boolean;
1637
+ unknownOption?: string;
1638
+ }
1639
+
1640
+ /** Fixed, closed option set for evid-disagreements -- exactly `--store` and
1641
+ * `--json`. Same WR-08 posture as every other verb's own parser: an
1642
+ * unimplemented flag is refused as `unknownOption`, and `--store` with a
1643
+ * missing or flag-shaped value is refused through its own `*MissingValue`
1644
+ * field rather than silently swallowing the next token. `--json` is a plain
1645
+ * boolean, parsed the same shape `--force`/`--check` already use. */
1646
+ function parseEvidDisagreementsArgs(rest: string[]): EvidDisagreementsParsedArgs {
1647
+ const positional: string[] = [];
1648
+ let store: string | undefined;
1649
+ let storeMissingValue = false;
1650
+ let json = false;
1651
+ let unknownOption: string | undefined;
1652
+ for (let i = 0; i < rest.length; i++) {
1653
+ const a = rest[i]!;
1654
+ if (a === "--store") {
1655
+ const value = rest[i + 1];
1656
+ if (isMissingOptionValue(value)) {
1657
+ storeMissingValue = true;
1658
+ } else {
1659
+ store = value;
1660
+ i++;
1661
+ }
1662
+ } else if (a === "--json") {
1663
+ json = true;
1664
+ } else if (a.startsWith("--")) {
1665
+ unknownOption ??= a;
1666
+ } else {
1667
+ positional.push(a);
1668
+ }
1669
+ }
1670
+ return { positional, store, storeMissingValue, json, unknownOption };
1671
+ }
1672
+
1673
+ /**
1674
+ * Renders `r` as three separately-headed, textually-distinguishable
1675
+ * sections -- disagreements FIRST, agreement as a single count line,
1676
+ * never-observed as its own count line stating plainly that absence proves
1677
+ * nothing. THE ONE RULE THIS FUNCTION EXISTS TO HOLD, the SAME rule
1678
+ * `printCoverageReport()` holds two verbs over: print every measure's own
1679
+ * number under its own heading and NEVER compute or print a combined
1680
+ * figure, a percentage or a rate at the point of display. `denominator`
1681
+ * rides beside every count for exactly that reason.
1682
+ */
1683
+ function printEvidDisagreementsReport(storePath: string, r: EvidReconciliation): void {
1684
+ console.log(`evid-disagreements: ${storePath}`);
1685
+ console.log("");
1686
+ console.log(` DISAGREEMENTS (${r.disagreementCount} of ${r.denominator})`);
1687
+ if (r.disagreements.length === 0) {
1688
+ console.log(" none");
1689
+ } else {
1690
+ for (const d of r.disagreements) {
1691
+ console.log(` ${hexAddr(d.address)} byte-derived=${d.byteDerived} runtime=${d.runtime} banks=${d.sourceBanks.join(",")}`);
1692
+ }
1693
+ }
1694
+ console.log("");
1695
+ console.log(` AGREEMENT: ${r.agreementCount} of ${r.denominator}`);
1696
+ console.log("");
1697
+ console.log(
1698
+ ` NO OBSERVATION: ${r.blockCoveredNeverObservedCount} of ${r.denominator} -- an address never observed executing ` +
1699
+ "proves NOTHING about what it is; absence is not evidence for or against any classification.",
1700
+ );
1701
+ console.log("");
1702
+ console.log(` OBSERVED OUTSIDE ANY BLOCK: ${r.observedOutsideAnyBlockCount}`);
1703
+ console.log(` OBSERVED AT UNDEFINED BLOCK: ${r.observedAtUndefinedBlockCount}`);
1704
+ console.log("");
1705
+ console.log(
1706
+ " Read these five figures against each other, never combined into one: together they name what the block " +
1707
+ "table covers, never what the program actually is.",
1708
+ );
1709
+ }
1710
+
1711
+ /**
1712
+ * `evid-disagreements --store FILE [--json]` -- the CLI route for EVID-03's
1713
+ * disagreement query (`43-RESEARCH.md` Open Question 3): the criterion that
1714
+ * settles the question is that a planted test needs the disagreement,
1715
+ * agreement and silence states rendered as three DIFFERENT pieces of TEXT
1716
+ * it can tell apart, which an MCP tool's JSON answer can only be inspected
1717
+ * structurally rather than textually.
1718
+ *
1719
+ * Opens the store READ-ONLY (`mustExist: true` -- this verb creates
1720
+ * nothing), fetches both sides itself (`listRanges()`/`listExecObservations()`),
1721
+ * maps the ranges through `blocksFromStore()` -- the ONE `RangeRow` ->
1722
+ * `BlockEntry` seam, never re-implemented here -- and calls
1723
+ * `reconcileObservedExecution()`, the SAME pure join
1724
+ * `anno_evid_disagreements` calls. `--json` prints the raw answer; otherwise
1725
+ * `printEvidDisagreementsReport()` renders the three states.
1726
+ */
1727
+ async function cmdEvidDisagreements(rest: string[]): Promise<number> {
1728
+ const { store, storeMissingValue, json, unknownOption } = parseEvidDisagreementsArgs(rest);
1729
+
1730
+ if (unknownOption) {
1731
+ console.error(`evid-disagreements: unknown option "${unknownOption}"\n`);
1732
+ console.log(USAGE);
1733
+ return 1;
1734
+ }
1735
+ if (storeMissingValue) {
1736
+ console.error("evid-disagreements: --store requires a value\n");
1737
+ console.log(USAGE);
1738
+ return 1;
1739
+ }
1740
+ if (!store) {
1741
+ console.error("evid-disagreements: --store FILE is required -- this verb answers a question about ONE annotation store.\n");
1742
+ console.log(USAGE);
1743
+ return 1;
1744
+ }
1745
+
1746
+ // T-19-22/T-29-28-shaped confinement, the SAME seam every other verb's
1747
+ // caller-supplied path goes through.
1748
+ const workspaceRoot = repoRoot();
1749
+ let storePath: string;
1750
+ try {
1751
+ storePath = storePathWithinWorkspace(store, workspaceRoot);
1752
+ } catch (err) {
1753
+ console.error(`evid-disagreements: ${errMsg(err)}`);
1754
+ return 1;
1755
+ }
1756
+ if (!existsSync(storePath)) {
1757
+ console.error(
1758
+ `evid-disagreements: annotation store not found: ${storePath} -- refusing to CREATE one, because "the ` +
1759
+ 'annotations are gone" and "there are no annotations" must not read the same.',
1760
+ );
1761
+ return 1;
1762
+ }
1763
+
1764
+ let handle: AnnoStoreHandle;
1765
+ try {
1766
+ handle = openStore(storePath, { workspaceRoot, mustExist: true });
1767
+ } catch (err) {
1768
+ console.error(`evid-disagreements: ${errMsg(err)}`);
1769
+ return 1;
1770
+ }
1771
+ let reconciliation: EvidReconciliation;
1772
+ // Rule 2 (missing critical functionality), phase 45 plan 45-01: `runIdentity`
1773
+ // is NOT an `EvidReconciliation` field -- it rides alongside the spread
1774
+ // reconciliation in the JSON envelope, exactly like `store` already does.
1775
+ // Added so `decomp-completeness` (D-09 mechanism 1/2) has a run identity to
1776
+ // validate this document against the SAME store's own `anno_evid_runs`
1777
+ // table, rather than accepting a fabricated or foreign empty document as
1778
+ // this run's own answer. `null` when the store holds zero or more than one
1779
+ // distinct run identity -- an ambiguous "which run" is refused by the
1780
+ // consuming verb, never guessed here.
1781
+ let runIdentity: { imageSha256: string; argvDigest: string; seed: string } | null = null;
1782
+ try {
1783
+ const blocks = blocksFromStore(listRanges(handle));
1784
+ const observations = listExecObservations(handle);
1785
+ reconciliation = reconcileObservedExecution({ blocks, observations });
1786
+ const { runs } = listObservedRuns(handle);
1787
+ if (runs.length === 1) {
1788
+ const run = runs[0]!;
1789
+ runIdentity = { imageSha256: run.imageSha256, argvDigest: run.argvDigest, seed: run.seed };
1790
+ }
1791
+ } catch (err) {
1792
+ console.error(`evid-disagreements: ${errMsg(err)}`);
1793
+ return 1;
1794
+ } finally {
1795
+ closeStore(handle);
1796
+ }
1797
+
1798
+ if (json) {
1799
+ console.log(JSON.stringify({ store: storePath, runIdentity, ...reconciliation }, null, 2));
1800
+ return 0;
1801
+ }
1802
+ printEvidDisagreementsReport(storePath, reconciliation);
1803
+ return 0;
1804
+ }
1805
+
1806
+ // ---------------------------------------------------------------------------
1807
+ // decomp-completeness (D-07, phase 45 plan 45-01) -- the fifth verb.
1808
+ // ---------------------------------------------------------------------------
1809
+
1810
+ /**
1811
+ * The frozen survivor prefix set (phase 45 plan 45-01 task 2), measured
1812
+ * against a real dxa+Ghidra-derived store rather than against roadmap prose
1813
+ * alone -- see docs/phase45-wave0-measurements.md for the MEASURED label
1814
+ * population (zero labels; derivation writes typed ranges and xrefs, never
1815
+ * names) and the reasoning this set was frozen against. `AUTO_NAME_PREFIX_RE`
1816
+ * (imported from anno-coverage.ts, NEVER restated as a second literal here --
1817
+ * a census over this file for any of its own eleven prefix strings returns
1818
+ * zero, proving that) covers the eleven upstream-analyser-shaped
1819
+ * prefixes; this file adds three defensive, ANCHORED, case-sensitive cases no
1820
+ * import route writes today, kept here in case a future one ever carries a
1821
+ * raw dxa or Ghidra name through unrenamed: `l_XXXX` (an underscored form no
1822
+ * current tool emits), `FUN_XXXX`/`LAB_XXXX` (Ghidra's own default naming),
1823
+ * and `lXXX`/`lXXXX` (dxa's own real, no-underscore listing convention,
1824
+ * `dxa-listing.test.ts:52`). Anchored at both ends, unlike
1825
+ * `AUTO_NAME_PREFIX_RE`'s prefix-only match, because these three shapes are
1826
+ * short enough that an unanchored match would false-fire on a legitimate
1827
+ * longer authored name that merely starts the same way.
1828
+ */
1829
+ const SURVIVOR_EXTRA_RE = /^(?:l_[0-9a-f]{4}|(?:FUN|LAB)_[0-9a-f]{4}|l[0-9a-f]{3,4})$/;
1830
+
1831
+ /** True iff `name` is a survivor under the frozen set above. ASCII
1832
+ * case-sensitive throughout -- `l_0810` IS a survivor, `L_0810` is NOT
1833
+ * (anno-coverage.test.ts's own `L_` exclusion precedent, restated for this
1834
+ * phase's own prefix set rather than reused blindly, since `L_` was never
1835
+ * one of `AUTO_NAME_PREFIX_RE`'s eleven prefixes to begin with). */
1836
+ function isSurvivorLabelName(name: string): boolean {
1837
+ return AUTO_NAME_PREFIX_RE.test(name) || SURVIVOR_EXTRA_RE.test(name);
1838
+ }
1839
+
1840
+ /** One row of the manifest `anno decomp-completeness --manifest FILE` reads
1841
+ * (D-13). `path` is relative to `src/mcp/vice/fixtures`; `reason` is
1842
+ * required (non-empty) when `execution` is `"not-executed"` and `null`
1843
+ * otherwise. */
1844
+ interface DecompExecutionManifestEntry {
1845
+ path: string;
1846
+ execution: "executed" | "not-executed";
1847
+ reason: string | null;
1848
+ ghidraRoute: "flat64k" | "prg";
1849
+ }
1850
+
1851
+ interface DecompExecutionManifest {
1852
+ fixtures: DecompExecutionManifestEntry[];
1853
+ }
1854
+
1855
+ /** Strips a trailing recognised extension and any leading directory
1856
+ * segments, so `dxa/tracer.prg` and `tracer.annostore` both reduce to the
1857
+ * bare stem `tracer` -- the ONE fixture-identity comparison this verb makes.
1858
+ * Never a full-path comparison: the manifest's paths are fixtures-relative,
1859
+ * the store's own path is caller-supplied and workspace-relative, and the
1860
+ * two coordinate systems only ever agree on the bare stem. */
1861
+ function fixtureStem(path: string): string {
1862
+ const base = basename(path);
1863
+ return base.replace(/\.[^./]+$/, "");
1864
+ }
1865
+
1866
+ /** The subset of `EvidReconciliation` (verbatim field names, never renamed --
1867
+ * D-10) that a `--disagreements` document must carry for
1868
+ * `decomp-completeness` to accept it as real, plus the `runIdentity` this
1869
+ * verb (via `cmdEvidDisagreements`'s own `--json` branch) adds alongside it.
1870
+ * `disagreementInput` in the `--json` answer below is exactly this shape. */
1871
+ interface DecompDisagreementInput extends EvidReconciliation {
1872
+ // Phase 45, plan 45-06 (Rule 1 fix, disclosed): `null` is a THIRD, LEGITIMATE
1873
+ // value here -- `anno evid-disagreements --json`'s own `runIdentity` field
1874
+ // reads `null` when the store holds zero observed runs (listObservedRuns()),
1875
+ // which is exactly the real, non-fabricated answer a D-13 non-executed
1876
+ // fixture's store produces. Refusing null unconditionally made a real
1877
+ // `anno evid-disagreements --json` answer for a non-executed fixture
1878
+ // unusable by this verb, contradicting this phase's own must_haves ("a
1879
+ // non-executed fixture's disagreement answer is a real answer over zero
1880
+ // observations ... never an omitted argument"). The anti-vacuity property
1881
+ // is preserved below: null is accepted ONLY when the store's own evid-runs
1882
+ // table is ALSO empty (cmdDecompCompleteness's own match-check) -- a store
1883
+ // that DOES carry real runs must still supply a real, matching identity.
1884
+ runIdentity: { imageSha256: string; argvDigest: string; seed: string } | null;
1885
+ }
1886
+
1887
+ const EVID_RECONCILIATION_FIELDS = [
1888
+ "disagreements",
1889
+ "disagreementCount",
1890
+ "agreementCount",
1891
+ "blockCoveredNeverObservedCount",
1892
+ "observedOutsideAnyBlockCount",
1893
+ "observedAtUndefinedBlockCount",
1894
+ "denominator",
1895
+ "positiveClass",
1896
+ "tier",
1897
+ ] as const;
1898
+
1899
+ /**
1900
+ * Validates a parsed `--disagreements` document has every `EvidReconciliation`
1901
+ * field AND a complete `runIdentity` -- refusing BY NAME, never silently
1902
+ * treating a missing field as an empty answer (D-09 mechanism 2: a required
1903
+ * output-schema field only the real `--disagreements` input can populate).
1904
+ * Returns the validated document (typed as `DecompDisagreementInput`) or a
1905
+ * refusal message string. Never throws.
1906
+ */
1907
+ function validateDisagreementDocumentShape(doc: unknown): DecompDisagreementInput | string {
1908
+ if (typeof doc !== "object" || doc === null) {
1909
+ return "decomp-completeness: the --disagreements document is not a JSON object -- refusing to render (D-09)";
1910
+ }
1911
+ const bag = doc as Record<string, unknown>;
1912
+ for (const field of EVID_RECONCILIATION_FIELDS) {
1913
+ if (!(field in bag)) {
1914
+ return (
1915
+ `decomp-completeness: the --disagreements document is missing the "${field}" field -- ` +
1916
+ "this is not a real anno evid-disagreements --json answer, refusing to render (D-09)"
1917
+ );
1918
+ }
1919
+ }
1920
+ const runIdentity = bag.runIdentity;
1921
+ // Rule 1 fix (disclosed, plan 45-06): `null` is accepted HERE as a
1922
+ // well-formed shape -- it is `anno evid-disagreements --json`'s own real
1923
+ // answer for a store with zero observed runs (a D-13 non-executed
1924
+ // fixture). It is NOT yet accepted as a legitimate ANSWER: cmdDecompCompleteness's
1925
+ // own match-check below still refuses a null identity unless the store's
1926
+ // evid-runs table is ALSO genuinely empty, so a store that DOES carry real
1927
+ // runs can never slip past validation with a null identity.
1928
+ if (runIdentity !== null) {
1929
+ if (
1930
+ typeof runIdentity !== "object" ||
1931
+ typeof (runIdentity as Record<string, unknown>).imageSha256 !== "string" ||
1932
+ typeof (runIdentity as Record<string, unknown>).argvDigest !== "string" ||
1933
+ typeof (runIdentity as Record<string, unknown>).seed !== "string"
1934
+ ) {
1935
+ return (
1936
+ "decomp-completeness: the --disagreements document carries no complete runIdentity " +
1937
+ "(image_sha256/argv_digest/seed) -- an empty or ambiguous-run document is refused rather than " +
1938
+ "rendered as \"no disagreements\" (D-09 mechanism 2, RESEARCH.md Pitfall 9)"
1939
+ );
1940
+ }
1941
+ }
1942
+ return doc as DecompDisagreementInput;
1943
+ }
1944
+
1945
+ // ---------------------------------------------------------------------------
1946
+ // The full measure set (phase 45 plan 45-04, task 1) -- rangeProvenance
1947
+ // (D-10), entryPoints, referencedAddresses (criterion 4) and
1948
+ // disagreementResolution (D-09's own gate-vs-bulletin distinction).
1949
+ // ---------------------------------------------------------------------------
1950
+
1951
+ /** One typed range's provenance classification (D-10 mechanism 2). Always
1952
+ * one of the three named values -- never a fourth, never a boolean. */
1953
+ type RangeTypedBy = "observed-executing" | "byte-derived" | "authored";
1954
+
1955
+ interface RangeProvenanceRow {
1956
+ start: number;
1957
+ endInclusive: number;
1958
+ dataType: DataType;
1959
+ /** `dataType` unless it is one of the four `SPLIT_DATA_TYPES` members, in
1960
+ * which case it renders as `"table"` (D-11) -- read from `anno-types.ts`'s
1961
+ * own `isSplitDataType()`, NEVER a restated literal, so the four split
1962
+ * spellings never appear in this file's own source as strings. */
1963
+ renderedType: string;
1964
+ typedBy: RangeTypedBy;
1965
+ }
1966
+
1967
+ interface EntryPointPurposeElements {
1968
+ function: boolean;
1969
+ inputs: boolean;
1970
+ outputs: boolean;
1971
+ sideEffects: boolean;
1972
+ }
1973
+
1974
+ interface EntryPointRow {
1975
+ address: number;
1976
+ name: string | null;
1977
+ /** True iff `name` is a real, authored label -- present AND not one of the
1978
+ * frozen survivor prefixes (an auto-generated name is not a name for this
1979
+ * gate's purposes, exactly like criterion 3's own survivor search). */
1980
+ hasName: boolean;
1981
+ purposeElements: EntryPointPurposeElements;
1982
+ }
1983
+
1984
+ interface ReferencedAddressesCensus {
1985
+ resolved: number[];
1986
+ declined: { address: number; reason: string }[];
1987
+ unresolved: number[];
1988
+ denominator: number;
1989
+ }
1990
+
1991
+ interface DisagreementResolutionRow {
1992
+ address: number;
1993
+ resolved: boolean;
1994
+ accepted: boolean;
1995
+ reason: string | null;
1996
+ }
1997
+
1998
+ interface DisagreementResolutionCensus {
1999
+ rows: DisagreementResolutionRow[];
2000
+ unresolvedCount: number;
2001
+ denominator: number;
2002
+ }
2003
+
2004
+ /**
2005
+ * The four hardware-chip memory-mapped register bands `c64-memory-mapping`'s
2006
+ * own `memmap.json` labels by name -- VIC-II, SID, CIA#1, CIA#2. Color RAM
2007
+ * ($D800-$DBFF) and the two generic "I/O Area" bands are deliberately
2008
+ * EXCLUDED: neither holds a chip register this project's curated
2009
+ * `anno-regbits.json` table names, and folding them in would make an
2010
+ * ordinary color-RAM write "hardware" by construction. `$0001` (the 6510's
2011
+ * own I/O port, zero page -- outside every one of these four bands) is
2012
+ * covered separately, by `hardwareRegisterAddresses()` below reading
2013
+ * `anno-regbits.json` itself, never a hand-restated address list.
2014
+ */
2015
+ const HARDWARE_CHIP_RANGES: readonly { start: number; endInclusive: number }[] = Object.freeze([
2016
+ { start: 0xd000, endInclusive: 0xd3ff }, // VIC-II
2017
+ { start: 0xd400, endInclusive: 0xd7ff }, // SID
2018
+ { start: 0xdc00, endInclusive: 0xdcff }, // CIA#1
2019
+ { start: 0xdd00, endInclusive: 0xddff }, // CIA#2
2020
+ ]);
2021
+
2022
+ const REGBITS_PATH_FOR_HARDWARE_CHECK = join(HERE, "anno-regbits.json");
2023
+
2024
+ let cachedHardwareRegBitsAddresses: ReadonlySet<number> | undefined;
2025
+
2026
+ /** Every address `anno-regbits.json` names, read directly (this file never
2027
+ * imports `anno-enum-gen.ts`'s own private `loadRegBits()`, which is not
2028
+ * exported) -- this is a KEY-EXISTENCE check against the generated,
2029
+ * committed artifact, never a second bit-name derivation from memmap.json
2030
+ * (that generator's own header reserves that job to itself). Cached once per
2031
+ * process, mirroring `anno-enum-gen.ts`'s own cache discipline for the same
2032
+ * file. */
2033
+ function hardwareRegBitsAddresses(): ReadonlySet<number> {
2034
+ if (cachedHardwareRegBitsAddresses === undefined) {
2035
+ const doc = JSON.parse(readFileSync(REGBITS_PATH_FOR_HARDWARE_CHECK, "utf8")) as Record<string, unknown>;
2036
+ const addresses = new Set<number>();
2037
+ for (const key of Object.keys(doc)) {
2038
+ if (key === "_generated") continue;
2039
+ const parsed = Number.parseInt(key.slice(1), 16);
2040
+ if (Number.isInteger(parsed)) addresses.add(parsed);
2041
+ }
2042
+ cachedHardwareRegBitsAddresses = addresses;
2043
+ }
2044
+ return cachedHardwareRegBitsAddresses;
2045
+ }
2046
+
2047
+ /** True iff `address` is a hardware register address -- the union `anno-
2048
+ * regbits.json`'s own keys and memmap.json's four labelled chip bands
2049
+ * classify as hardware (see `HARDWARE_CHIP_RANGES`'s own doc comment for
2050
+ * what is deliberately excluded and why). */
2051
+ function isHardwareRegisterAddress(address: number): boolean {
2052
+ if (hardwareRegBitsAddresses().has(address)) return true;
2053
+ return HARDWARE_CHIP_RANGES.some((r) => address >= r.start && address <= r.endInclusive);
2054
+ }
2055
+
2056
+ /** The address an instruction references for the purposes of this file's
2057
+ * entry-point and referenced-address censuses -- mirrors `anno-derive.ts`'s
2058
+ * own (private, unexported) `referencedAddress()` rule exactly: no operand
2059
+ * (`rts`), an `immediate` operand (the value itself, never an address) and an
2060
+ * `indirect` operand (the target lives AT the operand, not IN it) all
2061
+ * reference nothing; everything else resolves to `resolvedTarget` when the
2062
+ * decoder produced one (a branch, a `jmp`/`jsr` absolute) or `operand.value`
2063
+ * otherwise. Restated here, not imported, because `anno-derive.ts` does not
2064
+ * export it. */
2065
+ function instructionReferencedAddress(instruction: Instruction): number | undefined {
2066
+ const operand = instruction.operand;
2067
+ if (operand === undefined) return undefined;
2068
+ if (operand.role === "immediate" || operand.role === "indirect") return undefined;
2069
+ const target = instruction.resolvedTarget ?? operand.value;
2070
+ if (!Number.isInteger(target) || target < 0 || target > 0xffff) return undefined;
2071
+ return target;
2072
+ }
2073
+
2074
+ /** Decodes every `code`-typed range fresh (never memoised, never a second
2075
+ * decoder) and returns every instruction found, tagged with nothing but its
2076
+ * own decoded shape. `image` is the SAME `{origin, bytes}` pair
2077
+ * `loadProjectImage()` already produced for this store's own fixture file. */
2078
+ function decodeCodeRanges(ranges: readonly RangeRow[], image: { origin: number; bytes: Uint8Array }): Instruction[] {
2079
+ const instructions: Instruction[] = [];
2080
+ for (const range of ranges) {
2081
+ if (range.dataType !== "code") continue;
2082
+ const from = range.start - image.origin;
2083
+ const to = range.endInclusive - image.origin;
2084
+ if (from < 0 || to >= image.bytes.length || from > to) continue; // this image does not cover the range
2085
+ const bytes = image.bytes.subarray(from, to + 1);
2086
+ instructions.push(...decode(bytes, range.start, { end: range.endInclusive }));
2087
+ }
2088
+ return instructions;
2089
+ }
2090
+
2091
+ /** True iff any comment at `address` starts with `prefix`. */
2092
+ function hasCommentWithPrefix(comments: readonly CommentRow[], address: number, prefix: string): boolean {
2093
+ return comments.some((c) => c.address === address && c.text.startsWith(prefix));
2094
+ }
2095
+
2096
+ /** The first comment at `address` starting with `prefix`, its text with the
2097
+ * prefix stripped and trimmed -- or `null` when none exists. */
2098
+ function commentReasonAfterPrefix(comments: readonly CommentRow[], address: number, prefix: string): string | null {
2099
+ const found = comments.find((c) => c.address === address && c.text.startsWith(prefix));
2100
+ return found ? found.text.slice(prefix.length).trim() : null;
2101
+ }
2102
+
2103
+ /** D-10 mechanism 2: how ONE typed range was typed. Evidence beats
2104
+ * inference, stated as a fixed precedence that must never be reordered:
2105
+ * `observed-executing` (at least one real execute observation falls inside
2106
+ * the range) beats `authored` (the range's start address carries an
2107
+ * `AUTHORED_PROVENANCE_COMMENT_PREFIX` comment and no observation) beats
2108
+ * `byte-derived` (neither). */
2109
+ function typedByFor(hasObservation: boolean, hasAuthoredComment: boolean): RangeTypedBy {
2110
+ if (hasObservation) return "observed-executing";
2111
+ if (hasAuthoredComment) return "authored";
2112
+ return "byte-derived";
2113
+ }
2114
+
2115
+ /** Builds `rangeProvenance` (D-10 mechanism 2): one row per typed range,
2116
+ * sorted ascending by `start` then `endInclusive` (ranges never overlap, so
2117
+ * this is already the input order once `ranges` itself is pre-sorted, but
2118
+ * the sort is restated here so this function's OWN output contract does not
2119
+ * depend on a caller's sort surviving unchanged). */
2120
+ function buildRangeProvenance(
2121
+ ranges: readonly RangeRow[],
2122
+ observations: readonly { address: number }[],
2123
+ comments: readonly CommentRow[],
2124
+ ): RangeProvenanceRow[] {
2125
+ const sorted = [...ranges].sort((a, b) => a.start - b.start || a.endInclusive - b.endInclusive);
2126
+ return sorted.map((r) => {
2127
+ const hasObservation = observations.some((o) => o.address >= r.start && o.address <= r.endInclusive);
2128
+ const hasAuthoredComment = hasCommentWithPrefix(comments, r.start, AUTHORED_PROVENANCE_COMMENT_PREFIX);
2129
+ return {
2130
+ start: r.start,
2131
+ endInclusive: r.endInclusive,
2132
+ dataType: r.dataType,
2133
+ renderedType: isSplitDataType(r.dataType) ? "table" : r.dataType,
2134
+ typedBy: typedByFor(hasObservation, hasAuthoredComment),
2135
+ };
2136
+ });
2137
+ }
2138
+
2139
+ /** Builds `entryPoints`: every address that is the target of at least one
2140
+ * JSR-shaped cross-reference (a decoded `jsr` instruction in a `code` range,
2141
+ * unioned with every stored `listXrefs()` row whose target falls inside a
2142
+ * `code`-typed range -- the store's own `XrefAccessKind` vocabulary carries
2143
+ * no separate "call" member, so a stored xref landing in code is treated as
2144
+ * a call reference for this census), PLUS the image's own load/start
2145
+ * address (`image.origin`) -- the fixture's own natural entry point.
2146
+ * Sorted ascending by address.
2147
+ *
2148
+ * `image === null` (45-REVIEW WR-02, fixed 2026-09-11) means the fixture's
2149
+ * own bytes could not be located: no instructions are decoded and NO
2150
+ * `image.origin` candidate is added -- a missing image degrades this to
2151
+ * whatever the store's own stored `xrefs` already establish, never a
2152
+ * fabricated `$0000` from a placeholder's own zero origin. */
2153
+ function buildEntryPoints(
2154
+ ranges: readonly RangeRow[],
2155
+ image: { origin: number; bytes: Uint8Array } | null,
2156
+ xrefs: readonly { toAddress: number }[],
2157
+ labels: readonly LabelRow[],
2158
+ comments: readonly CommentRow[],
2159
+ ): EntryPointRow[] {
2160
+ const codeRanges = ranges.filter((r) => r.dataType === "code");
2161
+ const instructions = image === null ? [] : decodeCodeRanges(ranges, image);
2162
+
2163
+ const candidates = new Set<number>();
2164
+ if (image !== null) candidates.add(image.origin);
2165
+ for (const instr of instructions) {
2166
+ if (instr.mnemonic === "jsr") {
2167
+ const target = instructionReferencedAddress(instr);
2168
+ if (target !== undefined) candidates.add(target);
2169
+ }
2170
+ }
2171
+ for (const xref of xrefs) {
2172
+ if (codeRanges.some((r) => xref.toAddress >= r.start && xref.toAddress <= r.endInclusive)) {
2173
+ candidates.add(xref.toAddress);
2174
+ }
2175
+ }
2176
+
2177
+ const purposeLabelPatterns: Record<keyof EntryPointPurposeElements, RegExp> = {
2178
+ function: /function:/i,
2179
+ inputs: /inputs:/i,
2180
+ outputs: /outputs:/i,
2181
+ sideEffects: /side effects:/i,
2182
+ };
2183
+
2184
+ return [...candidates]
2185
+ .sort((a, b) => a - b)
2186
+ .map((address) => {
2187
+ const label = labels.find((l) => l.address === address);
2188
+ const hasName = label !== undefined && !isSurvivorLabelName(label.name);
2189
+ const addressComments = comments.filter((c) => c.address === address);
2190
+ const purposeElements: EntryPointPurposeElements = {
2191
+ function: addressComments.some((c) => purposeLabelPatterns.function.test(c.text)),
2192
+ inputs: addressComments.some((c) => purposeLabelPatterns.inputs.test(c.text)),
2193
+ outputs: addressComments.some((c) => purposeLabelPatterns.outputs.test(c.text)),
2194
+ sideEffects: addressComments.some((c) => purposeLabelPatterns.sideEffects.test(c.text)),
2195
+ };
2196
+ return { address, name: label?.name ?? null, hasName, purposeElements };
2197
+ });
2198
+ }
2199
+
2200
+ /** Builds `referencedAddresses` (criterion 4): every non-hardware address a
2201
+ * `code` range's decoded instructions or the store's own `listXrefs()` rows
2202
+ * reference, classified `resolved` (an authored, non-survivor label exists),
2203
+ * `declined` (a `DECLINE_COMMENT_PREFIX` comment exists, carrying the
2204
+ * decline's own reason), or `unresolved` (neither) -- sorted ascending by
2205
+ * address within each bucket.
2206
+ *
2207
+ * `image === null` (45-REVIEW WR-02, fixed 2026-09-11): no instructions are
2208
+ * decoded, so this degrades to whatever the store's own stored `xrefs`
2209
+ * establish -- never fabricated from a placeholder image's bytes. */
2210
+ function buildReferencedAddresses(
2211
+ ranges: readonly RangeRow[],
2212
+ image: { origin: number; bytes: Uint8Array } | null,
2213
+ xrefs: readonly { toAddress: number }[],
2214
+ labels: readonly LabelRow[],
2215
+ comments: readonly CommentRow[],
2216
+ ): ReferencedAddressesCensus {
2217
+ const instructions = image === null ? [] : decodeCodeRanges(ranges, image);
2218
+ const candidates = new Set<number>();
2219
+ for (const instr of instructions) {
2220
+ const target = instructionReferencedAddress(instr);
2221
+ if (target !== undefined && !isHardwareRegisterAddress(target)) candidates.add(target);
2222
+ }
2223
+ for (const xref of xrefs) {
2224
+ if (!isHardwareRegisterAddress(xref.toAddress)) candidates.add(xref.toAddress);
2225
+ }
2226
+
2227
+ const resolved: number[] = [];
2228
+ const declined: { address: number; reason: string }[] = [];
2229
+ const unresolved: number[] = [];
2230
+ for (const address of [...candidates].sort((a, b) => a - b)) {
2231
+ const label = labels.find((l) => l.address === address);
2232
+ if (label !== undefined && !isSurvivorLabelName(label.name)) {
2233
+ resolved.push(address);
2234
+ continue;
2235
+ }
2236
+ const reason = commentReasonAfterPrefix(comments, address, DECLINE_COMMENT_PREFIX);
2237
+ if (reason !== null) {
2238
+ declined.push({ address, reason });
2239
+ continue;
2240
+ }
2241
+ unresolved.push(address);
2242
+ }
2243
+ return { resolved, declined, unresolved, denominator: resolved.length + declined.length + unresolved.length };
2244
+ }
2245
+
2246
+ /** Builds `disagreementResolution` (D-09's gate-vs-bulletin distinction,
2247
+ * criterion 2): one row per disagreement the supplied `--disagreements`
2248
+ * document carries, `accepted` when the address carries a
2249
+ * `DISAGREEMENT_ACCEPTED_COMMENT_PREFIX` comment, `resolved` identically (the
2250
+ * only resolution mechanism this gate recognises today), `reason` the
2251
+ * accepting comment's own text with the prefix stripped. `unresolvedCount`
2252
+ * is a named line beside its own `denominator`, never folded into any other
2253
+ * count -- criterion 2's own words: a nonzero unresolved count BLOCKS rather
2254
+ * than being reported beside a pass. */
2255
+ function buildDisagreementResolution(
2256
+ disagreements: readonly { address: number }[],
2257
+ comments: readonly CommentRow[],
2258
+ ): DisagreementResolutionCensus {
2259
+ const rows = disagreements.map((d) => {
2260
+ const reason = commentReasonAfterPrefix(comments, d.address, DISAGREEMENT_ACCEPTED_COMMENT_PREFIX);
2261
+ const accepted = reason !== null;
2262
+ return { address: d.address, resolved: accepted, accepted, reason };
2263
+ });
2264
+ const unresolvedCount = rows.filter((r) => !r.resolved).length;
2265
+ return { rows, unresolvedCount, denominator: rows.length };
2266
+ }
2267
+
2268
+ interface DecompCompletenessParsedArgs {
2269
+ positional: string[];
2270
+ store?: string;
2271
+ storeMissingValue?: boolean;
2272
+ disagreements?: string;
2273
+ disagreementsMissingValue?: boolean;
2274
+ manifest?: string;
2275
+ manifestMissingValue?: boolean;
2276
+ json?: boolean;
2277
+ unknownOption?: string;
2278
+ }
2279
+
2280
+ /** Copies `parseEvidDisagreementsArgs()`'s own shape, function for function,
2281
+ * for three required flags instead of one -- same `*MissingValue` refusal,
2282
+ * same unknown-option refusal, same never-silently-swallow-the-next-token
2283
+ * discipline. */
2284
+ function parseDecompCompletenessArgs(rest: string[]): DecompCompletenessParsedArgs {
2285
+ const positional: string[] = [];
2286
+ let store: string | undefined;
2287
+ let storeMissingValue = false;
2288
+ let disagreements: string | undefined;
2289
+ let disagreementsMissingValue = false;
2290
+ let manifest: string | undefined;
2291
+ let manifestMissingValue = false;
2292
+ let json = false;
2293
+ let unknownOption: string | undefined;
2294
+ for (let i = 0; i < rest.length; i++) {
2295
+ const a = rest[i]!;
2296
+ if (a === "--store") {
2297
+ const value = rest[i + 1];
2298
+ if (isMissingOptionValue(value)) storeMissingValue = true;
2299
+ else {
2300
+ store = value;
2301
+ i++;
2302
+ }
2303
+ } else if (a === "--disagreements") {
2304
+ const value = rest[i + 1];
2305
+ if (isMissingOptionValue(value)) disagreementsMissingValue = true;
2306
+ else {
2307
+ disagreements = value;
2308
+ i++;
2309
+ }
2310
+ } else if (a === "--manifest") {
2311
+ const value = rest[i + 1];
2312
+ if (isMissingOptionValue(value)) manifestMissingValue = true;
2313
+ else {
2314
+ manifest = value;
2315
+ i++;
2316
+ }
2317
+ } else if (a === "--json") {
2318
+ json = true;
2319
+ } else if (a.startsWith("--")) {
2320
+ unknownOption ??= a;
2321
+ } else {
2322
+ positional.push(a);
2323
+ }
2324
+ }
2325
+ return { positional, store, storeMissingValue, disagreements, disagreementsMissingValue, manifest, manifestMissingValue, json, unknownOption };
2326
+ }
2327
+
2328
+ /**
2329
+ * `decomp-completeness --store FILE --disagreements FILE --manifest FILE
2330
+ * [--json]` -- copies `cmdEvidDisagreements()`'s own shape: parse -> refuse
2331
+ * unknown option -> refuse missing value -> refuse missing required argument
2332
+ * BY NAME -> `storePathWithinWorkspace()` every caller-supplied path -> open
2333
+ * the store `mustExist: true` -> gather -> `--json` branch or rendered
2334
+ * branch. Three required arguments, none defaulted from another (D-09
2335
+ * mechanism 1).
2336
+ */
2337
+ async function cmdDecompCompleteness(rest: string[]): Promise<number> {
2338
+ const { store, storeMissingValue, disagreements, disagreementsMissingValue, manifest, manifestMissingValue, json, unknownOption } =
2339
+ parseDecompCompletenessArgs(rest);
2340
+
2341
+ if (unknownOption) {
2342
+ console.error(`decomp-completeness: unknown option "${unknownOption}"\n`);
2343
+ console.log(USAGE);
2344
+ return 1;
2345
+ }
2346
+ if (storeMissingValue) {
2347
+ console.error("decomp-completeness: --store requires a value\n");
2348
+ console.log(USAGE);
2349
+ return 1;
2350
+ }
2351
+ if (disagreementsMissingValue) {
2352
+ console.error("decomp-completeness: --disagreements requires a value\n");
2353
+ console.log(USAGE);
2354
+ return 1;
2355
+ }
2356
+ if (manifestMissingValue) {
2357
+ console.error("decomp-completeness: --manifest requires a value\n");
2358
+ console.log(USAGE);
2359
+ return 1;
2360
+ }
2361
+ if (!store) {
2362
+ console.error("decomp-completeness: --store FILE is required -- this verb answers a question about ONE annotation store.\n");
2363
+ console.log(USAGE);
2364
+ return 1;
2365
+ }
2366
+ if (!disagreements) {
2367
+ console.error(
2368
+ "decomp-completeness: --disagreements FILE is required -- there is no default and no empty-array " +
2369
+ "substitute; omitting the disagreement input must never render the same report as a real, empty answer (D-09).\n",
2370
+ );
2371
+ console.log(USAGE);
2372
+ return 1;
2373
+ }
2374
+ if (!manifest) {
2375
+ console.error(
2376
+ "decomp-completeness: --manifest FILE is required -- a fixture absent from the manifest is refused, " +
2377
+ "never defaulted to \"executed\" (D-13).\n",
2378
+ );
2379
+ console.log(USAGE);
2380
+ return 1;
2381
+ }
2382
+
2383
+ const workspaceRoot = repoRoot();
2384
+ let storePath: string;
2385
+ let disagreementsPath: string;
2386
+ let manifestPath: string;
2387
+ try {
2388
+ storePath = storePathWithinWorkspace(store, workspaceRoot);
2389
+ disagreementsPath = storePathWithinWorkspace(disagreements, workspaceRoot);
2390
+ manifestPath = storePathWithinWorkspace(manifest, workspaceRoot);
2391
+ } catch (err) {
2392
+ console.error(`decomp-completeness: ${errMsg(err)}`);
2393
+ return 1;
2394
+ }
2395
+
2396
+ if (!existsSync(storePath)) {
2397
+ console.error(
2398
+ `decomp-completeness: annotation store not found: ${storePath} -- refusing to CREATE one, because "the ` +
2399
+ 'annotations are gone" and "there are no annotations" must not read the same.',
2400
+ );
2401
+ return 1;
2402
+ }
2403
+ if (!existsSync(disagreementsPath)) {
2404
+ console.error(`decomp-completeness: --disagreements file not found: ${disagreementsPath}`);
2405
+ return 1;
2406
+ }
2407
+ if (!existsSync(manifestPath)) {
2408
+ console.error(`decomp-completeness: --manifest file not found: ${manifestPath}`);
2409
+ return 1;
2410
+ }
2411
+
2412
+ let disagreementDoc: unknown;
2413
+ try {
2414
+ disagreementDoc = JSON.parse(readFileSync(disagreementsPath, "utf8"));
2415
+ } catch (err) {
2416
+ console.error(`decomp-completeness: --disagreements file is not valid JSON: ${errMsg(err)}`);
2417
+ return 1;
2418
+ }
2419
+ const validated = validateDisagreementDocumentShape(disagreementDoc);
2420
+ if (typeof validated === "string") {
2421
+ console.error(validated);
2422
+ return 1;
2423
+ }
2424
+ const disagreementInput = validated;
2425
+
2426
+ let manifestDoc: unknown;
2427
+ try {
2428
+ manifestDoc = JSON.parse(readFileSync(manifestPath, "utf8"));
2429
+ } catch (err) {
2430
+ console.error(`decomp-completeness: --manifest file is not valid JSON: ${errMsg(err)}`);
2431
+ return 1;
2432
+ }
2433
+ if (
2434
+ typeof manifestDoc !== "object" ||
2435
+ manifestDoc === null ||
2436
+ !Array.isArray((manifestDoc as Record<string, unknown>).fixtures)
2437
+ ) {
2438
+ console.error(`decomp-completeness: --manifest file does not carry a top-level "fixtures" array: ${manifestPath}`);
2439
+ return 1;
2440
+ }
2441
+ const manifestFixtures = (manifestDoc as DecompExecutionManifest).fixtures;
2442
+
2443
+ const stem = fixtureStem(storePath);
2444
+ const manifestEntry = manifestFixtures.find((f) => fixtureStem(f.path) === stem);
2445
+ if (!manifestEntry) {
2446
+ console.error(
2447
+ `decomp-completeness: no fixture matching store ${JSON.stringify(basename(storePath))} (stem ${JSON.stringify(stem)}) ` +
2448
+ `is listed in the manifest ${manifestPath} -- an unlisted fixture is refused, never defaulted to "executed" (D-13).`,
2449
+ );
2450
+ return 1;
2451
+ }
2452
+
2453
+ let handle: AnnoStoreHandle;
2454
+ try {
2455
+ handle = openStore(storePath, { workspaceRoot, mustExist: true });
2456
+ } catch (err) {
2457
+ console.error(`decomp-completeness: ${errMsg(err)}`);
2458
+ return 1;
2459
+ }
2460
+
2461
+ let report: {
2462
+ store: string;
2463
+ fixture: string;
2464
+ executionDisposition: "executed" | "not-executed";
2465
+ notExecutedReason: string | null;
2466
+ byteCensus: { byType: Record<string, number>; undefinedCount: number; denominator: number; undefinedRanges: { start: number; endInclusive: number }[] };
2467
+ survivors: { address: number; name: string }[];
2468
+ rangeProvenance: RangeProvenanceRow[];
2469
+ // 45-REVIEW WR-02 (fixed 2026-09-11): true when the fixture's own image
2470
+ // bytes could not be located -- see the fallback below. `entryPoints`/
2471
+ // `referencedAddresses` are DEGRADED (never fabricated) when this is
2472
+ // true: no synthetic `$0000` entry point is manufactured from a
2473
+ // zero-length placeholder's own `origin`.
2474
+ imageUnavailable: boolean;
2475
+ entryPoints: EntryPointRow[];
2476
+ referencedAddresses: ReferencedAddressesCensus;
2477
+ disagreementInput: DecompDisagreementInput;
2478
+ disagreementResolution: DisagreementResolutionCensus;
2479
+ };
2480
+ try {
2481
+ const ranges = listRanges(handle);
2482
+ const { runs } = listObservedRuns(handle);
2483
+ // Rule 1 fix (disclosed, plan 45-06): a `null` runIdentity is accepted
2484
+ // ONLY when the store's own evid-runs table is ALSO genuinely empty --
2485
+ // the real, honest answer for a D-13 non-executed fixture. A store that
2486
+ // DOES carry real runs must still supply a real, matching identity; the
2487
+ // anti-vacuity property this whole check exists for is unaffected.
2488
+ if (disagreementInput.runIdentity === null) {
2489
+ if (runs.length !== 0) {
2490
+ console.error(
2491
+ `decomp-completeness: the --disagreements document carries a null run identity, but ${storePath}'s own ` +
2492
+ `evid-runs table is NOT empty (${runs.length} recorded run(s)) -- a store with real runs must supply a ` +
2493
+ "real, matching identity, never null.",
2494
+ );
2495
+ closeStore(handle);
2496
+ return 1;
2497
+ }
2498
+ } else {
2499
+ const matchesSomeRun = runs.some(
2500
+ (r) =>
2501
+ r.imageSha256 === disagreementInput.runIdentity!.imageSha256 &&
2502
+ r.argvDigest === disagreementInput.runIdentity!.argvDigest &&
2503
+ r.seed === disagreementInput.runIdentity!.seed,
2504
+ );
2505
+ if (!matchesSomeRun) {
2506
+ console.error(
2507
+ `decomp-completeness: the --disagreements document's run identity (image_sha256=${disagreementInput.runIdentity.imageSha256}, ` +
2508
+ `argv_digest=${disagreementInput.runIdentity.argvDigest}, seed=${JSON.stringify(disagreementInput.runIdentity.seed)}) ` +
2509
+ `matches no row in ${storePath}'s own evid-runs table -- a fabricated or foreign document is refused, never rendered.`,
2510
+ );
2511
+ closeStore(handle);
2512
+ return 1;
2513
+ }
2514
+ }
2515
+
2516
+ const sortedRanges = [...ranges].sort((a, b) => a.start - b.start);
2517
+ const byType: Record<string, number> = {};
2518
+ let denominator = 0;
2519
+ let undefinedCount = 0;
2520
+ // Every gap between typed ranges, by ADDRESS -- so the gate can name
2521
+ // exactly which byte(s) are Undefined rather than reporting a bare
2522
+ // count (Task 1 Test 1: "a store with one undefined-typed byte ... renders
2523
+ // that byte's address"). Sorted ascending, matching every other array
2524
+ // this verb returns.
2525
+ const undefinedRanges: { start: number; endInclusive: number }[] = [];
2526
+ let cursor = sortedRanges.length > 0 ? sortedRanges[0]!.start : 0;
2527
+ for (const r of sortedRanges) {
2528
+ if (r.start > cursor) {
2529
+ const gap = r.start - cursor;
2530
+ undefinedCount += gap;
2531
+ denominator += gap;
2532
+ undefinedRanges.push({ start: cursor, endInclusive: r.start - 1 });
2533
+ }
2534
+ const len = r.endInclusive - r.start + 1;
2535
+ byType[r.dataType] = (byType[r.dataType] ?? 0) + len;
2536
+ denominator += len;
2537
+ cursor = Math.max(cursor, r.endInclusive + 1);
2538
+ }
2539
+
2540
+ const labels = listLabels(handle);
2541
+ const survivors = labels
2542
+ .filter((l) => isSurvivorLabelName(l.name) && sortedRanges.some((r) => r.dataType === "code" && l.address >= r.start && l.address <= r.endInclusive))
2543
+ .map((l) => ({ address: l.address, name: l.name }))
2544
+ .sort((a, b) => a.address - b.address);
2545
+
2546
+ // The full measure set (phase 45 plan 45-04). All four use the SAME
2547
+ // fixture bytes the derivation route itself read -- the fixtures-relative
2548
+ // manifest path, resolved beside this module (`fixtures/<manifestEntry.path>`),
2549
+ // never a second guess at where the image lives. An image that cannot be
2550
+ // located (never expected for a committed fixture, but never fabricated
2551
+ // either) degrades entryPoints/referencedAddresses to EMPTY -- never a
2552
+ // synthetic zero-length placeholder whose own `origin` (0) would read as
2553
+ // a real `$0000` entry point (45-REVIEW WR-02, fixed 2026-09-11: the
2554
+ // placeholder's origin was previously unioned into the candidate set
2555
+ // unconditionally, fabricating a plausible-looking but fictitious
2556
+ // finding). `imageUnavailable` reports the condition BY NAME instead.
2557
+ const comments = listComments(handle);
2558
+ const xrefs = listXrefs(handle);
2559
+ const fixtureImagePath = join(HERE, "fixtures", manifestEntry.path);
2560
+ const loadedImage = existsSync(fixtureImagePath) ? projectImage(fixtureImagePath) : null;
2561
+ const imageUnavailable = loadedImage === null;
2562
+
2563
+ const rangeProvenance = buildRangeProvenance(sortedRanges, listExecObservations(handle), comments);
2564
+ const entryPoints = buildEntryPoints(sortedRanges, loadedImage, xrefs, labels, comments);
2565
+ const referencedAddresses = buildReferencedAddresses(sortedRanges, loadedImage, xrefs, labels, comments);
2566
+ const disagreementResolution = buildDisagreementResolution(disagreementInput.disagreements, comments);
2567
+
2568
+ report = {
2569
+ store: storePath,
2570
+ fixture: manifestEntry.path,
2571
+ executionDisposition: manifestEntry.execution,
2572
+ notExecutedReason: manifestEntry.execution === "not-executed" ? manifestEntry.reason : null,
2573
+ byteCensus: { byType, undefinedCount, denominator, undefinedRanges },
2574
+ survivors,
2575
+ rangeProvenance,
2576
+ imageUnavailable,
2577
+ entryPoints,
2578
+ referencedAddresses,
2579
+ disagreementInput,
2580
+ disagreementResolution,
2581
+ };
2582
+ } catch (err) {
2583
+ console.error(`decomp-completeness: ${errMsg(err)}`);
2584
+ closeStore(handle);
2585
+ return 1;
2586
+ }
2587
+ closeStore(handle);
2588
+
2589
+ if (json) {
2590
+ console.log(JSON.stringify(report, null, 2));
2591
+ return 0;
2592
+ }
2593
+ printDecompCompletenessReport(report);
2594
+ return 0;
2595
+ }
2596
+
2597
+ /**
2598
+ * Copies `printEvidDisagreementsReport()`'s rendering discipline exactly:
2599
+ * every measure under its own heading, disagreements first, `denominator`
2600
+ * beside every count, an explicit sentence stating what absence does NOT
2601
+ * prove, and never a percentage, rate or combined figure (D-05's own rule,
2602
+ * applied here too). This is the FALLBACK text renderer for a direct CLI
2603
+ * invocation without `--json`; `completeness-report.mjs`'s
2604
+ * `renderCompletenessReport()` is the report the routine-queue-walker skill
2605
+ * actually reads, built from this same verb's `--json` answer.
2606
+ */
2607
+ function printDecompCompletenessReport(r: {
2608
+ store: string;
2609
+ fixture: string;
2610
+ executionDisposition: "executed" | "not-executed";
2611
+ notExecutedReason: string | null;
2612
+ byteCensus: { byType: Record<string, number>; undefinedCount: number; denominator: number; undefinedRanges: { start: number; endInclusive: number }[] };
2613
+ survivors: { address: number; name: string }[];
2614
+ rangeProvenance: RangeProvenanceRow[];
2615
+ imageUnavailable: boolean;
2616
+ entryPoints: EntryPointRow[];
2617
+ referencedAddresses: ReferencedAddressesCensus;
2618
+ disagreementInput: DecompDisagreementInput;
2619
+ disagreementResolution: DisagreementResolutionCensus;
2620
+ }): void {
2621
+ console.log(`decomp-completeness: ${r.store}`);
2622
+ console.log(` FIXTURE: ${r.fixture}`);
2623
+ if (r.executionDisposition === "not-executed") {
2624
+ console.log(` NOT EXECUTED: ${r.notExecutedReason ?? "(no reason recorded)"}`);
2625
+ } else {
2626
+ console.log(" EXECUTED: this fixture was run under the reproducible-run protocol (REPRO-02).");
2627
+ }
2628
+ console.log("");
2629
+ console.log(` BYTE CENSUS (denominator ${r.byteCensus.denominator})`);
2630
+ for (const [type, count] of Object.entries(r.byteCensus.byType).sort()) {
2631
+ console.log(` ${type}: ${count} of ${r.byteCensus.denominator}`);
2632
+ }
2633
+ console.log(` undefined: ${r.byteCensus.undefinedCount} of ${r.byteCensus.denominator}`);
2634
+ if (r.byteCensus.undefinedRanges.length > 0) {
2635
+ for (const gap of r.byteCensus.undefinedRanges) {
2636
+ console.log(` UNDEFINED: ${hexAddr(gap.start)}-${hexAddr(gap.endInclusive)}`);
2637
+ }
2638
+ }
2639
+ console.log("");
2640
+ console.log(` SURVIVORS (${r.survivors.length})`);
2641
+ if (r.survivors.length === 0) {
2642
+ console.log(" none");
2643
+ } else {
2644
+ for (const s of r.survivors) console.log(` ${hexAddr(s.address)} ${s.name}`);
2645
+ }
2646
+ console.log("");
2647
+ console.log(` DISAGREEMENTS (${r.disagreementInput.disagreementCount} of ${r.disagreementInput.denominator})`);
2648
+ if (r.disagreementInput.disagreements.length === 0) {
2649
+ console.log(" none");
2650
+ } else {
2651
+ for (const d of r.disagreementInput.disagreements) {
2652
+ console.log(` ${hexAddr(d.address)} byte-derived=${d.byteDerived} runtime=${d.runtime} banks=${d.sourceBanks.join(",")}`);
2653
+ }
2654
+ }
2655
+ console.log(` AGREEMENT: ${r.disagreementInput.agreementCount} of ${r.disagreementInput.denominator}`);
2656
+ console.log(
2657
+ ` NO OBSERVATION: ${r.disagreementInput.blockCoveredNeverObservedCount} of ${r.disagreementInput.denominator} -- ` +
2658
+ "an address never observed executing proves NOTHING about what it is; absence is not evidence for or against any classification.",
2659
+ );
2660
+ console.log(
2661
+ ` DISAGREEMENT RESOLUTION: ${r.disagreementResolution.rows.length - r.disagreementResolution.unresolvedCount} accepted, ` +
2662
+ `${r.disagreementResolution.unresolvedCount} unresolved of ${r.disagreementResolution.denominator} -- criterion 2's own gate: ` +
2663
+ "a nonzero unresolved count BLOCKS rather than being reported beside a pass.",
2664
+ );
2665
+ console.log("");
2666
+
2667
+ console.log(` RANGE PROVENANCE (${r.rangeProvenance.length} range(s))`);
2668
+ if (r.rangeProvenance.length === 0) {
2669
+ console.log(" none");
2670
+ } else {
2671
+ for (const row of r.rangeProvenance) {
2672
+ console.log(` ${hexAddr(row.start)}-${hexAddr(row.endInclusive)} ${row.renderedType} typedBy: ${row.typedBy}`);
2673
+ }
2674
+ }
2675
+ console.log("");
2676
+
2677
+ // 45-REVIEW WR-02 (fixed 2026-09-11): named BY NAME, not inferred from a
2678
+ // suspiciously-empty entryPoints/referencedAddresses census.
2679
+ if (r.imageUnavailable) {
2680
+ console.log(" IMAGE UNAVAILABLE: the fixture's own image bytes could not be located -- entryPoints and referencedAddresses below are degraded to what the store's own stored xrefs establish, never fabricated from a placeholder image.");
2681
+ console.log("");
2682
+ }
2683
+
2684
+ const fullyDocumented = r.entryPoints.filter(
2685
+ (e) => e.hasName && e.purposeElements.function && e.purposeElements.inputs && e.purposeElements.outputs && e.purposeElements.sideEffects,
2686
+ ).length;
2687
+ console.log(` ENTRY POINTS (${fullyDocumented} of ${r.entryPoints.length})`);
2688
+ if (r.entryPoints.length === 0) {
2689
+ console.log(" none -- a zero-entry-point count is a fact about the candidate set, never evidence of completeness.");
2690
+ } else {
2691
+ for (const e of r.entryPoints) {
2692
+ const missing = (["function", "inputs", "outputs", "sideEffects"] as const).filter((k) => !e.purposeElements[k]);
2693
+ console.log(
2694
+ ` ${hexAddr(e.address)} ${e.name ?? "(unnamed)"} hasName=${e.hasName}` +
2695
+ (missing.length > 0 ? ` MISSING: ${missing.join(", ")}` : " purpose comment complete"),
2696
+ );
2697
+ }
2698
+ }
2699
+ console.log("");
2700
+
2701
+ console.log(` REFERENCED NON-HARDWARE ADDRESSES (${r.referencedAddresses.resolved.length} resolved of ${r.referencedAddresses.denominator})`);
2702
+ if (r.referencedAddresses.denominator === 0) {
2703
+ console.log(" none -- a zero-referenced-address count is a fact about the candidate set, never evidence of completeness.");
2704
+ } else {
2705
+ console.log(` RESOLVED: ${r.referencedAddresses.resolved.map(hexAddr).join(", ") || "none"}`);
2706
+ console.log(
2707
+ ` DECLINED: ${r.referencedAddresses.declined.length === 0 ? "none" : r.referencedAddresses.declined.map((d) => `${hexAddr(d.address)} (${d.reason})`).join(", ")}`,
2708
+ );
2709
+ console.log(` UNRESOLVED: ${r.referencedAddresses.unresolved.length === 0 ? "none" : r.referencedAddresses.unresolved.map(hexAddr).join(", ")}`);
2710
+ }
2711
+ console.log("");
2712
+ console.log(
2713
+ " Read every figure above against the others, never combined into one -- together they name what this " +
2714
+ "store's block table covers, never what the program actually is.",
2715
+ );
2716
+ }
2717
+
2718
+ // ---------------------------------------------------------------------------
2719
+ // hazard-report -- the CLI route for the movement-hazard report.
2720
+ // ---------------------------------------------------------------------------
2721
+
2722
+ interface HazardReportParsedArgs {
2723
+ positional: string[];
2724
+ store?: string;
2725
+ storeMissingValue?: boolean;
2726
+ image?: string;
2727
+ imageMissingValue?: boolean;
2728
+ json?: boolean;
2729
+ unknownOption?: string;
2730
+ }
2731
+
2732
+ /** Fixed, closed option set for hazard-report -- exactly `--store`,
2733
+ * `--image` and `--json`. Same WR-08 posture as every other verb's own
2734
+ * parser: an unimplemented flag is refused as `unknownOption`, and an
2735
+ * option with a missing or flag-shaped value is refused through its own
2736
+ * `*MissingValue` field rather than silently swallowing the next token. */
2737
+ function parseHazardReportArgs(rest: string[]): HazardReportParsedArgs {
2738
+ const positional: string[] = [];
2739
+ let store: string | undefined;
2740
+ let storeMissingValue = false;
2741
+ let image: string | undefined;
2742
+ let imageMissingValue = false;
2743
+ let json = false;
2744
+ let unknownOption: string | undefined;
2745
+ for (let i = 0; i < rest.length; i++) {
2746
+ const a = rest[i]!;
2747
+ if (a === "--store") {
2748
+ const value = rest[i + 1];
2749
+ if (isMissingOptionValue(value)) {
2750
+ storeMissingValue = true;
2751
+ } else {
2752
+ store = value;
2753
+ i++;
2754
+ }
2755
+ } else if (a === "--image") {
2756
+ const value = rest[i + 1];
2757
+ if (isMissingOptionValue(value)) {
2758
+ imageMissingValue = true;
2759
+ } else {
2760
+ image = value;
2761
+ i++;
2762
+ }
2763
+ } else if (a === "--json") {
2764
+ json = true;
2765
+ } else if (a.startsWith("--")) {
2766
+ unknownOption ??= a;
2767
+ } else {
2768
+ positional.push(a);
2769
+ }
2770
+ }
2771
+ return { positional, store, storeMissingValue, image, imageMissingValue, json, unknownOption };
2772
+ }
2773
+
2774
+ /**
2775
+ * Renders a hazard report as separately-headed, textually-distinguishable
2776
+ * sections -- findings first, then region dispositions grouped by outcome
2777
+ * under three separate headings, then the named limits verbatim. No
2778
+ * percentage, rate or combined verdict is ever printed at the point of
2779
+ * display: every heading prints its own count against the report's own
2780
+ * `denominator`, the same convention `printEvidDisagreementsReport()` uses.
2781
+ * The NO-SIGNAL heading's own text states, in as many words, that no
2782
+ * detection is not evidence that a region is safe to move -- so that
2783
+ * sentence is never left to a reader's inference.
2784
+ */
2785
+ function printHazardReport(storePath: string, imagePath: string, r: HazardReport & { matched: number; returned: number }): void {
2786
+ console.log(`hazard-report: ${storePath}`);
2787
+ console.log(` image: ${imagePath}`);
2788
+ console.log("");
2789
+ console.log(` FINDINGS (${r.matched} of ${r.denominator}, ${r.returned} shown${r.truncated ? ", truncated" : ""})`);
2790
+ if (r.findings.length === 0) {
2791
+ console.log(" none");
2792
+ } else {
2793
+ for (const f of r.findings) {
2794
+ const blocked = f.blockedAddress !== null ? ` blocked=${hexAddr(f.blockedAddress)}` : "";
2795
+ console.log(` ${hexAddr(f.anchorAddress)} class=${f.hazardClass} mechanism=${f.mechanism} strength=${f.strength}${blocked}`);
2796
+ console.log(` ${f.detail}`);
2797
+ }
2798
+ }
2799
+ console.log("");
2800
+
2801
+ const hazardReported = r.regions.filter((region) => region.outcome === "hazard-reported");
2802
+ const noSignal = r.regions.filter((region) => region.outcome === "no-signal");
2803
+ const unclassified = r.regions.filter((region) => region.outcome === "unclassified");
2804
+
2805
+ console.log(` HAZARD-REPORTED REGIONS (${hazardReported.length} of ${r.denominator})`);
2806
+ if (hazardReported.length === 0) console.log(" none");
2807
+ else for (const region of hazardReported) console.log(` ${hexAddr(region.start)}..${hexAddr(region.endInclusive)}`);
2808
+ console.log("");
2809
+
2810
+ console.log(
2811
+ ` NO-SIGNAL REGIONS (${noSignal.length} of ${r.denominator}) -- no detection is not evidence that a region is ` +
2812
+ "safe to move, clean, or hazard-free; it means nothing this report knows how to look for fired there.",
2813
+ );
2814
+ if (noSignal.length === 0) console.log(" none");
2815
+ else for (const region of noSignal) console.log(` ${hexAddr(region.start)}..${hexAddr(region.endInclusive)}`);
2816
+ console.log("");
2817
+
2818
+ console.log(` UNCLASSIFIED REGIONS (${unclassified.length} of ${r.denominator})`);
2819
+ if (unclassified.length === 0) console.log(" none");
2820
+ else for (const region of unclassified) console.log(` ${hexAddr(region.start)}..${hexAddr(region.endInclusive)} (${region.reason ?? "no reason recorded"})`);
2821
+ console.log("");
2822
+
2823
+ // WR-03: rendered here so an operator reading ONLY the human-readable
2824
+ // report (never --json) still sees the declined dispatch candidates
2825
+ // HazardReport's own doc comment insists must never be silently dropped --
2826
+ // "an honest decline indistinguishable from an absence" is exactly the
2827
+ // confusion `HAZARD_LIMITS`'s `indexed-dispatch` entry warns against.
2828
+ console.log(
2829
+ ` UNPROVEN DISPATCH CANDIDATES (${r.unprovenDispatchCandidates.length}) -- declined by the imported scanner's promotion gate; ` +
2830
+ "a decline here is not a claim that no computed dispatch exists in this region, see LIMITS below",
2831
+ );
2832
+ if (r.unprovenDispatchCandidates.length === 0) {
2833
+ console.log(" none");
2834
+ } else {
2835
+ for (const c of r.unprovenDispatchCandidates) {
2836
+ const targets = c.orientationResolved ? addressList(c.targets) : "unresolved -- byte-swap orientation unknown, not printed as addresses";
2837
+ console.log(
2838
+ ` ${hexAddr(c.at)} lo=${hexAddr(c.loBase)} hi=${hexAddr(c.hiBase)} entries=${c.entries}${c.truncated ? " truncated" : ""}`,
2839
+ );
2840
+ console.log(` targets: ${targets}`);
2841
+ }
2842
+ }
2843
+ console.log("");
2844
+
2845
+ console.log(" LIMITS");
2846
+ for (const l of r.limits) {
2847
+ console.log(` [${l.hazardClass ?? "all classes"}] ${l.limit}`);
2848
+ console.log(` ${l.consequence}`);
2849
+ }
2850
+ }
2851
+
2852
+ /**
2853
+ * `hazard-report --store FILE --image FILE [--json]` -- the CLI route for
2854
+ * the movement-hazard report, run here against a real store and a real
2855
+ * image rather than only exposed as an MCP answer (the same reason
2856
+ * `evid-disagreements` carries a CLI verb).
2857
+ *
2858
+ * Opens the store READ-ONLY (`mustExist: true` -- this verb creates
2859
+ * nothing), fetches every input itself (`listRanges()`/`listLabels()`/
2860
+ * `listComments()`/`listXrefs()`/`listExecObservations()`), maps the ranges
2861
+ * through `blocksFromStore()` -- the ONE `RangeRow` -> `BlockEntry` seam,
2862
+ * never re-implemented here -- loads the image through `projectImage()`,
2863
+ * and calls `buildHazardReport()`, the SAME pure function
2864
+ * `anno_hazard_report` calls. `--json` prints the raw answer; otherwise
2865
+ * `printHazardReport()` renders it.
2866
+ */
2867
+ async function cmdHazardReport(rest: string[]): Promise<number> {
2868
+ const { store, storeMissingValue, image, imageMissingValue, json, unknownOption } = parseHazardReportArgs(rest);
2869
+
2870
+ if (unknownOption) {
2871
+ console.error(`hazard-report: unknown option "${unknownOption}"\n`);
2872
+ console.log(USAGE);
2873
+ return 1;
2874
+ }
2875
+ if (storeMissingValue) {
2876
+ console.error("hazard-report: --store requires a value\n");
2877
+ console.log(USAGE);
2878
+ return 1;
2879
+ }
2880
+ if (imageMissingValue) {
2881
+ console.error("hazard-report: --image requires a value\n");
2882
+ console.log(USAGE);
2883
+ return 1;
2884
+ }
2885
+ if (!store) {
2886
+ console.error("hazard-report: --store FILE is required -- this verb answers a question about ONE annotation store.\n");
2887
+ console.log(USAGE);
2888
+ return 1;
2889
+ }
2890
+ if (!image) {
2891
+ console.error("hazard-report: --image FILE is required -- the store holds annotations, never bytes.\n");
2892
+ console.log(USAGE);
2893
+ return 1;
2894
+ }
2895
+
2896
+ const workspaceRoot = repoRoot();
2897
+ let storePath: string;
2898
+ let imagePath: string;
2899
+ try {
2900
+ storePath = storePathWithinWorkspace(store, workspaceRoot);
2901
+ imagePath = storePathWithinWorkspace(image, workspaceRoot);
2902
+ } catch (err) {
2903
+ console.error(`hazard-report: ${errMsg(err)}`);
2904
+ return 1;
2905
+ }
2906
+ if (!existsSync(storePath)) {
2907
+ console.error(
2908
+ `hazard-report: annotation store not found: ${storePath} -- refusing to CREATE one, because "the ` +
2909
+ 'annotations are gone" and "there are no annotations" must not read the same.',
2910
+ );
2911
+ return 1;
2912
+ }
2913
+ if (!existsSync(imagePath)) {
2914
+ console.error(`hazard-report: image not found: ${imagePath}`);
2915
+ return 1;
2916
+ }
2917
+
2918
+ let handle: AnnoStoreHandle;
2919
+ try {
2920
+ handle = openStore(storePath, { workspaceRoot, mustExist: true });
2921
+ } catch (err) {
2922
+ console.error(`hazard-report: ${errMsg(err)}`);
2923
+ return 1;
2924
+ }
2925
+ let report: ReturnType<typeof buildHazardReport>;
2926
+ try {
2927
+ const ranges = blocksFromStore(listRanges(handle));
2928
+ const symbols = listLabels(handle);
2929
+ const comments = listComments(handle);
2930
+ const xrefs = listXrefs(handle);
2931
+ const execObservations = listExecObservations(handle);
2932
+ const loadedImage = projectImage(imagePath);
2933
+ if (loadedImage === null) {
2934
+ console.error(`hazard-report: ${imagePath} did not decode -- supply a .prg or an exactly-65536-byte flat capture`);
2935
+ return 1;
2936
+ }
2937
+ report = buildHazardReport({
2938
+ bytes: loadedImage.bytes,
2939
+ origin: loadedImage.origin,
2940
+ symbols,
2941
+ comments,
2942
+ ranges,
2943
+ xrefs,
2944
+ execObservations,
2945
+ });
2946
+ } catch (err) {
2947
+ console.error(`hazard-report: ${errMsg(err)}`);
2948
+ return 1;
2949
+ } finally {
2950
+ closeStore(handle);
2951
+ }
2952
+
2953
+ // `matched`/`returned` always equal `report.findings.length` here -- this
2954
+ // verb has no `--max-results`/pagination option (unlike the MCP tool's
2955
+ // `anno_hazard_report`, which genuinely slices `report.findings` against
2956
+ // one). They are kept only to mirror that tool's JSON shape; a future
2957
+ // `--max-results` flag on THIS verb would need to make these two diverge
2958
+ // again, the same way the MCP tool's `dispatchHazardReport` already does.
2959
+ if (json) {
2960
+ console.log(JSON.stringify({ store: storePath, image: imagePath, ...report, returned: report.findings.length, matched: report.findings.length }, null, 2));
2961
+ return 0;
2962
+ }
2963
+ printHazardReport(storePath, imagePath, { ...report, returned: report.findings.length, matched: report.findings.length });
2964
+ return 0;
2965
+ }
2966
+
2967
+ /**
2968
+ * Entry point for the `anno` subcommand. Returns an exit code; never calls
2969
+ * exit the process directly (the bin does that). Handles `--help`/no verb/unknown
2970
+ * verb per `acme.mjs`'s own dispatch convention (`src/skills/acme-build/
2971
+ * scripts/acme.mjs`), with one deliberate difference: an explicit `--help`
2972
+ * returns 0 (a no-op invocation with no verb also returns 0), while an
2973
+ * unrecognised verb returns 1.
2974
+ */
2975
+ export async function runAnnoCli(argv: string[]): Promise<number> {
2976
+ const [verb, ...rest] = argv;
2977
+
2978
+ if (!verb || verb === "--help" || verb === "-h") {
2979
+ console.log(USAGE);
2980
+ return 0;
2981
+ }
2982
+
2983
+ try {
2984
+ // IN-06 (D-11.1-04): the single call site for the shared verb-options
2985
+ // check, run BEFORE dispatch so a refused option never reaches any cmd*
2986
+ // function -- one place enforces the closed option set for every verb,
2987
+ // rather than seven places each doing (or, as `verify` proved, NOT doing)
2988
+ // it themselves.
2989
+ //
2990
+ // INSIDE the try since 2026-08-31 (30-REVIEW CR-01, defence in depth).
2991
+ // It used to sit above this block, so a throw from it escaped
2992
+ // `runAnnoCli()` entirely -- which is exactly what a prototype-key verb
2993
+ // did. `checkAcceptedOptions()` is now own-property-safe and cannot
2994
+ // throw for that reason, but the never-throw contract this file's header
2995
+ // states should not depend on one callee staying careful: every
2996
+ // pre-dispatch check belongs under the last-resort net below.
2997
+ const optionError = checkAcceptedOptions(verb, rest);
2998
+ if (optionError) {
2999
+ console.error(optionError);
3000
+ console.log(USAGE);
3001
+ return 1;
3002
+ }
3003
+
3004
+ switch (verb) {
3005
+ case "render-memmap":
3006
+ return await cmdRenderMemmap(rest);
3007
+ case "coverage":
3008
+ return await cmdCoverage(rest);
3009
+ case "export-asm":
3010
+ return await cmdExportAsm(rest);
3011
+ case "evid-disagreements":
3012
+ return await cmdEvidDisagreements(rest);
3013
+ case "decomp-completeness":
3014
+ return await cmdDecompCompleteness(rest);
3015
+ case "hazard-report":
3016
+ return await cmdHazardReport(rest);
3017
+ default:
3018
+ // WR-14 site 2, corrected 2026-08-30 (plan 29-16). This prefix read
3019
+ // `anno:` -- the subcommand renamed to `anno` on 2026-08-29 (29-09)
3020
+ // -- so a user who mistyped a verb was answered by a subcommand that
3021
+ // no longer dispatches. Only the STRING moved: the enclosing function
3022
+ // keeps its current name, so no consumer, test or record entry moves
3023
+ // with it (see the plan's <wr14_scope_decision>).
3024
+ console.error(
3025
+ `anno: unknown verb "${verb}" -- this CLI has exactly six: render-memmap, coverage, export-asm, ` +
3026
+ "evid-disagreements, decomp-completeness and hazard-report\n",
3027
+ );
3028
+ console.log(USAGE);
3029
+ return 1;
3030
+ }
3031
+ } catch (err) {
3032
+ // A last-resort net: every expected failure path above already returns its
3033
+ // own code with its own message, so anything arriving here is unexpected
3034
+ // and is reported verbatim rather than swallowed. The loud failure is the
3035
+ // point (D-07).
3036
+ // WR-14 site 2, second half -- same correction, same reason.
3037
+ console.error(`anno: ${errMsg(err)}`);
3038
+ return 1;
3039
+ }
3040
+ }