@henols/c64-re-tools 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 (37) hide show
  1. package/bin/cli.mjs +7 -4
  2. package/package.json +2 -2
  3. package/skills/acme-build/SKILL.md +39 -23
  4. package/skills/acme-build/scripts/acme.mjs +159 -64
  5. package/skills/acme-build/template.a +1 -1
  6. package/skills/c64-disk-access/SKILL.md +156 -0
  7. package/skills/c64-disk-access/scripts/c1541.mjs +569 -0
  8. package/skills/c64-memory-mapping/SKILL.md +30 -23
  9. package/skills/c64-memory-mapping/scripts/driver.mjs +1 -1
  10. package/skills/c64-petcat/SKILL.md +87 -0
  11. package/skills/c64-petcat/scripts/petcat.mjs +221 -0
  12. package/skills/c64-program-recon/SKILL.md +93 -39
  13. package/skills/c64-program-recon/references/control-flow.md +12 -15
  14. package/skills/c64-program-recon/references/graphics.md +1 -1
  15. package/skills/c64-program-recon/references/observation-hazards.md +18 -16
  16. package/skills/c64-program-recon/references/reconstruction.md +1 -2
  17. package/skills/c64-program-recon/references/sound-and-input.md +6 -8
  18. package/skills/c64-program-recon/references/tool-selection.md +36 -17
  19. package/skills/c64-program-recon/scripts/packer-finding.mjs +165 -87
  20. package/skills/c64-program-recon/templates/memory-map.template.md +2 -2
  21. package/skills/c64-provenance-diff/SKILL.md +40 -5
  22. package/skills/c64-provenance-diff/scripts/diff-images.mjs +8 -8
  23. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +7 -5
  24. package/skills/c64-ram-capture/SKILL.md +112 -44
  25. package/skills/c64-ram-capture/scripts/compare.mjs +2 -2
  26. package/skills/c64-ram-capture/scripts/derive-transients.mjs +575 -0
  27. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +3 -3
  28. package/skills/c64-ram-capture/scripts/mcp-module.mjs +174 -0
  29. package/skills/c64-ram-capture/scripts/releases.mjs +1 -1
  30. package/skills/c64-ram-capture/scripts/vsf-slice.mjs +147 -0
  31. package/skills/c64-ram-capture/scripts/watch-loads.mjs +15 -15
  32. package/skills/c64-ram-capture/templates/capture-record.template.md +44 -4
  33. package/skills/c64-ram-capture/transients/README.md +136 -0
  34. package/skills/routine-queue-walker/SKILL.md +114 -22
  35. package/skills/routine-queue-walker/scripts/completeness-report.mjs +465 -0
  36. package/skills/vice-wedge-triage/SKILL.md +96 -89
  37. package/skills/c64-ram-capture/scripts/d64-parse.mjs +0 -243
@@ -81,7 +81,24 @@ line comment. That is the only test; do not guess from the label name.
81
81
  2. Call `anno_get_comments`, again with an explicit `max_results`. Keep that
82
82
  too — the true match count rides beside the list, so truncation is a fact
83
83
  you are told rather than one you infer.
84
- 3. Keep a label as a routine candidate when any of these holds:
84
+ 3. **Candidate source A cross-reference and block-derived, checked FIRST and
85
+ independently of whatever `anno_get_symbols` returned.** Call
86
+ `anno_get_blocks` with `block_type: "code"` for every code-typed range,
87
+ read each one with `anno_disassemble`, and collect every `jsr` target
88
+ address. For each candidate target, confirm it and gather its full caller
89
+ list with `anno_get_cross_references` (a generous `max_results` — this is
90
+ also the call that fills in "called from" when the entry is written up in
91
+ Phase 2.2). Every one of these targets is a routine candidate **regardless
92
+ of whether it carries any label at all**. A real measured derivation run
93
+ (dxa disassemble, then Ghidra import) found that a purely dxa/Ghidra-derived
94
+ store carries ZERO labels of any shape — derivation writes typed ranges and
95
+ cross-references, never names — so a queue built only from Candidate source
96
+ B below finds nothing to do on such a store and silently reports a clean,
97
+ empty queue on a program nothing has been named in yet. Source A does not
98
+ depend on step 1 having found anything.
99
+ 4. **Candidate source B — the label-prefix path, for a store that DOES carry
100
+ externally-imported auto-names.** Keep a label as a routine candidate when
101
+ any of these holds:
85
102
  - its name starts with `s_` (an auto-generated subroutine label);
86
103
  - it sits in a code region and is the target of at least one `JSR`
87
104
  cross-reference (`anno_get_cross_references`);
@@ -91,25 +108,27 @@ line comment. That is the only test; do not guess from the label name.
91
108
  or jump-table and callback targets. Treat every one of them as a
92
109
  candidate rather than pattern-matching specific vector addresses;
93
110
  - it is the label named exactly `start`.
94
- 4. Drop every candidate that already carries a line comment.
95
- 5. What is left is the routine queue.
96
- 6. **Order it with `start` first** when `start` is in it. The entry point sets
111
+ 5. **Union sources A and B by address** — a routine reachable both ways counts
112
+ once. A store may carry either shape, or both, so neither source alone is
113
+ sufficient.
114
+ 6. Drop every candidate that already carries a line comment.
115
+ 7. What is left is the routine queue.
116
+ 8. **Order it with `start` first** when `start` is in it. The entry point sets
97
117
  the context every other routine is read against.
