@henols/c64-re-tools 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.
@@ -1,339 +0,0 @@
1
- // Coverage for the on-demand-load detector's pure logic (01-04 Task 1).
2
- // Every test here runs with no emulator present -- small synthetic fixtures
3
- // for the boundary/attribution/ordering behaviour, plus two cases that read
4
- // the real committed sidecars to prove the pure functions reproduce
5
- // already-recorded evidence. The import-purity guard test at the bottom is
6
- // the durable, mechanical statement of the one-permitted-route rule: this
7
- // whole file runs to completion with the emulator absent, and the guard
8
- // keeps it that way.
9
- import { test } from "node:test";
10
- import assert from "node:assert/strict";
11
- import { readFileSync } from "node:fs";
12
- import { fileURLToPath } from "node:url";
13
- import { dirname, join, resolve } from "node:path";
14
-
15
- import {
16
- WATCH_SET,
17
- attributeAddress,
18
- reportHits,
19
- idleGate,
20
- classifyHit,
21
- screenSignature,
22
- addrNum,
23
- hex4,
24
- } from "./watch-loads.mjs";
25
- import { buildChipState, buildRangeManifest } from "./dump-artifacts.mjs";
26
- import { firstDumpArtifact, skipUnless } from "./test-corpus.mjs";
27
-
28
- const HERE = dirname(fileURLToPath(import.meta.url));
29
- const REPO_ROOT = resolve(HERE, "..", "..", "..", ".."); // scripts -> skill -> skills -> .claude -> repo root
30
-
31
- // ----------------------------------------------------------------- addrNum
32
-
33
- test("addrNum parses $-hex, 0x-hex, decimal strings and numbers", () => {
34
- assert.equal(addrNum("$08B1"), 0x08b1);
35
- assert.equal(addrNum("0x08b1"), 0x08b1);
36
- assert.equal(addrNum("2225"), 2225);
37
- assert.equal(addrNum(2225), 2225);
38
- });
39
-
40
- test("hex4 formats a 4-digit uppercase $ address", () => {
41
- assert.equal(hex4(0x08b1), "$08B1");
42
- assert.equal(hex4(0), "$0000");
43
- });
44
-
45
- // -------------------------------------------------------------- WATCH_SET
46
-
47
- function fakeRegistry(loaderRanges) {
48
- return {
49
- releases: [
50
- {
51
- id: "fake",
52
- loader_ranges: loaderRanges,
53
- dumps: [{ label: "run1", range_manifest: "recovery/fake/dumps/fake-run1.map.json" }],
54
- },
55
- ],
56
- };
57
- }
58
-
59
- const FAKE_MANIFEST = {
60
- ranges: [
61
- { start: 0, end: 15, kind: "unclassified" },
62
- { start: 16, end: 31, kind: "unused", note: "power-on pattern" },
63
- { start: 32, end: 63, kind: "unclassified" },
64
- ],
65
- };
66
-
67
- test("WATCH_SET assigns tier stopping to loader-reentry ranges and tier counting to never-populated ranges and the register sentinel", () => {
68
- const reg = fakeRegistry([{ start: "$0900", end: "$0901", note: "cracktro poll", evidence: "LDA $DC00" }]);
69
- const set = WATCH_SET("fake", { registry: reg, manifest: FAKE_MANIFEST });
70
- const tiers = new Map(set.map((s) => [s.kind, s.tier]));
71
- assert.equal(tiers.get("loader-reentry"), "stopping");
72
- assert.equal(tiers.get("never-populated"), "counting");
73
- assert.equal(tiers.get("register"), "counting");
74
- const dd00 = set.find((s) => s.name === "reg:$DD00");
75
- assert.ok(dd00, "register sentinel present");
76
- });
77
-
78
- test("WATCH_SET throws an actionable message when the release has no loader_ranges recorded", () => {
79
- const reg = fakeRegistry([]);
80
- assert.throws(() => WATCH_SET("fake", { registry: reg, manifest: FAKE_MANIFEST }), /no loader_ranges recorded/);
81
- });
82
-
83
- // --------------------------------------------------------- attributeAddress
84
-
85
- test("attributeAddress resolves the first and last byte of every declared range", () => {
86
- const sentinels = [
87
- { name: "a", start: 0x0900, end: 0x0910 },
88
- { name: "b", start: 0x1000, end: 0x1fff },
89
- ];
90
- assert.equal(attributeAddress(0x0900, sentinels).name, "a");
91
- assert.equal(attributeAddress(0x0910, sentinels).name, "a");
92
- assert.equal(attributeAddress(0x1000, sentinels).name, "b");
93
- assert.equal(attributeAddress(0x1fff, sentinels).name, "b");
94
- });
95
-
96
- test("attributeAddress keeps abutting ranges separate -- exactly one owner at the shared boundary", () => {
97
- const sentinels = [
98
- { name: "a", start: 0x1000, end: 0x1fff },
99
- { name: "b", start: 0x2000, end: 0x2fff },
100
- ];
101
- const atBoundaryEnd = attributeAddress(0x1fff, sentinels);
102
- const atBoundaryStart = attributeAddress(0x2000, sentinels);
103
- assert.equal(atBoundaryEnd.name, "a");
104
- assert.equal(atBoundaryStart.name, "b");
105
- assert.notEqual(atBoundaryEnd.name, atBoundaryStart.name);
106
- });
107
-
108
- test("attributeAddress throws naming both sentinels for an overlapping resolved set", () => {
109
- const sentinels = [
110
- { name: "a", start: 0x1000, end: 0x1fff },
111
- { name: "b", start: 0x1800, end: 0x2000 },
112
- ];
113
- assert.throws(() => attributeAddress(0x1900, sentinels), (err) => {
114
- return /overlapping or duplicate/.test(err.message) && err.message.includes("a") && err.message.includes("b");
115
- });
116
- });
117
-
118
- test("attributeAddress returns an explicit unmatched result for an out-of-range address, never the nearest neighbour", () => {
119
- const sentinels = [{ name: "a", start: 0x1000, end: 0x1fff }];
120
- const result = attributeAddress(0x5000, sentinels);
121
- assert.equal(result.matched, false);
122
- assert.equal(result.name, null);
123
- });
124
-
125
- // ------------------------------------------------------------- reportHits
126
-
127
- test("reportHits orders by cycle, then address, then sentinel name -- swapping input order is stable", () => {
128
- const hits = [
129
- { cycle: 5, address: "$1000", sentinel: "z" },
130
- { cycle: 5, address: "$1000", sentinel: "a" },
131
- { cycle: 3, address: "$2000", sentinel: "m" },
132
- ];
133
- const a = reportHits(hits);
134
- const b = reportHits([...hits].reverse());
135
- assert.deepEqual(a, b);
136
- assert.equal(a[0].cycle, 3);
137
- assert.equal(a[1].sentinel, "a");
138
- assert.equal(a[2].sentinel, "z");
139
- });
140
-
141
- test("reportHits over an empty hit log returns an empty result rather than throwing", () => {
142
- assert.deepEqual(reportHits({ hits: [] }), []);
143
- assert.deepEqual(reportHits([]), []);
144
- assert.doesNotThrow(() => reportHits({}));
145
- });
146
-
147
- test("reportHits is idempotent -- calling twice on the same log produces identical output", () => {
148
- const log = { hits: [{ cycle: 1, address: "$1000", sentinel: "a" }, { cycle: 1, address: "$1000", sentinel: "b" }] };
149
- assert.deepEqual(reportHits(log), reportHits(log));
150
- });
151
-
152
- // --------------------------------------------------------------- idleGate
153
-
154
- test("idleGate accepts a calibration in which every stopping-tier sentinel recorded zero hits", () => {
155
- const cal = {
156
- cycles_advanced: 12345,
157
- sentinels: [
158
- { name: "loader:a", tier: "stopping", hits: 0 },
159
- { name: "unused:b", tier: "counting", hits: 3 },
160
- ],
161
- };
162
- assert.equal(idleGate(cal).ok, true);
163
- });
164
-
165
- test("idleGate rejects a calibration in which a stopping-tier sentinel recorded a non-zero count, naming it", () => {
166
- const cal = {
167
- cycles_advanced: 12345,
168
- sentinels: [{ name: "loader:a", tier: "stopping", hits: 113 }],
169
- };
170
- const result = idleGate(cal);
171
- assert.equal(result.ok, false);
172
- assert.equal(result.violations[0].name, "loader:a");
173
- assert.equal(result.violations[0].hits, 113);
174
- });
175
-
176
- test("idleGate rejects a calibration whose cycles_advanced is zero even when every hit count is zero", () => {
177
- const cal = { cycles_advanced: 0, sentinels: [{ name: "loader:a", tier: "stopping", hits: 0 }] };
178
- const result = idleGate(cal);
179
- assert.equal(result.ok, false);
180
- assert.match(result.reasons.join(" "), /cycles_advanced/);
181
- });
182
-
183
- // -------------------------------------------------------------- classifyHit
184
-
185
- test("classifyHit returns unattributed when pc, backtrace or disassembly is missing", () => {
186
- assert.equal(classifyHit({}), "unattributed");
187
- assert.equal(classifyHit({ pc: "$8E9D", backtrace: [], disassembly: "STA ($04),Y" }), "unattributed");
188
- assert.equal(classifyHit({ pc: "$8E9D", backtrace: ["$0800"], disassembly: "" }), "unattributed");
189
- });
190
-
191
- test("classifyHit returns the recorded classification only when pc, backtrace and disassembly are all present", () => {
192
- const hit = { pc: "$8E9D", backtrace: ["$0800"], disassembly: "STA ($04),Y", classification: "gameplay-write" };
193
- assert.equal(classifyHit(hit), "gameplay-write");
194
- const loadHit = { ...hit, classification: "load-candidate" };
195
- assert.equal(classifyHit(loadHit), "load-candidate");
196
- });
197
-
198
- // ---------------------------------------------------------- screenSignature
199
-
200
- test("screenSignature hashes exactly 1000 bytes and carries the sprite-enable value through", () => {
201
- const hex = "00".repeat(1000);
202
- const result = screenSignature(hex, 60);
203
- assert.equal(result.sprite_enable, 60);
204
- assert.equal(typeof result.digest, "string");
205
- assert.equal(result.digest.length, 64);
206
- });
207
-
208
- test("screenSignature throws for a screen matrix that is not exactly 1000 bytes", () => {
209
- assert.throws(() => screenSignature("00".repeat(999), 0), /expected 1000 bytes/);
210
- });
211
-
212
- // ------------------------------------------------ real committed sidecars
213
-
214
- const CHIP_STATE = firstDumpArtifact("chip_state");
215
-
216
- test("buildChipState reproduces a committed sidecar's recorded derivations from that file's own raw readings",
217
- { skip: skipUnless(CHIP_STATE, "a committed chip-state sidecar") }, () => {
218
- const committed = JSON.parse(readFileSync(CHIP_STATE.path, "utf8"));
219
- const raw = {
220
- dd00_raw: committed.derived.dd00_raw,
221
- d018_raw: committed.derived.d018_raw,
222
- port01_raw: committed.derived.port01.raw,
223
- sprite_pointers: committed.derived.sprite_pointers,
224
- registers: committed.registers,
225
- sprites: committed.sprites,
226
- cpu: committed.cpu,
227
- };
228
- const result = buildChipState(raw);
229
- assert.equal(result.derived.vic_bank, committed.derived.vic_bank);
230
- assert.equal(result.derived.screen_base, committed.derived.screen_base);
231
- assert.equal(result.derived.charset_base, committed.derived.charset_base);
232
- assert.deepEqual(result.derived.sprite_data_addresses, committed.derived.sprite_data_addresses);
233
- });
234
-
235
- const BIN = firstDumpArtifact("bin");
236
-
237
- test("buildRangeManifest over a committed image produces ranges whose union covers all 65536 addresses",
238
- { skip: skipUnless(BIN, "a committed 64K image") }, () => {
239
- const image = readFileSync(BIN.path);
240
- const manifest = buildRangeManifest(image, { release: BIN.release, label: BIN.label });
241
- assert.equal(manifest.classification_state, "ranges-only");
242
- let expected = 0;
243
- for (const r of manifest.ranges) {
244
- assert.equal(r.start, expected, `gap or overlap before ${r.start}`);
245
- expected = r.end + 1;
246
- }
247
- assert.equal(expected, 65536);
248
- });
249
-
250
- // ------------------------------------------------- import-purity guard (T-01-25)
251
-
252
- function stripComments(src) {
253
- return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
254
- }
255
-
256
- function importSpecifiers(src) {
257
- const specs = [];
258
- const re = /import\s+(?:[^'"]+?\s+from\s+)?["']([^"']+)["']/g;
259
- let m;
260
- while ((m = re.exec(src))) specs.push(m[1]);
261
- return specs;
262
- }
263
-
264
- test("every import specifier in watch-loads.mjs and dump-artifacts.mjs is a node: built-in or a sibling file inside tools/ -- the mechanical proof of the one permitted route", () => {
265
- const files = ["watch-loads.mjs", "dump-artifacts.mjs"];
266
- let totalSpecifiers = 0;
267
- for (const f of files) {
268
- const src = stripComments(readFileSync(join(HERE, f), "utf8"));
269
- const specs = importSpecifiers(src);
270
- assert.ok(specs.length > 0, `${f} should have at least one import specifier (this assertion fails if removed, so it cannot pass vacuously)`);
271
- totalSpecifiers += specs.length;
272
- for (const spec of specs) {
273
- const isNodeBuiltin = spec.startsWith("node:");
274
- const isSiblingPath = spec.startsWith("./") || spec.startsWith("../");
275
- assert.ok(
276
- isNodeBuiltin || isSiblingPath,
277
- `${f} imports "${spec}", which is neither a node: built-in nor a relative path -- this module must never acquire an outside dependency`
278
- );
279
- if (isSiblingPath) {
280
- const resolved = resolve(HERE, spec);
281
- assert.ok(resolved.startsWith(HERE), `${f}'s import "${spec}" resolves outside tools/ (${resolved})`);
282
- }
283
- }
284
- }
285
- assert.ok(totalSpecifiers > 0, "at least one specifier must have been checked across both modules");
286
- });
287
-
288
- // ----------------------------------------------------------------- renderLoading
289
-
290
- test("renderLoading flags a blocked run's zero count as unevidenced rather than rendering it as a plain zero", async () => {
291
- const { renderLoading } = await import("./watch-loads.mjs");
292
- const blockedLog = {
293
- machine: "C64SC",
294
- video_standard: "PAL",
295
- vice_version: "3.10",
296
- run_status: "blocked",
297
- run_status_note: "Boot never progressed past its pre-loader state; two independent cycles_advanced brackets both measured zero.",
298
- armed: [],
299
- idle_calibration: { cycles_advanced: 0, sentinels: [] },
300
- hits: [],
301
- };
302
- const md = renderLoading([{ id: "example", log: blockedLog }]);
303
- assert.match(md, /NOT AN EVIDENCED ZERO/, "a blocked run must not render its zero count as a plain, unqualified result");
304
- assert.match(md, /never progressed past its pre-loader state/, "the blocked-run reason must be surfaced in the rendered document");
305
- });
306
-
307
- test("renderLoading flags a blocked run's NON-zero count as a partial result, not a plain evidenced count", async () => {
308
- const { renderLoading } = await import("./watch-loads.mjs");
309
- const partialLog = {
310
- machine: "C64SC",
311
- video_standard: "PAL",
312
- vice_version: "3.10",
313
- run_status: "blocked",
314
- run_status_note: "Play-through halted by a genuine silent host VICE stall after 2 of the required milestones.",
315
- armed: [],
316
- idle_calibration: { cycles_advanced: 100, sentinels: [] },
317
- hits: [
318
- { sentinel: "reg:$DD00", address: "$DD00", cycle: 1, pc: "$07DB", backtrace: [{ return_address: 1 }], disassembly: "STA $DD00", classification: "gameplay-write" },
319
- ],
320
- };
321
- const md = renderLoading([{ id: "example", log: partialLog }]);
322
- assert.match(md, /PARTIAL RESULT, NOT A COMPLETED COVERAGE CLAIM/, "a blocked run with a non-zero count must not be rendered with the zero-specific warning text");
323
- assert.doesNotMatch(md, /The count above is `0` only because/, "must not claim the count is 0 when it is not");
324
- assert.match(md, /halted by a genuine silent host VICE stall/, "the blocked-run reason must still be surfaced");
325
- });
326
-
327
- test("renderLoading does NOT add the blocked-run warning for an ordinary (non-blocked) log", async () => {
328
- const { renderLoading } = await import("./watch-loads.mjs");
329
- const normalLog = {
330
- machine: "C64SC",
331
- video_standard: "PAL",
332
- vice_version: "3.10",
333
- armed: [],
334
- idle_calibration: { cycles_advanced: 100, sentinels: [] },
335
- hits: [],
336
- };
337
- const md = renderLoading([{ id: "example", log: normalLog }]);
338
- assert.doesNotMatch(md, /NOT AN EVIDENCED ZERO/, "an ordinary log must not be flagged as a blocked run");
339
- });