@henols/vice-mcp 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +4 -3
  2. package/THIRD-PARTY-NOTICES.md +423 -25
  3. package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
  4. package/anno-bank.ts +171 -0
  5. package/anno-cli.ts +3040 -0
  6. package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
  7. package/anno-coverage.ts +2465 -0
  8. package/anno-derive.ts +590 -0
  9. package/anno-details.ts +169 -0
  10. package/anno-enum-gen.ts +919 -0
  11. package/anno-export-asm.ts +2396 -0
  12. package/anno-graphics.ts +338 -0
  13. package/anno-hazard-report.ts +1367 -0
  14. package/anno-import.ts +495 -0
  15. package/anno-index.ts +150 -0
  16. package/anno-join.ts +480 -0
  17. package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
  18. package/anno-provenance-ledger.ts +472 -0
  19. package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
  20. package/{r2000-regbits.json → anno-regbits.json} +2 -2
  21. package/anno-register.ts +399 -0
  22. package/anno-store-export.ts +661 -0
  23. package/anno-store.ts +4002 -0
  24. package/anno-symbols.ts +266 -0
  25. package/anno-tools.ts +3264 -0
  26. package/anno-types.ts +1909 -0
  27. package/backend-detect.mts +124 -312
  28. package/block-class.ts +201 -0
  29. package/build.ts +4 -2
  30. package/capture-predicate.ts +597 -0
  31. package/channel-lock.ts +349 -0
  32. package/disasm-decoder.ts +14 -14
  33. package/disasm-opcodes.ts +4 -4
  34. package/disasm-renderer.ts +2 -2
  35. package/evid-ingest.ts +217 -0
  36. package/evid-reconcile.ts +316 -0
  37. package/host-tool-client.ts +430 -0
  38. package/hostpath.ts +1 -1
  39. package/incident-record.ts +23 -12
  40. package/install-resources.ts +30 -14
  41. package/memmap-lookup.ts +285 -0
  42. package/package.json +48 -23
  43. package/prg-image.ts +118 -0
  44. package/repo-root.ts +107 -8
  45. package/resources/backend-detect.mjs +98 -236
  46. package/resources/broker-control.mjs +189 -16
  47. package/resources/broker-epoch.mjs +1 -1
  48. package/resources/broker-kill.mjs +8 -2
  49. package/resources/broker-launch.mjs +373 -214
  50. package/resources/broker-state.mjs +64 -18
  51. package/resources/container-guard.mjs +1 -1
  52. package/resources/ghidra-project.mjs +790 -0
  53. package/resources/host-tool.mjs +2561 -0
  54. package/resources/vice-broker.mjs +330 -184
  55. package/resources/vice-launcher.sh +130 -12
  56. package/stock-address.ts +6 -6
  57. package/stock-cia.ts +2 -2
  58. package/stock-condition.ts +8 -8
  59. package/stock-connect.ts +10 -6
  60. package/stock-derived.ts +29 -37
  61. package/stock-diagnose.ts +200 -36
  62. package/stock-dispatch.ts +200 -68
  63. package/stock-execution.ts +5 -3
  64. package/stock-handler.ts +1 -1
  65. package/stock-input.ts +9 -9
  66. package/stock-machine.ts +17 -6
  67. package/stock-paths.ts +18 -14
  68. package/stock-petscii.ts +1 -1
  69. package/stock-protocol.ts +17 -12
  70. package/stock-recycle.ts +83 -2
  71. package/stock-registers.ts +54 -29
  72. package/stock-reproducible-run.ts +811 -0
  73. package/stock-run-until.ts +100 -1
  74. package/stock-sprites.ts +3 -3
  75. package/stock-symbols.ts +13 -13
  76. package/stock-timing.ts +2 -2
  77. package/stock-vicii.ts +1 -1
  78. package/stop-oracle.ts +167 -0
  79. package/text-capability-probe.ts +660 -0
  80. package/text-connect.ts +157 -0
  81. package/text-protocol.ts +810 -0
  82. package/text-tools.ts +778 -0
  83. package/textmon-backtrace.ts +385 -0
  84. package/textmon-cpuhistory.ts +335 -0
  85. package/textmon-memmap.ts +494 -0
  86. package/textmon-profile.ts +458 -0
  87. package/textmon-registers.ts +748 -0
  88. package/tools-manifest.stock.json +864 -3
  89. package/version.ts +1 -1
  90. package/vice-broker-client.ts +189 -42
  91. package/vice-errors.ts +268 -0
  92. package/vice-proxy.ts +392 -2175
  93. package/vsf-slice.ts +640 -0
  94. package/capability-registry.ts +0 -388
  95. package/r2000-cli.ts +0 -1103
  96. package/r2000-d64.ts +0 -310
  97. package/r2000-enum-gen.ts +0 -574
  98. package/r2000-launch.ts +0 -357
  99. package/r2000-mcp-client.ts +0 -596
  100. package/r2000-project.ts +0 -190
  101. package/r2000-symbols.ts +0 -388
  102. package/r2000-tools.ts +0 -914
  103. package/r2000-verify.ts +0 -184
  104. package/refresh-manifest.ts +0 -124
  105. package/tools-manifest.json +0 -1223
  106. package/vice-probe.ts +0 -278
  107. package/vice-sync.ts +0 -336
  108. package/vice.ts +0 -772