98
118
 
99
119
  ### 2.2 Walk it
100
120
 
101
- - **Always work from an explicit address** `$XXXX`, or the decimal
102
- equivalent. Never from "wherever we are"; there is no editor cursor in this
103
- project's route, and upstream's own text forbids relying on one anyway. Read
104
- the routine's bytes with `anno_read_region` over the explicit range.
105
- - Take **one** entry at a time, to completion, before starting the next.
106
- - For each entry, do the full job: rename the label (`anno_set_label_name`),
107
- add a header line comment describing what the routine does and what it
108
- leaves in the registers and memory, add side comments on the instructions
109
- that carry the meaning (`anno_set_comment`), and record anything you are
110
- unsure about rather than smoothing it over.
111
- - Record per entry: the address, the old label, the new label, a one-line
112
- summary, and any uncertainty. That record is the report in Phase 4.
121
+ Take **one** entry at a time, to completion, before starting the next — the
122
+ queue discipline this section owns. For each entry, run `c64-program-recon`
123
+ `SKILL.md`'s **"Documenting one routine, end to end"** procedure (steps 1-7)
124
+ against the entry's explicit address including its 4096-byte
125
+ `anno_read_region` cap (consecutive ranges above it, never a raised cap) and
126
+ its tail-call / fall-through bounds rules (`JMP shared_epilogue` still ends
127
+ the routine; no return may mean fall-through say so). Do not re-derive or
128
+ paraphrase that procedure here.
129
+
130
+ Record per entry, for Phase 4: the address, the old label, the new label, a
131
+ one-line summary, and any uncertainty.
113
132
 
114
133
  ### 2.3 Refresh point
115
134
 
@@ -131,12 +150,30 @@ when it is a well-known system address (hardware register, KERNAL entry point,
131
150
  OS variable).
132
151
 
133
152
  1. Call `anno_get_symbols` **again** — Phase 2 renamed things.
