Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,30 @@ 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
---

# 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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <path>`) 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: <https://www.tosecdev.org/news/releases/177-tosec-release-2025-03-13>,
<https://mail.tosecdev.org/downloads/category/59-2025-03-13>.

## 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.
184 changes: 184 additions & 0 deletions tools/roms/fetch-datfile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
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",
};

function manifestJson(pins: readonly Partial<DatfilePin>[]): 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 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("<SHA256-VERIFY-ON-FETCH>")).toBe(true);
expect(isPlaceholder("<DIRECT-DAT-URL-VERIFY-ON-FETCH>")).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: "<DIRECT-DAT-URL-VERIFY-ON-FETCH>" };
expect(fetchBlockReason(pin)).toMatch(/downloadUrl.*placeholder/);
});

test("blocks a placeholder sha256", () => {
const pin = { ...VERIFIED_PIN, sha256: "<SHA256-VERIFY-ON-FETCH>" };
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: "<placeholder>" }]),
);
expect(
await main(["--platform", "test-2600", "--manifest", path]),
).toBe(2);
});
});
Loading
Loading