diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index d8b07a2353..67a9835b60 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -54,6 +54,7 @@ are closed (status: closed in frontmatter)._ - [x] **[B-0066](backlog/P1/B-0066-memory-md-marker-vs-index-harness-verify-q1-automemory-aaron-2026-04-28.md)** MEMORY.md marker-vs-index — verify harness contract + Q1 AutoDream/AutoMemory compatibility, then migrate (Aaron 2026-04-28) - [x] **[B-0067](backlog/P1/B-0067-cadenced-git-hotspot-detection-aaron-2026-04-28.md)** Cadenced git-hotspot detection — all 3 phases complete - [ ] **[B-0083](backlog/P1/B-0083-atari-2600-rom-canonical-naming-tosec-goodtools-tooling-aaron-2026-04-28.md)** Atari 2600 ROM canonical-naming + safe-vs-unsafe folder split + TOSEC/Good-Tools-style hash-lookup tooling +- [ ] **[B-0083.1](backlog/P1/B-0083.1-atari-rom-datfile-as-dependency-pin-fetch-verify-2026-05-29.md)** ROM datfile-as-dependency — pin + fetch + SHA-256 verify + refresh - [ ] **[B-0087](backlog/P1/B-0087-github-settings-drift-workflow-broken-invalid-permission-administration-otto-2026-04-28.md)** github-settings-drift.yml broken since PR #45 — option A landed (invalid permission removed); options B/C remain maintainer-gated - [x] **[B-0110](backlog/P1/B-0110-acehack-mirror-protocol-drift-2026-04-30.md)** AceHack mirror-refresh protocol drift — Path 2 chosen, doctrine update landing in same PR (2026-04-30) - [x] **[B-0125](backlog/P1/B-0125-skip-fsharp-analyze-on-docs-only-prs-2026-05-01.md)** Skip F#/Analyze (csharp) on docs-only PRs without tripping `code_quality severity:all` diff --git a/docs/backlog/P1/B-0083-atari-2600-rom-canonical-naming-tosec-goodtools-tooling-aaron-2026-04-28.md b/docs/backlog/P1/B-0083-atari-2600-rom-canonical-naming-tosec-goodtools-tooling-aaron-2026-04-28.md index 5233e2ad80..706daa5e4a 100644 --- a/docs/backlog/P1/B-0083-atari-2600-rom-canonical-naming-tosec-goodtools-tooling-aaron-2026-04-28.md +++ b/docs/backlog/P1/B-0083-atari-2600-rom-canonical-naming-tosec-goodtools-tooling-aaron-2026-04-28.md @@ -7,9 +7,9 @@ tier: factory-tooling effort: M ask: maintainer Aaron 2026-04-28 (autonomous-loop ROM-drop + canonical-naming request) created: 2026-04-28 -last_updated: 2026-05-02 +last_updated: 2026-05-29 decomposition: decomposed -children: [B-0272, B-0273] +children: [B-0272, B-0273, B-0083.1] depends_on: [] tags: [aaron-2026-04-28, roms, atari-2600, tosec, good-tools, canonical-naming, datfile, license-safety, gitignore-already-protects, high-priority-after-0-0-0, scheduled-after-0-0-0] type: friction-reducer @@ -17,6 +17,20 @@ type: friction-reducer # B-0083 — Atari 2600 ROM canonical-naming + tooling +## Decomposition status (2026-05-29) + +- **B-0272** (canonical naming via TOSEC/No-Intro hash lookup) — **closed** + 2026-05-16. `tools/roms/canonicalize.ts` (+ tests). +- **B-0273** (safe/unsafe folder split) — **closed** 2026-05-29. + `tools/roms/split-by-license.ts` + `roms-safe/atari/2600/` + allowlist + + README cross-refs. +- **B-0083.1** (datfile-as-dependency: pin + fetch + SHA-256 verify + refresh) + — **open**. The parent acceptance criterion #6 ("Tooling refreshes on TOSEC + datfile updates") + the "Datfile-as-dependency" design section were not + covered by either earlier child; B-0083.1 builds the pin manifest + + fetch-and-verify tool. Parent stays open until B-0083.1's operator-fill + + optional-refresh-cadence steps resolve. + ## Source Aaron 2026-04-28T18:55Z verbatim: diff --git a/docs/backlog/P1/B-0083.1-atari-rom-datfile-as-dependency-pin-fetch-verify-2026-05-29.md b/docs/backlog/P1/B-0083.1-atari-rom-datfile-as-dependency-pin-fetch-verify-2026-05-29.md new file mode 100644 index 0000000000..a913715a1d --- /dev/null +++ b/docs/backlog/P1/B-0083.1-atari-rom-datfile-as-dependency-pin-fetch-verify-2026-05-29.md @@ -0,0 +1,83 @@ +--- +id: B-0083.1 +priority: P1 +status: open +title: "ROM datfile-as-dependency — pin + fetch + SHA-256 verify + refresh" +created: 2026-05-29 +last_updated: 2026-05-29 +parent: B-0083 +depends_on: [B-0272] +classification: buildable-now +decomposition: atomic +type: friction-reducer +tags: [roms, atari-2600, tosec, datfile, dep-pin, fetch-verify, otto-247] +--- + +# B-0083.1 — ROM datfile-as-dependency + +The third slice the parent B-0083 acceptance list named but neither closed +sibling covered. B-0272 built `canonicalize.ts` (which *consumes* +`--datfile `) and B-0273 built `split-by-license.ts` + `roms-safe/`. +Neither built the mechanism that *produces* the pinned, verified datfile. + +This row operationalizes the parent's **"Datfile-as-dependency"** design +section + the **"Tooling refreshes on TOSEC datfile updates"** acceptance +criterion: pin a datfile version, download it, verify its SHA-256, refresh +on update. + +## Pre-start checklist + +- [x] Prior-art search: read parent B-0083 (algorithm + "Datfile-as-dependency" + + "Recommended approach"); both children B-0272 (closed) + B-0273 (closed); + `tools/roms/canonicalize.ts` (`--datfile` consumer) + `split-by-license.ts`; + grepped `tools/` + `.github/workflows/` for any datfile fetch/pin/refresh + tooling — none existed. +- [x] Dependency walk: depends on B-0272 (the consumer of the fetched datfile, + already closed). Sibling B-0273 closed. No other deps. +- [x] Otto-247 / dep-pin: WebSearch'd "TOSEC Atari 2600 datfile latest" — + latest TOSEC release is **2025-03-13** (no 2026 release as of 2026-05). + Sources: , + . + +## Acceptance criteria + +- [x] Structured pin manifest at `tools/roms/manifests/datfiles.json` + (platform → source/release/datfileName/sourceUrl/downloadUrl/sha256). +- [x] Fetch-and-verify tool at `tools/roms/fetch-datfile.ts`: download the + pinned datfile, SHA-256-verify against the pin, write to a gitignored + cache (`roms/.datfiles/`), emit the `canonicalize.ts --datfile` follow-up. +- [x] **Fail-closed** on unverified pins: any `<...>` placeholder + (`downloadUrl`/`sha256`) is refused (exit 2), never written — per + `.claude/rules/dep-pin-search-first-authority.md`. +- [x] `--list` surfaces pinned platforms + verification status. +- [x] Tests (`fetch-datfile.test.ts`) for manifest parse, placeholder gate, + SHA-256 verify, and CLI error paths — no network in tests. +- [x] `atari-2600` pinned to the WebSearch-verified TOSEC 2025-03-13 release, + with `downloadUrl` + `sha256` as explicit fail-closed placeholders. +- [ ] **Operator action (not code)**: on first network-enabled run, fill the + verified `downloadUrl` + `sha256` for the atari-2600 datfile in the + manifest (the tool's refusal message names the exact steps). This is the + one value that requires a real download to verify and so could not be + pinned at authoring time. +- [ ] **Optional / deferred**: a scheduled GHA refresh cadence (manual-trigger + workflow). Out of scope for this slice; the parent marks it "scheduled cron + optional". File a follow-up if/when a refresh cadence is wanted. + +## Why this is the smallest safe slice of B-0083 + +Both decomposed children (B-0272, B-0273) are closed. The parent's only +genuinely-unbuilt, repo-shippable acceptance criterion was the +datfile-as-dependency mechanism (the "rename all 3461 ROMs" criterion is a +local-data op on gitignored files, and the README cross-ref already landed in +B-0273). This slice closes that gap with a bounded, tested tool that composes +with the existing `canonicalize.ts --datfile` consumer. + +## Composes with + +- `tools/roms/canonicalize.ts` (B-0272) — consumes the fetched + verified datfile. +- `tools/roms/split-by-license.ts` (B-0273) — downstream of canonicalization. +- `.claude/rules/dep-pin-search-first-authority.md` — the fail-closed placeholder + pattern + WebSearch-verified pin discipline. +- `roms/.gitignore` — `roms/.datfiles/` cache is gitignored by the existing + depth-limited rule (only READMEs tracked). +- Parent B-0083 acceptance criterion #6 + "Datfile-as-dependency" design section. diff --git a/tools/roms/fetch-datfile.test.ts b/tools/roms/fetch-datfile.test.ts new file mode 100644 index 0000000000..1ddff83f2e --- /dev/null +++ b/tools/roms/fetch-datfile.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + parseManifest, + isPlaceholder, + sha256Hex, + verifyChecksum, + fetchBlockReason, + loadManifest, + main, + DEFAULT_MANIFEST, + type DatfilePin, +} from "./fetch-datfile.ts"; + +const VERIFIED_PIN: DatfilePin = { + platform: "test-2600", + source: "TOSEC", + release: "2025-03-13", + datfileName: "Test (TOSEC).dat", + sourceUrl: "https://example.test/release", + // sha256Hex of UTF-8 "abc" + downloadUrl: "https://example.test/test.dat", + sha256: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + romsDir: "test/2600", +}; + +function manifestJson(pins: readonly Partial[]): string { + return JSON.stringify({ datfiles: pins }); +} + +describe("parseManifest", () => { + test("parses a well-formed entry into a platform-keyed map", () => { + const pins = parseManifest(manifestJson([VERIFIED_PIN])); + expect(pins.size).toBe(1); + expect(pins.get("test-2600")?.source).toBe("TOSEC"); + }); + + test("throws on invalid JSON", () => { + expect(() => parseManifest("{not json")).toThrow(/not valid JSON/); + }); + + test("throws when datfiles array is missing", () => { + expect(() => parseManifest("{}")).toThrow(/missing "datfiles"/); + }); + + test("throws on an entry missing a required field", () => { + const { sha256, ...partial } = VERIFIED_PIN; + expect(() => parseManifest(manifestJson([partial]))).toThrow(/sha256/); + }); + + test("throws on an empty-string required field", () => { + expect(() => + parseManifest(manifestJson([{ ...VERIFIED_PIN, platform: "" }])), + ).toThrow(/platform/); + }); + + test("throws on a missing romsDir (explicit-path field is required)", () => { + const { romsDir, ...partial } = VERIFIED_PIN; + expect(() => parseManifest(manifestJson([partial]))).toThrow(/romsDir/); + }); + + test("throws on a duplicate platform", () => { + expect(() => + parseManifest(manifestJson([VERIFIED_PIN, VERIFIED_PIN])), + ).toThrow(/duplicate platform/); + }); + + test("ignores the _comment field", () => { + const json = JSON.stringify({ + _comment: ["doc string"], + datfiles: [VERIFIED_PIN], + }); + expect(parseManifest(json).size).toBe(1); + }); +}); + +describe("isPlaceholder", () => { + test("flags angle-bracket-wrapped values", () => { + expect(isPlaceholder("")).toBe(true); + expect(isPlaceholder("")).toBe(true); + }); + + test("passes concrete values", () => { + expect(isPlaceholder("https://example.test/x.dat")).toBe(false); + expect(isPlaceholder("ba7816bf")).toBe(false); + expect(isPlaceholder("")).toBe(false); + }); +}); + +describe("sha256Hex / verifyChecksum", () => { + const abc = new TextEncoder().encode("abc"); + const knownAbc = + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + + test("computes a known SHA-256", () => { + expect(sha256Hex(abc)).toBe(knownAbc); + }); + + test("verifyChecksum accepts a match (case-insensitive expected)", () => { + expect(verifyChecksum(abc, knownAbc)).toBe(true); + expect(verifyChecksum(abc, knownAbc.toUpperCase())).toBe(true); + }); + + test("verifyChecksum rejects a mismatch", () => { + expect(verifyChecksum(abc, "deadbeef")).toBe(false); + expect(verifyChecksum(new TextEncoder().encode("abd"), knownAbc)).toBe( + false, + ); + }); +}); + +describe("fetchBlockReason (fail-closed gate)", () => { + test("blocks a placeholder downloadUrl", () => { + const pin = { ...VERIFIED_PIN, downloadUrl: "" }; + expect(fetchBlockReason(pin)).toMatch(/downloadUrl.*placeholder/); + }); + + test("blocks a placeholder sha256", () => { + const pin = { ...VERIFIED_PIN, sha256: "" }; + expect(fetchBlockReason(pin)).toMatch(/sha256.*placeholder/); + }); + + test("allows a fully verified pin", () => { + expect(fetchBlockReason(VERIFIED_PIN)).toBeNull(); + }); +}); + +describe("real pinned manifest", () => { + test("tools/roms/manifests/datfiles.json parses", () => { + const pins = loadManifest(DEFAULT_MANIFEST); + expect(pins.has("atari-2600")).toBe(true); + }); + + test("atari-2600 pins the WebSearch-verified TOSEC release", () => { + const pin = loadManifest(DEFAULT_MANIFEST).get("atari-2600")!; + expect(pin.source).toBe("TOSEC"); + expect(pin.release).toBe("2025-03-13"); + }); + + test("atari-2600 is fail-closed until the operator verifies the pin", () => { + const pin = loadManifest(DEFAULT_MANIFEST).get("atari-2600")!; + expect(fetchBlockReason(pin)).not.toBeNull(); + }); +}); + +describe("main (CLI, no network)", () => { + test("no args => usage error exit 64", async () => { + expect(await main([])).toBe(64); + }); + + test("unknown arg => exit 64", async () => { + expect(await main(["--bogus"])).toBe(64); + }); + + test("--help => exit 0", async () => { + expect(await main(["--help"])).toBe(0); + }); + + test("missing manifest => exit 1", async () => { + expect( + await main(["--platform", "atari-2600", "--manifest", "/no/such/file"]), + ).toBe(1); + }); + + test("--list against real manifest => exit 0", async () => { + expect(await main(["--list"])).toBe(0); + }); + + test("unknown platform => exit 1", async () => { + expect(await main(["--platform", "nonesuch-9999"])).toBe(1); + }); + + test("placeholder pin fails closed => exit 2 (no network)", async () => { + expect(await main(["--platform", "atari-2600"])).toBe(2); + }); + + test("verified pin with missing platform in a temp manifest still exit 2 when placeholder", async () => { + const dir = mkdtempSync(join(tmpdir(), "datfile-test-")); + const path = join(dir, "m.json"); + writeFileSync( + path, + manifestJson([{ ...VERIFIED_PIN, downloadUrl: "" }]), + ); + expect( + await main(["--platform", "test-2600", "--manifest", path]), + ).toBe(2); + }); +}); diff --git a/tools/roms/fetch-datfile.ts b/tools/roms/fetch-datfile.ts new file mode 100644 index 0000000000..545ea5e8a8 --- /dev/null +++ b/tools/roms/fetch-datfile.ts @@ -0,0 +1,293 @@ +#!/usr/bin/env bun +// fetch-datfile.ts -- the datfile-as-dependency half of B-0083 (tracked as +// B-0083.1). Reads a pinned datfile manifest (tools/roms/manifests/datfiles.json), +// downloads the pinned datfile, verifies its SHA-256 against the pin, and writes +// it to a gitignored cache so tools/roms/canonicalize.ts can consume it via +// --datfile. This closes the B-0083 "Tooling refreshes on TOSEC datfile updates" +// + "Datfile-as-dependency (pin version + download + verify via SHA256)" +// acceptance criteria that siblings B-0272/B-0273 did not cover. +// +// Usage: +// bun tools/roms/fetch-datfile.ts --list +// bun tools/roms/fetch-datfile.ts --platform atari-2600 +// bun tools/roms/fetch-datfile.ts --platform atari-2600 --out roms/.datfiles +// +// Fails CLOSED on any <...> placeholder pin (per dep-pin-search-first-authority): +// an unverified downloadUrl/sha256 is refused, never written. The operator fills +// the verified values on the first network-enabled fetch. + +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +// --- Manifest model --- + +export interface DatfilePin { + readonly platform: string; + readonly source: string; + readonly release: string; + readonly datfileName: string; + readonly sourceUrl: string; + readonly downloadUrl: string; + readonly sha256: string; + /** + * Canonical roms/ subdirectory for this platform (e.g. "atari/2600"). + * Stated explicitly in the manifest rather than inferred from the slug: + * slug→path inference (replacing hyphens) is only correct for two-segment + * slugs like "atari-2600" and breaks for slugs like + * "nintendo-entertainment-system". + */ + readonly romsDir: string; +} + +interface RawManifest { + readonly datfiles?: readonly Partial[]; +} + +const REQUIRED_FIELDS: readonly (keyof DatfilePin)[] = [ + "platform", + "source", + "release", + "datfileName", + "sourceUrl", + "downloadUrl", + "sha256", + "romsDir", +]; + +/** + * Parse the datfiles manifest JSON into a platform-keyed map of pins. + * Throws on malformed JSON or entries missing required fields. Pure (no IO). + */ +export function parseManifest(json: string): ReadonlyMap { + let raw: RawManifest; + try { + raw = JSON.parse(json) as RawManifest; + } catch (e) { + throw new Error(`manifest is not valid JSON: ${(e as Error).message}`); + } + if (!Array.isArray(raw.datfiles)) { + throw new Error('manifest missing "datfiles" array'); + } + + const pins = new Map(); + for (const entry of raw.datfiles) { + for (const field of REQUIRED_FIELDS) { + if (typeof entry[field] !== "string" || entry[field] === "") { + throw new Error( + `manifest entry missing required string field "${field}": ` + + JSON.stringify(entry), + ); + } + } + const pin = entry as DatfilePin; + if (pins.has(pin.platform)) { + throw new Error(`duplicate platform in manifest: ${pin.platform}`); + } + pins.set(pin.platform, pin); + } + return pins; +} + +/** + * A pin value is a placeholder if it is wrapped in angle brackets, e.g. + * "". Placeholder values are unverified per + * dep-pin-search-first-authority and MUST NOT be acted on. Pure. + */ +export function isPlaceholder(value: string): boolean { + return value.startsWith("<") && value.endsWith(">"); +} + +/** Compute the lowercase hex SHA-256 of bytes. Pure. */ +export function sha256Hex(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +/** + * Verify bytes against an expected lowercase-hex SHA-256. Case-insensitive on + * the expected value. Pure. + */ +export function verifyChecksum( + bytes: Uint8Array, + expectedSha256: string, +): boolean { + return sha256Hex(bytes) === expectedSha256.toLowerCase(); +} + +/** + * Reasons a pin cannot be fetched. null => the pin is fetchable. + * Pure: surfaces the fail-closed decision without performing IO. + */ +export function fetchBlockReason(pin: DatfilePin): string | null { + if (isPlaceholder(pin.downloadUrl)) { + return ( + `downloadUrl for "${pin.platform}" is an unverified placeholder ` + + `(${pin.downloadUrl}). Per dep-pin-search-first-authority, download the ` + + `datpack from ${pin.sourceUrl}, locate "${pin.datfileName}", and record ` + + `its direct URL + sha256 in tools/roms/manifests/datfiles.json before fetching.` + ); + } + if (isPlaceholder(pin.sha256)) { + return ( + `sha256 for "${pin.platform}" is an unverified placeholder (${pin.sha256}). ` + + `Record the sha256sum of "${pin.datfileName}" in the manifest before fetching.` + ); + } + return null; +} + +// --- IO boundary --- + +export const DEFAULT_MANIFEST = "tools/roms/manifests/datfiles.json"; +export const DEFAULT_OUT_DIR = "roms/.datfiles"; + +export function loadManifest(path: string): ReadonlyMap { + return parseManifest(readFileSync(path, "utf8")); +} + +// --- CLI --- + +interface Args { + readonly manifest: string; + readonly outDir: string; + readonly platform: string | null; + readonly list: boolean; +} + +class ArgError extends Error { + readonly exitCode: number; + constructor(message: string, exitCode: number) { + super(message); + this.exitCode = exitCode; + } +} + +function parseArgs(argv: readonly string[]): Args { + let manifest = DEFAULT_MANIFEST; + let outDir = DEFAULT_OUT_DIR; + let platform: string | null = null; + let list = false; + + function readValue(index: number, flag: string): string { + const value = argv[index + 1]; + if (value === undefined) throw new ArgError(`missing value for ${flag}`, 64); + return value; + } + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--platform" || arg === "-p") { + platform = readValue(i, arg); + i++; + } else if (arg === "--manifest" || arg === "-m") { + manifest = readValue(i, arg); + i++; + } else if (arg === "--out" || arg === "-o") { + outDir = readValue(i, arg); + i++; + } else if (arg === "--list" || arg === "-l") { + list = true; + } else if (arg === "--help" || arg === "-h") { + process.stdout.write( + "Usage: fetch-datfile.ts [--platform ] [--manifest ] [--out ] [--list]\n\n" + + " --platform, -p Platform slug to fetch (e.g. atari-2600).\n" + + " --manifest, -m Manifest path (default: tools/roms/manifests/datfiles.json).\n" + + " --out, -o Output dir for the verified datfile (default: roms/.datfiles).\n" + + " --list, -l List pinned platforms and their verification status.\n", + ); + throw new ArgError("", 0); + } else { + throw new ArgError(`unknown arg: ${arg}`, 64); + } + } + + if (!list && platform === null) { + throw new ArgError("either --platform or --list is required", 64); + } + return { manifest, outDir, platform, list }; +} + +export async function main(argv: readonly string[]): Promise { + let args: Args; + try { + args = parseArgs(argv); + } catch (e) { + if (e instanceof ArgError) { + if (e.message) process.stderr.write(`${e.message}\n`); + return e.exitCode; + } + throw e; + } + + if (!existsSync(args.manifest)) { + process.stderr.write(`manifest not found: ${args.manifest}\n`); + return 1; + } + + let pins: ReadonlyMap; + try { + pins = loadManifest(args.manifest); + } catch (e) { + process.stderr.write(`${(e as Error).message}\n`); + return 1; + } + + if (args.list) { + for (const pin of pins.values()) { + const status = fetchBlockReason(pin) === null ? "verified" : "PINNED-UNVERIFIED"; + process.stdout.write( + `${pin.platform}\t${pin.source} ${pin.release}\t${status}\n`, + ); + } + return 0; + } + + const pin = pins.get(args.platform!); + if (!pin) { + process.stderr.write( + `no pin for platform "${args.platform}" in ${args.manifest}\n`, + ); + return 1; + } + + const block = fetchBlockReason(pin); + if (block !== null) { + process.stderr.write(`refusing to fetch (fail-closed): ${block}\n`); + return 2; + } + + process.stderr.write( + `fetching ${pin.datfileName} (${pin.source} ${pin.release}) from ${pin.downloadUrl}\n`, + ); + const response = await fetch(pin.downloadUrl); + if (!response.ok) { + process.stderr.write( + `download failed: HTTP ${response.status} ${response.statusText}\n`, + ); + return 1; + } + const bytes = new Uint8Array(await response.arrayBuffer()); + + if (!verifyChecksum(bytes, pin.sha256)) { + process.stderr.write( + `checksum MISMATCH for ${pin.datfileName}: ` + + `expected ${pin.sha256.toLowerCase()}, got ${sha256Hex(bytes)}. ` + + `Not writing (the pin or the upstream file changed).\n`, + ); + return 1; + } + + if (!existsSync(args.outDir)) mkdirSync(args.outDir, { recursive: true }); + const outPath = join(args.outDir, pin.datfileName); + writeFileSync(outPath, bytes); + process.stdout.write(outPath + "\n"); + process.stderr.write( + `verified + wrote ${bytes.length} bytes to ${outPath}\n` + + `next: bun tools/roms/canonicalize.ts --datfile "${outPath}" --dir roms/${pin.romsDir}\n`, + ); + return 0; +} + +if (import.meta.main) { + process.exit(await main(process.argv.slice(2))); +} diff --git a/tools/roms/manifests/datfiles.json b/tools/roms/manifests/datfiles.json new file mode 100644 index 0000000000..c684d9c04c --- /dev/null +++ b/tools/roms/manifests/datfiles.json @@ -0,0 +1,36 @@ +{ + "_comment": [ + "Pinned ROM datfile dependencies consumed by tools/roms/fetch-datfile.ts.", + "Each entry pins ONE datfile (Logiqx XML) by direct download URL + SHA-256.", + "The fetched + verified datfile is then fed to tools/roms/canonicalize.ts via", + "--datfile, closing the B-0083 'datfile-as-dependency / refresh-on-update'", + "acceptance criterion (tracked as B-0083.1).", + "", + "DEP-PIN DISCIPLINE (.claude/rules/dep-pin-search-first-authority.md): the", + "'release' + 'sourceUrl' fields are WebSearch-verified (TOSEC release", + "2025-03-13 is the latest as of 2026-05; no 2026 release exists yet). The", + "'downloadUrl' + 'sha256' fields require a network download to verify, which", + "is out of scope for the authoring environment, so they carry the explicit", + "<...-VERIFY-ON-FETCH> placeholder per dep-pin Anchor-3 (B-0802 pattern).", + "fetch-datfile.ts fails CLOSED on any <...> placeholder: it refuses to write", + "an unverified datfile. The operator fills the verified values on the first", + "network-enabled fetch (download the datpack, locate the per-platform .dat,", + "record its direct URL + sha256sum here).", + "", + "REFRESH CADENCE: when TOSEC publishes a new datpack, bump 'release' +", + "'sourceUrl' (WebSearch-verify the new date), then re-fill downloadUrl +", + "sha256 on the next fetch. Re-run canonicalize.ts against the refreshed dat." + ], + "datfiles": [ + { + "platform": "atari-2600", + "source": "TOSEC", + "release": "2025-03-13", + "datfileName": "Atari 2600 - Games (TOSEC).dat", + "sourceUrl": "https://www.tosecdev.org/news/releases/177-tosec-release-2025-03-13", + "downloadUrl": "", + "sha256": "", + "romsDir": "atari/2600" + } + ] +}