@@ -0,0 +1,2465 @@
1
+ #!/usr/bin/env node
2
+ // anno-coverage.ts -- the ONE place that measures how well a binary has
3
+ // actually been reverse-engineered (COV-01, COV-02).
4
+ //
5
+ // ---------------------------------------------------------------------------
6
+ // WHY THIS FILE EXISTS
7
+ // ---------------------------------------------------------------------------
8
+ // COV-01 asks for a coverage instrument, and the obvious implementation --
9
+ // ask the external analyser how much of the image it has classified as `Code` and
10
+ // call that "completeness" -- is CIRCULAR, and provably so at upstream's own
11
+ // source. `follow_indirect_jumps()` (`analyzer.rs:445-540` at the pinned
12
+ // commit) walks a linear sweep over bytes whose `block_types` entry is
13
+ // ALREADY `Code`, and only acts on opcode `0x6C` whose pointer location is
14
+ // ALREADY classified `Address`. On an under-classified binary -- which is
15
+ // exactly the state a coverage instrument exists to measure -- that walk
16
+ // finds nothing. An instrument built on it would report "nothing left to do"
17
+ // on a binary nobody has looked at yet. That is the defect this module is
18
+ // shaped to make unreachable, not merely to avoid.
19
+ //
20
+ // So the structural census here is a pure function of TWO things: the raw
21
+ // bytes, and the seed set the caller supplies. The store's own block table
22
+ // enters this file through exactly ONE boundary -- `block-class.ts`, which
23
+ // is the only place in the tree that interprets a store block-type string --
24
+ // and it reaches only a sub-report that is explicitly named as a comparison,
25
+ // never a measure of completeness. This file compares NEUTRAL block classes
26
+ // and never a store vocabulary. `anno-coverage.test.ts` pins that boundary
27
+ // twice: by rewriting every block entry to one type and asserting no census
28
+ // byte count moves, and by substituting a second block vocabulary through
29
+ // the boundary and asserting the same thing.
30
+ //
31
+ // ---------------------------------------------------------------------------
32
+ // WHAT THIS IS THE ONE AUTHORITATIVE PLACE FOR
33
+ // ---------------------------------------------------------------------------
34
+ // - the byte census (`computeStructuralCensus`) and its four disjoint byte
35
+ // classes;
36
+ // - the WIDENED indirect-dispatch scan (`scanIndirectDispatch`) that reaches
37
+ // the four classes upstream's own walk does not: zero-page vectors,
38
+ // multi-entry dispatch tables, split lo/hi tables, and the stack-return
39
+ // dispatch idiom (which contains no indirect-jump opcode at all and is
40
+ // therefore completely invisible to an opcode-keyed walk);
41
+ // - the DISPATCH-CONTEXT GATE that decides whether a Class-3 split lo/hi
42
+ // pairing is PROVEN or merely ADVISORY -- same index register, a dispatch
43
+ // consumer in evidence, every reconstructed target in-image and decodable,
44
+ // and a lo/hi orientation something other than address order determined.
45
+ // An ungated pairing is reported in `splitTableCandidates`, contributes
46
+ // nothing to `discoveredTargets` and nothing to `tableEntryAddresses`;
47
+ // - `provenDispatchTargets()` -- the ONE seam that decides what may seed a
48
+ // recursive descent. Every `extraSeeds:` assignment in this file reads it
49
+ // and reads nothing else;
50
+ // - the two label figures (`computeLabelRatio`), one of which is gameable
51
+ // and one of which is not;
52
+ // - the comment-vacuity measure (`computeCommentVacuity`) and its exact
53
+ // normalisation rules;
54
+ // - the sampled reproducibility result (`computeReproducibility`), its
55
+ // deterministic sample rule, and the ANCHORED multi-caller rule
56
+ // (`namesACaller`) -- a caller reference is a DELIMITED token, never a
57
+ // substring, so a comment mentioning an unrelated address whose leading
58
+ // digits coincide with a caller's short form buys nothing;
59
+ // - the pinned report schema (`COVERAGE_SCHEMA_VERSION`,
60
+ // `buildCoverageReport`).
61
+ //
62
+ // ---------------------------------------------------------------------------
63
+ // WHAT NOT TO DO -- each of these is a specific, named trap
64
+ // ---------------------------------------------------------------------------
65
+ // 1. NEVER derive any measure from the store's block-type listing. The
66
+ // listing enters this file only through `block-class.ts` and leaves it
67
+ // as a comparison. A "completeness" number sourced from the block table
68
+ // measures the annotator's bookkeeping, not the annotation -- and mass
69
+ // `anno_set_data_type` calls would move it for free. Nor may this file
70
+ // compare a store block-type string directly: the boundary owns that
71
+ // vocabulary, and a comparison written here would be a second answer to
72
+ // "what class is this address" beside the one the boundary gives.
73
+ // 2. NEVER sum the linear-sweep figure into completeness. Upstream's own
74
+ // Pitfall 1 says it plainly: "Random data routinely disassembles into
75
+ // plausible-looking instruction sequences -- this does NOT make it
76
+ // code." `reachedAsInstruction` means REACHED BY RECURSIVE DESCENT FROM
77
+ // A SEED. `linearSweepDecodable` is reported beside it, deliberately
78
+ // under a different name, and is never added to it.
79
+ // 3. NEVER emit a single combined coverage figure -- not in the report
80
+ // object, not in a summary line, not derived at the point of display.
81
+ // COV-01's substance IS that the measures stay separately addressable.
82
+ // `coverageFindings()` below is a boolean verdict with per-measure
83
+ // reasons, NOT an aggregate: it never averages, sums or weights the
84
+ // measures, and every finding names exactly one of them.
85
+ // 4. NEVER define a second confidence vocabulary. `CONFIDENCE_GRADES` from
86
+ // `./anno-confidence.ts` is the only one; that module's own header
87
+ // forbids a second spelling.
88
+ // 5. NEVER import this repository's host-path or container-path translation
89
+ // modules. The whole anno module family is asserted ABSENT from that
90
+ // consumer set by a derived-from-disk scan (the `anno-*.ts` glob in the
91
+ // path-consumer guard suite), and this file joins that family by name --
92
+ // an import here would fail that guard rather than merely violate a
93
+ // convention. Note that the guard's own filename is deliberately not
94
+ // written out in this file: an acceptance check greps this source for
95
+ // the two module names and a mention would trip it.
96
+ // 6. NEVER add a file-write call, a project-save call, or a live-session
97
+ // import here. A coverage run is read-only over a project file BY
98
+ // CONSTRUCTION, so two concurrent runs cannot corrupt a project and an
99
+ // interrupted run leaves no partial report behind -- there is nothing on
100
+ // disk for it to leave. `anno-coverage.test.ts` asserts that at source
101
+ // level.
102
+ // 7. NEVER let an absent input read as a pass (COV-02). A missing payload,
103
+ // an undecodable one, or an empty comment set reports an explicit `null`
104
+ // ratio plus a stated reason -- never a silently-omitted measure and
105
+ // never a zero that reads like "clean".
106
+ // 8. NEVER promote a RECONSTRUCTED value to a descent seed without evidence
107
+ // that something dispatches through it. Two indexed loads inside eight
108
+ // instructions of each other is the single most ordinary shape in C64
109
+ // code -- a screen-plus-colour copy loop -- and reading the bytes at
110
+ // their two operand bases as a lo/hi address table turns ordinary DATA
111
+ // into `reached-as-instruction`, which trap 2 defines as REACHED BY
112
+ // RECURSIVE DESCENT FROM A SEED. The census's whole meaning is that
113
+ // reachability was PROVEN; injecting arbitrary data into the seed set
114
+ // destroys that meaning by the other route, without ever touching the
115
+ // linear-sweep figure trap 2 guards. Reproduced at report level before
116
+ // the gate landed: two 64-byte programs at $0810 with 7 bytes of real
117
+ // code each, differing ONLY in immediate versus indexed addressing,
118
+ // reported reached=7 and reached=55. Adding a source to
119
+ // `provenDispatchTargets()` IS the decision to treat that source as
120
+ // proof of code -- make it deliberately or not at all.
121
+ //
122
+ // ---------------------------------------------------------------------------
123
+ // NAMED DEVIATION FROM THE RESEARCH RECOMMENDATION (recorded, deliberate)
124
+ // ---------------------------------------------------------------------------
125
+ // 19-RESEARCH.md §3.4 suggested reproducing Phase 11's two-SESSION answer key
126
+ // -- a second agent session re-deriving the answer independently. Nested
127
+ // headless agent sessions stall indefinitely in this project's environment,
128
+ // so that axis is not runnable here. The independence axis used instead is
129
+ // BYTES-VERSUS-STORE: one side classifies an address using only the raw bytes
130
+ // and this file's census, the other using only the store's own documentation
131
+ // (confidence grade, block type). Neither side reads the other's input. The
132
+ // store side's own vocabulary now lives behind the named boundary
133
+ // (`block-class.ts`), which takes the block listing and an address and
134
+ // nothing else -- so the axis cannot be collapsed by quietly handing the
135
+ // store side a look at the bytes. The
136
+ // seal (`evidence/coverage-reproducibility/ANSWER.sha256`) is what makes the
137
+ // result non-retrofittable, exactly as it was in Phase 11: the hash is
138
+ // committed before the re-derivation is written, and a missing or empty
139
+ // re-derivation FAILS rather than skips.
140
+
141
+ import { blockClassAt, type BlockClass, type BlockClassifier, type BlockEntry } from "./block-class.ts";
142
+ import { decode, type Instruction } from "./disasm-decoder.ts";
143
+ import { decodeRawData, flatImageOrigin, parsePrg } from "./prg-image.ts";
144
+ import { CONFIDENCE_GRADES, parseConfidencePrefix } from "./anno-confidence.ts";
145
+ import { readFileSync } from "node:fs";
146
+ import { extname } from "node:path";
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Schema
150
+ // ---------------------------------------------------------------------------
151
+
152
+ /**
153
+ * The report's schema version. The decomposition sweep reads this report
154
+ * repeatedly and the reassembly gate reuses the dispatch scan, so the field
155
+ * set is a contract, not an implementation detail. Bump this ONLY together
156
+ * with `anno-coverage.test.ts`'s exact top-level key-set assertion -- that
157
+ * test exists so a silent field rename fails loudly rather than quietly
158
+ * feeding two consumers `undefined`.
159
+ *
160
+ * VERSION HISTORY
161
+ * 1 -- the original nine top-level keys.
162
+ * 2 -- the top-level key set is UNCHANGED; the `dispatch` sub-object's
163
+ * target vocabulary changed. `discoveredTargets` narrowed to
164
+ * EVIDENCE-BACKED targets only, and the ungated split lo/hi pairings
165
+ * it used to include moved to the new advisory sibling
166
+ * `splitTableCandidates`. A consumer reading `discoveredTargets` gets
167
+ * a smaller, honest set than it did at version 1; this bump is the
168
+ * signal that a nested meaning changed. Accepted by a human at
169
+ * 19-08's decision checkpoint (option `narrow-and-add-sibling`),
170
+ * which also discharged 19-VERIFICATION.md's `human_verification`
171
+ * item 2.
172
+ */
173
+ export const COVERAGE_SCHEMA_VERSION = 2;
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Bounds. Both are explicit, both surface a truncation flag rather than
177
+ // looping (T-19-12/T-19-13). Neither is a heuristic "walk while plausible".
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /** Hard cap on recursive-descent steps before the walk reports truncation. */
181
+ export const MAX_WALK_STEPS = 200_000;
182
+
183
+ /** Hard cap on entries read from any one dispatch table. A table longer than
184
+ * this reports `truncated: true` for that table rather than walking on. */
185
+ export const MAX_TABLE_ENTRIES = 64;
186
+
187
+ /** How many decoded instructions a split-table pairing may span. Two indexed
188
+ * loads further apart than this are not treated as a lo/hi pair. */
189
+ /** How many decoded instructions the class-3 pairing window spans, and the
190
+ * `reach` every `hasDispatchContext()` / `resolveSplitOrientation()` call is
191
+ * given. EXPORTED so the gate-interior witness in `anno-coverage.test.ts` can
192
+ * ask its question over the same window the predicate rules on, rather than
193
+ * over a number typed into a test that could silently drift from this one. */
194
+ export const SPLIT_TABLE_WINDOW = 8;
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Input shapes -- exactly what the curated read tools return
198
+ // (`anno-tools.ts`: anno_get_symbols / anno_get_comments / anno_get_blocks
199
+ // / anno_get_cross_references). This module never calls those tools itself;
200
+ // a caller fetches and hands the data in. That is what keeps it pure, keeps
201
+ // it session-free, and keeps it testable with no child process.
202
+ // ---------------------------------------------------------------------------
203
+
204
+ export interface AnnoSymbol {
205
+ address: number;
206
+ name: string;
207
+ /** `LabelKind`'s Debug form: `"User"`, `"Auto"` or `"System"`. */
208
+ kind: string;
209
+ /** `LabelType`'s Debug form (`"Subroutine"`, `"AbsoluteAddress"`, ...). */
210
+ type?: string;
211
+ }
212
+
213
+ export interface AnnoComment {
214
+ address: number;
215
+ /** `"line"` or `"side"`. */
216
+ type: string;
217
+ comment: string;
218
+ }
219
+
220
+ // The store's block-entry shape is NOT declared here. It lives in
221
+ // `block-class.ts` as `BlockEntry`, together with the one function allowed to
222
+ // interpret its `type` field -- see invariant 1 above.
223
+
224
+ export interface AnnoCrossReference {
225
+ address: number;
226
+ /** The sorted, deduped caller list `anno_get_cross_references` returns. */
227
+ callers: readonly number[];
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Errors
232
+ // ---------------------------------------------------------------------------
233
+
234
+ export interface AnnoCoverageInputErrorOptions {
235
+ cause?: unknown;
236
+ projectPath?: string;
237
+ }
238
+
239
+ /**
240
+ * Thrown ONLY for a caller contract violation -- an absent or unreadable
241
+ * project path. Never thrown for malformed bytes: a payload that will not
242
+ * gunzip, or a project file that is not JSON, is reported as an explicit
243
+ * `payloadDecoded: false` plus a reason (COV-02's "never a silent skip", and
244
+ * never a throw the caller has to guess at either). Mirrors
245
+ * `AnnoProjectSettingsError`'s named-field convention so a caller never has
246
+ * to parse message text to recover the path.
247
+ */
248
+ export class AnnoCoverageInputError extends Error {
249
+ projectPath: string | undefined;
250
+
251
+ constructor(message: string, { cause, projectPath }: AnnoCoverageInputErrorOptions = {}) {
252
+ super(message);
253
+ this.name = "AnnoCoverageInputError";
254
+ this.projectPath = projectPath;
255
+ if (cause !== undefined) {
256
+ (this as { cause?: unknown }).cause = cause;
257
+ }
258
+ }
259
+ }
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // (a) The structural census
263
+ // ---------------------------------------------------------------------------
264
+
265
+ /**
266
+ * The four DISJOINT byte classes. Every byte in `[origin, origin + size)`
267
+ * lands in exactly one, and their counts always sum to `rangeBytes`.
268
+ *
269
+ * `reached-as-instruction` means REACHED BY RECURSIVE DESCENT FROM A SEED --
270
+ * never "these bytes happened to decode". See the header's trap 2.
271
+ */
272
+ export type ByteClass = "reached-as-instruction" | "table-entry" | "referenced-as-data" | "unreached";
273
+
274
+ const CLASS_ORDER: readonly ByteClass[] = [
275
+ "reached-as-instruction",
276
+ "table-entry",
277
+ "referenced-as-data",
278
+ "unreached",
279
+ ];
280
+
281
+ /** A run of consecutive addresses sharing one class. The census reports runs
282
+ * rather than a per-byte array so the report stays JSON-safe and stays
283
+ * deep-comparable between two runs (the idempotency test). */
284
+ export interface ClassRun {
285
+ start: number;
286
+ end: number;
287
+ class: ByteClass;
288
+ }
289
+
290
+ export interface StructuralCensus {
291
+ origin: number;
292
+ size: number;
293
+ /** `size`, restated: every count below is over the HALF-OPEN range
294
+ * `[origin, origin + size)`, and these four counts sum to exactly this. */
295
+ rangeBytes: number;
296
+ /** The seed set actually used, ascending and deduped. */
297
+ seeds: number[];
298
+ reachedAsInstruction: number;
299
+ tableEntry: number;
300
+ referencedAsData: number;
301
+ unreached: number;
302
+ /**
303
+ * Bytes a plain LINEAR SWEEP from `origin` decodes into legal, complete
304
+ * instructions. Reported deliberately under its own name and NEVER added to
305
+ * `reachedAsInstruction` -- decodability is not evidence of code.
306
+ */
307
+ linearSweepDecodable: number;
308
+ /** True when the walk hit `MAX_WALK_STEPS` and stopped early. */
309
+ truncated: boolean;
310
+ /** Steps the descent walker actually took. */
311
+ steps: number;
312
+ classRuns: ClassRun[];
313
+ }
314
+
315
+ const TERMINATORS = new Set([0x00 /* brk */, 0x40 /* rti */, 0x4c /* jmp abs */, 0x60 /* rts */, 0x6c /* jmp ind */]);
316
+
317
+ /** Mnemonics whose absolute/zero-page operand names a DATA location. Used to
318
+ * mark `referenced-as-data`. Exactly one byte is marked per reference: the
319
+ * extent of an indexed access is not determinable from the bytes, so guessing
320
+ * a length here would manufacture coverage that was never proven. */
321
+ const DATA_REF_MNEMONICS = new Set([
322
+ "lda", "ldx", "ldy", "sta", "stx", "sty",
323
+ "adc", "sbc", "and", "ora", "eor", "cmp", "cpx", "cpy",
324
+ "bit", "inc", "dec", "asl", "lsr", "rol", "ror",
325
+ ]);
326
+
327
+ function sortedUniqueNumbers(values: Iterable<number>): number[] {
328
+ return [...new Set(values)].sort((a, b) => a - b);
329
+ }
330
+
331
+ function toRuns(classes: Uint8Array, origin: number): ClassRun[] {
332
+ const runs: ClassRun[] = [];
333
+ if (classes.length === 0) return runs;
334
+ let runStart = 0;
335
+ for (let i = 1; i <= classes.length; i++) {
336
+ if (i === classes.length || classes[i] !== classes[runStart]) {
337
+ runs.push({
338
+ start: origin + runStart,
339
+ end: origin + i - 1,
340
+ class: CLASS_ORDER[classes[runStart]!]!,
341
+ });
342
+ runStart = i;
343
+ }
344
+ }
345
+ return runs;
346
+ }
347
+
348
+ /** Looks up the class of a single address in a census, or `null` when the
349
+ * address lies outside the censused range. Linear over runs, which is what
350
+ * keeps the census JSON-safe -- see `ClassRun`. */
351
+ export function classAt(census: StructuralCensus, address: number): ByteClass | null {
352
+ for (const run of census.classRuns) {
353
+ if (address >= run.start && address <= run.end) return run.class;
354
+ }
355
+ return null;
356
+ }
357
+
358
+ export interface StructuralCensusOptions {
359
+ /** Addresses already known to be dispatch-table entries (two bytes each),
360
+ * normally `scanIndirectDispatch()`'s reconstructed table entry addresses. */
361
+ tableEntryAddresses?: Iterable<number>;
362
+ /** Extra addresses to descend from, normally the widened dispatch scan's
363
+ * discovered targets. */
364
+ extraSeeds?: Iterable<number>;
365
+ maxSteps?: number;
366
+ }
367
+
368
+ /**
369
+ * Does `decoded` exist, and is it an instruction a program could actually
370
+ * EXECUTE -- one the decoder did not flag illegal and did not have to
371
+ * truncate?
372
+ *
373
+ * THE ONE DECODABILITY PREDICATE IN THIS MODULE, and the reason it exists is
374
+ * an incident rather than tidiness. Three places here answer "is this byte an
375
+ * instruction": the linear sweep, the entry-point gate inside
376
+ * `scanIndirectDispatch()`, and the recursive descent that produces the
377
+ * HEADLINE number. The first two tested the decoder's illegal flag. The third
378
+ * never asked. It walked straight THROUGH an illegal opcode, claimed its bytes
379
+ * as reached code, and carried on into whatever followed.
380
+ *
381
+ * Measured on a 64-byte image at $0810 holding `lda #$01` / `ldx #$00` and then
382
+ * sixty `$02` bytes: `reachedAsInstruction=64`, `unreached=0`,
383
+ * `linearSweepDecodable=4`. One hundred per cent structural completeness on a
384
+ * ninety-four-per-cent-garbage image, with the sibling figure on the SAME
385
+ * report disagreeing sixteen-fold. Two figures describing two different byte
386
+ * sets is not a rounding difference; it is a report contradicting itself.
387
+ *
388
+ * The predicate exists so those two figures cannot describe different byte sets
389
+ * again. It is the same extraction `isPlausibleEntryPoint()` itself received
390
+ * when the two halves of `provenDispatchTargets()` were found held to different
391
+ * standards -- one definition, every reader on it.
392
+ *
393
+ * It reads the decoder's OWN boolean flag and its own `truncated` note. Never a
394
+ * mnemonic string comparison and never an opcode-byte range: which opcodes are
395
+ * illegal is the opcode table's fact to state, not this module's to restate.
396
+ */
397
+ function isDecodableAsInstruction(decoded: Instruction | undefined): decoded is Instruction {
398
+ return !!decoded && !decoded.illegal && !decoded.notes.includes("truncated");
399
+ }
400
+
401
+ /**
402
+ * Classifies every byte in `[origin, origin + size)` by recursive descent
403
+ * from `seeds`.
404
+ *
405
+ * Bounded by construction: an explicit worklist and a visited set, NO
406
+ * recursion (mirroring `decode()`'s own discipline, which this walker sits on
407
+ * top of), and an explicit `MAX_WALK_STEPS` cap that sets `truncated` rather
408
+ * than looping. Never throws: a non-`Uint8Array` payload, a nonsense origin
409
+ * or an empty seed set all produce a well-formed census.
410
+ *
411
+ * An EMPTY or SEEDLESS input reports `reachedAsInstruction: 0` and
412
+ * `unreached: rangeBytes` -- a real, readable zero, never an error and never
413
+ * an omitted measure.
414
+ */
415
+ export function computeStructuralCensus(
416
+ bytes: Uint8Array,
417
+ origin: number,
418
+ seeds: Iterable<number> = [],
419
+ opts: StructuralCensusOptions = {},
420
+ ): StructuralCensus {
421
+ const safeBytes = bytes instanceof Uint8Array ? bytes : new Uint8Array(0);
422
+ const safeOrigin = Number.isSafeInteger(origin) && origin >= 0 && origin <= 0xffff ? origin : 0;
423
+ const size = safeBytes.length;
424
+ const maxSteps = Number.isSafeInteger(opts.maxSteps) && opts.maxSteps! > 0 ? opts.maxSteps! : MAX_WALK_STEPS;
425
+
426
+ // IN-04. The censused range is bounded at the 16-bit address space, not at
427
+ // `origin + size`. A payload whose origin plus length runs past $FFFF is
428
+ // MALFORMED INPUT -- a `.regen2000proj` file the operator did not author
429
+ // can claim any origin and carry any length -- and this module's contract
430
+ // on malformed input is to produce a well-formed census, never to wrap and
431
+ // never to classify an address the machine cannot address. Bytes at or
432
+ // beyond $10000 are not classified, not counted, and not swept.
433
+ const effectiveEnd = Math.min(safeOrigin + size, 0x10000);
434
+ const rangeSize = Math.max(0, effectiveEnd - safeOrigin);
435
+
436
+ // Class codes are indices into CLASS_ORDER. A zero-initialised array would
437
+ // mean "reached-as-instruction", which is exactly the wrong default for an
438
+ // instrument whose entire point is that reachability must be PROVEN, so
439
+ // fill with 3 ("unreached") explicitly.
440
+ const classes = new Uint8Array(rangeSize);
441
+ classes.fill(3);
442
+
443
+ const inRange = (addr: number): boolean => addr >= safeOrigin && addr < effectiveEnd;
444
+ const mark = (addr: number, klass: number): void => {
445
+ if (!inRange(addr)) return;
446
+ const idx = addr - safeOrigin;
447
+ // Lower index wins: reached-as-instruction beats table-entry beats
448
+ // referenced-as-data beats unreached. Disjointness by construction.
449
+ if (klass < classes[idx]!) classes[idx] = klass;
450
+ };
451
+
452
+ const seedList = sortedUniqueNumbers([...seeds, ...(opts.extraSeeds ?? [])].filter((a) => Number.isSafeInteger(a)));
453
+
454
+ const worklist: number[] = seedList.filter(inRange);
455
+ const visited = new Set<number>(worklist);
456
+ let steps = 0;
457
+ let truncated = false;
458
+
459
+ while (worklist.length > 0) {
460
+ if (steps >= maxSteps) {
461
+ truncated = true;
462
+ break;
463
+ }
464
+ steps++;
465
+
466
+ let pc = worklist.pop()!;
467
+
468
+ // Walk this trace linearly until it terminates, leaves the range, or
469
+ // revisits a byte already walked as an instruction.
470
+ while (inRange(pc)) {
471
+ if (steps >= maxSteps) {
472
+ truncated = true;
473
+ break;
474
+ }
475
+ steps++;
476
+
477
+ const offset = pc - safeOrigin;
478
+ const decoded = decode(safeBytes.subarray(offset), pc, { count: 1 })[0];
479
+ // THE PREDICATE IS CONSULTED HERE, BEFORE THE MARKING LOOP BELOW, and
480
+ // that order is the whole of the WR-03 fix -- not an accident of how the
481
+ // statements happened to be written.
482
+ //
483
+ // Consulted before: the illegal byte is never marked, so it stays
484
+ // `unreached` and the four class counts still sum to `rangeBytes`.
485
+ // Consulted after: the byte would be claimed as reached code and only
486
+ // then abandoned, which is precisely the behaviour that reported
487
+ // sixty-four of sixty-four bytes as executed code on a four-byte program.
488
+ //
489
+ // A later reader who keeps the predicate but moves this test below the
490
+ // marking loop reintroduces the defect while leaving every mention of the
491
+ // predicate in place. Do not reorder these two statements.
492
+ if (!isDecodableAsInstruction(decoded)) break;
493
+
494
+ for (let i = 0; i < decoded.bytes.length; i++) mark(pc + i, 0);
495
+
496
+ // Data references: exactly one byte, the named base. Never a guessed
497
+ // extent -- see DATA_REF_MNEMONICS.
498
+ const operand = decoded.operand;
499
+ if (
500
+ operand &&
501
+ (operand.role === "absolute" || operand.role === "zeropage") &&
502
+ DATA_REF_MNEMONICS.has(decoded.mnemonic)
503
+ ) {
504
+ mark(operand.value, 2);
505
+ }
506
+
507
+ // Control flow: a branch or a jsr forks; an unconditional terminator
508
+ // ends the trace.
509
+ const target = decoded.resolvedTarget;
510
+ if (target !== undefined && inRange(target) && !visited.has(target)) {
511
+ visited.add(target);
512
+ worklist.push(target);
513
+ }
514
+
515
+ if (TERMINATORS.has(decoded.opcode)) break;
516
+
517
+ pc += decoded.bytes.length;
518
+ }
519
+ }
520
+
521
+ for (const addr of opts.tableEntryAddresses ?? []) mark(addr, 1);
522
+
523
+ // Linear-sweep decodability -- reported, never summed. See trap 2. Swept
524
+ // over the SAME bounded range as the census, so the two figures describe
525
+ // the same bytes (IN-04).
526
+ // The skip is expressed through the SAME predicate the descent above reads,
527
+ // so the two figures are comparable by construction rather than by
528
+ // coincidence. This figure's MEANING is untouched: it still counts bytes that
529
+ // decode as legal, non-truncated instructions, exactly as it always did. The
530
+ // descent was brought to this standard; this standard was never loosened to
531
+ // the descent's, because `linearSweepDecodable` is a published field of a
532
+ // report other phases consume and redefining it would be a schema question.
533
+ let linearSweepDecodable = 0;
534
+ for (const insn of decode(safeBytes.subarray(0, rangeSize), safeOrigin)) {
535
+ if (!isDecodableAsInstruction(insn)) continue;
536
+ linearSweepDecodable += insn.bytes.length;
537
+ }
538
+
539
+ const counts = [0, 0, 0, 0];
540
+ for (let i = 0; i < rangeSize; i++) {
541
+ const code = classes[i]!;
542
+ counts[code] = counts[code]! + 1;
543
+ }
544
+
545
+ return {
546
+ origin: safeOrigin,
547
+ // `size` is the payload's own length; `rangeBytes` is how much of it lies
548
+ // inside the 16-bit address space and was therefore censused. The two
549
+ // differ only for a malformed origin/length pair (IN-04).
550
+ size,
551
+ rangeBytes: rangeSize,
552
+ seeds: seedList,
553
+ reachedAsInstruction: counts[0]!,
554
+ tableEntry: counts[1]!,
555
+ referencedAsData: counts[2]!,
556
+ unreached: counts[3]!,
557
+ linearSweepDecodable,
558
+ truncated,
559
+ steps,
560
+ classRuns: toRuns(classes, safeOrigin),
561
+ };
562
+ }
563
+
564
+ // ---------------------------------------------------------------------------
565
+ // (b) The widened indirect-dispatch scan
566
+ // ---------------------------------------------------------------------------
567
+
568
+ export interface IndirectJumpFinding {
569
+ /** Address of the `jmp ($nnnn)` instruction itself. */
570
+ at: number;
571
+ /** The pointer location named by the operand. */
572
+ pointer: number;
573
+ /** The 16-bit word read at `pointer`, or `null` when the pointer lies
574
+ * outside the image (a ZERO-PAGE VECTOR is the common case, and upstream's
575
+ * own walk skips it entirely -- reporting it with a null target is the
576
+ * whole point). */
577
+ target: number | null;
578
+ pointerInImage: boolean;
579
+ pointerInZeroPage: boolean;
580
+ }
581
+
582
+ export interface DispatchTableFinding {
583
+ /** Address of the instruction that named the table, when there is one. */
584
+ at: number;
585
+ base: number;
586
+ entries: number;
587
+ targets: number[];
588
+ truncated: boolean;
589
+ }
590
+
591
+ export interface SplitTableFinding {
592
+ at: number;
593
+ loBase: number;
594
+ hiBase: number;
595
+ entries: number;
596
+ targets: number[];
597
+ truncated: boolean;
598
+ /** True only when something OTHER THAN ADDRESS ORDER decided which base
599
+ * holds the low bytes -- for Class 3 that is the pairing's own store
600
+ * construction (the load whose value reaches the LOWER of two consecutive
601
+ * zero-page addresses is the lo table).
602
+ *
603
+ * WR-01 is why this field exists: the shipped scan assigned the roles with
604
+ * `Math.min`/`Math.max` over the two operand addresses, which is not
605
+ * evidence of anything, and on the stack-return idiom it produced a
606
+ * byte-swapped twin of a finding the OTHER class had already reported
607
+ * correctly ($05c0 for $c005). When this is false the finding is ADVISORY,
608
+ * `loBase`/`hiBase` are recorded in ENCOUNTER order with no claim about
609
+ * which is which, and `targets` is EMPTY -- a byte-swapped value is not an
610
+ * address and must never be printed as one. */
611
+ orientationResolved: boolean;
612
+ }
613
+
614
+ export interface StackReturnFinding {
615
+ /** Address of the first instruction of the matched idiom. */
616
+ at: number;
617
+ loBase: number;
618
+ hiBase: number;
619
+ entries: number;
620
+ targets: number[];
621
+ truncated: boolean;
622
+ /** Always `true` for this class, and stated rather than implied: the 6502
623
+ * pushes the HIGH byte first, so the idiom's own push order -- not address
624
+ * order -- names which base holds which half. This is the one place a lo/hi
625
+ * assignment was always justified, which is why Class 4 runs first and
626
+ * Class 3 declines any window it claimed (WR-01). */
627
+ orientationResolved: true;
628
+ }
629
+
630
+ export interface IndirectDispatchScan {
631
+ indirectJumps: IndirectJumpFinding[];
632
+ /** PROVEN Class-3 pairings only: those that passed the dispatch-context
633
+ * gate. An ungated pairing is in `splitTableCandidates`, never here. */
634
+ splitTables: SplitTableFinding[];
635
+ multiEntryTables: DispatchTableFinding[];
636
+ stackReturnDispatch: StackReturnFinding[];
637
+ /** ADVISORY Class-3 pairings: two indexed loads that LOOK like a split
638
+ * lo/hi table but carry no evidence that anything dispatches through them.
639
+ *
640
+ * Reported BESIDE the proven classes and NEVER summed into them -- exactly
641
+ * the discipline `linearSweepDecodable` carries (header trap 2), and for
642
+ * exactly the same reason. An advisory pairing contributes nothing to
643
+ * `discoveredTargets`, nothing to `tableEntryAddresses`, and therefore
644
+ * moves not one byte of the census.
645
+ *
646
+ * It exists so the observation is not DISCARDED: "something indexes two
647
+ * tables here and we cannot prove what it dispatches to" is precisely what
648
+ * the hazard report wants to see, flagged as unproven. Its findings
649
+ * carry `orientationResolved: false` and an empty `targets` list. */
650
+ splitTableCandidates: SplitTableFinding[];
651
+ /** Every EVIDENCE-BACKED target the scan discovered, ascending and deduped:
652
+ * real `jmp ($nnnn)` targets, the multi-entry tables those jumps name, the
653
+ * stack-return idiom's push-order-justified reconstruction, and PROVEN
654
+ * split tables. Reconstructed values from ungated pairings are NOT here --
655
+ * that narrowing is the schema-version-2 change (header trap 8).
656
+ *
657
+ * An ADDRESS LIST, not a figure -- the classes stay separately addressable
658
+ * above so the hazard report can consume just the one it needs. */
659
+ discoveredTargets: number[];
660
+ /** Addresses occupied by reconstructed table entries (two bytes each).
661
+ * Proven classes only, for the same reason as `discoveredTargets`. */
662
+ tableEntryAddresses: number[];
663
+ truncated: boolean;
664
+ }
665
+
666
+ const INDEXED_LOAD_MODES = new Set(["absolute_x", "absolute_y", "zeropage_x"]);
667
+
668
+ /** The index register an indexed addressing mode reads, or `null` for a mode
669
+ * that indexes through neither. Compared instead of mere membership in
670
+ * `INDEXED_LOAD_MODES`, so an `absolute_x` load paired with an `absolute_y`
671
+ * load is not mistaken for a lo/hi pair: two tables walked by two different
672
+ * registers are two tables, not one split one. */
673
+ function indexRegisterOf(insn: Instruction): "x" | "y" | null {
674
+ if (insn.mode === "absolute_x" || insn.mode === "zeropage_x") return "x";
675
+ if (insn.mode === "absolute_y" || insn.mode === "zeropage_y") return "y";
676
+ return null;
677
+ }
678
+
679
+ /** True iff both instructions index through the SAME register. */
680
+ function sameIndexRegister(a: Instruction, b: Instruction): boolean {
681
+ const ra = indexRegisterOf(a);
682
+ return ra !== null && ra === indexRegisterOf(b);
683
+ }
684
+
685
+ const STORE_MNEMONICS = new Set(["sta", "stx", "sty"]);
686
+
687
+ /** True iff `insn` stores into a zero-page location. */
688
+ function zeroPageStoreTarget(insn: Instruction): number | null {
689
+ if (!insn.operand) return null;
690
+ if (!STORE_MNEMONICS.has(insn.mnemonic)) return null;
691
+ if (insn.operand.role !== "zeropage") return null;
692
+ return insn.operand.value;
693
+ }
694
+
695
+ /**
696
+ * Which of the two reconstructed bases holds the LOW byte of the vector the
697
+ * pairing builds. The named return type of `resolveSplitOrientation()`, given
698
+ * a name so the pairing under test can be carried across a call boundary as
699
+ * one value rather than re-derived inside every predicate that needs it.
700
+ */
701
+ interface SplitOrientation {
702
+ loBase: number;
703
+ hiBase: number;
704
+ /**
705
+ * The LOWER of the two consecutive zero-page addresses the pairing's own two
706
+ * loads are consumed by -- the address a little-endian 6502 vector's LOW byte
707
+ * lives at, which is therefore the address an indirect jump through that
708
+ * vector names. A fact about the construction, not a convention.
709
+ *
710
+ * Carried on the orientation rather than re-derived, because it comes out of
711
+ * the SAME two consumer stores that decided which base holds the low byte:
712
+ * the vector address and the orientation that justified it travel together
713
+ * and cannot disagree.
714
+ */
715
+ vectorLow: number;
716
+ }
717
+
718
+ /**
719
+ * THE PAIRING A DISPATCH PREDICATE IS BEING ASKED TO RULE ON.
720
+ *
721
+ * `hasDispatchContext()` takes this rather than re-guessing it from the
722
+ * window, because a predicate that never looks at the pairing under test
723
+ * cannot say anything about it. `firstIndex` and `secondIndex` are the two
724
+ * indexed loads' own instruction indices, and `oriented` is the orientation
725
+ * their store construction justified -- the call site only reaches the
726
+ * predicate once that orientation is non-null.
727
+ */
728
+ interface DispatchPairing {
729
+ firstIndex: number;
730
+ secondIndex: number;
731
+ oriented: SplitOrientation;
732
+ }
733
+
734
+ /**
735
+ * The COMPLETE, frozen list of shapes `hasDispatchContext()` accepts as proof
736
+ * that something dispatches through a reconstructed pair of tables. One stable
737
+ * string id per sufficient shape, in the order the predicate tests them.
738
+ *
739
+ * A SHAPE LISTED HERE IS THE DECISION TO TREAT THAT SHAPE AS PROOF OF CODE --
740
+ * the same decision `provenDispatchTargets()`'s doc comment describes, made one
741
+ * level down. The class-3 gate reads this predicate, and `splitTables` is one
742
+ * of the four sources that seam publishes, so a shape admitted here becomes a
743
+ * recursive-descent seed and turns whatever it points at into headline
744
+ * `reachedAsInstruction`.
745
+ *
746
+ * ADDING A SHAPE HERE WITHOUT A NEGATIVE CONTROL THAT REACHES ITS INTERIOR
747
+ * FAILS THE TEST SUITE BY NAME. `anno-coverage.test.ts`'s
748
+ * `GATE_INTERIOR_DECLARATIONS` must claim every id in this array, and a
749
+ * declaration is checked mechanically by a witness that decodes the payload --
750
+ * not accepted as a claim. That mechanism exists because CR-04 was a real
751
+ * false-positive route that a 2517-passing suite concealed: every negative
752
+ * control the gate had bracketed it from the OUTSIDE, and a negative control
753
+ * built from the outside of the predicate it constrains is not a control.
754
+ *
755
+ * The count is also tied to the predicate mechanically: a test reads
756
+ * `hasDispatchContext()`'s body from this module's source text and asserts that
757
+ * the number of true-returning sites in it equals this array's length, so a
758
+ * fourth branch added without a matching id reds the suite rather than sliding
759
+ * through a hand-maintained mirror.
760
+ */
761
+ export const DISPATCH_CONTEXT_SHAPES: readonly string[] = Object.freeze([
762
+ "stack-return-push-idiom",
763
+ "zeropage-vector-jumped-through",
764
+ ]);
765
+
766
+ /** One gate that may publish a proven dispatch finding whose evidence is one
767
+ * of `DISPATCH_CONTEXT_SHAPES`. See `DISPATCH_GATE_ROUTES`. */
768
+ export interface DispatchGateRoute {
769
+ /** The route's stable string id, used as the second half of a control
770
+ * target's identity in the test suite. */
771
+ readonly id: string;
772
+ /** Does this gate reach its verdict by calling `hasDispatchContext()`? A
773
+ * route that does accepts EXACTLY the shapes declared above, which is what
774
+ * makes reachability on it a derived fact rather than a declaration. */
775
+ readonly consultsSharedGate: boolean;
776
+ /** The name of the scan collection this route writes its proven findings
777
+ * into -- an `IndirectDispatchScan` field name, and a member of
778
+ * `PROVEN_TARGET_SOURCES`. */
779
+ readonly publishesInto: string;
780
+ }
781
+
782
+ /**
783
+ * The COMPLETE, frozen list of ROUTES by which a shape above can become a
784
+ * proven dispatch finding. One record per gate.
785
+ *
786
+ * A ROUTE IS THE SECOND HALF OF A CONTROL TARGET'S IDENTITY, and this array
787
+ * exists because keying control targets on shape ALONE shipped a hole big
788
+ * enough to drive the defect it was built to catch straight through. One of
789
+ * the two shapes above is ruled on by TWO gates, not one -- the class-3 pass,
790
+ * which consults `hasDispatchContext()`, and the class-4 pass, whose own
791
+ * five-instruction window is its gate. The test suite's interior witness used
792
+ * to define that shape's interior as a DISJUNCTION of the two routes, so every
793
+ * control declared against the shape satisfied the class-4 half and the
794
+ * class-3 route into it had no control at all. The knowledge was already
795
+ * written down one comment away; nothing forced a control for it.
796
+ *
797
+ * `publishesInto` is what makes a route's DECLINE measurable. A negative
798
+ * control on the class-3 route is proved to decline through `splitTables` --
799
+ * the collection that route publishes into -- and never through the aggregate
800
+ * `provenDispatchTargets()` seam, because the stack-return payload is
801
+ * simultaneously a class-3 decline and a class-4 acceptance: measured through
802
+ * the seam it looks accepted, and its class-3 decline becomes inexpressible.
803
+ *
804
+ * ADDING A THIRD GATE HERE WITHOUT A CONTROL FOR IT FAILS THE TEST SUITE BY
805
+ * NAME, and so does adding one to `scanIndirectDispatch()` without recording
806
+ * it here. Four assertions in `anno-coverage.test.ts` hold this array down
807
+ * against the module's own text rather than against a hand-maintained mirror:
808
+ * the number of `hasDispatchContext(` call sites equals the number of records
809
+ * whose `consultsSharedGate` is true; each record's `publishesInto` occurs
810
+ * exactly once as a publication site inside `scanIndirectDispatch()`, and the
811
+ * total equals this array's length; every `publishesInto` is a member of
812
+ * `PROVEN_TARGET_SOURCES`; and the class-4 publication site precedes the
813
+ * shared gate's only call site, with no call to the gate before it -- which is
814
+ * what makes "the class-4 pass is a route, not a caller of the shared gate" a
815
+ * source-level fact instead of a claim in a table.
816
+ */
817
+ export const DISPATCH_GATE_ROUTES: readonly DispatchGateRoute[] = Object.freeze([
818
+ Object.freeze({ id: "class-3-pass", consultsSharedGate: true, publishesInto: "splitTables" }),
819
+ Object.freeze({ id: "class-4-pass", consultsSharedGate: false, publishesInto: "stackReturnDispatch" }),
820
+ ]);
821
+
822
+ /**
823
+ * Does the instruction window starting at `start` carry evidence that
824
+ * something DISPATCHES through a reconstructed pair of tables?
825
+ *
826
+ * Accepts either ONE of the two shapes named in `DISPATCH_CONTEXT_SHAPES`:
827
+ *
828
+ * - `stack-return-push-idiom` -- the RTS trick, matched as a DATA FLOW from
829
+ * the pairing under test: the instruction immediately after EACH of the
830
+ * two paired loads is a `pha`, and an `rts` follows both of those pushes
831
+ * inside the window. Each load must push the byte it just read, because
832
+ * that is the whole mechanism -- `rts` jumps to the address assembled from
833
+ * the two pushed bytes, so a pairing whose bytes were never pushed is not
834
+ * the thing that address came from.
835
+ *
836
+ * WHY THE PRESENCE OF A PUSH IDIOM IS NOT EVIDENCE ABOUT THIS PAIRING.
837
+ * This branch previously accepted any two `pha` bytes and any `rts` seen
838
+ * anywhere in the window, on the stated rationale that the Class-4 pass
839
+ * runs first and claims its windows, so a pairing inside one is never
840
+ * promoted here. That rationale is FALSE and has been removed rather than
841
+ * kept: Class 4 claims only its exact five-instruction shape
842
+ * (`indexed load : pha : indexed load : pha : rts`), and every time it
843
+ * DECLINES -- mixed index registers, an implausible reconstructed entry
844
+ * point, or any instruction sitting between a load and its push -- the
845
+ * window is left unclaimed and this pass rules on the pairing itself.
846
+ * `lda lo,x : sta $fb : lda hi,x : sta $fc : pha : txa : pha : tya : rts`
847
+ * is the concrete case: the two pushes carry the accumulator's leftover
848
+ * value and the X register, neither load's byte reaches the stack, and
849
+ * the payload was still promoted -- manufacturing eight "proven" entry
850
+ * points and 47 of 64 bytes of code-or-table out of a 15-byte program.
851
+ * A `pha`/`pha`/`rts` in the same neighbourhood as two indexed loads is
852
+ * an extremely ordinary coincidence; the LINK is the evidence, not the
853
+ * shape.
854
+ * - `zeropage-vector-jumped-through` -- the pairing's OWN two loads are
855
+ * consumed by two CONSECUTIVE zero-page stores (which is what resolves the
856
+ * orientation), AND an indirect jump within reach names the LOWER of those
857
+ * two addresses. That is the classic "build a vector in zero page, then
858
+ * `jmp (vector)`" idiom, matched END TO END and matched against the pairing
859
+ * under test.
860
+ *
861
+ * WHY THE CONSTRUCTION ALONE IS NOT EVIDENCE (CR-04). Two stores into
862
+ * consecutive zero-page addresses is how EVERY 16-bit pointer on a 6502 is
863
+ * built, and `lda ($fb),y` -- indirect-indexed DATA access, far more common in
864
+ * real code than indirect jump -- needs exactly the identical construction.
865
+ * A predicate that never looks at what CONSUMES the vector it saw being built
866
+ * cannot tell a jump table from a screen pointer, and every ordinary pointer
867
+ * setup then promotes its data to `reachedAsInstruction`. So a bare
868
+ * indirect-jump opcode "within reach" is not accepted either: an indirect jump
869
+ * through some OTHER vector near two indexed loads is not evidence that those
870
+ * loads feed it. The operand value must equal the vector that was built.
871
+ *
872
+ * WHY A CONSUMER OF SOME OTHER VECTOR IS NOT EVIDENCE ABOUT THIS PAIRING. The
873
+ * sentence above is only half the rule, and this branch previously held only
874
+ * that half: it demanded a consumer, then went looking for one by scanning the
875
+ * WHOLE window for any two zero-page store targets differing by exactly one
876
+ * with a jump naming the lower. A second, unrelated consecutive zero-page pair
877
+ * inside the same window defeats that outright --
878
+ * `lda lo,x : sta $fb : lda hi,x : sta $fc : sta $fd : sta $fe : jmp ($00fd)`
879
+ * builds the pairing's vector at `$fb`/`$fc`, builds a foreign one at
880
+ * `$fd`/`$fe`, jumps through the FOREIGN one, and was promoted on the strength
881
+ * of a link that has nothing to do with the tables being reconstructed. Two
882
+ * consecutive zero-page pairs in one window is not an exotic shape; a routine
883
+ * that sets up a source pointer and a destination pointer has two. So the
884
+ * address compared here is `vectorLow` -- the one the pairing's own two
885
+ * consumer stores built -- and no other.
886
+ */
887
+ function hasDispatchContext(insns: readonly Instruction[], start: number, reach: number, pairing: DispatchPairing): boolean {
888
+ const end = Math.min(insns.length, start + reach + 1);
889
+
890
+ // `stack-return-push-idiom`, decided against the PAIRING rather than against
891
+ // the window's contents: the two paired loads must each be immediately
892
+ // followed by the `pha` that carries the byte they just read, and the `rts`
893
+ // that consumes the assembled address must follow both of those pushes.
894
+ // Read at the two loads' own successors, so no `pha` anywhere else in the
895
+ // window can stand in for either of them.
896
+ if (insns[pairing.firstIndex + 1]?.opcode === 0x48 && insns[pairing.secondIndex + 1]?.opcode === 0x48) {
897
+ for (let k = pairing.secondIndex + 2; k < end; k++) {
898
+ if (insns[k]!.opcode === 0x60) return true;
899
+ }
900
+ }
901
+
902
+ /** The pointer each indirect jump in the window dispatches THROUGH, collected
903
+ * rather than treated as sufficient on sight -- see the doc comment. */
904
+ const indirectJumpPointers: number[] = [];
905
+ for (let k = start; k < end; k++) {
906
+ const insn = insns[k]!;
907
+ if (insn.opcode === 0x6c && insn.operand) indirectJumpPointers.push(insn.operand.value);
908
+ }
909
+
910
+ // `zeropage-vector-jumped-through`, decided against the PAIRING rather than
911
+ // against the window's contents: the vector compared is the one the pairing's
912
+ // OWN two consumer stores built, carried on the orientation as `vectorLow`.
913
+ // An exact numeric equality on the zero-page address -- `jmp ($00fb)` decodes
914
+ // to operand.value 0xfb and `sta $fb` to operand.value 0xfb -- never a string
915
+ // or hex-text comparison.
916
+ if (indirectJumpPointers.includes(pairing.oriented.vectorLow)) return true;
917
+ return false;
918
+ }
919
+
920
+ /**
921
+ * Which of two indexed loads feeds the LOW byte, decided by the pairing's own
922
+ * store construction rather than by address order (WR-01).
923
+ *
924
+ * For each load, the nearest FOLLOWING zero-page store within reach is the
925
+ * store that consumes it. When the two loads are consumed by two DIFFERENT,
926
+ * CONSECUTIVE zero-page addresses, the one reaching the lower address holds
927
+ * the low byte -- a 6502 vector is little-endian, so that is a fact about the
928
+ * construction, not a convention. Any other shape returns `null`, and a
929
+ * `null` orientation makes the pairing ADVISORY however good its other
930
+ * evidence is: an unresolved orientation would otherwise be resolved by
931
+ * `Math.min`, which is the exact defect this replaces.
932
+ */
933
+ function resolveSplitOrientation(
934
+ insns: readonly Instruction[],
935
+ firstIndex: number,
936
+ secondIndex: number,
937
+ reach: number,
938
+ ): SplitOrientation | null {
939
+ const consumerOf = (from: number): number | null => {
940
+ const end = Math.min(insns.length, from + reach + 1);
941
+ for (let k = from + 1; k < end; k++) {
942
+ const zp = zeroPageStoreTarget(insns[k]!);
943
+ if (zp !== null) return zp;
944
+ }
945
+ return null;
946
+ };
947
+
948
+ const firstZp = consumerOf(firstIndex);
949
+ const secondZp = consumerOf(secondIndex);
950
+ if (firstZp === null || secondZp === null) return null;
951
+ if (Math.abs(firstZp - secondZp) !== 1) return null;
952
+
953
+ const firstBase = insns[firstIndex]!.operand!.value;
954
+ const secondBase = insns[secondIndex]!.operand!.value;
955
+ const vectorLow = Math.min(firstZp, secondZp);
956
+ return firstZp < secondZp
957
+ ? { loBase: firstBase, hiBase: secondBase, vectorLow }
958
+ : { loBase: secondBase, hiBase: firstBase, vectorLow };
959
+ }
960
+
961
+ /**
962
+ * Reaches the four classes upstream's `follow_indirect_jumps()` does not.
963
+ *
964
+ * Every table walk is bounded by `MAX_TABLE_ENTRIES` and reports its own
965
+ * `truncated` flag -- never "walk while plausible". Never throws.
966
+ */
967
+ export function scanIndirectDispatch(
968
+ instructions: readonly Instruction[],
969
+ bytes: Uint8Array,
970
+ origin: number,
971
+ ): IndirectDispatchScan {
972
+ const safeBytes = bytes instanceof Uint8Array ? bytes : new Uint8Array(0);
973
+ const safeOrigin = Number.isSafeInteger(origin) && origin >= 0 && origin <= 0xffff ? origin : 0;
974
+ const insns = Array.isArray(instructions) ? instructions : [];
975
+ const size = safeBytes.length;
976
+
977
+ // IN-05. THE ONE BOUND THIS SCAN DESCRIBES, stated once and read everywhere
978
+ // below. `computeStructuralCensus()` clamps its range at the 16-bit address
979
+ // space for IN-04's reason -- a `.regen2000proj` the operator did not author
980
+ // can claim any origin and carry any length -- and this scan, whose output is
981
+ // that report's own dispatch sub-report, was left unbounded. Values at or
982
+ // above $10000 caused no crash (the census's `mark()` filters them) but they
983
+ // were written into the JSON that the decomposition and reassembly work
984
+ // consumes, and
985
+ // `anno-cli.ts`'s `hexAddr()` renders them as five hex digits: a report
986
+ // whose two halves describe two different address spaces is misleading even
987
+ // when nothing throws. Computed the SAME way as the census's clamp so the two
988
+ // are one quantity, not two that happen to agree.
989
+ const effectiveEnd = Math.min(safeOrigin + size, 0x10000);
990
+
991
+ const inImage = (addr: number): boolean => addr >= safeOrigin && addr + 1 < effectiveEnd;
992
+ const wordAt = (addr: number): number | null => {
993
+ if (!inImage(addr)) return null;
994
+ const idx = addr - safeOrigin;
995
+ return safeBytes[idx]! | (safeBytes[idx + 1]! << 8);
996
+ };
997
+
998
+ /**
999
+ * Is `value` an address a program could actually be ENTERED at -- strictly
1000
+ * inside the image, and on a byte that decodes as a legal, non-truncated
1001
+ * instruction?
1002
+ *
1003
+ * The ONE predicate both gated reconstructions read: class 3's condition (e)
1004
+ * and the class-4 walk's condition (d). Extracted rather than written twice
1005
+ * (WR-14) because the two halves of `provenDispatchTargets()` were held to
1006
+ * DIFFERENT standards for exactly as long as this test existed in only one
1007
+ * of them. A value pointing at a byte that does not decode is not an entry
1008
+ * point, and a mid-instruction address is not evidence of code however
1009
+ * confidently it is printed.
1010
+ *
1011
+ * This gate COMPOSES the module's decodability predicate with its own
1012
+ * in-image bound rather than restating the decodability test. Its behaviour
1013
+ * is unchanged by that composition -- the two conditions it applied were
1014
+ * already the predicate's two conditions.
1015
+ */
1016
+ const isPlausibleEntryPoint = (value: number): boolean => {
1017
+ if (!(value >= safeOrigin && value < effectiveEnd)) return false;
1018
+ return isDecodableAsInstruction(decode(safeBytes.subarray(value - safeOrigin), value, { count: 1 })[0]);
1019
+ };
1020
+
1021
+ const indirectJumps: IndirectJumpFinding[] = [];
1022
+ const multiEntryTables: DispatchTableFinding[] = [];
1023
+ const splitTables: SplitTableFinding[] = [];
1024
+ const splitTableCandidates: SplitTableFinding[] = [];
1025
+ const stackReturnDispatch: StackReturnFinding[] = [];
1026
+ const tableEntryAddresses = new Set<number>();
1027
+ const discovered = new Set<number>();
1028
+ let truncated = false;
1029
+
1030
+ // --- Class 1 + 2: indirect jumps (pointer ANYWHERE, including zero page)
1031
+ // and the multi-entry tables they name.
1032
+ for (const insn of insns) {
1033
+ if (insn.opcode !== 0x6c || !insn.operand) continue;
1034
+ const pointer = insn.operand.value;
1035
+ const target = wordAt(pointer);
1036
+ indirectJumps.push({
1037
+ at: insn.address,
1038
+ pointer,
1039
+ target,
1040
+ pointerInImage: inImage(pointer),
1041
+ pointerInZeroPage: pointer < 0x100,
1042
+ });
1043
+ if (target !== null) discovered.add(target);
1044
+
1045
+ if (!inImage(pointer)) continue;
1046
+
1047
+ // Upstream reads EXACTLY ONE entry here. Read successive little-endian
1048
+ // 16-bit entries while each resolves inside the image, bounded.
1049
+ const targets: number[] = [];
1050
+ let cursor = pointer;
1051
+ let tableTruncated = false;
1052
+ while (true) {
1053
+ if (targets.length >= MAX_TABLE_ENTRIES) {
1054
+ tableTruncated = true;
1055
+ truncated = true;
1056
+ break;
1057
+ }
1058
+ const entry = wordAt(cursor);
1059
+ if (entry === null) break;
1060
+ if (!(entry >= safeOrigin && entry < effectiveEnd)) break;
1061
+ targets.push(entry);
1062
+ tableEntryAddresses.add(cursor);
1063
+ tableEntryAddresses.add(cursor + 1);
1064
+ discovered.add(entry);
1065
+ cursor += 2;
1066
+ }
1067
+ if (targets.length > 0) {
1068
+ multiEntryTables.push({ at: insn.address, base: pointer, entries: targets.length, targets, truncated: tableTruncated });
1069
+ }
1070
+ }
1071
+
1072
+ // --- Class 4: the stack-return dispatch idiom. `lda hi,X : pha : lda lo,X
1073
+ // : pha : rts` contains NO indirect-jump opcode, so an opcode-keyed walk
1074
+ // cannot see it at all. Sliding window over the decoded stream.
1075
+ //
1076
+ // THIS PASS RUNS BEFORE CLASS 3, DELIBERATELY (WR-01). The idiom's hi/lo
1077
+ // assignment is JUSTIFIED -- the 6502 pushes the high byte first, so the
1078
+ // first load reads the hi table -- whereas the Class-3 pass has no such
1079
+ // evidence. Where both would match the same five instructions, the
1080
+ // justified one must win and the other must not be reported at all;
1081
+ // otherwise the same two instructions appear twice with contradictory
1082
+ // roles, and the byte-swapped twin ($09c0 for $c009) is emitted as if it
1083
+ // were an address. Every instruction of a matched window is recorded here
1084
+ // and the Class-3 pass declines any pairing whose leading load sits in one.
1085
+ //
1086
+ // GATED TO THE SAME STANDARD AS CLASS 3 (WR-14). This pass feeds the same
1087
+ // `provenDispatchTargets()` seam class 3 feeds, and gating one half of a
1088
+ // seam while the other half is ungated is not a gate. A window is PROVEN
1089
+ // only when ALL of:
1090
+ // (a) the five instructions match the shape: indexed load, `pha`, indexed
1091
+ // load, `pha`, `rts`;
1092
+ // (b) both loads index through the SAME register -- two tables walked by
1093
+ // two different registers are two tables, not one split one, which is
1094
+ // the sentence class 3's own comment already makes. Pre-gate,
1095
+ // `lda $c010,x : pha : lda $c013,y : pha : rts` yielded a proven
1096
+ // target;
1097
+ // (c) the lo/hi orientation is justified rather than assumed. This is the
1098
+ // ONE condition the idiom supplies for free -- the 6502 pushes the
1099
+ // high byte first, so the first load reads the hi table -- and it is
1100
+ // why this pass runs before class 3 rather than after it;
1101
+ // (d) EVERY published entry point is a plausible one
1102
+ // (`isPlausibleEntryPoint`): strictly inside the image, and on a byte
1103
+ // that decodes as a legal, non-truncated instruction. The entry count
1104
+ // is derived from the DISTANCE between the two bases and is therefore
1105
+ // a guess, so the walk is bounded by evidence rather than by that
1106
+ // arithmetic: it stops at the first implausible value, marks the
1107
+ // finding truncated and raises the scan-level `truncated` flag, so a
1108
+ // walk cut short is REPORTED rather than shown as a clean empty list.
1109
+ // Nothing is published until (d) has been applied to it: `discovered` and
1110
+ // `tableEntryAddresses` are written only from the surviving prefix, exactly
1111
+ // the way class 3 reconstructs before its gate.
1112
+ const classFourWindow = new Set<number>();
1113
+ for (let i = 0; i + 4 < insns.length; i++) {
1114
+ const [a, b, c, d, e] = [insns[i]!, insns[i + 1]!, insns[i + 2]!, insns[i + 3]!, insns[i + 4]!];
1115
+ const isIndexedLoad = (x: Instruction): boolean => !!x.operand && INDEXED_LOAD_MODES.has(x.mode) && x.mnemonic.startsWith("ld");
1116
+ if (!isIndexedLoad(a)) continue;
1117
+ if (b.opcode !== 0x48) continue; // pha
1118
+ if (!isIndexedLoad(c)) continue;
1119
+ if (d.opcode !== 0x48) continue; // pha
1120
+ if (e.opcode !== 0x60) continue; // rts
1121
+ // (b). Checked BEFORE the window is claimed: a mismatched-register window
1122
+ // is not class 4's, so class 3 must still be free to report the pairing
1123
+ // (which it will decline on its own condition (b), as an advisory
1124
+ // candidate rather than silence).
1125
+ if (!sameIndexRegister(a, c)) continue;
1126
+
1127
+ for (const claimed of [a, b, c, d, e]) classFourWindow.add(claimed.address);
1128
+
1129
+ // The HIGH byte is pushed first, so `a` reads the hi table and `c` the lo.
1130
+ const hiBase = a.operand!.value;
1131
+ const loBase = c.operand!.value;
1132
+ const span = Math.abs(hiBase - loBase);
1133
+ let entries = span > 0 ? span : 1;
1134
+ let tableTruncated = false;
1135
+ if (entries > MAX_TABLE_ENTRIES) {
1136
+ entries = MAX_TABLE_ENTRIES;
1137
+ tableTruncated = true;
1138
+ truncated = true;
1139
+ }
1140
+
1141
+ // Reconstruct WITHOUT publishing anything yet: nothing below touches
1142
+ // `discovered` or `tableEntryAddresses` until (d) has passed on it.
1143
+ const targets: number[] = [];
1144
+ const entryAddresses: number[] = [];
1145
+ for (let k = 0; k < entries; k++) {
1146
+ const loIdx = loBase + k - safeOrigin;
1147
+ const hiIdx = hiBase + k - safeOrigin;
1148
+ // IN-05. The upper bound is the scan's ONE `effectiveEnd`, expressed on
1149
+ // the addresses rather than on the indices, so this walk stops where the
1150
+ // census stops instead of at the payload's declared length. Both halves
1151
+ // of the pair must be inside it: publishing `loBase + k` as a table entry
1152
+ // address while `hiBase + k` lies outside the machine's address space
1153
+ // would put a value in the report the measured machine cannot address.
1154
+ if (loIdx < 0 || hiIdx < 0 || loBase + k >= effectiveEnd || hiBase + k >= effectiveEnd) break;
1155
+ // The idiom pushes `target - 1`, because `rts` increments before
1156
+ // jumping. Reconstruct the real entry point.
1157
+ const pushed = safeBytes[loIdx]! | (safeBytes[hiIdx]! << 8);
1158
+ const value = (pushed + 1) & 0xffff;
1159
+ // (d). The entry count is a guess, so the walk stops here rather than
1160
+ // publishing an address a program cannot be entered at -- and says it
1161
+ // stopped.
1162
+ if (!isPlausibleEntryPoint(value)) {
1163
+ tableTruncated = true;
1164
+ truncated = true;
1165
+ break;
1166
+ }
1167
+ targets.push(value);
1168
+ entryAddresses.push(loBase + k, hiBase + k);
1169
+ }
1170
+ if (targets.length === 0) continue;
1171
+ for (const value of targets) discovered.add(value);
1172
+ for (const addr of entryAddresses) tableEntryAddresses.add(addr);
1173
+ stackReturnDispatch.push({ at: a.address, loBase, hiBase, entries: targets.length, targets, truncated: tableTruncated, orientationResolved: true });
1174
+ }
1175
+
1176
+ // --- Class 3: split lo/hi tables. Paired indexed loads whose two bases are
1177
+ // a fixed distance N apart; reconstruct N targets.
1178
+ //
1179
+ // GATED (header trap 8). Two indexed loads within eight instructions of
1180
+ // each other is the most ordinary shape in C64 code, so the pairing alone
1181
+ // is not evidence of anything. A pairing is PROVEN only when ALL of:
1182
+ // (a) it is not inside a window Class 4 already claimed;
1183
+ // (b) both loads index through the SAME register;
1184
+ // (c) something in reach CONSUMES the pair as a dispatch
1185
+ // (`hasDispatchContext`) -- either the stack-return push idiom, or a
1186
+ // zero-page vector that an indirect jump in reach actually jumps
1187
+ // THROUGH. The mere construction of a zero-page vector is not enough:
1188
+ // an indirect-indexed data read builds the identical pointer (CR-04);
1189
+ // (d) its lo/hi orientation is decided by the pairing's own store
1190
+ // construction rather than by address order (`resolveSplitOrientation`);
1191
+ // (e) EVERY reconstructed target lands strictly inside the image AND on a
1192
+ // byte that decodes as a legal, non-truncated instruction. A value
1193
+ // pointing at a byte that does not decode is not an entry point.
1194
+ // Anything else is ADVISORY: recorded in `splitTableCandidates` with no
1195
+ // orientation claim and no targets, contributing to neither `discovered`
1196
+ // nor `tableEntryAddresses`.
1197
+ //
1198
+ // AN ADVISORY RECORDING DOES NOT CONSUME THE LEADING LOAD (WR-15). Only a
1199
+ // PROVEN pairing does. Otherwise one unrelated indexed load between the two
1200
+ // halves of a real split table erases it: the advisory pairing takes the
1201
+ // leading load, the genuine pairing behind it is never examined, and the
1202
+ // report shows a clean-looking empty `splitTables`. The direction of that
1203
+ // error is safe -- an under-report, never an over-report -- but it is
1204
+ // silent, which is the one thing a coverage instrument may not be.
1205
+ for (let i = 0; i < insns.length; i++) {
1206
+ const first = insns[i]!;
1207
+ if (!first.operand || !INDEXED_LOAD_MODES.has(first.mode)) continue;
1208
+ if (!first.mnemonic.startsWith("ld")) continue;
1209
+ if (classFourWindow.has(first.address)) continue; // (a)
1210
+
1211
+ // ONLY A PROVEN PAIRING CONSUMES ITS LEADING LOAD (WR-15). An ADVISORY
1212
+ // recording does not: the first advisory pairing seen for this leading
1213
+ // load is remembered here and emitted only if the window closes with no
1214
+ // proven pairing found. Until 19-11 the inner loop broke on BOTH
1215
+ // branches, so one unrelated indexed load sitting between the two halves
1216
+ // of a real split table consumed the leading load and the genuine pairing
1217
+ // behind it was never examined -- a dispatch table with a real
1218
+ // `jmp ($00fb)` consumer became invisible, and its eight targets vanished
1219
+ // from the seed set. The direction of that error is safe (under-report,
1220
+ // not over-report) but it is SILENT: the report showed two advisory
1221
+ // candidates and a clean-looking empty `splitTables`, with no indication
1222
+ // that a proven pairing had been preempted.
1223
+ //
1224
+ // At most ONE advisory candidate per leading load is still emitted -- the
1225
+ // first seen, in encounter order, so the output is deterministic -- and a
1226
+ // leading load that produces a proven pairing emits none.
1227
+ let pendingAdvisory: SplitTableFinding | null = null;
1228
+ let pendingAdvisoryTruncated = false;
1229
+ for (let j = i + 1; j < Math.min(insns.length, i + 1 + SPLIT_TABLE_WINDOW); j++) {
1230
+ const second = insns[j]!;
1231
+ if (!second.operand || !INDEXED_LOAD_MODES.has(second.mode)) continue;
1232
+ if (!second.mnemonic.startsWith("ld")) continue;
1233
+
1234
+ const a = first.operand.value;
1235
+ const b = second.operand.value;
1236
+ if (a === b) continue;
1237
+ if (!inImage(a) || !inImage(b)) continue;
1238
+
1239
+ // (b) + (d). The orientation is the ONLY thing that may name a base
1240
+ // "lo": `Math.min` over two addresses is not evidence (WR-01).
1241
+ const oriented = sameIndexRegister(first, second) ? resolveSplitOrientation(insns, i, j, SPLIT_TABLE_WINDOW) : null;
1242
+ // (c). The pairing under test crosses the call boundary: a predicate
1243
+ // that re-guesses which loads it is ruling on cannot rule on them.
1244
+ const gatedSoFar = oriented !== null && hasDispatchContext(insns, i, SPLIT_TABLE_WINDOW, { firstIndex: i, secondIndex: j, oriented });
1245
+
1246
+ // Encounter order for the advisory case; the resolved roles otherwise.
1247
+ const loBase = oriented ? oriented.loBase : a;
1248
+ const hiBase = oriented ? oriented.hiBase : b;
1249
+ const span = Math.abs(hiBase - loBase);
1250
+ if (span <= 0) continue;
1251
+
1252
+ let entries = span;
1253
+ let tableTruncated = false;
1254
+ if (entries > MAX_TABLE_ENTRIES) {
1255
+ entries = MAX_TABLE_ENTRIES;
1256
+ tableTruncated = true;
1257
+ }
1258
+
1259
+ // Reconstruct WITHOUT publishing anything yet: nothing below touches
1260
+ // `discovered` or `tableEntryAddresses` until the gate has passed.
1261
+ const targets: number[] = [];
1262
+ const entryAddresses: number[] = [];
1263
+ for (let k = 0; k < entries; k++) {
1264
+ const loIdx = loBase + k - safeOrigin;
1265
+ const hiIdx = hiBase + k - safeOrigin;
1266
+ // IN-05, as in the class-4 walk above: the scan's ONE `effectiveEnd`,
1267
+ // never the payload's declared length.
1268
+ if (loIdx < 0 || hiIdx < 0 || loBase + k >= effectiveEnd || hiBase + k >= effectiveEnd) break;
1269
+ targets.push(safeBytes[loIdx]! | (safeBytes[hiIdx]! << 8));
1270
+ entryAddresses.push(loBase + k, hiBase + k);
1271
+ }
1272
+ if (targets.length === 0) continue;
1273
+
1274
+ // (e) every target in-image and decodable as a legal instruction. The
1275
+ // predicate is shared with the class-4 walk's condition (d) -- one
1276
+ // definition, read by both gated reconstructions (WR-14).
1277
+ const everyTargetIsAPlausibleEntryPoint = targets.every(isPlausibleEntryPoint);
1278
+
1279
+ if (gatedSoFar && everyTargetIsAPlausibleEntryPoint) {
1280
+ for (const value of targets) discovered.add(value);
1281
+ for (const addr of entryAddresses) tableEntryAddresses.add(addr);
1282
+ splitTables.push({ at: first.address, loBase, hiBase, entries: targets.length, targets, truncated: tableTruncated, orientationResolved: true });
1283
+ if (tableTruncated) truncated = true;
1284
+ pendingAdvisory = null; // a proven pairing emits no advisory candidate
1285
+ break; // a PROVEN pairing consumes its leading load -- and only that
1286
+ }
1287
+
1288
+ // Advisory: remember the FIRST one and keep scanning the window.
1289
+ if (pendingAdvisory === null) {
1290
+ pendingAdvisory = {
1291
+ at: first.address,
1292
+ // ENCOUNTER order, not lo/hi roles -- see `orientationResolved`.
1293
+ loBase: a,
1294
+ hiBase: b,
1295
+ entries: targets.length,
1296
+ targets: [],
1297
+ truncated: tableTruncated,
1298
+ orientationResolved: false,
1299
+ };
1300
+ pendingAdvisoryTruncated = tableTruncated;
1301
+ }
1302
+ }
1303
+ if (pendingAdvisory !== null) {
1304
+ splitTableCandidates.push(pendingAdvisory);
1305
+ if (pendingAdvisoryTruncated) truncated = true;
1306
+ }
1307
+ }
1308
+
1309
+ return {
1310
+ indirectJumps,
1311
+ multiEntryTables,
1312
+ splitTables,
1313
+ splitTableCandidates,
1314
+ stackReturnDispatch,
1315
+ discoveredTargets: sortedUniqueNumbers(discovered),
1316
+ tableEntryAddresses: sortedUniqueNumbers(tableEntryAddresses),
1317
+ truncated,
1318
+ };
1319
+ }
1320
+
1321
+ /**
1322
+ * The COMPLETE, frozen set of scan collections `provenDispatchTargets()` reads
1323
+ * -- the class-1 indirect jumps, the class-2 multi-entry tables, the class-4
1324
+ * stack-return findings and the class-3 split tables, named as the
1325
+ * `IndirectDispatchScan` fields they are.
1326
+ *
1327
+ * ADDING A SOURCE HERE IS THE DECISION TO TREAT THAT SOURCE AS PROOF OF CODE,
1328
+ * which is the same decision the function's own doc comment below describes,
1329
+ * stated as data so a test can read it. `splitTableCandidates` is deliberately
1330
+ * NOT a member: an advisory pairing that became a seam source would seed a
1331
+ * recursive descent from evidence the gate explicitly declined, and that is
1332
+ * the whole point of it being advisory.
1333
+ *
1334
+ * This declares what the function already does and changes none of it. A test
1335
+ * extracts the function's body from this module's text, collects the `scan.`
1336
+ * fields it iterates, and asserts set equality with this array in both
1337
+ * directions -- so a fifth source added to the seam reds the suite by name,
1338
+ * and so does a route publishing into a collection the seam never reads.
1339
+ */
1340
+ export const PROVEN_TARGET_SOURCES: readonly string[] = Object.freeze([
1341
+ "indirectJumps",
1342
+ "multiEntryTables",
1343
+ "stackReturnDispatch",
1344
+ "splitTables",
1345
+ ]);
1346
+
1347
+ /**
1348
+ * The ONE place that decides what may seed a recursive descent.
1349
+ *
1350
+ * Every `extraSeeds:` assignment in this module reads this function and reads
1351
+ * nothing else. Built from real `jmp ($nnnn)` targets, the multi-entry tables
1352
+ * those jumps name, the stack-return idiom's push-order-justified
1353
+ * reconstruction, and PROVEN split tables -- and from nothing else.
1354
+ *
1355
+ * ADDING A SOURCE HERE IS THE DECISION TO TREAT THAT SOURCE AS PROOF OF CODE.
1356
+ * `reachedAsInstruction` means REACHED BY RECURSIVE DESCENT FROM A SEED
1357
+ * (header trap 2); a seed that is not evidence-backed turns ordinary data
1358
+ * into headline structural coverage without ever touching the linear-sweep
1359
+ * figure trap 2 guards. `splitTableCandidates` is deliberately NOT read here
1360
+ * -- that is the whole point of it being advisory.
1361
+ */
1362
+ export function provenDispatchTargets(scan: IndirectDispatchScan): number[] {
1363
+ const proven = new Set<number>();
1364
+ for (const jump of scan.indirectJumps) {
1365
+ if (jump.target !== null) proven.add(jump.target);
1366
+ }
1367
+ for (const table of scan.multiEntryTables) {
1368
+ for (const target of table.targets) proven.add(target);
1369
+ }
1370
+ for (const idiom of scan.stackReturnDispatch) {
1371
+ for (const target of idiom.targets) proven.add(target);
1372
+ }
1373
+ for (const split of scan.splitTables) {
1374
+ for (const target of split.targets) proven.add(target);
1375
+ }
1376
+ return sortedUniqueNumbers(proven);
1377
+ }
1378
+
1379
+ // ---------------------------------------------------------------------------
1380
+ // (c) The two label figures
1381
+ // ---------------------------------------------------------------------------
1382
+
1383
+ /**
1384
+ * The auto-name prefixes from upstream's `LabelType::prefix()`.
1385
+ *
1386
+ * `L_` is DELIBERATELY EXCLUDED. Upstream assigns `L_` to `Predefined`,
1387
+ * `UserDefined` AND `LocalUserDefined` alike (`types.rs:394-396`), so it
1388
+ * cannot distinguish an auto-generated name from a user-chosen one --
1389
+ * including it would count every hand-named local label as auto and report a
1390
+ * false positive. Matching is ASCII case-sensitive, exactly as upstream emits
1391
+ * the prefixes.
1392
+ */
1393
+ export const AUTO_NAME_PREFIX_RE = /^(zpf_|f_|zpa_|a_|p_|zpp_|e_|j_|s_|b_|r_)/;
1394
+
1395
+ export interface LabelRatio {
1396
+ /** Over NON-`System` labels only. Platform-provided symbols are excluded
1397
+ * because a large KERNAL symbol set would otherwise inflate the fraction
1398
+ * for free. */
1399
+ kindRatio: { user: number; auto: number; userFraction: number | null };
1400
+ /** Labels whose NAME still matches `AUTO_NAME_PREFIX_RE`, regardless of
1401
+ * kind. Catches renaming a label to its own existing auto name so `kind`
1402
+ * flips to `User` while the name never changed. */
1403
+ autoPrefixNamesRemaining: number;
1404
+ autoPrefixNameAddresses: number[];
1405
+ systemExcluded: number;
1406
+ /** Addresses removed from the `user` tally by the multi-caller rule. */
1407
+ excludedByMultiCallerRule: number[];
1408
+ }
1409
+
1410
+ export interface LabelRatioOptions {
1411
+ /** Addresses of `User` labels that the multi-caller rule disqualified.
1412
+ * Supplied by `buildCoverageReport()` after `computeReproducibility()` has
1413
+ * applied the rule -- kept as a parameter rather than recomputed here so
1414
+ * the rule lives in exactly one place. */
1415
+ excludeUserAddresses?: Iterable<number>;
1416
+ }
1417
+
1418
+ export function computeLabelRatio(symbols: readonly AnnoSymbol[], opts: LabelRatioOptions = {}): LabelRatio {
1419
+ const list = Array.isArray(symbols) ? symbols : [];
1420
+ const excluded = new Set<number>([...(opts.excludeUserAddresses ?? [])]);
1421
+
1422
+ let user = 0;
1423
+ let auto = 0;
1424
+ let systemExcluded = 0;
1425
+ const autoPrefixNameAddresses: number[] = [];
1426
+ const reallyExcluded: number[] = [];
1427
+
1428
+ for (const sym of list) {
1429
+ if (!sym || typeof sym.name !== "string") continue;
1430
+ const kind = String(sym.kind ?? "");
1431
+ if (kind === "System" || kind === "Platform") {
1432
+ systemExcluded++;
1433
+ } else if (kind === "User") {
1434
+ if (excluded.has(sym.address)) {
1435
+ reallyExcluded.push(sym.address);
1436
+ } else {
1437
+ user++;
1438
+ }
1439
+ } else {
1440
+ auto++;
1441
+ }
1442
+ if (AUTO_NAME_PREFIX_RE.test(sym.name)) autoPrefixNameAddresses.push(sym.address);
1443
+ }
1444
+
1445
+ const denominator = user + auto;
1446
+ // WR-02: the count is a count OF the list printed beside it. Deduped ONCE
1447
+ // into a local, then both fields read from that local -- two symbols at one
1448
+ // address must not report "2 label name(s) ... at $1000", a sentence that
1449
+ // contradicts itself.
1450
+ const autoPrefixAddresses = sortedUniqueNumbers(autoPrefixNameAddresses);
1451
+ return {
1452
+ kindRatio: { user, auto, userFraction: denominator === 0 ? null : user / denominator },
1453
+ autoPrefixNamesRemaining: autoPrefixAddresses.length,
1454
+ autoPrefixNameAddresses: autoPrefixAddresses,
1455
+ systemExcluded,
1456
+ excludedByMultiCallerRule: sortedUniqueNumbers(reallyExcluded),
1457
+ };
1458
+ }
1459
+
1460
+ // ---------------------------------------------------------------------------
1461
+ // (d) The comment-vacuity measure
1462
+ // ---------------------------------------------------------------------------
1463
+
1464
+ /**
1465
+ * Comments that never count as documentation, stored ALREADY NORMALISED (see
1466
+ * `normaliseComment`). A comment equal to one of these after normalisation is
1467
+ * vacuous no matter how it was capitalised or emphasised.
1468
+ */
1469
+ export const BANNED_GENERIC_COMMENTS: ReadonlySet<string> = new Set([
1470
+ "handles data",
1471
+ "does stuff",
1472
+ "routine",
1473
+ "subroutine",
1474
+ "function",
1475
+ "data",
1476
+ "code",
1477
+ "unknown",
1478
+ "todo",
1479
+ "fixme",
1480
+ "n/a",
1481
+ ]);
1482
+
1483
+ /**
1484
+ * The EXACT normalisation the schema requires, in order:
1485
+ * 1. ASCII lowercase;
1486
+ * 2. strip backticks and emphasis markers (`` ` ``, `*`, `_`);
1487
+ * 3. collapse whitespace runs to a single space;
1488
+ * 4. trim.
1489
+ * Two normalised comments are then compared by EXACT STRING EQUALITY -- there
1490
+ * is no fuzzy match, no stemming and no similarity threshold anywhere here.
1491
+ */
1492
+ export function normaliseComment(comment: string): string {
1493
+ if (typeof comment !== "string") return "";
1494
+ return comment
1495
+ .replace(/[A-Z]/g, (ch) => ch.toLowerCase())
1496
+ .replace(/[`*_]/g, "")
1497
+ .replace(/\s+/g, " ")
1498
+ .trim();
1499
+ }
1500
+
1501
+ export interface CommentVacuity {
1502
+ /** Distinct addresses carrying a `line` comment. */
1503
+ commentedAddresses: number;
1504
+ /** Distinct NORMALISED comment texts. The same text at N addresses counts
1505
+ * ONCE, not N times. */
1506
+ distinctComments: number;
1507
+ /** `distinctComments / commentedAddresses`, or `null` when there are no
1508
+ * comments at all -- an explicit null, never a division and never a zero
1509
+ * that could read as "clean". */
1510
+ distinctCommentRatio: number | null;
1511
+ /** Fraction of commented addresses carrying a confidence grade that is not
1512
+ * `[unknown]` -- `[unknown]` means "not yet documented". `null` when there
1513
+ * are no comments. */
1514
+ gradedFraction: number | null;
1515
+ gradedAddresses: number;
1516
+ unknownGradedAddresses: number;
1517
+ /** Addresses whose comment equals a banned-generic entry after
1518
+ * normalisation. Ascending. */
1519
+ bannedGenericAddresses: number[];
1520
+ /**
1521
+ * Addresses whose comment opens with a bracket token that is NOT one of the
1522
+ * five valid grades. `parseConfidencePrefix()` throws on those by design;
1523
+ * this module catches it because a measurement pass must never throw, and
1524
+ * reports the near-miss as the measured defect it is rather than letting it
1525
+ * degrade silently into "ungraded".
1526
+ */
1527
+ malformedGradeAddresses: number[];
1528
+ /** Stated when a measure could not be computed, so an absent input can
1529
+ * never read as a pass (COV-02). `null` when everything was computable. */
1530
+ reason: string | null;
1531
+ }
1532
+
1533
+ const UNKNOWN_GRADE_TOKEN = CONFIDENCE_GRADES.find((g) => g.token === "unknown")!.token;
1534
+
1535
+ interface ParsedComment {
1536
+ address: number;
1537
+ gradeToken: string | null;
1538
+ malformed: boolean;
1539
+ normalised: string;
1540
+ }
1541
+
1542
+ function parseLineComments(comments: readonly AnnoComment[]): ParsedComment[] {
1543
+ const list = Array.isArray(comments) ? comments : [];
1544
+ const byAddress = new Map<number, ParsedComment>();
1545
+ for (const entry of list) {
1546
+ if (!entry || typeof entry.comment !== "string") continue;
1547
+ if (String(entry.type ?? "line") !== "line") continue;
1548
+ let gradeToken: string | null = null;
1549
+ let malformed = false;
1550
+ let rest = entry.comment;
1551
+ try {
1552
+ const parsed = parseConfidencePrefix(entry.comment);
1553
+ gradeToken = parsed.grade ? parsed.grade.token : null;
1554
+ rest = parsed.rest;
1555
+ } catch {
1556
+ malformed = true;
1557
+ }
1558
+ byAddress.set(entry.address, {
1559
+ address: entry.address,
1560
+ gradeToken,
1561
+ malformed,
1562
+ normalised: normaliseComment(rest),
1563
+ });
1564
+ }
1565
+ return [...byAddress.values()].sort((a, b) => a.address - b.address);
1566
+ }
1567
+
1568
+ export function computeCommentVacuity(comments: readonly AnnoComment[]): CommentVacuity {
1569
+ const parsed = parseLineComments(comments);
1570
+ const commentedAddresses = parsed.length;
1571
+
1572
+ if (commentedAddresses === 0) {
1573
+ return {
1574
+ commentedAddresses: 0,
1575
+ distinctComments: 0,
1576
+ distinctCommentRatio: null,
1577
+ gradedFraction: null,
1578
+ gradedAddresses: 0,
1579
+ unknownGradedAddresses: 0,
1580
+ bannedGenericAddresses: [],
1581
+ malformedGradeAddresses: [],
1582
+ reason: "no line comments were supplied -- the vacuity measure is unavailable, not clean",
1583
+ };
1584
+ }
1585
+
1586
+ const distinct = new Set<string>();
1587
+ const banned: number[] = [];
1588
+ const malformed: number[] = [];
1589
+ let graded = 0;
1590
+ let unknownGraded = 0;
1591
+
1592
+ for (const entry of parsed) {
1593
+ distinct.add(entry.normalised);
1594
+ if (BANNED_GENERIC_COMMENTS.has(entry.normalised)) banned.push(entry.address);
1595
+ if (entry.malformed) malformed.push(entry.address);
1596
+ if (entry.gradeToken !== null) {
1597
+ if (entry.gradeToken === UNKNOWN_GRADE_TOKEN) unknownGraded++;
1598
+ else graded++;
1599
+ }
1600
+ }
1601
+
1602
+ return {
1603
+ commentedAddresses,
1604
+ distinctComments: distinct.size,
1605
+ distinctCommentRatio: distinct.size / commentedAddresses,
1606
+ gradedFraction: graded / commentedAddresses,
1607
+ gradedAddresses: graded,
1608
+ unknownGradedAddresses: unknownGraded,
1609
+ bannedGenericAddresses: sortedUniqueNumbers(banned),
1610
+ malformedGradeAddresses: sortedUniqueNumbers(malformed),
1611
+ reason: null,
1612
+ };
1613
+ }
1614
+
1615
+ /** True iff this comment counts as documentation at all: present, and not
1616
+ * equal to a banned-generic entry after normalisation. */
1617
+ function isNonVacuous(entry: ParsedComment | undefined): boolean {
1618
+ if (!entry) return false;
1619
+ if (entry.malformed) return false;
1620
+ if (entry.normalised.length === 0) return false;
1621
+ return !BANNED_GENERIC_COMMENTS.has(entry.normalised);
1622
+ }
1623
+
1624
+ // ---------------------------------------------------------------------------
1625
+ // (e) The sampled independent-reproducibility result
1626
+ // ---------------------------------------------------------------------------
1627
+
1628
+ /** The coarse classification both independent sides speak. Deliberately
1629
+ * three-valued: the two sides derive it from completely different inputs, so
1630
+ * a finer vocabulary would manufacture disagreement out of vocabulary drift
1631
+ * rather than measuring anything. */
1632
+ export type DerivedClass = "code" | "data" | "unreached";
1633
+
1634
+ export interface ReproducibilityComparison {
1635
+ address: number;
1636
+ fromBytes: DerivedClass;
1637
+ fromStore: DerivedClass;
1638
+ agreed: boolean;
1639
+ }
1640
+
1641
+ export interface Reproducibility {
1642
+ sampled: number;
1643
+ agreed: number;
1644
+ disagreed: number;
1645
+ agreementRate: number | null;
1646
+ /** The sample rule, stated in full so the sample is auditable without
1647
+ * re-running the code that produced it. */
1648
+ sampleRule: string;
1649
+ /** The sampled addresses themselves, ascending -- recorded so a reader can
1650
+ * reproduce the sample by hand. */
1651
+ addresses: number[];
1652
+ comparisons: ReproducibilityComparison[];
1653
+ /** Labels with STRICTLY MORE THAN ONE caller whose comment does not name a
1654
+ * caller. Excluded from the label figure's user tally. */
1655
+ multiCallerUndocumented: { count: number; addresses: number[] };
1656
+ reason: string | null;
1657
+ }
1658
+
1659
+ export interface ReproducibilityInput {
1660
+ census: StructuralCensus;
1661
+ dispatch: IndirectDispatchScan;
1662
+ symbols: readonly AnnoSymbol[];
1663
+ comments: readonly AnnoComment[];
1664
+ blocks: readonly BlockEntry[];
1665
+ crossReferences: readonly AnnoCrossReference[];
1666
+ sampleSize?: number;
1667
+ /** REQUIRED, with NO default. The store side's block vocabulary reaches
1668
+ * this function only through here. An internal caller that forgets it is a
1669
+ * typecheck error, which is the point: a defaulted classifier would let a
1670
+ * forgetful site quietly fall back to one particular store's spelling. */
1671
+ blockClassifier: BlockClassifier;
1672
+ }
1673
+
1674
+ const DEFAULT_SAMPLE_SIZE = 8;
1675
+
1676
+ /** `provenTargets` is `provenDispatchTargets(dispatch)`, computed ONCE per
1677
+ * report by the caller. A bare membership test against the scan's own
1678
+ * `discoveredTargets` used to live here and inherited the ungated-pairing
1679
+ * defect straight into the reproducibility comparison (header trap 8); the
1680
+ * seam is passed in so there is no second, un-narrowed read of it. */
1681
+ function classFromBytes(census: StructuralCensus, provenTargets: readonly number[], address: number): DerivedClass {
1682
+ if (provenTargets.includes(address)) return "code";
1683
+ const klass = classAt(census, address);
1684
+ if (klass === "reached-as-instruction") return "code";
1685
+ if (klass === "table-entry" || klass === "referenced-as-data") return "data";
1686
+ return "unreached";
1687
+ }
1688
+
1689
+ /** `blockClass` is a NEUTRAL class from `block-class.ts`, never a store
1690
+ * vocabulary string. That is what lets a second annotation substrate be
1691
+ * substituted without this function changing at all. */
1692
+ function classFromStore(gradeToken: string | null, blockClass: BlockClass | null): DerivedClass {
1693
+ if (gradeToken === "confirmed-code" || gradeToken === "probable-code") return "code";
1694
+ if (gradeToken === "confirmed-data" || gradeToken === "probable-data") return "data";
1695
+ // `[unknown]` and ungraded fall through to the store's own block class.
1696
+ if (blockClass === "code") return "code";
1697
+ if (blockClass === null || blockClass === "undefined") return "unreached";
1698
+ return "data";
1699
+ }
1700
+
1701
+ /** Escapes `value` so it can be interpolated into a `RegExp` as a LITERAL.
1702
+ *
1703
+ * A label name is store data, not a literal this file controls: it arrives
1704
+ * from an annotation store the operator did not necessarily author
1705
+ * (a cracked release's annotation store, a shared project). A name carrying
1706
+ * regex metacharacters must therefore become text rather than a pattern.
1707
+ * Same discipline this repository applies to any externally-sourced string
1708
+ * that reaches a regex. */
1709
+ function escapeRegExp(value: string): string {
1710
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1711
+ }
1712
+
1713
+ /**
1714
+ * The adjacent signals that turn a caller's label NAME into a caller
1715
+ * CITATION -- the marker set the name branch of `namesACaller()` reads (WR-13).
1716
+ *
1717
+ * A NAMED CONSTANT rather than literals inlined in the regex, so the decision
1718
+ * is inspectable in one place and widening it is a one-line edit somewhere
1719
+ * obvious rather than a change buried in a pattern string.
1720
+ *
1721
+ * Each word is matched on identifier boundaries, and in the pattern built from
1722
+ * this list it must sit within three NON-IDENTIFIER characters of the name it
1723
+ * introduces: `from init`, `called by init`, `callers: init`. `by` carries
1724
+ * `called by` / `invoked by` / `used by` -- a comment saying that some routine
1725
+ * uses this one is naming a caller.
1726
+ *
1727
+ * Spelled at both the lowercase and the sentence-initial-capital form rather
1728
+ * than matched case-insensitively, because the NAME half of the same regex is
1729
+ * case-SENSITIVE (a label's name is its name, and `Init` is a different symbol
1730
+ * from `init`) and one regex carries both halves.
1731
+ */
1732
+ const CALLER_CITATION_WORDS: readonly string[] = Object.freeze([
1733
+ "from",
1734
+ "by",
1735
+ "call",
1736
+ "called",
1737
+ "caller",
1738
+ "callers",
1739
+ "calls",
1740
+ ]);
1741
+
1742
+ /** `CALLER_CITATION_WORDS` as a regex alternation, each word at its lowercase
1743
+ * and its sentence-initial-capital spelling. */
1744
+ const CALLER_CITATION_ALTERNATION = CALLER_CITATION_WORDS.map(
1745
+ (word) => `${word}|${word.charAt(0).toUpperCase()}${word.slice(1)}`,
1746
+ ).join("|");
1747
+
1748
+ /**
1749
+ * Does `rawComment` use `name` -- the user label recorded at a caller's
1750
+ * address -- AS A REFERENCE to that caller?
1751
+ *
1752
+ * THE DECISION, RECORDED (WR-13). Bare presence of the name is NOT enough.
1753
+ * the external analyser label names are routinely ordinary English words -- `loop`,
1754
+ * `init`, `main`, `start`, `data`, `table`, `draw` -- and an ordinary
1755
+ * description of what a routine does will contain one by accident. The
1756
+ * reproduced case: callers `[$0012, $0034]`, comment "sets the mode flag
1757
+ * before the main loop runs", caller `$0012` named `loop`. Nothing in that
1758
+ * comment refers to the routine at `$0012`, yet the pre-WR-13 identifier-
1759
+ * bounded test matched `loop` inside "main loop runs" and certified the label
1760
+ * as documenting its caller -- a falsely-clean verdict, and worse than a noisy
1761
+ * one, because a label counted as documented stays in `labels.kindRatio.user`
1762
+ * and stays in the reproducibility sample, so the measure that exists to catch
1763
+ * it can no longer see it.
1764
+ *
1765
+ * So a name counts only in one of three shapes, all still bounded on
1766
+ * identifier boundaries so `my_entry_pointer` still does not name
1767
+ * `entry_point`:
1768
+ *
1769
+ * (a) MARKED UP AS A SYMBOL -- the name in backticks. An annotator who
1770
+ * fences a token is quoting an identifier, not writing prose.
1771
+ * (b) INTRODUCED BY A CALLER-NAMING WORD from `CALLER_CITATION_WORDS`,
1772
+ * within three non-identifier characters: `from init`, `called by init`,
1773
+ * `callers: init`.
1774
+ * (c) FOLLOWED BY ITS OWN PARENTHESISED HEX ADDRESS -- `init ($0012)`.
1775
+ *
1776
+ * THE ALTERNATIVE WEIGHED AND REJECTED: drop the name branch entirely and
1777
+ * accept only the hex form, which CR-01's fix already anchors correctly. It is
1778
+ * strictly safer and strictly simpler. It was rejected because it would
1779
+ * silently reclassify every project whose annotator cites callers by name
1780
+ * rather than by address -- a real and reasonable convention -- turning a
1781
+ * measure of documentation quality into a measure of citation style, with no
1782
+ * signal to the operator that the rule had changed underneath them. The
1783
+ * tightening above is the cheapest change that refuses the coincidence while
1784
+ * still accepting a genuine name citation.
1785
+ *
1786
+ * THE RESIDUAL, STATED: a marker word can still precede a coincidental name
1787
+ * ("copies bytes from screen" where a caller is named `screen`). That is a
1788
+ * far narrower coincidence than bare presence, and it errs toward accepting a
1789
+ * citation rather than toward manufacturing one; widening the refusal further
1790
+ * would need a corpus, not a guess.
1791
+ */
1792
+ function citesCallerByName(rawComment: string, name: string): boolean {
1793
+ const token = escapeRegExp(name);
1794
+ const notIdentBefore = "(?<![0-9A-Za-z_])";
1795
+ const notIdentAfter = "(?![0-9A-Za-z_])";
1796
+ const patterns = [
1797
+ // (a) marked up as a symbol.
1798
+ "`" + token + "`",
1799
+ // (b) introduced by a caller-naming word.
1800
+ `${notIdentBefore}(?:${CALLER_CITATION_ALTERNATION})${notIdentAfter}[^0-9A-Za-z_]{1,3}${token}${notIdentAfter}`,
1801
+ // (c) followed by its own parenthesised hex address.
1802
+ `${notIdentBefore}${token}${notIdentAfter}\\s*\\(\\$[0-9a-fA-F]{1,4}\\)`,
1803
+ ];
1804
+ return patterns.some((pattern) => new RegExp(pattern).test(rawComment));
1805
+ }
1806
+
1807
+ /** Does `comment` literally name at least one of `callers` -- either as a
1808
+ * hexadecimal address, or as a REFERENCE to the user label name recorded at a
1809
+ * caller address?
1810
+ *
1811
+ * The match is ANCHORED, not a substring test:
1812
+ *
1813
+ * - a HEX reference is `$` plus the caller's address at either its bare
1814
+ * width or the canonical four-digit width, followed by a character that is
1815
+ * NOT a hexadecimal digit -- end of string counts as a boundary. So a
1816
+ * comment mentioning an unrelated and entirely ordinary address whose
1817
+ * leading digits merely coincide with a caller's short form names NO
1818
+ * caller: `$8106` is not `$0810`. Case-insensitive, as before.
1819
+ * - a NAME reference must stand on an identifier boundary on BOTH sides --
1820
+ * the characters either side may not be an ASCII letter, digit or
1821
+ * underscore, so `my_entry_pointer` does not name `entry_point` -- AND
1822
+ * must be USED AS A REFERENCE rather than merely present. See
1823
+ * `citesCallerByName()` for what counts, why bare presence does not, and
1824
+ * which alternative was rejected (WR-13).
1825
+ *
1826
+ * Why anchored rather than "purely textual": this rule is the one measure
1827
+ * whose entire subject is refusing to be talked into a clean verdict, and an
1828
+ * unanchored `includes()` could be satisfied by a string that merely TOUCHES a
1829
+ * caller's short form -- a falsely-clean verdict on the anti-gaming measure
1830
+ * itself (T-19-14, T-19G-06-01, T-19G-12-01). Held down in BOTH directions by
1831
+ * three committed controls in `anno-coverage.test.ts`: "ANCHORING: a
1832
+ * colliding longer hex never satisfies the multi-caller rule ...", "ANCHORING:
1833
+ * a caller's label name satisfies the rule only on an identifier boundary",
1834
+ * and "WR-13: a caller's label name counts only when the comment USES it as a
1835
+ * reference ...". */
1836
+ function namesACaller(
1837
+ rawComment: string,
1838
+ callers: readonly number[],
1839
+ nameByAddress: ReadonlyMap<number, string>,
1840
+ ): boolean {
1841
+ for (const caller of callers) {
1842
+ const hex = caller.toString(16).toLowerCase();
1843
+ // Deduped through a Set: a caller at or above $1000 is already four digits
1844
+ // wide, so its bare and canonical forms are the same token and testing it
1845
+ // twice would be dead work.
1846
+ for (const token of new Set([hex, hex.padStart(4, "0")])) {
1847
+ if (new RegExp(`\\$${escapeRegExp(token)}(?![0-9a-f])`, "i").test(rawComment)) return true;
1848
+ }
1849
+ const name = nameByAddress.get(caller);
1850
+ if (name && citesCallerByName(rawComment, name)) return true;
1851
+ }
1852
+ return false;
1853
+ }
1854
+
1855
+ export function computeReproducibility(input: ReproducibilityInput): Reproducibility {
1856
+ const { census, dispatch, symbols, comments, blocks, crossReferences, blockClassifier } = input;
1857
+ const sampleSize =
1858
+ Number.isSafeInteger(input.sampleSize) && input.sampleSize! > 0 ? input.sampleSize! : DEFAULT_SAMPLE_SIZE;
1859
+
1860
+ const symbolList = Array.isArray(symbols) ? symbols : [];
1861
+ const blockList = Array.isArray(blocks) ? blocks : [];
1862
+ const parsed = parseLineComments(comments);
1863
+ const commentByAddress = new Map(parsed.map((p) => [p.address, p]));
1864
+ const rawByAddress = new Map<number, string>();
1865
+ for (const c of Array.isArray(comments) ? comments : []) {
1866
+ if (c && typeof c.comment === "string" && String(c.type ?? "line") === "line") rawByAddress.set(c.address, c.comment);
1867
+ }
1868
+ const nameByAddress = new Map<number, string>();
1869
+ for (const s of symbolList) {
1870
+ if (s && typeof s.name === "string" && String(s.kind ?? "") === "User") nameByAddress.set(s.address, s.name);
1871
+ }
1872
+ const callersByAddress = new Map<number, readonly number[]>();
1873
+ for (const x of Array.isArray(crossReferences) ? crossReferences : []) {
1874
+ if (x && Array.isArray(x.callers)) callersByAddress.set(x.address, x.callers);
1875
+ }
1876
+
1877
+ // --- The multi-caller rule (COV-02). Strictly MORE THAN ONE caller.
1878
+ const multiCallerUndocumented: number[] = [];
1879
+ for (const sym of symbolList) {
1880
+ if (!sym) continue;
1881
+ const callers = callersByAddress.get(sym.address) ?? [];
1882
+ if (callers.length <= 1) continue;
1883
+ const entry = commentByAddress.get(sym.address);
1884
+ const raw = rawByAddress.get(sym.address) ?? "";
1885
+ if (!isNonVacuous(entry) || !namesACaller(raw, callers, nameByAddress)) {
1886
+ multiCallerUndocumented.push(sym.address);
1887
+ }
1888
+ }
1889
+ const undocumented = new Set(multiCallerUndocumented);
1890
+ // WR-02, again: ONE deduped list, and every number reported beside it is
1891
+ // derived from it. Same rule as `computeLabelRatio` above -- a count printed
1892
+ // in the same sentence as a list must be a count of that list, or the
1893
+ // finding text contradicts itself.
1894
+ const multiCallerAddresses = sortedUniqueNumbers(multiCallerUndocumented);
1895
+
1896
+ // --- The deterministic sample: documented labels sorted ascending by
1897
+ // address, take every Nth where N = ceil(population / sampleSize).
1898
+ const documented = symbolList
1899
+ .filter((s) => s && isNonVacuous(commentByAddress.get(s.address)) && !undocumented.has(s.address))
1900
+ .map((s) => s.address)
1901
+ .sort((a, b) => a - b);
1902
+ const population = documented.length;
1903
+
1904
+ if (population === 0) {
1905
+ return {
1906
+ sampled: 0,
1907
+ agreed: 0,
1908
+ disagreed: 0,
1909
+ agreementRate: null,
1910
+ sampleRule: "no documented labels -- nothing to sample",
1911
+ addresses: [],
1912
+ comparisons: [],
1913
+ multiCallerUndocumented: { count: multiCallerAddresses.length, addresses: multiCallerAddresses },
1914
+ reason: "no label carries a non-vacuous line comment, so reproducibility is UNKNOWN rather than clean",
1915
+ };
1916
+ }
1917
+
1918
+ const step = Math.max(1, Math.ceil(population / sampleSize));
1919
+ const addresses: number[] = [];
1920
+ for (let i = 0; i < population; i += step) addresses.push(documented[i]!);
1921
+ const sampleRule =
1922
+ `documented labels sorted ascending by address (population ${population}), take every ` +
1923
+ `${step}${step === 1 ? "st" : "th"} (step = ceil(population / sampleSize), sampleSize ${sampleSize})`;
1924
+
1925
+ const comparisons: ReproducibilityComparison[] = [];
1926
+ const provenTargets = provenDispatchTargets(dispatch);
1927
+ for (const address of addresses) {
1928
+ const fromBytes = classFromBytes(census, provenTargets, address);
1929
+ const entry = commentByAddress.get(address);
1930
+ const fromStore = classFromStore(entry?.gradeToken ?? null, blockClassifier(blockList, address));
1931
+ comparisons.push({ address, fromBytes, fromStore, agreed: fromBytes === fromStore });
1932
+ }
1933
+
1934
+ const agreed = comparisons.filter((c) => c.agreed).length;
1935
+ return {
1936
+ sampled: comparisons.length,
1937
+ agreed,
1938
+ disagreed: comparisons.length - agreed,
1939
+ agreementRate: comparisons.length === 0 ? null : agreed / comparisons.length,
1940
+ sampleRule,
1941
+ addresses,
1942
+ comparisons,
1943
+ multiCallerUndocumented: { count: multiCallerAddresses.length, addresses: multiCallerAddresses },
1944
+ reason: null,
1945
+ };
1946
+ }
1947
+
1948
+ // ---------------------------------------------------------------------------
1949
+ // The divergence sub-report -- the store's block table, read through the
1950
+ // boundary and reported as a comparison, never as a measure
1951
+ // ---------------------------------------------------------------------------
1952
+
1953
+ export interface DivergenceReport {
1954
+ /** Bytes the census reached as instructions whose store block class is NOT
1955
+ * the code class. This is the direction that means the STORE missed
1956
+ * something. */
1957
+ censusCodeStoreNotCode: number;
1958
+ /** Bytes whose store block class IS the code class that the census never
1959
+ * reached. This is the ordinary direction on an image with unreachable
1960
+ * filler; it is reported, not treated as a defect. */
1961
+ storeCodeCensusUnreached: number;
1962
+ /** Bytes inside the censused range that no block entry covers at all. */
1963
+ uncoveredByStore: number;
1964
+ comparedBytes: number;
1965
+ /** False when the caller supplied no block listing at all. The counts above
1966
+ * are still computed and still true, but they compare against NOTHING, so
1967
+ * an absent listing is reported explicitly rather than read as "the store
1968
+ * classified none of it" (COV-02). */
1969
+ blocksSupplied: boolean;
1970
+ /** Stated whenever `blocksSupplied` is false; `null` otherwise. */
1971
+ reason: string | null;
1972
+ /**
1973
+ * The systematic bias this sub-report's STORE side carries, stated so a
1974
+ * reader never mistakes it for instrument error.
1975
+ */
1976
+ note: string;
1977
+ }
1978
+
1979
+ const DIVERGENCE_NOTE =
1980
+ "KNOWN, NAMED BIAS ON THE STORE SIDE: an annotation store may merge two adjacent same-type " +
1981
+ "blocks that carry no boundary marker between them, and no verb on this project's curated " +
1982
+ "surface sets that marker. An over-merge on the store side is therefore expected and is not " +
1983
+ "evidence of a census error. The census side reads no block data at all.";
1984
+
1985
+ /** `blockClassifier` is REQUIRED with NO default -- see
1986
+ * `ReproducibilityInput`'s field of the same name for why. */
1987
+ function computeDivergence(
1988
+ census: StructuralCensus,
1989
+ blocks: readonly BlockEntry[],
1990
+ blockClassifier: BlockClassifier,
1991
+ ): DivergenceReport {
1992
+ const blockList = Array.isArray(blocks) ? blocks : [];
1993
+ let censusCodeStoreNotCode = 0;
1994
+ let storeCodeCensusUnreached = 0;
1995
+ let uncoveredByStore = 0;
1996
+
1997
+ for (const run of census.classRuns) {
1998
+ for (let addr = run.start; addr <= run.end; addr++) {
1999
+ // Neutral classes only. The arithmetic and the counter names are
2000
+ // exactly what they were when this loop compared store strings.
2001
+ const blockClass = blockClassifier(blockList, addr);
2002
+ if (blockClass === null) uncoveredByStore++;
2003
+ if (run.class === "reached-as-instruction" && blockClass !== "code") censusCodeStoreNotCode++;
2004
+ if (run.class === "unreached" && blockClass === "code") storeCodeCensusUnreached++;
2005
+ }
2006
+ }
2007
+
2008
+ return {
2009
+ censusCodeStoreNotCode,
2010
+ storeCodeCensusUnreached,
2011
+ uncoveredByStore,
2012
+ comparedBytes: census.rangeBytes,
2013
+ blocksSupplied: blockList.length > 0,
2014
+ reason:
2015
+ blockList.length > 0
2016
+ ? null
2017
+ : "no block listing was supplied -- the divergence comparison is UNAVAILABLE, not clean, and its counts compare against nothing",
2018
+ note: DIVERGENCE_NOTE,
2019
+ };
2020
+ }
2021
+
2022
+ // ---------------------------------------------------------------------------
2023
+ // (f) The report
2024
+ // ---------------------------------------------------------------------------
2025
+
2026
+ export interface CoverageProjectInfo {
2027
+ path: string;
2028
+ origin: number;
2029
+ size: number;
2030
+ /** False when the project file was unreadable as JSON or its payload would
2031
+ * not decode. Never a throw, never a silent skip -- COV-02. */
2032
+ payloadDecoded: boolean;
2033
+ /** Stated whenever `payloadDecoded` is false; `null` otherwise. */
2034
+ reason: string | null;
2035
+ }
2036
+
2037
+ /**
2038
+ * The pinned report shape: FLAT SIBLING OBJECTS, one level deep. Each measure
2039
+ * is addressable by its own stable top-level key, which is what makes "never
2040
+ * one aggregate" structurally obvious and makes the schema test a plain
2041
+ * key-set assertion.
2042
+ *
2043
+ * `generatedAt` is the ONLY field that differs between two runs over the same
2044
+ * input; the idempotency test asserts exactly that.
2045
+ */
2046
+ export interface CoverageReport {
2047
+ schemaVersion: number;
2048
+ generatedAt: string;
2049
+ project: CoverageProjectInfo;
2050
+ structural: StructuralCensus;
2051
+ dispatch: IndirectDispatchScan;
2052
+ labels: LabelRatio;
2053
+ commentVacuity: CommentVacuity;
2054
+ reproducibility: Reproducibility;
2055
+ divergence: DivergenceReport;
2056
+ }
2057
+
2058
+ /** The exact top-level key set, in order. Exported so the schema test asserts
2059
+ * against ONE definition rather than a second hand-typed copy that could
2060
+ * drift from the interface above. */
2061
+ export const COVERAGE_REPORT_KEYS: readonly string[] = [
2062
+ "schemaVersion",
2063
+ "generatedAt",
2064
+ "project",
2065
+ "structural",
2066
+ "dispatch",
2067
+ "labels",
2068
+ "commentVacuity",
2069
+ "reproducibility",
2070
+ "divergence",
2071
+ ];
2072
+
2073
+ export interface CoverageOptions {
2074
+ projectPath: string;
2075
+ symbols?: readonly AnnoSymbol[];
2076
+ comments?: readonly AnnoComment[];
2077
+ blocks?: readonly BlockEntry[];
2078
+ crossReferences?: readonly AnnoCrossReference[];
2079
+ /** Extra descent seeds beyond the origin and the `User` label addresses. */
2080
+ entryPoints?: readonly number[];
2081
+ sampleSize?: number;
2082
+ /** Injected clock, for callers that need a fixed timestamp. Defaults to the
2083
+ * real wall clock. */
2084
+ now?: () => string;
2085
+ /**
2086
+ * THE SUBSTITUTABILITY PROOF'S SEAM, and nothing else.
2087
+ *
2088
+ * Defaults to `blockClassAt` -- the one production classifier -- so a
2089
+ * caller that omits it gets correct behaviour rather than some particular
2090
+ * store's raw spelling. PRODUCTION MUST NOT PASS THIS. A store change is
2091
+ * made by editing `block-class.ts`, never by threading a different
2092
+ * classifier in from a call site: a second production classifier is a
2093
+ * second answer to "what class is this address", which is exactly what the
2094
+ * boundary exists to prevent. `anno-coverage.test.ts` is the only caller
2095
+ * that supplies it, and it supplies a vocabulary sharing no string with
2096
+ * the real one so that a comparison site left behind anywhere in this file
2097
+ * moves a census byte count and fails loudly.
2098
+ */
2099
+ blockClassifier?: BlockClassifier;
2100
+ }
2101
+
2102
+ export interface LoadedProject {
2103
+ origin: number;
2104
+ bytes: Uint8Array;
2105
+ payloadDecoded: boolean;
2106
+ reason: string | null;
2107
+ }
2108
+
2109
+ /**
2110
+ * The ONE piece of a `JSON.parse` failure that is safe to report: the byte
2111
+ * offset at which parsing stopped, as ` (at byte offset N)`, or `""` when the
2112
+ * runtime did not name one.
2113
+ *
2114
+ * WHY THIS IS A DIGIT EXTRACTOR AND NOT A MESSAGE PASS-THROUGH (CR-03,
2115
+ * `T-29-16-06`). V8's JSON `SyntaxError` embeds a SNIPPET OF THE INPUT in its
2116
+ * own message -- `Unexpected token 'Q', "QQZZORACLE"... is not valid JSON` --
2117
+ * so any code that forwards `err.message` from a JSON parse over
2118
+ * caller-supplied bytes is a content-disclosure oracle. The capture group is
2119
+ * `(\d+)` and nothing else, so no byte of the parsed file can reach the
2120
+ * returned string however the runtime words its message.
2121
+ *
2122
+ * DELIBERATELY A SECOND COPY of `anno-memmap-render.ts`'s function of the same
2123
+ * name, not an import: that module is the memory-map RENDERER and pulls in the
2124
+ * annotation store, the provenance schema and the confidence vocabulary. This
2125
+ * module is the census instrument, which declares its own input shapes and
2126
+ * imports none of that. A three-line pure digit extractor duplicated with an
2127
+ * explicit cross-reference is cheaper than coupling the instrument to the
2128
+ * renderer; if a third caller ever appears, that is the moment to give it a
2129
+ * shared home. Keep the two in step: widening either regex beyond digits
2130
+ * reopens CR-03 on that side.
2131
+ */
2132
+ function jsonParsePosition(err: unknown): string {
2133
+ const match = /\bat position (\d+)\b/.exec(err instanceof Error ? err.message : String(err));
2134
+ return match ? ` (at byte offset ${match[1]})` : "";
2135
+ }
2136
+
2137
+ /**
2138
+ * A flat capture's `LoadedProject`, or the refusal `flatImageOrigin()` raises
2139
+ * for one that is not exactly 65536 bytes. Split out so both extension-first
2140
+ * branches reach the SAME refusal rather than two spellings of it.
2141
+ */
2142
+ function flatImage(projectPath: string, bytes: Uint8Array): LoadedProject {
2143
+ try {
2144
+ return { origin: flatImageOrigin(bytes), bytes, payloadDecoded: true, reason: null };
2145
+ } catch (err) {
2146
+ return imageRefusal(projectPath, err);
2147
+ }
2148
+ }
2149
+
2150
+ /**
2151
+ * The shared shape for an extension-dispatched refusal. `prg-image.ts` throws
2152
+ * a bare `Error` by design (it is a pure byte-layout module with no error
2153
+ * family of its own), and its two messages are a user-visible contract, so
2154
+ * they are carried through verbatim beside the path the caller named.
2155
+ *
2156
+ * These messages state a LENGTH -- "input is 4096 byte(s)", "a .prg needs at
2157
+ * least 3 bytes" -- and never a byte of the file's content, so unlike the JSON
2158
+ * syntax branch below they are safe to interpolate.
2159
+ */
2160
+ function imageRefusal(projectPath: string, err: unknown): LoadedProject {
2161
+ return {
2162
+ origin: 0,
2163
+ bytes: new Uint8Array(0),
2164
+ payloadDecoded: false,
2165
+ reason: `${projectPath} is not an image this surface can read -- ${err instanceof Error ? err.message : String(err)}`,
2166
+ };
2167
+ }
2168
+
2169
+ /**
2170
+ * THE ONE DEFINITION of how the coverage verb turns a path into bytes plus an
2171
+ * origin. It has exactly TWO callers -- `buildCoverageReport()`'s census
2172
+ * below, and `anno-cli.ts`'s `projectImage()`, which supplies the byte source
2173
+ * for the cross-reference derivation. A third hand-rolled decode anywhere is
2174
+ * the defect this export exists to remove: until 2026-08-30 there were two,
2175
+ * and the two halves of one report could therefore describe different
2176
+ * programs (`T-29-16-02`).
2177
+ *
2178
+ * DISPATCH IS BY EXTENSION FIRST, NEVER BY BYTE LENGTH, and that order is a
2179
+ * CONTRACT rather than a style choice. It is copied from `anno-tools.ts`'s
2180
+ * `loadImage()` -- the surface's own image loader -- rather than re-derived,
2181
+ * so the two views of "what is an image" cannot drift. The incident it
2182
+ * encodes (WR-07): a 4096-byte flat `.raw` capture fell through to the `.prg`
2183
+ * parser, whose first two bytes become the load address, so a truncated
2184
+ * capture silently reported a complete-looking measurement with an origin
2185
+ * read backwards out of its own payload bytes, and exited zero -- every
2186
+ * downstream address wrong, no diagnostic. Running the extension check before
2187
+ * any length check is what keeps `flatImageOrigin()`'s named refusal
2188
+ * reachable for those two extensions.
2189
+ *
2190
+ * The retired JSON-project form is the TRAILING branch and nothing more: its
2191
+ * only producer was deleted by D-14 (2026-08-29), so it is retained purely so a
2192
+ * caller with an existing project file on disk is not broken. Its diagnoses
2193
+ * are byte-identical to what they were, with the single exception recorded on
2194
+ * the syntax branch below.
2195
+ *
2196
+ * Failure of an EXTENSION-DISPATCHED branch is a `payloadDecoded: false` with
2197
+ * the underlying refusal as the reason, never a throw. The one throw left is
2198
+ * for a path that cannot be READ at all -- a caller-contract violation.
2199
+ */
2200
+ export function loadProjectImage(projectPath: string): LoadedProject {
2201
+ let bytes: Uint8Array;
2202
+ try {
2203
+ bytes = new Uint8Array(readFileSync(projectPath));
2204
+ } catch (err) {
2205
+ // A path that cannot be read is a CALLER CONTRACT violation, not
2206
+ // malformed data -- the one class this module throws for. The
2207
+ // interpolated message here is an ERRNO-class failure (ENOENT, EACCES,
2208
+ // EISDIR) that carries no byte of the file's content, so it is left
2209
+ // interpolated on purpose; plan 29-14 left the equivalent read-failure
2210
+ // branch on the sibling verb alone for exactly this reason.
2211
+ throw new AnnoCoverageInputError(
2212
+ `buildCoverageReport: could not read ${projectPath} -- ${err instanceof Error ? err.message : String(err)}`,
2213
+ { cause: err, projectPath },
2214
+ );
2215
+ }
2216
+
2217
+ // The live image forms, in `loadImage()`'s own branch order.
2218
+ const ext = extname(projectPath).toLowerCase();
2219
+ if (ext === ".raw" || ext === ".bin") {
2220
+ return flatImage(projectPath, bytes);
2221
+ }
2222
+ if (ext !== ".prg" && bytes.length === 65536) {
2223
+ return flatImage(projectPath, bytes);
2224
+ }
2225
+ if (ext === ".prg") {
2226
+ try {
2227
+ const { origin, body } = parsePrg(bytes);
2228
+ return { origin, bytes: new Uint8Array(body), payloadDecoded: true, reason: null };
2229
+ } catch (err) {
2230
+ return imageRefusal(projectPath, err);
2231
+ }
2232
+ }
2233
+
2234
+ // The retired project form, reached only when nothing above matched.
2235
+ let parsed: unknown;
2236
+ try {
2237
+ parsed = JSON.parse(new TextDecoder().decode(bytes));
2238
+ } catch (err) {
2239
+ // NEVER INTERPOLATE THE UNDERLYING PARSE ERROR HERE (CR-03,
2240
+ // `T-29-16-06`). V8's SyntaxError quotes a snippet of the input it choked
2241
+ // on, so passing it through turns `<project>` -- a positional the shipped
2242
+ // playbooks tell an LLM to compose in a Bash invocation -- into a
2243
+ // CONTENT-DISCLOSURE ORACLE. Reproduced verbatim on this very tree before
2244
+ // the fix: `game.prg is not valid JSON -- Unexpected token '', "<the
2245
+ // file's own opening bytes>"... is not valid JSON`.
2246
+ //
2247
+ // Plan 29-14 (`T-29-14-01`) applies exactly this treatment to the
2248
+ // `render-memmap --provenance` sidecar's syntax failure. Both sibling
2249
+ // verbs therefore give one treatment to one defect class. Residual
2250
+ // severity is MEDIUM here rather than 29-14's HIGH only because
2251
+ // `storePathWithinWorkspace()` confines this positional before the
2252
+ // loader sees it, so the oracle cannot leave the workspace -- an
2253
+ // in-workspace content echo is still a content echo.
2254
+ //
2255
+ // What survives is everything a caller legitimately needs: WHICH file,
2256
+ // and THAT it is not JSON. The byte OFFSET is included where the runtime
2257
+ // exposes one, because a position is a fact about where parsing stopped
2258
+ // and not about what the file contains. Do not "improve" this by
2259
+ // restoring the parser's message.
2260
+ return {
2261
+ origin: 0,
2262
+ bytes: new Uint8Array(0),
2263
+ payloadDecoded: false,
2264
+ reason:
2265
+ `${projectPath} is not valid JSON${jsonParsePosition(err)} and is not a .prg or an exactly-65536-byte flat capture ` +
2266
+ "(the underlying parser message is deliberately NOT included -- it quotes the file's own bytes, CR-03)",
2267
+ };
2268
+ }
2269
+
2270
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
2271
+ return { origin: 0, bytes: new Uint8Array(0), payloadDecoded: false, reason: `${projectPath}'s top-level JSON value is not an object` };
2272
+ }
2273
+
2274
+ const project = parsed as Record<string, unknown>;
2275
+ const origin = typeof project.origin === "number" && Number.isSafeInteger(project.origin) ? project.origin : 0;
2276
+ const payload = project.raw_data_base64;
2277
+ if (typeof payload !== "string") {
2278
+ return { origin, bytes: new Uint8Array(0), payloadDecoded: false, reason: `${projectPath} carries no raw_data_base64 payload` };
2279
+ }
2280
+
2281
+ try {
2282
+ return { origin, bytes: new Uint8Array(decodeRawData(payload)), payloadDecoded: true, reason: null };
2283
+ } catch (err) {
2284
+ return {
2285
+ origin,
2286
+ bytes: new Uint8Array(0),
2287
+ payloadDecoded: false,
2288
+ reason: `${projectPath}'s raw_data_base64 payload would not decode -- ${err instanceof Error ? err.message : String(err)}`,
2289
+ };
2290
+ }
2291
+ }
2292
+
2293
+ /**
2294
+ * Assembles the whole report. Read-only over `projectPath` by construction:
2295
+ * this function opens the file for reading and there is no write, save or
2296
+ * session call anywhere in this module.
2297
+ */
2298
+ export function buildCoverageReport(opts: CoverageOptions): CoverageReport {
2299
+ if (!opts || typeof opts.projectPath !== "string" || opts.projectPath.length === 0) {
2300
+ throw new AnnoCoverageInputError("buildCoverageReport: projectPath is required and must be a non-empty string");
2301
+ }
2302
+
2303
+ const symbols = opts.symbols ?? [];
2304
+ const comments = opts.comments ?? [];
2305
+ const blocks = opts.blocks ?? [];
2306
+ const crossReferences = opts.crossReferences ?? [];
2307
+ const now = typeof opts.now === "function" ? opts.now : () => new Date().toISOString();
2308
+ // Resolved ONCE and passed to both consumers, so the two sub-reports can
2309
+ // never disagree about which store vocabulary they are reading.
2310
+ const blockClassifier = typeof opts.blockClassifier === "function" ? opts.blockClassifier : blockClassAt;
2311
+
2312
+ const loaded = loadProjectImage(opts.projectPath);
2313
+
2314
+ const seeds = new Set<number>();
2315
+ seeds.add(loaded.origin);
2316
+ for (const sym of symbols) {
2317
+ if (sym && String(sym.kind ?? "") === "User") seeds.add(sym.address);
2318
+ }
2319
+ for (const entry of opts.entryPoints ?? []) {
2320
+ if (Number.isSafeInteger(entry)) seeds.add(entry);
2321
+ }
2322
+
2323
+ const linear = decode(loaded.bytes, loaded.origin);
2324
+ const dispatch = scanIndirectDispatch(linear, loaded.bytes, loaded.origin);
2325
+ const structural = computeStructuralCensus(loaded.bytes, loaded.origin, seeds, {
2326
+ tableEntryAddresses: dispatch.tableEntryAddresses,
2327
+ // The ONE seam. Never `dispatch.discoveredTargets` and never
2328
+ // `dispatch.splitTableCandidates` -- see `provenDispatchTargets()`.
2329
+ extraSeeds: provenDispatchTargets(dispatch),
2330
+ });
2331
+
2332
+ const commentVacuity = computeCommentVacuity(comments);
2333
+ const reproducibility = computeReproducibility({
2334
+ census: structural,
2335
+ dispatch,
2336
+ symbols,
2337
+ comments,
2338
+ blocks,
2339
+ crossReferences,
2340
+ blockClassifier,
2341
+ ...(opts.sampleSize !== undefined ? { sampleSize: opts.sampleSize } : {}),
2342
+ });
2343
+ const labels = computeLabelRatio(symbols, { excludeUserAddresses: reproducibility.multiCallerUndocumented.addresses });
2344
+ const divergence = computeDivergence(structural, blocks, blockClassifier);
2345
+
2346
+ return {
2347
+ schemaVersion: COVERAGE_SCHEMA_VERSION,
2348
+ generatedAt: now(),
2349
+ project: {
2350
+ path: opts.projectPath,
2351
+ origin: loaded.origin,
2352
+ size: loaded.bytes.length,
2353
+ payloadDecoded: loaded.payloadDecoded,
2354
+ reason: loaded.reason,
2355
+ },
2356
+ structural,
2357
+ dispatch,
2358
+ labels,
2359
+ commentVacuity,
2360
+ reproducibility,
2361
+ divergence,
2362
+ };
2363
+ }
2364
+
2365
+ // ---------------------------------------------------------------------------
2366
+ // Findings -- a per-measure verdict, NEVER an aggregate
2367
+ // ---------------------------------------------------------------------------
2368
+
2369
+ export interface CoverageFinding {
2370
+ /** The single top-level report key that produced this finding. Exactly one
2371
+ * -- a finding is never derived from two measures at once. */
2372
+ measure: "structural" | "labels" | "commentVacuity" | "reproducibility" | "divergence" | "project";
2373
+ reason: string;
2374
+ }
2375
+
2376
+ export interface CoverageFindings {
2377
+ clean: boolean;
2378
+ findings: CoverageFinding[];
2379
+ }
2380
+
2381
+ /** Thresholds, each attached to exactly one measure and each stated once. */
2382
+ const MIN_USER_FRACTION = 0.5;
2383
+ const MIN_DISTINCT_COMMENT_RATIO = 0.5;
2384
+ const MIN_AGREEMENT_RATE = 0.8;
2385
+
2386
+ /**
2387
+ * Turns a report into a boolean verdict plus per-measure reasons.
2388
+ *
2389
+ * This is NOT a combined coverage figure and must never become one (header
2390
+ * trap 3): it emits no number, it never averages or weights the measures, and
2391
+ * every finding names exactly one of them. It is a threshold gate over
2392
+ * separately-addressable measures, which is what the six committed controls
2393
+ * assert against -- five must be non-clean for a NAMED reason, one must be
2394
+ * clean, and without that last one the whole instrument would be vacuous.
2395
+ */
2396
+ export function coverageFindings(report: CoverageReport): CoverageFindings {
2397
+ const findings: CoverageFinding[] = [];
2398
+
2399
+ if (!report.project.payloadDecoded) {
2400
+ findings.push({ measure: "project", reason: `payload unavailable: ${report.project.reason ?? "reason not recorded"}` });
2401
+ }
2402
+
2403
+ if (report.structural.truncated) {
2404
+ findings.push({ measure: "structural", reason: "the descent walk hit its step bound and was truncated" });
2405
+ }
2406
+
2407
+ const { userFraction } = report.labels.kindRatio;
2408
+ if (userFraction === null) {
2409
+ findings.push({ measure: "labels", reason: "no non-System labels, so the Auto-versus-User figure is unavailable rather than clean" });
2410
+ } else if (userFraction < MIN_USER_FRACTION) {
2411
+ findings.push({ measure: "labels", reason: `user fraction ${userFraction.toFixed(3)} is below ${MIN_USER_FRACTION}` });
2412
+ }
2413
+ if (report.labels.autoPrefixNamesRemaining > 0) {
2414
+ findings.push({
2415
+ measure: "labels",
2416
+ reason: `${report.labels.autoPrefixNamesRemaining} label name(s) still carry an auto-name prefix at ${report.labels.autoPrefixNameAddresses.map((a) => `$${a.toString(16)}`).join(", ")}`,
2417
+ });
2418
+ }
2419
+
2420
+ const vac = report.commentVacuity;
2421
+ if (vac.distinctCommentRatio === null) {
2422
+ findings.push({ measure: "commentVacuity", reason: vac.reason ?? "comment vacuity is unavailable" });
2423
+ } else if (vac.distinctCommentRatio < MIN_DISTINCT_COMMENT_RATIO) {
2424
+ findings.push({
2425
+ measure: "commentVacuity",
2426
+ reason: `distinct-comment ratio ${vac.distinctCommentRatio.toFixed(3)} is below ${MIN_DISTINCT_COMMENT_RATIO}`,
2427
+ });
2428
+ }
2429
+ if (vac.bannedGenericAddresses.length > 0) {
2430
+ findings.push({
2431
+ measure: "commentVacuity",
2432
+ reason: `${vac.bannedGenericAddresses.length} address(es) carry a banned-generic comment at ${vac.bannedGenericAddresses.map((a) => `$${a.toString(16)}`).join(", ")}`,
2433
+ });
2434
+ }
2435
+ if (vac.malformedGradeAddresses.length > 0) {
2436
+ findings.push({
2437
+ measure: "commentVacuity",
2438
+ reason: `${vac.malformedGradeAddresses.length} comment(s) open with a near-miss confidence token`,
2439
+ });
2440
+ }
2441
+
2442
+ const repro = report.reproducibility;
2443
+ if (repro.multiCallerUndocumented.count > 0) {
2444
+ findings.push({
2445
+ measure: "reproducibility",
2446
+ reason: `${repro.multiCallerUndocumented.count} multi-caller label(s) documented without naming a caller at ${repro.multiCallerUndocumented.addresses.map((a) => `$${a.toString(16)}`).join(", ")}`,
2447
+ });
2448
+ }
2449
+ if (repro.agreementRate === null) {
2450
+ findings.push({ measure: "reproducibility", reason: repro.reason ?? "reproducibility is unavailable" });
2451
+ } else if (repro.agreementRate < MIN_AGREEMENT_RATE) {
2452
+ findings.push({ measure: "reproducibility", reason: `agreement rate ${repro.agreementRate.toFixed(3)} is below ${MIN_AGREEMENT_RATE}` });
2453
+ }
2454
+
2455
+ if (!report.divergence.blocksSupplied) {
2456
+ findings.push({ measure: "divergence", reason: report.divergence.reason ?? "divergence is unavailable" });
2457
+ } else if (report.divergence.censusCodeStoreNotCode > 0) {
2458
+ findings.push({
2459
+ measure: "divergence",
2460
+ reason: `${report.divergence.censusCodeStoreNotCode} byte(s) the census reached as instructions are not classified Code by the store`,
2461
+ });
2462
+ }
2463
+
2464
+ return { clean: findings.length === 0, findings };
2465
+ }