@henols/c64-re-tools 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/skills/acme-build/SKILL.md +39 -23
- package/skills/acme-build/scripts/acme.mjs +159 -64
- package/skills/acme-build/template.a +1 -1
- package/skills/c64-disk-access/SKILL.md +156 -0
- package/skills/c64-disk-access/scripts/c1541.mjs +569 -0
- package/skills/c64-memory-mapping/SKILL.md +30 -23
- package/skills/c64-memory-mapping/scripts/driver.mjs +1 -1
- package/skills/c64-petcat/SKILL.md +87 -0
- package/skills/c64-petcat/scripts/petcat.mjs +221 -0
- package/skills/c64-program-recon/SKILL.md +93 -39
- package/skills/c64-program-recon/references/control-flow.md +12 -15
- package/skills/c64-program-recon/references/graphics.md +1 -1
- package/skills/c64-program-recon/references/observation-hazards.md +18 -16
- package/skills/c64-program-recon/references/reconstruction.md +1 -2
- package/skills/c64-program-recon/references/sound-and-input.md +6 -8
- package/skills/c64-program-recon/references/tool-selection.md +36 -17
- package/skills/c64-program-recon/scripts/packer-finding.mjs +165 -87
- package/skills/c64-program-recon/templates/memory-map.template.md +2 -2
- package/skills/c64-provenance-diff/SKILL.md +40 -5
- package/skills/c64-provenance-diff/scripts/diff-images.mjs +8 -8
- package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +7 -5
- package/skills/c64-ram-capture/SKILL.md +112 -44
- package/skills/c64-ram-capture/scripts/compare.mjs +2 -2
- package/skills/c64-ram-capture/scripts/derive-transients.mjs +575 -0
- package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +3 -3
- package/skills/c64-ram-capture/scripts/mcp-module.mjs +174 -0
- package/skills/c64-ram-capture/scripts/releases.mjs +1 -1
- package/skills/c64-ram-capture/scripts/vsf-slice.mjs +147 -0
- package/skills/c64-ram-capture/scripts/watch-loads.mjs +15 -15
- package/skills/c64-ram-capture/templates/capture-record.template.md +44 -4
- package/skills/c64-ram-capture/transients/README.md +136 -0
- package/skills/routine-queue-walker/SKILL.md +114 -22
- package/skills/routine-queue-walker/scripts/completeness-report.mjs +463 -0
- package/skills/vice-wedge-triage/SKILL.md +96 -89
- 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.
|
|
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**. `docs/phase45-wave0-
|
|
93
|
+
measurements.md`'s own MEASUREMENT A 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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
-
|
|
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.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
+
`docs/phase45-wave0-measurements.md`'s own MEASUREMENT A 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,463 @@
|
|
|
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 -- see
|
|
79
|
+
* docs/phase45-wave0-measurements.md for the MEASURED label population this
|
|
80
|
+
* was frozen against. Exported here, separately from the verb's own copy,
|
|
81
|
+
* because this script's own tests must be able to assert on the predicate in
|
|
82
|
+
* isolation, without a live store or a subprocess -- and because this
|
|
83
|
+
* script's own header forbids it from reading a store directly, so it
|
|
84
|
+
* cannot import the verb's copy through anything but a duplicate literal.
|
|
85
|
+
*
|
|
86
|
+
* WHAT NOT TO DO: if the frozen set in `anno-cli.ts` ever changes, this copy
|
|
87
|
+
* moves in the SAME commit, or the two renderers silently disagree about
|
|
88
|
+
* what "survivor" means. Never restate `AUTO_NAME_PREFIX_RE`'s eleven
|
|
89
|
+
* prefixes as their own literal strings here -- this predicate matches
|
|
90
|
+
* against a caller-SUPPLIED name (from the verb's own `survivors` answer),
|
|
91
|
+
* never derives a name from a store itself, so there is no store-derived
|
|
92
|
+
* value to keep in sync beyond this one regex pair.
|
|
93
|
+
*/
|
|
94
|
+
const AUTO_NAME_PREFIX_RE = /^(zpf_|f_|zpa_|a_|p_|zpp_|e_|j_|s_|b_|r_)/;
|
|
95
|
+
const SURVIVOR_EXTRA_RE = /^(?:l_[0-9a-f]{4}|(?:FUN|LAB)_[0-9a-f]{4}|l[0-9a-f]{3,4})$/;
|
|
96
|
+
|
|
97
|
+
/** True iff `name` is a survivor under the frozen set. ASCII case-sensitive:
|
|
98
|
+
* `l_0810` IS a survivor, `L_0810` is NOT -- `anno-coverage.test.ts`'s own
|
|
99
|
+
* `L_` exclusion precedent, restated for this phase's own prefix set. */
|
|
100
|
+
export function isSurvivorName(name) {
|
|
101
|
+
return AUTO_NAME_PREFIX_RE.test(name) || SURVIVOR_EXTRA_RE.test(name);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The precedence rule, mirrored here (see this file's own
|
|
106
|
+
* header on why a mirror rather than an import) so this script's own test
|
|
107
|
+
* tier can assert the PRECEDENCE explicitly, not merely pass through a
|
|
108
|
+
* verb-computed value. `anno-cli.ts`'s `typedByFor()` is the authoritative
|
|
109
|
+
* copy that actually runs against a real store; this one exists only to be
|
|
110
|
+
* unit-tested in isolation, exactly like `isSurvivorName()` above. Evidence
|
|
111
|
+
* beats inference: `observed-executing` (a real execute observation exists
|
|
112
|
+
* inside the range) beats `authored` (an `AUTHORED_PROVENANCE_COMMENT_PREFIX`
|
|
113
|
+
* comment exists and there is no observation) beats `byte-derived` (neither).
|
|
114
|
+
* A range that is BOTH observed and authored renders `observed-executing`.
|
|
115
|
+
*/
|
|
116
|
+
export function typedByFor(hasObservation, hasAuthoredComment) {
|
|
117
|
+
if (hasObservation) return "observed-executing";
|
|
118
|
+
if (hasAuthoredComment) return "authored";
|
|
119
|
+
return "byte-derived";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const PURPOSE_ELEMENT_KEYS = ["function", "inputs", "outputs", "sideEffects"];
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Normalises a raw `anno decomp-completeness --json` answer into the report
|
|
126
|
+
* shape this module renders and gates. Pure: no filesystem, no subprocess, no
|
|
127
|
+
* store. Throws a plain `Error` (never `MissingDisagreementInputError`, which
|
|
128
|
+
* is `renderCompletenessReport()`'s own refusal) when `answer` is not even a
|
|
129
|
+
* plausible answer object -- a caller error, distinct from a missing
|
|
130
|
+
* disagreement input.
|
|
131
|
+
*
|
|
132
|
+
* Every NEW field defaults to the CONSERVATIVE (gate-failing or vacuity-
|
|
133
|
+
* naming) shape when absent, never to a shape that would silently pass --
|
|
134
|
+
* The same refuse-by-name discipline, applied to every field, not only the
|
|
135
|
+
* original disagreement input.
|
|
136
|
+
*/
|
|
137
|
+
export function buildCompletenessReport(answer) {
|
|
138
|
+
if (typeof answer !== "object" || answer === null) {
|
|
139
|
+
throw new Error("buildCompletenessReport: expected a decomp-completeness --json answer object, got " + JSON.stringify(answer));
|
|
140
|
+
}
|
|
141
|
+
const disagreementInput = answer.disagreementInput;
|
|
142
|
+
const disagreementResolution =
|
|
143
|
+
answer.disagreementResolution && typeof answer.disagreementResolution === "object"
|
|
144
|
+
? {
|
|
145
|
+
rows: Array.isArray(answer.disagreementResolution.rows) ? answer.disagreementResolution.rows : [],
|
|
146
|
+
unresolvedCount: answer.disagreementResolution.unresolvedCount ?? (disagreementInput?.disagreementCount ?? 0),
|
|
147
|
+
denominator: answer.disagreementResolution.denominator ?? (disagreementInput?.denominator ? disagreementInput.disagreementCount : 0),
|
|
148
|
+
}
|
|
149
|
+
: {
|
|
150
|
+
rows: [],
|
|
151
|
+
// Conservative default: an answer that carries a real disagreement
|
|
152
|
+
// count but no resolution census at all is treated as ENTIRELY
|
|
153
|
+
// unresolved, never as a silent pass -- the same discipline
|
|
154
|
+
// applies to the disagreement input itself, applied here to its
|
|
155
|
+
// resolution.
|
|
156
|
+
unresolvedCount: disagreementInput?.disagreementCount ?? 0,
|
|
157
|
+
denominator: disagreementInput?.disagreementCount ?? 0,
|
|
158
|
+
};
|
|
159
|
+
return {
|
|
160
|
+
store: answer.store,
|
|
161
|
+
fixture: answer.fixture,
|
|
162
|
+
executionDisposition: answer.executionDisposition,
|
|
163
|
+
notExecutedReason: answer.notExecutedReason ?? null,
|
|
164
|
+
byteCensus: answer.byteCensus,
|
|
165
|
+
survivors: Array.isArray(answer.survivors) ? answer.survivors : [],
|
|
166
|
+
rangeProvenance: Array.isArray(answer.rangeProvenance) ? answer.rangeProvenance : [],
|
|
167
|
+
entryPoints: Array.isArray(answer.entryPoints) ? answer.entryPoints : [],
|
|
168
|
+
referencedAddresses:
|
|
169
|
+
answer.referencedAddresses && typeof answer.referencedAddresses === "object"
|
|
170
|
+
? {
|
|
171
|
+
resolved: Array.isArray(answer.referencedAddresses.resolved) ? answer.referencedAddresses.resolved : [],
|
|
172
|
+
declined: Array.isArray(answer.referencedAddresses.declined) ? answer.referencedAddresses.declined : [],
|
|
173
|
+
unresolved: Array.isArray(answer.referencedAddresses.unresolved) ? answer.referencedAddresses.unresolved : [],
|
|
174
|
+
denominator: answer.referencedAddresses.denominator ?? 0,
|
|
175
|
+
}
|
|
176
|
+
: { resolved: [], declined: [], unresolved: [], denominator: 0 },
|
|
177
|
+
disagreementInput,
|
|
178
|
+
disagreementResolution,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function addr(a) {
|
|
183
|
+
return typeof a === "number" ? `$${a.toString(16).padStart(4, "0")}` : String(a);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Renders `report` as text, copying `printEvidDisagreementsReport()`'s own
|
|
188
|
+
* rendering discipline exactly: every measure under its own heading,
|
|
189
|
+
* disagreements first, `denominator` beside every count, an explicit
|
|
190
|
+
* sentence stating what absence does NOT prove, and never a percentage,
|
|
191
|
+
* rate or combined figure.
|
|
192
|
+
*
|
|
193
|
+
* THROWS `MissingDisagreementInputError` when
|
|
194
|
+
* `report.disagreementInput` is absent, or present but its own
|
|
195
|
+
* `runIdentity` is neither `null` nor a complete
|
|
196
|
+
* `{ imageSha256, argvDigest, seed }` object. An empty `disagreements`
|
|
197
|
+
* ARRAY alone is not enough to refuse -- that is a real, non-vacuous "zero
|
|
198
|
+
* disagreements" answer; what is refused is the ABSENCE of the input
|
|
199
|
+
* itself.
|
|
200
|
+
*
|
|
201
|
+
* `identity === null` is a THIRD,
|
|
202
|
+
* legitimate value here, mirroring anno-cli.ts's own
|
|
203
|
+
* `validateDisagreementDocumentShape()`/match-check -- the real answer `anno
|
|
204
|
+
* evid-disagreements --json` produces for a store with zero observed runs
|
|
205
|
+
* (a non-executed fixture). By the time a report reaches this
|
|
206
|
+
* function, `anno decomp-completeness`'s own server-side check has already
|
|
207
|
+
* proven that null against the store's own evid-runs table (refusing a
|
|
208
|
+
* null identity on a store that DOES carry real runs) -- this function
|
|
209
|
+
* never re-derives that proof, only trusts an already-validated report.
|
|
210
|
+
*/
|
|
211
|
+
export function renderCompletenessReport(report) {
|
|
212
|
+
const input = report?.disagreementInput;
|
|
213
|
+
const identity = input?.runIdentity;
|
|
214
|
+
const identityIsWellFormed =
|
|
215
|
+
identity === null ||
|
|
216
|
+
(typeof identity === "object" && identity !== null && typeof identity.imageSha256 === "string" && typeof identity.argvDigest === "string" && typeof identity.seed === "string");
|
|
217
|
+
if (input === undefined || input === null || !identityIsWellFormed) {
|
|
218
|
+
throw new MissingDisagreementInputError(
|
|
219
|
+
"renderCompletenessReport: no disagreement input is present on this report -- refusing to render. " +
|
|
220
|
+
"Pass --disagreements to `anno decomp-completeness` (the JSON `anno evid-disagreements --json` wrote " +
|
|
221
|
+
"for the SAME store); an omitted query and a query that found nothing must never render the same report.",
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const lines = [];
|
|
226
|
+
lines.push(`decomposition completeness: ${report.store ?? "(unknown store)"}`);
|
|
227
|
+
lines.push(` FIXTURE: ${report.fixture ?? "(unknown fixture)"}`);
|
|
228
|
+
if (report.executionDisposition === "not-executed") {
|
|
229
|
+
lines.push(` NOT EXECUTED: ${report.notExecutedReason ?? "(no reason recorded)"}`);
|
|
230
|
+
} else {
|
|
231
|
+
lines.push(" EXECUTED: this fixture was run under the reproducible-run protocol.");
|
|
232
|
+
}
|
|
233
|
+
lines.push("");
|
|
234
|
+
|
|
235
|
+
const census = report.byteCensus ?? { byType: {}, undefinedCount: 0, denominator: 0 };
|
|
236
|
+
lines.push(` BYTE CENSUS (denominator ${census.denominator ?? 0})`);
|
|
237
|
+
for (const [type, count] of Object.entries(census.byType ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
|
|
238
|
+
lines.push(` ${type}: ${count} of ${census.denominator ?? 0}`);
|
|
239
|
+
}
|
|
240
|
+
lines.push(` undefined: ${census.undefinedCount ?? 0} of ${census.denominator ?? 0}`);
|
|
241
|
+
for (const gap of Array.isArray(census.undefinedRanges) ? census.undefinedRanges : []) {
|
|
242
|
+
lines.push(` UNDEFINED: ${addr(gap.start)}-${addr(gap.endInclusive)}`);
|
|
243
|
+
}
|
|
244
|
+
lines.push("");
|
|
245
|
+
|
|
246
|
+
const survivors = report.survivors ?? [];
|
|
247
|
+
lines.push(` SURVIVORS (${survivors.length})`);
|
|
248
|
+
if (survivors.length === 0) {
|
|
249
|
+
lines.push(" none");
|
|
250
|
+
} else {
|
|
251
|
+
for (const s of survivors) {
|
|
252
|
+
lines.push(` ${addr(s.address)} ${s.name}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
lines.push("");
|
|
256
|
+
|
|
257
|
+
lines.push(` DISAGREEMENTS (${input.disagreementCount ?? 0} of ${input.denominator ?? 0})`);
|
|
258
|
+
const disagreements = Array.isArray(input.disagreements) ? input.disagreements : [];
|
|
259
|
+
if (disagreements.length === 0) {
|
|
260
|
+
lines.push(" none");
|
|
261
|
+
} else {
|
|
262
|
+
for (const d of disagreements) {
|
|
263
|
+
const banks = Array.isArray(d.sourceBanks) ? d.sourceBanks.join(",") : "";
|
|
264
|
+
lines.push(` ${addr(d.address)} byte-derived=${d.byteDerived} runtime=${d.runtime} banks=${banks}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
lines.push(` AGREEMENT: ${input.agreementCount ?? 0} of ${input.denominator ?? 0}`);
|
|
268
|
+
lines.push(
|
|
269
|
+
` NO OBSERVATION: ${input.blockCoveredNeverObservedCount ?? 0} of ${input.denominator ?? 0} -- an address never observed ` +
|
|
270
|
+
"executing proves NOTHING about what it is; absence is not evidence for or against any classification.",
|
|
271
|
+
);
|
|
272
|
+
const resolution = report.disagreementResolution ?? { rows: [], unresolvedCount: 0, denominator: 0 };
|
|
273
|
+
const acceptedCount = resolution.rows.length - resolution.unresolvedCount;
|
|
274
|
+
lines.push(
|
|
275
|
+
` DISAGREEMENT RESOLUTION: ${acceptedCount} accepted, ${resolution.unresolvedCount} unresolved of ${resolution.denominator} -- ` +
|
|
276
|
+
"criterion 2's own gate: a nonzero unresolved count BLOCKS rather than being reported beside a pass.",
|
|
277
|
+
);
|
|
278
|
+
lines.push("");
|
|
279
|
+
|
|
280
|
+
const rangeProvenance = report.rangeProvenance ?? [];
|
|
281
|
+
lines.push(` RANGE PROVENANCE (${rangeProvenance.length} range(s))`);
|
|
282
|
+
if (rangeProvenance.length === 0) {
|
|
283
|
+
lines.push(" none");
|
|
284
|
+
} else {
|
|
285
|
+
for (const row of rangeProvenance) {
|
|
286
|
+
lines.push(` ${addr(row.start)}-${addr(row.endInclusive)} ${row.renderedType} typedBy: ${row.typedBy}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
lines.push("");
|
|
290
|
+
|
|
291
|
+
const entryPoints = report.entryPoints ?? [];
|
|
292
|
+
const fullyDocumented = entryPoints.filter(
|
|
293
|
+
(e) => e.hasName && PURPOSE_ELEMENT_KEYS.every((k) => e.purposeElements && e.purposeElements[k]),
|
|
294
|
+
).length;
|
|
295
|
+
lines.push(` ENTRY POINTS (${fullyDocumented} of ${entryPoints.length})`);
|
|
296
|
+
if (entryPoints.length === 0) {
|
|
297
|
+
lines.push(" none -- a zero-entry-point count is a fact about the candidate set, never evidence of completeness.");
|
|
298
|
+
} else {
|
|
299
|
+
for (const e of entryPoints) {
|
|
300
|
+
const missing = PURPOSE_ELEMENT_KEYS.filter((k) => !(e.purposeElements && e.purposeElements[k]));
|
|
301
|
+
lines.push(
|
|
302
|
+
` ${addr(e.address)} ${e.name ?? "(unnamed)"} hasName=${Boolean(e.hasName)}` +
|
|
303
|
+
(missing.length > 0 ? ` MISSING: ${missing.join(", ")}` : " purpose comment complete"),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
lines.push("");
|
|
308
|
+
|
|
309
|
+
const refs = report.referencedAddresses ?? { resolved: [], declined: [], unresolved: [], denominator: 0 };
|
|
310
|
+
lines.push(` REFERENCED NON-HARDWARE ADDRESSES (${refs.resolved.length} resolved of ${refs.denominator})`);
|
|
311
|
+
if (refs.denominator === 0) {
|
|
312
|
+
lines.push(" none -- a zero-referenced-address count is a fact about the candidate set, never evidence of completeness.");
|
|
313
|
+
} else {
|
|
314
|
+
lines.push(` RESOLVED: ${refs.resolved.length === 0 ? "none" : refs.resolved.map(addr).join(", ")}`);
|
|
315
|
+
lines.push(` DECLINED: ${refs.declined.length === 0 ? "none" : refs.declined.map((d) => `${addr(d.address)} (${d.reason})`).join(", ")}`);
|
|
316
|
+
lines.push(` UNRESOLVED: ${refs.unresolved.length === 0 ? "none" : refs.unresolved.map(addr).join(", ")}`);
|
|
317
|
+
}
|
|
318
|
+
lines.push("");
|
|
319
|
+
|
|
320
|
+
lines.push(
|
|
321
|
+
" Read every figure above against the others, never combined into one -- together they name what this " +
|
|
322
|
+
"store's block table covers, never what the program actually is.",
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const failures = computeGateFailures(report);
|
|
326
|
+
lines.push("");
|
|
327
|
+
if (failures.length === 0) {
|
|
328
|
+
lines.push(" GATE: PASS -- every measure above cleared its own bar.");
|
|
329
|
+
} else {
|
|
330
|
+
lines.push(` GATE: FAIL (${failures.length})`);
|
|
331
|
+
for (const f of failures) lines.push(` - ${f}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return lines.join("\n");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* THE GATE (the numeric stop condition; criterion 2's own words: a
|
|
339
|
+
* nonzero unresolved count BLOCKS rather than being reported beside a
|
|
340
|
+
* pass). Returns an array of human-readable failure strings, each naming
|
|
341
|
+
* the offending address where one exists; an empty array means the gate
|
|
342
|
+
* passes. Never throws -- a malformed report renders its own absence as a
|
|
343
|
+
* failure (see the individual guards below) rather than crashing the report
|
|
344
|
+
* that exists to surface exactly this kind of gap.
|
|
345
|
+
*
|
|
346
|
+
* ALL FIVE gate conditions, restated from the plan this implements:
|
|
347
|
+
* 1. `byteCensus.undefinedCount === 0`
|
|
348
|
+
* 2. `disagreementResolution.unresolvedCount === 0`
|
|
349
|
+
* 3. `survivors` is empty
|
|
350
|
+
* 4. every `entryPoints` row has `hasName` true and all four
|
|
351
|
+
* `purposeElements` true
|
|
352
|
+
* 5. `referencedAddresses.unresolved` is empty
|
|
353
|
+
*/
|
|
354
|
+
export function computeGateFailures(report) {
|
|
355
|
+
const failures = [];
|
|
356
|
+
|
|
357
|
+
const undefinedCount = report?.byteCensus?.undefinedCount ?? 0;
|
|
358
|
+
if (undefinedCount !== 0) {
|
|
359
|
+
const gaps = Array.isArray(report?.byteCensus?.undefinedRanges) ? report.byteCensus.undefinedRanges : [];
|
|
360
|
+
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)";
|
|
361
|
+
failures.push(`byte census: ${undefinedCount} Undefined byte(s) remain at ${named} (must be 0)`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const survivors = Array.isArray(report?.survivors) ? report.survivors : [];
|
|
365
|
+
if (survivors.length > 0) {
|
|
366
|
+
for (const s of survivors) failures.push(`survivor auto-name at ${addr(s.address)} (${s.name}) still sits in a code region`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const entryPoints = Array.isArray(report?.entryPoints) ? report.entryPoints : [];
|
|
370
|
+
for (const e of entryPoints) {
|
|
371
|
+
if (!e.hasName) {
|
|
372
|
+
failures.push(`entry point ${addr(e.address)} has no authored name`);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
const missing = PURPOSE_ELEMENT_KEYS.filter((k) => !(e.purposeElements && e.purposeElements[k]));
|
|
376
|
+
if (missing.length > 0) {
|
|
377
|
+
failures.push(`entry point ${addr(e.address)} is missing purpose-comment element(s): ${missing.join(", ")}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const refs = report?.referencedAddresses ?? { unresolved: [] };
|
|
382
|
+
for (const a of Array.isArray(refs.unresolved) ? refs.unresolved : []) {
|
|
383
|
+
failures.push(`referenced address ${addr(a)} is neither named nor declined`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const resolution = report?.disagreementResolution ?? { unresolvedCount: 0 };
|
|
387
|
+
const unresolvedCount = resolution.unresolvedCount ?? 0;
|
|
388
|
+
if (unresolvedCount !== 0) {
|
|
389
|
+
failures.push(`${unresolvedCount} disagreement(s) remain unresolved (no DISAGREEMENT-ACCEPTED comment)`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return failures;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Forwards `["anno", "decomp-completeness", ...argv, "--json"]` to the
|
|
397
|
+
* resolved MCP-side `vice-proxy.ts`, parses its stdout as JSON, and returns
|
|
398
|
+
* `buildCompletenessReport()`'s own normalised shape. Never rejects: an
|
|
399
|
+
* unresolved MCP module, a non-zero exit, or unparsable stdout all resolve
|
|
400
|
+
* to a thrown `Error` with the seam's own refusal text (or, per this
|
|
401
|
+
* script's never-throw posture at the CLI boundary, `main()` below catches
|
|
402
|
+
* it and reports it as an exit code) -- this function itself may throw,
|
|
403
|
+
* since it is the in-process API a test or another script calls directly.
|
|
404
|
+
*/
|
|
405
|
+
export function fetchCompletenessReport(argv) {
|
|
406
|
+
const resolved = resolveMcpModule(TARGET_FILE);
|
|
407
|
+
if (!resolved.ok) {
|
|
408
|
+
throw new Error(
|
|
409
|
+
`completeness-report.mjs: ${refusalMessage(TARGET_FILE, resolved.rungs)}\n` +
|
|
410
|
+
`${TARGET_FILE} is where the fifth anno CLI verb lives (${TARGET_PACKAGE}). Refusing rather than ` +
|
|
411
|
+
"reading a store directly here -- a second copy of that read is exactly the divergence this script's own header forbids.",
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
const fullArgv = ["anno", "decomp-completeness", ...argv, "--json"];
|
|
415
|
+
const run = spawnSync(process.execPath, [resolved.path, ...fullArgv], { encoding: "utf8" });
|
|
416
|
+
if (run.error) {
|
|
417
|
+
throw new Error(`completeness-report.mjs: could not run ${resolved.path}: ${run.error.message}`);
|
|
418
|
+
}
|
|
419
|
+
if (run.signal) {
|
|
420
|
+
throw new Error(`completeness-report.mjs: ${resolved.path} was killed by ${run.signal}`);
|
|
421
|
+
}
|
|
422
|
+
if (run.status !== 0) {
|
|
423
|
+
throw new Error(`completeness-report.mjs: anno decomp-completeness exited ${run.status}: ${run.stderr || run.stdout}`);
|
|
424
|
+
}
|
|
425
|
+
let parsed;
|
|
426
|
+
try {
|
|
427
|
+
parsed = JSON.parse(run.stdout);
|
|
428
|
+
} catch (err) {
|
|
429
|
+
throw new Error(`completeness-report.mjs: anno decomp-completeness --json did not print valid JSON: ${err.message}`);
|
|
430
|
+
}
|
|
431
|
+
return buildCompletenessReport(parsed);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** CLI entry point: `node completeness-report.mjs --store FILE --disagreements FILE --manifest FILE`.
|
|
435
|
+
* Forwards argv verbatim to the resolved verb, renders the result, and
|
|
436
|
+
* returns a process exit code -- never calls `process.exit()` itself, so
|
|
437
|
+
* `main()` stays testable in-process. A thrown `MissingDisagreementInputError`
|
|
438
|
+
* is reported with its own message and nothing more (the refusal IS the
|
|
439
|
+
* report); any other thrown error is reported the same way, verbatim,
|
|
440
|
+
* never swallowed. On a SUCCESSFULLY RENDERED report, the exit code is THE
|
|
441
|
+
* GATE's own verdict (`computeGateFailures()`), never a bare 0 -- this is
|
|
442
|
+
* the numeric stop condition, and softening it here is exactly the
|
|
443
|
+
* regression planted controls 1/2 (task 2) exist to catch. */
|
|
444
|
+
export function main(argv) {
|
|
445
|
+
let report;
|
|
446
|
+
try {
|
|
447
|
+
report = fetchCompletenessReport(argv);
|
|
448
|
+
} catch (err) {
|
|
449
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
450
|
+
return 1;
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
console.log(renderCompletenessReport(report));
|
|
454
|
+
return computeGateFailures(report).length === 0 ? 0 : 1;
|
|
455
|
+
} catch (err) {
|
|
456
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
457
|
+
return 1;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
462
|
+
process.exitCode = main(process.argv.slice(2));
|
|
463
|
+
}
|