diff --git a/docs/backlog/P1/B-0272-atari-rom-canonical-naming-tosec-lookup-2026-05-08.md b/docs/backlog/P1/B-0272-atari-rom-canonical-naming-tosec-lookup-2026-05-08.md
index 87ac963323..7424bd492c 100644
--- a/docs/backlog/P1/B-0272-atari-rom-canonical-naming-tosec-lookup-2026-05-08.md
+++ b/docs/backlog/P1/B-0272-atari-rom-canonical-naming-tosec-lookup-2026-05-08.md
@@ -4,7 +4,7 @@ priority: P1
status: open
title: "Atari 2600 ROM canonical naming via TOSEC/No-Intro hash lookup"
created: 2026-05-08
-last_updated: 2026-05-08
+last_updated: 2026-05-09
parent: B-0083
depends_on: []
classification: buildable-now
diff --git a/tools/roms/canonicalize.test.ts b/tools/roms/canonicalize.test.ts
index f7472d63f9..7f9e851afb 100644
--- a/tools/roms/canonicalize.test.ts
+++ b/tools/roms/canonicalize.test.ts
@@ -2,7 +2,13 @@ import { describe, expect, test } from "bun:test";
import { existsSync, mkdtempSync, writeFileSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
-import { parseDatfile, hashFileSha1, scanRomFiles, matchAndReport } from "./canonicalize.ts";
+import {
+ main,
+ parseDatfile,
+ hashFileSha1,
+ scanRomFiles,
+ matchAndReport,
+} from "./canonicalize.ts";
const FIXTURE_DATFILE = `
@@ -12,7 +18,7 @@ const FIXTURE_DATFILE = `
Combat (1977)(Atari)
-
+
Adventure (1980)(Atari)
@@ -92,6 +98,8 @@ describe("scanRomFiles", () => {
const tmp = mkdtempSync(join(tmpdir(), "rom-scan-"));
writeFileSync(join(tmp, "notes.txt"), "data");
writeFileSync(join(tmp, "save.sav"), "data");
+ writeFileSync(join(tmp, "README"), "data");
+ writeFileSync(join(tmp, ".DS_Store"), "data");
const files = scanRomFiles(tmp);
expect(files.length).toBe(0);
@@ -179,3 +187,27 @@ describe("matchAndReport", () => {
expect(results[0]?.renamed).toBe(false);
});
});
+
+describe("main", () => {
+ test("reports missing datfile value clearly", () => {
+ const originalStderrWrite = process.stderr.write;
+ const originalExit = process.exit;
+ let stderr = "";
+
+ process.stderr.write = ((chunk: string | Uint8Array) => {
+ stderr += String(chunk);
+ return true;
+ }) as typeof process.stderr.write;
+ process.exit = ((code?: number) => {
+ throw new Error(`exit:${code}`);
+ }) as typeof process.exit;
+
+ try {
+ expect(() => main(["--datfile", "--dir", "/tmp"])).toThrow("exit:64");
+ expect(stderr).toContain("missing value for --datfile");
+ } finally {
+ process.stderr.write = originalStderrWrite;
+ process.exit = originalExit;
+ }
+ });
+});
diff --git a/tools/roms/canonicalize.ts b/tools/roms/canonicalize.ts
index 82e9b7b3d9..01e920ac78 100644
--- a/tools/roms/canonicalize.ts
+++ b/tools/roms/canonicalize.ts
@@ -6,17 +6,21 @@
// bun tools/roms/canonicalize.ts --datfile --dir
// bun tools/roms/canonicalize.ts --datfile --dir --apply
//
-// Output (default dry-run): JSON array of { file, sha1, match, canonicalName }.
+// Output (default dry-run): JSON array of
+// { file, sha1, matched, canonicalName, renamed }.
// --apply: renames matched files to their canonical names.
import { createHash } from "node:crypto";
import {
+ closeSync,
+ openSync,
readdirSync,
readFileSync,
+ readSync,
renameSync,
existsSync,
} from "node:fs";
-import { basename, extname, join } from "node:path";
+import { basename, dirname, extname, join } from "node:path";
// --- Datfile parsing (Logiqx XML) ---
@@ -132,8 +136,21 @@ export function parseDatfile(xml: string): ReadonlyMap {
// --- File hashing ---
export function hashFileSha1(path: string): string {
- const data = readFileSync(path);
- return createHash("sha1").update(data).digest("hex");
+ const hash = createHash("sha1");
+ const fd = openSync(path, "r");
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
+ try {
+ let bytesRead = 0;
+ do {
+ bytesRead = readSync(fd, buffer, 0, buffer.length, null);
+ if (bytesRead > 0) {
+ hash.update(buffer.subarray(0, bytesRead));
+ }
+ } while (bytesRead > 0);
+ } finally {
+ closeSync(fd);
+ }
+ return hash.digest("hex");
}
// --- Directory scanning ---
@@ -143,7 +160,7 @@ export function scanRomFiles(dir: string): readonly string[] {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isFile()) continue;
const ext = extname(entry.name).toLowerCase();
- if (ROM_EXTENSIONS.has(ext) || ext === "") {
+ if (ROM_EXTENSIONS.has(ext)) {
files.push(join(dir, entry.name));
}
}
@@ -189,7 +206,7 @@ export function matchAndReport(
let renamed = false;
if (apply && !alreadyCorrect) {
- const dir = filePath.slice(0, filePath.length - currentName.length);
+ const dir = dirname(filePath);
if (!isSafeCanonicalName(canonicalName)) {
process.stderr.write(
`skip: unsafe canonical name from datfile: ${canonicalName}\n`,
@@ -241,12 +258,23 @@ function parseArgs(argv: readonly string[]): Args {
let dir: string | undefined;
let apply = false;
+ function readOptionValue(index: number, flag: string): string {
+ const value = argv[index + 1];
+ if (value === undefined || value.startsWith("-")) {
+ process.stderr.write(`missing value for ${flag}\n`);
+ process.exit(64);
+ }
+ return value;
+ }
+
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--datfile" || arg === "-d") {
- datfile = argv[++i];
+ datfile = readOptionValue(i, arg);
+ i++;
} else if (arg === "--dir") {
- dir = argv[++i];
+ dir = readOptionValue(i, arg);
+ i++;
} else if (arg === "--apply") {
apply = true;
} else if (arg === "--help" || arg === "-h") {