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