@henols/vice-mcp 0.2.2 → 0.2.4

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