@henols/vice-mcp 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/THIRD-PARTY-NOTICES.md +1 -24
- package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
- package/anno-cli.ts +1465 -0
- package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
- package/anno-coverage.ts +2465 -0
- package/{r2000-d64.ts → anno-d64.ts} +5 -5
- package/anno-derive.ts +590 -0
- package/anno-details.ts +169 -0
- package/anno-enum-gen.ts +533 -0
- package/anno-export-asm.ts +1310 -0
- package/anno-index.ts +150 -0
- package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
- package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
- package/{r2000-regbits.json → anno-regbits.json} +2 -2
- package/anno-register.ts +240 -0
- package/anno-store.ts +3486 -0
- package/anno-symbols.ts +266 -0
- package/anno-tools.ts +2111 -0
- package/anno-types.ts +1636 -0
- package/block-class.ts +201 -0
- package/build.ts +1 -1
- package/capability-registry.ts +3 -1
- package/disasm-decoder.ts +14 -14
- package/disasm-opcodes.ts +4 -4
- package/disasm-renderer.ts +2 -2
- package/hostpath.ts +1 -1
- package/install-resources.ts +1 -1
- package/package.json +23 -17
- package/prg-image.ts +119 -0
- package/repo-root.ts +20 -5
- package/resources/broker-launch.mjs +8 -4
- package/resources/vice-launcher.sh +3 -3
- package/stock-address.ts +5 -5
- package/stock-cia.ts +2 -2
- package/stock-condition.ts +7 -7
- package/stock-connect.ts +1 -1
- package/stock-dispatch.ts +35 -5
- package/stock-execution.ts +5 -3
- package/stock-input.ts +9 -9
- package/stock-machine.ts +17 -6
- package/stock-protocol.ts +16 -11
- package/stock-registers.ts +54 -29
- package/stock-sprites.ts +3 -3
- package/stock-symbols.ts +9 -9
- package/stock-timing.ts +1 -1
- package/stock-vicii.ts +1 -1
- package/version.ts +1 -1
- package/vice-proxy.ts +68 -46
- package/r2000-cli.ts +0 -1103
- package/r2000-enum-gen.ts +0 -574
- package/r2000-launch.ts +0 -357
- package/r2000-mcp-client.ts +0 -596
- package/r2000-project.ts +0 -190
- package/r2000-symbols.ts +0 -388
- package/r2000-tools.ts +0 -914
- package/r2000-verify.ts +0 -184
package/anno-coverage.ts
ADDED
|
@@ -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
|
+
}
|