134
- 2. Keep every label whose name still matches an auto-generated pattern:
135
- `zpp_XX`, `zpf_XX`, `zpa_XX` in the zero page; `p_XXXX`, `f_XXXX`, `a_XXXX`
136
- and `e_XXXX` outside it.
137
- 3. Exclude: `s_XXXX` (Phase 2 handled those), `b_XXXX` (branch targets, not
138
- data symbols), and any `p_XXXX` inside a code region (also Phase 2's).
139
- 4. What is left is the symbol queue.
153
+ 2. **Candidate source A cross-reference and block-derived, checked FIRST
154
+ and independently of whatever label population exists.** Call
155
+ `anno_get_blocks` (with `include: ["enum_usage"]` where useful) for every
156
+ typed range, then use `anno_get_cross_references` to find every address
157
+ that is: referenced by one half of a split lo/hi pair or by an address
158
+ table (a `lo_hi_address`/`hi_lo_address`/`lo_hi_word`/`hi_lo_word` range
159
+ the `_address` forms produce cross-references, the `_word` forms do not,
160
+ per that data type's own schema distinction), OR referenced from a code
161
+ range while NOT itself sitting inside one. Every one of these is a symbol
162
+ candidate **regardless of whether it carries any label at all**.
163
+ The same measured derivation run found that a
164
+ purely dxa/Ghidra-derived store carries ZERO labels of any shape, so
165
+ Candidate source B below finds nothing to do on such a store and silently
166
+ reports a clean, empty queue on a program nothing has been named in yet.
167
+ 3. **Candidate source B — the label-prefix path, for a store that DOES carry
168
+ externally-imported auto-names.** Keep every label whose name still
169
+ matches an auto-generated pattern: `zpp_XX`, `zpf_XX`, `zpa_XX` in the zero
170
+ page; `p_XXXX`, `f_XXXX`, `a_XXXX` and `e_XXXX` outside it.
171
+ 4. Exclude, from BOTH sources: `s_XXXX` (Phase 2 handled those), `b_XXXX`
172
+ (branch targets, not data symbols), and any `p_XXXX`-shaped or
173
+ xref-derived candidate inside a code region (also Phase 2's).
174
+ 5. **Union sources A and B by address** — a symbol reachable both ways counts
175
+ once.
176
+ 6. What is left is the symbol queue.
140
177
 
141
178
  ### 3.2 Walk it
142
179
 
@@ -257,6 +294,45 @@ Anything the per-measure findings list names belongs in Phase 4's leftovers
257
294
  table, by address. A finding is a named defect in one named measure — it is
258
295
  never a rating, and there is no number to report as "the coverage".
259
296
 
297
+ ### The decomposition-completeness gate
298
+
299
+ This is a DIFFERENT, non-overlapping measurement from the `anno coverage`
300
+ call above — neither replaces the other. `anno coverage` is the byte-census
301
+ and label-ratio instrument: a derived-from-bytes census this store's own
302
+ block table cannot move. `anno decomp-completeness` is the
303
+ disagreement-gated closure gate: whether this fixture's byte-derived block
304
+ classification and its own real, observed-execution evidence agree, every
305
+ code entry point carries a name and a complete purpose comment, every
306
+ referenced non-hardware address resolves to a name or a decline, and no
307
+ auto-named survivor remains in a code region — with the disagreement query
308
+ itself a required, non-defaultable input rather than an optional
309
+ cross-check.
310
+
311
+ ```
312
+ node src/mcp/vice/vice-proxy.ts anno decomp-completeness --store <fixture>.annostore --disagreements <fixture>-disagreements.json --manifest src/mcp/vice/fixtures/decomp-execution-manifest.json
313
+ ```
314
+
315
+ All three arguments are REQUIRED, and none is derived from another: `--store`
316
+ names the annotation store; `--disagreements` names the JSON `anno
317
+ evid-disagreements --store <same store> --json` wrote for THIS store's own
318
+ run; `--manifest` names the committed execution manifest recording which of
319
+ the nine fixtures were actually run under the reproducible-run protocol, and
320
+ which were declared not-executed and why. Omitting any of the three refuses
321
+ by name rather than rendering an empty-disagreement report — "the query was
322
+ never run" and "the query found nothing" must never read the same.
323
+
324
+ **The stop condition is a measured exit code, not a belief.** The walk
325
+ described in Phases 2-4 above is finished for a fixture when `node
326
+ src/skills/routine-queue-walker/scripts/completeness-report.mjs --store
327
+ <fixture>.annostore --disagreements <fixture>-disagreements.json --manifest
328
+ src/mcp/vice/fixtures/decomp-execution-manifest.json` **exits 0** — never when
329
+ the agent believes the queue is empty. A non-zero exit names, by address,
330
+ exactly which measure still fails (an Undefined byte, a surviving auto-name,
331
+ an entry point missing a name or a purpose-comment element, an unresolved
332
+ referenced address, or an unresolved disagreement); go back to the
333
+ corresponding phase and close it, then re-run the gate. Do not report a pass
334
+ from reading the rendered text alone — read the process exit code.
335
+
260
336
  ## When something fails
261
337
 
262
338
  - A failed call is not a reason to drop a queue entry. Log the address, the
@@ -271,3 +347,19 @@ never a rating, and there is no number to report as "the coverage".
271
347
  - Never invent an answer to make a queue entry go away. An honest "this looks
272
348
  like a table, callers unclear" in the leftovers table is worth more than a
273
349
  confident wrong label that the next reader has to un-learn.
350
+ - **A genuinely unresolvable target gets a `DECLINED:` comment, never a
351
+ fabricated name.** When a referenced address's target is truly
352
+ path-dependent or otherwise cannot be determined, record it with
353
+ `anno_set_comment` using the literal prefix `DECLINED:` naming what is
354
+ unknown and why — the same convention `.annostore`'s own importer already
355
+ uses for bank-state declines, never a second mechanism. A confident wrong
356
+ label is worse than an absent one.
357
+ - **An accepted disagreement gets a `DISAGREEMENT-ACCEPTED:` comment.** When
358
+ the decomposition-completeness gate's disagreement census flags a byte the
359
+ byte-derived block table calls `data` but the runtime evidence shows
360
+ executing, and review confirms the runtime evidence is correct (or the
361
+ disagreement is otherwise a reviewed, accepted fact rather than a
362
+ classification bug), record it with `anno_set_comment` using the literal
363
+ prefix `DISAGREEMENT-ACCEPTED:` naming why — greppable, and read by the gate
364
+ itself as the resolution for that address. Both conventions ride the
365
+ existing `anno_set_comment` tool; neither is a new mechanism.
@@ -0,0 +1,465 @@
1
+ #!/usr/bin/env node
2
+ // completeness-report.mjs -- renders the per-fixture decomposition
3
+ // completeness report, and OWNS the GATE: this script's own process exit
4
+ // code is routine-queue-walker's numeric stop condition -- 0 only when every measure below
5
+ // clears its own bar, 1 the instant any one of them does not, naming which
6
+ // measure and which address failed.
7
+ //
8
+ // WHY THIS FILE EXISTS: criterion 2's disagreement query and criterion 1's
9
+ // zero-`Undefined` census must be answered TOGETHER, from ONE real store, or
10
+ // a completeness claim can hide a weak measure behind a strong one -- exactly
11
+ // the failure `printCoverageReport()`/`printEvidDisagreementsReport()` were
12
+ // each already built to avoid, one verb over. `routine-queue-walker` already
13
+ // exists to drive an annotation store's backlog to closure and report every
14
+ // leftover; this script supplies the numeric stop condition it currently
15
+ // lacks.
16
+ //
17
+ // WHAT THIS IS THE ONE AUTHORITATIVE PLACE FOR: rendering the per-fixture
18
+ // decomposition-completeness report, REFUSING to render at all without the
19
+ // disagreement input (a required output-schema field only that input can
20
+ // populate), and computing the GATE's pass/fail verdict
21
+ // and exit code from the rendered measures.
22
+ //
23
+ // WHAT NOT TO DO:
24
+ // - Never derive a completeness measure from the store's block-type
25
+ // listing directly in THIS file. `anno-coverage.ts`'s own trap 1 forbids
26
+ // it there, and extending or re-implementing that module here is
27
+ // equally forbidden -- this script never reads a store; it renders the fifth CLI
28
+ // verb's own `--json` answer, which already did the reading.
29
+ // - Never render without the disagreement-query input. A missing input is
30
+ // refused by name (`MissingDisagreementInputError`), never defaulted to
31
+ // an empty array -- an omitted query and a query that found nothing must
32
+ // never look the same.
33
+ // - Never print a percentage, rate or combined figure. Every count in this
34
+ // report carries its own denominator, exactly like
35
+ // `printEvidDisagreementsReport()`'s own discipline.
36
+ // - Never re-implement `evid-reconcile.ts`'s four-bucket join, or rename
37
+ // any of its field names. This script only ever reads
38
+ // `disagreementInput`'s fields verbatim, as `anno decomp-completeness
39
+ // --json` already named them.
40
+ // - Never restate any of the four split-table `SPLIT_DATA_TYPES` spellings
41
+ // as a literal string in THIS file (a mechanical grep guard over this
42
+ // exact file is a standing acceptance criterion). The "table" naming
43
+ // is computed on the VERB side (`anno-cli.ts`'s own
44
+ // `renderedType` field, read from `anno-types.ts`'s `isSplitDataType()`)
45
+ // and this script only ever renders `renderedType` verbatim.
46
+ // - Never soften a gate failure into a bulletin. `computeGateFailures()`
47
+ // below is the ONE place a measure becomes a pass/fail verdict; a
48
+ // softened refusal here is exactly what planted controls 1 and 2 (task
49
+ // 2) exist to catch going RED.
50
+ // - Never carry a second copy of the MCP-module resolution ladder. It
51
+ // lives ONE place, `../../c64-ram-capture/scripts/mcp-module.mjs`, and is
52
+ // imported from there.
53
+ import { spawnSync } from "node:child_process";
54
+
55
+ import { resolveMcpModule, refusalMessage, TARGET_PACKAGE } from "../../c64-ram-capture/scripts/mcp-module.mjs";
56
+
57
+ /** The MCP-side entry point this script forwards to -- the SAME "node
58
+ * vice-proxy.ts anno <verb>" invocation `routine-queue-walker/SKILL.md`'s
59
+ * skill's own closing `anno coverage` call already uses (no broker, no
60
+ * container-out seam -- the store is `node:sqlite` in-process, and this
61
+ * script's own job is orchestration, never a store read of its own). */
62
+ const TARGET_FILE = "vice-proxy.ts";
63
+
64
+ /** Thrown by `renderCompletenessReport()` when `report.disagreementInput` is
65
+ * absent, or present but missing a complete `runIdentity` -- mechanism
66
+ * 2's own required output-schema field. The message always names the
67
+ * `--disagreements` flag literally, so a caller reading only the thrown
68
+ * message still knows what to pass. */
69
+ export class MissingDisagreementInputError extends Error {
70
+ constructor(message) {
71
+ super(message);
72
+ this.name = "MissingDisagreementInputError";
73
+ }
74
+ }
75
+
76
+ /**
77
+ * The frozen survivor prefix set, MIRRORED from
78
+ * `src/mcp/vice/anno-cli.ts`'s own frozen set -- frozen from a real
79
+ * derivation run (dxa disassemble, then Ghidra import) that measured ZERO
80
+ * labels written by import alone, confirming the eleven prefixes had
81
+ * nothing populated to positively test against rather than contradicting
82
+ * them. Exported here, separately from the verb's own copy, because this
83
+ * script's own tests must be able to assert on the predicate in isolation,
84
+ * without a live store or a subprocess -- and because this script's own
85
+ * header forbids it from reading a store directly, so it cannot import the
86
+ * verb's copy through anything but a duplicate literal.
87
+ *
88
+ * WHAT NOT TO DO: if the frozen set in `anno-cli.ts` ever changes, this copy
89
+ * moves in the SAME commit, or the two renderers silently disagree about
90
+ * what "survivor" means. Never restate `AUTO_NAME_PREFIX_RE`'s eleven
91
+ * prefixes as their own literal strings here -- this predicate matches
92
+ * against a caller-SUPPLIED name (from the verb's own `survivors` answer),
93
+ * never derives a name from a store itself, so there is no store-derived
94
+ * value to keep in sync beyond this one regex pair.
95
+ */
96
+ const AUTO_NAME_PREFIX_RE = /^(zpf_|f_|zpa_|a_|p_|zpp_|e_|j_|s_|b_|r_)/;
97
+ const SURVIVOR_EXTRA_RE = /^(?:l_[0-9a-f]{4}|(?:FUN|LAB)_[0-9a-f]{4}|l[0-9a-f]{3,4})$/;
98
+
99
+ /** True iff `name` is a survivor under the frozen set. ASCII case-sensitive:
100
+ * `l_0810` IS a survivor, `L_0810` is NOT -- `anno-coverage.test.ts`'s own
101
+ * `L_` exclusion precedent, restated for this phase's own prefix set. */
102
+ export function isSurvivorName(name) {
103
+ return AUTO_NAME_PREFIX_RE.test(name) || SURVIVOR_EXTRA_RE.test(name);
104
+ }
105
+
106
+ /**
107
+ * The precedence rule, mirrored here (see this file's own
108
+ * header on why a mirror rather than an import) so this script's own test
109
+ * tier can assert the PRECEDENCE explicitly, not merely pass through a
110
+ * verb-computed value. `anno-cli.ts`'s `typedByFor()` is the authoritative
111
+ * copy that actually runs against a real store; this one exists only to be
112
+ * unit-tested in isolation, exactly like `isSurvivorName()` above. Evidence
113
+ * beats inference: `observed-executing` (a real execute observation exists
114
+ * inside the range) beats `authored` (an `AUTHORED_PROVENANCE_COMMENT_PREFIX`
115
+ * comment exists and there is no observation) beats `byte-derived` (neither).
116
+ * A range that is BOTH observed and authored renders `observed-executing`.
117
+ */
118
+ export function typedByFor(hasObservation, hasAuthoredComment) {
119
+ if (hasObservation) return "observed-executing";
120
+ if (hasAuthoredComment) return "authored";
121
+ return "byte-derived";
122
+ }
123
+
124
+ const PURPOSE_ELEMENT_KEYS = ["function", "inputs", "outputs", "sideEffects"];
125
+
126
+ /**
127
+ * Normalises a raw `anno decomp-completeness --json` answer into the report
128
+ * shape this module renders and gates. Pure: no filesystem, no subprocess, no
129
+ * store. Throws a plain `Error` (never `MissingDisagreementInputError`, which
130
+ * is `renderCompletenessReport()`'s own refusal) when `answer` is not even a
131
+ * plausible answer object -- a caller error, distinct from a missing
132
+ * disagreement input.
133
+ *
134
+ * Every NEW field defaults to the CONSERVATIVE (gate-failing or vacuity-
135
+ * naming) shape when absent, never to a shape that would silently pass --
136
+ * The same refuse-by-name discipline, applied to every field, not only the
137
+ * original disagreement input.
138
+ */
139
+ export function buildCompletenessReport(answer) {
140
+ if (typeof answer !== "object" || answer === null) {
141
+ throw new Error("buildCompletenessReport: expected a decomp-completeness --json answer object, got " + JSON.stringify(answer));
142
+ }
143
+ const disagreementInput = answer.disagreementInput;
144
+ const disagreementResolution =
145
+ answer.disagreementResolution && typeof answer.disagreementResolution === "object"
146
+ ? {
147
+ rows: Array.isArray(answer.disagreementResolution.rows) ? answer.disagreementResolution.rows : [],
148
+ unresolvedCount: answer.disagreementResolution.unresolvedCount ?? (disagreementInput?.disagreementCount ?? 0),
149
+ denominator: answer.disagreementResolution.denominator ?? (disagreementInput?.denominator ? disagreementInput.disagreementCount : 0),
150
+ }
151
+ : {
152
+ rows: [],
153
+ // Conservative default: an answer that carries a real disagreement
154
+ // count but no resolution census at all is treated as ENTIRELY
155
+ // unresolved, never as a silent pass -- the same discipline
156
+ // applies to the disagreement input itself, applied here to its
157
+ // resolution.
158
+ unresolvedCount: disagreementInput?.disagreementCount ?? 0,
159
+ denominator: disagreementInput?.disagreementCount ?? 0,
160
+ };
161
+ return {
162
+ store: answer.store,
163
+ fixture: answer.fixture,
164
+ executionDisposition: answer.executionDisposition,
165
+ notExecutedReason: answer.notExecutedReason ?? null,
166
+ byteCensus: answer.byteCensus,
167
+ survivors: Array.isArray(answer.survivors) ? answer.survivors : [],
168
+ rangeProvenance: Array.isArray(answer.rangeProvenance) ? answer.rangeProvenance : [],
169
+ entryPoints: Array.isArray(answer.entryPoints) ? answer.entryPoints : [],
170
+ referencedAddresses:
171
+ answer.referencedAddresses && typeof answer.referencedAddresses === "object"
172
+ ? {
173
+ resolved: Array.isArray(answer.referencedAddresses.resolved) ? answer.referencedAddresses.resolved : [],
174
+ declined: Array.isArray(answer.referencedAddresses.declined) ? answer.referencedAddresses.declined : [],
175
+ unresolved: Array.isArray(answer.referencedAddresses.unresolved) ? answer.referencedAddresses.unresolved : [],
176
+ denominator: answer.referencedAddresses.denominator ?? 0,
177
+ }
178
+ : { resolved: [], declined: [], unresolved: [], denominator: 0 },
179
+ disagreementInput,
180
+ disagreementResolution,
181
+ };
182
+ }
183
+
184
+ function addr(a) {
185
+ return typeof a === "number" ? `$${a.toString(16).padStart(4, "0")}` : String(a);
186
+ }
187
+
188
+ /**
189
+ * Renders `report` as text, copying `printEvidDisagreementsReport()`'s own
190
+ * rendering discipline exactly: every measure under its own heading,
191
+ * disagreements first, `denominator` beside every count, an explicit
192
+ * sentence stating what absence does NOT prove, and never a percentage,
193
+ * rate or combined figure.
194
+ *
195
+ * THROWS `MissingDisagreementInputError` when
196
+ * `report.disagreementInput` is absent, or present but its own
197
+ * `runIdentity` is neither `null` nor a complete
198
+ * `{ imageSha256, argvDigest, seed }` object. An empty `disagreements`
199
+ * ARRAY alone is not enough to refuse -- that is a real, non-vacuous "zero
200
+ * disagreements" answer; what is refused is the ABSENCE of the input
201
+ * itself.
202
+ *
203
+ * `identity === null` is a THIRD,
204
+ * legitimate value here, mirroring anno-cli.ts's own
205
+ * `validateDisagreementDocumentShape()`/match-check -- the real answer `anno
206
+ * evid-disagreements --json` produces for a store with zero observed runs
207
+ * (a non-executed fixture). By the time a report reaches this
208
+ * function, `anno decomp-completeness`'s own server-side check has already
209
+ * proven that null against the store's own evid-runs table (refusing a
210
+ * null identity on a store that DOES carry real runs) -- this function
211
+ * never re-derives that proof, only trusts an already-validated report.
212
+ */
213
+ export function renderCompletenessReport(report) {
214
+ const input = report?.disagreementInput;
215
+ const identity = input?.runIdentity;
216
+ const identityIsWellFormed =
217
+ identity === null ||
218
+ (typeof identity === "object" && identity !== null && typeof identity.imageSha256 === "string" && typeof identity.argvDigest === "string" && typeof identity.seed === "string");
219
+ if (input === undefined || input === null || !identityIsWellFormed) {
220
+ throw new MissingDisagreementInputError(
221
+ "renderCompletenessReport: no disagreement input is present on this report -- refusing to render. " +
222
+ "Pass --disagreements to `anno decomp-completeness` (the JSON `anno evid-disagreements --json` wrote " +
223
+ "for the SAME store); an omitted query and a query that found nothing must never render the same report.",
224
+ );
225
+ }
226
+
227
+ const lines = [];
228
+ lines.push(`decomposition completeness: ${report.store ?? "(unknown store)"}`);
229
+ lines.push(` FIXTURE: ${report.fixture ?? "(unknown fixture)"}`);
230
+ if (report.executionDisposition === "not-executed") {
231
+ lines.push(` NOT EXECUTED: ${report.notExecutedReason ?? "(no reason recorded)"}`);
232
+ } else {
233
+ lines.push(" EXECUTED: this fixture was run under the reproducible-run protocol.");
234
+ }
235
+ lines.push("");
236
+
237
+ const census = report.byteCensus ?? { byType: {}, undefinedCount: 0, denominator: 0 };
238
+ lines.push(` BYTE CENSUS (denominator ${census.denominator ?? 0})`);
239
+ for (const [type, count] of Object.entries(census.byType ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
240
+ lines.push(` ${type}: ${count} of ${census.denominator ?? 0}`);
241
+ }
242
+ lines.push(` undefined: ${census.undefinedCount ?? 0} of ${census.denominator ?? 0}`);
243
+ for (const gap of Array.isArray(census.undefinedRanges) ? census.undefinedRanges : []) {
244
+ lines.push(` UNDEFINED: ${addr(gap.start)}-${addr(gap.endInclusive)}`);
245
+ }
246
+ lines.push("");
247
+
248
+ const survivors = report.survivors ?? [];
249
+ lines.push(` SURVIVORS (${survivors.length})`);
250
+ if (survivors.length === 0) {
251
+ lines.push(" none");
252
+ } else {
253
+ for (const s of survivors) {
254
+ lines.push(` ${addr(s.address)} ${s.name}`);
255
+ }
256
+ }
257
+ lines.push("");
258
+
259
+ lines.push(` DISAGREEMENTS (${input.disagreementCount ?? 0} of ${input.denominator ?? 0})`);
260
+ const disagreements = Array.isArray(input.disagreements) ? input.disagreements : [];
261
+ if (disagreements.length === 0) {
262
+ lines.push(" none");
263
+ } else {
264
+ for (const d of disagreements) {
265
+ const banks = Array.isArray(d.sourceBanks) ? d.sourceBanks.join(",") : "";
266
+ lines.push(` ${addr(d.address)} byte-derived=${d.byteDerived} runtime=${d.runtime} banks=${banks}`);
267
+ }
268
+ }
269
+ lines.push(` AGREEMENT: ${input.agreementCount ?? 0} of ${input.denominator ?? 0}`);
270
+ lines.push(
271
+ ` NO OBSERVATION: ${input.blockCoveredNeverObservedCount ?? 0} of ${input.denominator ?? 0} -- an address never observed ` +
272
+ "executing proves NOTHING about what it is; absence is not evidence for or against any classification.",
273
+ );
274
+ const resolution = report.disagreementResolution ?? { rows: [], unresolvedCount: 0, denominator: 0 };
275
+ const acceptedCount = resolution.rows.length - resolution.unresolvedCount;
276
+ lines.push(
277
+ ` DISAGREEMENT RESOLUTION: ${acceptedCount} accepted, ${resolution.unresolvedCount} unresolved of ${resolution.denominator} -- ` +
278
+ "criterion 2's own gate: a nonzero unresolved count BLOCKS rather than being reported beside a pass.",
279
+ );
280
+ lines.push("");
281
+
282
+ const rangeProvenance = report.rangeProvenance ?? [];
283
+ lines.push(` RANGE PROVENANCE (${rangeProvenance.length} range(s))`);
284
+ if (rangeProvenance.length === 0) {
285
+ lines.push(" none");
286
+ } else {
287
+ for (const row of rangeProvenance) {
288
+ lines.push(` ${addr(row.start)}-${addr(row.endInclusive)} ${row.renderedType} typedBy: ${row.typedBy}`);
289
+ }
290
+ }
291
+ lines.push("");
292
+
293
+ const entryPoints = report.entryPoints ?? [];
294
+ const fullyDocumented = entryPoints.filter(
295
+ (e) => e.hasName && PURPOSE_ELEMENT_KEYS.every((k) => e.purposeElements && e.purposeElements[k]),
296
+ ).length;
297
+ lines.push(` ENTRY POINTS (${fullyDocumented} of ${entryPoints.length})`);
298
+ if (entryPoints.length === 0) {
299
+ lines.push(" none -- a zero-entry-point count is a fact about the candidate set, never evidence of completeness.");
300
+ } else {
301
+ for (const e of entryPoints) {
302
+ const missing = PURPOSE_ELEMENT_KEYS.filter((k) => !(e.purposeElements && e.purposeElements[k]));
303
+ lines.push(
304
+ ` ${addr(e.address)} ${e.name ?? "(unnamed)"} hasName=${Boolean(e.hasName)}` +
305
+ (missing.length > 0 ? ` MISSING: ${missing.join(", ")}` : " purpose comment complete"),
306
+ );
307
+ }
308
+ }
309
+ lines.push("");
310
+
311
+ const refs = report.referencedAddresses ?? { resolved: [], declined: [], unresolved: [], denominator: 0 };
312
+ lines.push(` REFERENCED NON-HARDWARE ADDRESSES (${refs.resolved.length} resolved of ${refs.denominator})`);
313
+ if (refs.denominator === 0) {
314
+ lines.push(" none -- a zero-referenced-address count is a fact about the candidate set, never evidence of completeness.");
315
+ } else {
316
+ lines.push(` RESOLVED: ${refs.resolved.length === 0 ? "none" : refs.resolved.map(addr).join(", ")}`);
317
+ lines.push(` DECLINED: ${refs.declined.length === 0 ? "none" : refs.declined.map((d) => `${addr(d.address)} (${d.reason})`).join(", ")}`);
318
+ lines.push(` UNRESOLVED: ${refs.unresolved.length === 0 ? "none" : refs.unresolved.map(addr).join(", ")}`);
319
+ }
320
+ lines.push("");
321
+
322
+ lines.push(
323
+ " Read every figure above against the others, never combined into one -- together they name what this " +
324
+ "store's block table covers, never what the program actually is.",
325
+ );
326
+
327
+ const failures = computeGateFailures(report);
328
+ lines.push("");
329
+ if (failures.length === 0) {
330
+ lines.push(" GATE: PASS -- every measure above cleared its own bar.");
331
+ } else {
332
+ lines.push(` GATE: FAIL (${failures.length})`);
333
+ for (const f of failures) lines.push(` - ${f}`);
334
+ }
335
+
336
+ return lines.join("\n");
337
+ }
338
+
339
+ /**
340
+ * THE GATE (the numeric stop condition; criterion 2's own words: a
341
+ * nonzero unresolved count BLOCKS rather than being reported beside a
342
+ * pass). Returns an array of human-readable failure strings, each naming
343
+ * the offending address where one exists; an empty array means the gate
344
+ * passes. Never throws -- a malformed report renders its own absence as a
345
+ * failure (see the individual guards below) rather than crashing the report
346
+ * that exists to surface exactly this kind of gap.
347
+ *
348
+ * ALL FIVE gate conditions, restated from the plan this implements:
349
+ * 1. `byteCensus.undefinedCount === 0`
350
+ * 2. `disagreementResolution.unresolvedCount === 0`
351
+ * 3. `survivors` is empty
352
+ * 4. every `entryPoints` row has `hasName` true and all four
353
+ * `purposeElements` true
354
+ * 5. `referencedAddresses.unresolved` is empty
355
+ */
356
+ export function computeGateFailures(report) {
357
+ const failures = [];
358
+
359
+ const undefinedCount = report?.byteCensus?.undefinedCount ?? 0;
360
+ if (undefinedCount !== 0) {
361
+ const gaps = Array.isArray(report?.byteCensus?.undefinedRanges) ? report.byteCensus.undefinedRanges : [];
362
+ const named = gaps.length > 0 ? gaps.map((g) => (g.start === g.endInclusive ? addr(g.start) : `${addr(g.start)}-${addr(g.endInclusive)}`)).join(", ") : "(address not reported)";
363
+ failures.push(`byte census: ${undefinedCount} Undefined byte(s) remain at ${named} (must be 0)`);
364
+ }
365
+
366
+ const survivors = Array.isArray(report?.survivors) ? report.survivors : [];
367
+ if (survivors.length > 0) {
368
+ for (const s of survivors) failures.push(`survivor auto-name at ${addr(s.address)} (${s.name}) still sits in a code region`);
369
+ }
370
+
371
+ const entryPoints = Array.isArray(report?.entryPoints) ? report.entryPoints : [];
372
+ for (const e of entryPoints) {
373
+ if (!e.hasName) {
374
+ failures.push(`entry point ${addr(e.address)} has no authored name`);
375
+ continue;
376
+ }
377
+ const missing = PURPOSE_ELEMENT_KEYS.filter((k) => !(e.purposeElements && e.purposeElements[k]));
378
+ if (missing.length > 0) {
379
+ failures.push(`entry point ${addr(e.address)} is missing purpose-comment element(s): ${missing.join(", ")}`);
380
+ }
381
+ }
382
+
383
+ const refs = report?.referencedAddresses ?? { unresolved: [] };
384
+ for (const a of Array.isArray(refs.unresolved) ? refs.unresolved : []) {
385
+ failures.push(`referenced address ${addr(a)} is neither named nor declined`);
386
+ }
387
+
388
+ const resolution = report?.disagreementResolution ?? { unresolvedCount: 0 };
389
+ const unresolvedCount = resolution.unresolvedCount ?? 0;
390
+ if (unresolvedCount !== 0) {
391
+ failures.push(`${unresolvedCount} disagreement(s) remain unresolved (no DISAGREEMENT-ACCEPTED comment)`);
392
+ }
393
+
394
+ return failures;
395
+ }
396
+
397
+ /**
398
+ * Forwards `["anno", "decomp-completeness", ...argv, "--json"]` to the
399
+ * resolved MCP-side `vice-proxy.ts`, parses its stdout as JSON, and returns
400
+ * `buildCompletenessReport()`'s own normalised shape. Never rejects: an
401
+ * unresolved MCP module, a non-zero exit, or unparsable stdout all resolve
402
+ * to a thrown `Error` with the seam's own refusal text (or, per this
403
+ * script's never-throw posture at the CLI boundary, `main()` below catches
404
+ * it and reports it as an exit code) -- this function itself may throw,
405
+ * since it is the in-process API a test or another script calls directly.
406
+ */
407
+ export function fetchCompletenessReport(argv) {
408
+ const resolved = resolveMcpModule(TARGET_FILE);
409
+ if (!resolved.ok) {
410
+ throw new Error(
411
+ `completeness-report.mjs: ${refusalMessage(TARGET_FILE, resolved.rungs)}\n` +
412
+ `${TARGET_FILE} is where the fifth anno CLI verb lives (${TARGET_PACKAGE}). Refusing rather than ` +
413
+ "reading a store directly here -- a second copy of that read is exactly the divergence this script's own header forbids.",
414
+ );
415
+ }
416
+ const fullArgv = ["anno", "decomp-completeness", ...argv, "--json"];
417
+ const run = spawnSync(process.execPath, [resolved.path, ...fullArgv], { encoding: "utf8" });
418
+ if (run.error) {
419
+ throw new Error(`completeness-report.mjs: could not run ${resolved.path}: ${run.error.message}`);
420
+ }
421
+ if (run.signal) {
422
+ throw new Error(`completeness-report.mjs: ${resolved.path} was killed by ${run.signal}`);
423
+ }
424
+ if (run.status !== 0) {
425
+ throw new Error(`completeness-report.mjs: anno decomp-completeness exited ${run.status}: ${run.stderr || run.stdout}`);
426
+ }
427
+ let parsed;
428
+ try {
429
+ parsed = JSON.parse(run.stdout);
430
+ } catch (err) {
431
+ throw new Error(`completeness-report.mjs: anno decomp-completeness --json did not print valid JSON: ${err.message}`);
432
+ }
433
+ return buildCompletenessReport(parsed);
434
+ }
435
+
436
+ /** CLI entry point: `node completeness-report.mjs --store FILE --disagreements FILE --manifest FILE`.
437
+ * Forwards argv verbatim to the resolved verb, renders the result, and
438
+ * returns a process exit code -- never calls `process.exit()` itself, so
439
+ * `main()` stays testable in-process. A thrown `MissingDisagreementInputError`
440
+ * is reported with its own message and nothing more (the refusal IS the
441
+ * report); any other thrown error is reported the same way, verbatim,
442
+ * never swallowed. On a SUCCESSFULLY RENDERED report, the exit code is THE
443
+ * GATE's own verdict (`computeGateFailures()`), never a bare 0 -- this is
444
+ * the numeric stop condition, and softening it here is exactly the
445
+ * regression planted controls 1/2 (task 2) exist to catch. */
446
+ export function main(argv) {
447
+ let report;
448
+ try {
449
+ report = fetchCompletenessReport(argv);
450
+ } catch (err) {
451
+ console.error(err instanceof Error ? err.message : String(err));
452
+ return 1;
453
+ }
454
+ try {
455
+ console.log(renderCompletenessReport(report));
456
+ return computeGateFailures(report).length === 0 ? 0 : 1;
457
+ } catch (err) {
458
+ console.error(err instanceof Error ? err.message : String(err));
459
+ return 1;
460
+ }
461
+ }
462
+
463
+ if (import.meta.url === `file://${process.argv[1]}`) {
464
+ process.exitCode = main(process.argv.slice(2));
465
+ }