import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync, existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join, resolve, relative, sep } from "node:path"; import { walkJs } from "./_helpers.js"; // The sim's determinism rests on the engine drawing ALL randomness from the one // seeded rng (engine/rng.js) and never reading the wall clock. This guard scans // the engine for the usual nondeterminism leaks so a future edit can't quietly // reintroduce one — the kind of regression the same-seed-twice test can only // catch by luck. A line that is genuinely not the sim (an unseeded map default, // the render loop's wall clock) opts out with a `deterministic-exempt` comment. const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const engineDir = join(root, "engine"); const FORBIDDEN = /\bMath\.random\b|\bDate\.now\b|\bnew Date\b|\bperformance\.now\b/; const SPEC = /(?:import|export)[^"'`]*?from\s*["']([^"']+)["']|import\(\s*["']([^"']+)["']\s*\)|import\s+["']([^"']+)["']/g; // Extra scan roots: the files under tools/ that the SHIPPED APP actually loads. // // tools/ was once purely Node CLI benches, and the scan's "engine/ plus what engine/ imports" // boundary was exactly right for that. It is not any more. boot.js imports tools/selfplay.js, // competition.js and competitionWorker.js import tools/duelCore.js and tools/genome.js, and // playerFingerprint.js imports tools/genome.js — so today three "bench" files run inside a browser // Worker and decide the outcomes that become persisted Elo ratings. Nothing in engine/ imports // them, so the fixed-point walk below never reached them: structurally identical code was guarded // on one side of a directory line and completely unguarded on the other. // // Both halves of the guard are the right ones for these files. A Worker has no `document`, so a // DOM reference is a crash rather than a style violation; and a duel that does not replay // identically makes every rating derived from it meaningless. // // DERIVED, NOT LISTED. Walking out from index.html means a fourth tools/ file pulled into the app // tomorrow is covered the day it is imported, with no list to remember to update. function browserReachableToolsFiles() { const html = readFileSync(join(root, "index.html"), "utf8"); const entries = [...html.matchAll(/]*type="module"[^>]*src="([^"]+)"/g)].map(m => resolve(root, m[1])); // competitionWorker.js is reached by `new Worker(new URL(...))`, not by an import statement, so // the walk cannot see that edge — seed it explicitly, exactly as static-integrity.test.js // documents for the same file. It is the module that pulls in duelCore/genome for real. const workerEntry = join(root, "competitionWorker.js"); const queue = [...entries, ...(existsSync(workerEntry) ? [workerEntry] : [])]; const seen = new Set(queue); while (queue.length) { const file = queue.pop(); if (!existsSync(file)) continue; for (const m of readFileSync(file, "utf8").matchAll(SPEC)) { const path = m[1] || m[2] || m[3]; if (!path || !path.startsWith(".")) continue; const abs = resolve(dirname(file), path); if (seen.has(abs) || !existsSync(abs)) continue; seen.add(abs); queue.push(abs); } } return [...seen].filter(f => relative(root, f).startsWith("tools" + sep)).sort(); } // The purity rules are transitive: the engine's determinism guarantee is only as strong as the // modules it imports. `data.js` lives at the repo ROOT but is imported by nine engine modules // (and is the repo's second-most-imported file), so a `Date.now()`-seeded default in a PLANETS // entry would break same-seed replays while this guard stayed green and determinism.test.js // failed cryptically, pointing away from the culprit. So: walk engine/ at every depth, then // follow relative imports out of it to their fixed point. A side benefit — if an engine file // ever imports a UI module, that module's DOM references fail the scan below, which is exactly // the right alarm for an engine→view leak. function purityScanFiles() { const files = [...walkJs(engineDir), ...browserReachableToolsFiles()]; const seen = new Set(files); for (let i = 0; i < files.length; i++) { const src = readFileSync(files[i], "utf8"); for (const m of src.matchAll(SPEC)) { const path = m[1] || m[2]; if (!path || !path.startsWith(".")) continue; const abs = resolve(dirname(files[i]), path); if (!abs.endsWith(".js") || seen.has(abs) || !existsSync(abs)) continue; seen.add(abs); files.push(abs); } } return files; } const label = f => relative(root, f); test("the purity scan reaches every engine file at any depth, plus what the engine imports", () => { // A guard on the guards below. Both scans used a non-recursive readdirSync over engine/ only: // a file at engine/net/lockstep.js calling Math.random, Date.now, window.localStorage AND // document passed all of them. This pins the file set itself so that can't come back. const scanned = purityScanFiles().map(label); assert.ok(scanned.includes("engine/sim.js"), "the scan must cover engine/ itself"); assert.ok(scanned.includes("data.js"), "the scan must follow the engine's own imports out of engine/ — data.js is imported by nine engine modules"); assert.deepEqual( walkJs(engineDir).map(label).filter(f => !scanned.includes(f)), [], "every .js under engine/, at any depth, must be scanned"); }); test("the scan also covers every tools/ file the shipped app loads, and knows which those are", () => { // The guard that keeps the tools/ extension honest in BOTH directions. // // Downward: these three really are browser code today (boot.js -> selfplay.js; competition.js // and competitionWorker.js -> duelCore.js + genome.js; playerFingerprint.js -> genome.js), and a // refactor that stops importing one should make this list shrink visibly rather than silently // dropping a file out of the purity net while it is still shipping. // // Upward: the Node-only benches must STAY out. tools/ailab.js imports node:fs and legitimately // calls Date.now for wall-clock run timing; sweeping it in would fail the scan for no reason and // teach the next contributor that the purity guard cries wolf. const reachable = browserReachableToolsFiles().map(label); assert.deepEqual(reachable, ["tools/duelCore.js", "tools/genome.js", "tools/selfplay.js"], "the set of tools/ files the browser loads changed — if that is intended, update this list; " + "if not, a Node-only bench has been pulled into the shipped app"); const scanned = purityScanFiles().map(label); for (const f of reachable) assert.ok(scanned.includes(f), `${f} ships to the browser but is not purity-scanned`); assert.ok(!scanned.includes("tools/ailab.js"), "tools/ailab.js is a Node CLI bench (node:fs, wall-clock timing) and must stay outside the scan"); }); test("engine/ contains no unsanctioned nondeterminism (Math.random / Date / performance.now)", () => { const offenders = []; for (const file of purityScanFiles()) { readFileSync(file, "utf8").split("\n").forEach((line, i) => { if (FORBIDDEN.test(line) && !line.includes("deterministic-exempt")) { offenders.push(`${label(file)}:${i + 1} ${line.trim()}`); } }); } assert.deepEqual(offenders, [], "nondeterministic call(s) in engine/ — route randomness through a seeded rng, or mark the line " + "`deterministic-exempt` if it genuinely isn't the sim:\n" + offenders.join("\n")); }); // The engine is meant to be pure logic — no DOM, no browser globals — so the whole sim // runs headless under `node --test` and could one day run server-side (netcode, replays). // This guard catches a DOM/browser dependency creeping in (a stray `document.querySelector`, // a `localStorage` read, a `fetch`). Unlike the determinism guard above it scans CODE ONLY: // words like "window" (grace window) and "fetch" (fetch a colony ship) appear legitimately in // prose, so comments are stripped first (newline-preserving, to keep line numbers). The one // sanctioned browser seam — loop.js's requestAnimationFrame render driver, which touches no sim // state — opts each line out with a `browser-exempt` marker. const BROWSER_GLOBAL = /\b(document|window|localStorage|sessionStorage|navigator|XMLHttpRequest|requestAnimationFrame|cancelAnimationFrame|alert|history|location)\b|\bfetch\s*\(/; // Blank out comments while preserving line count and columns: block comments become spaces // (newlines kept), line comments are trimmed to end-of-line. Good enough for a source guard — // engine code never hides a DOM call inside a string literal. function stripComments(src) { return src .replace(/\/\*[\s\S]*?\*\//g, m => m.replace(/[^\n]/g, " ")) .split("\n").map(l => l.replace(/\/\/.*$/, "")).join("\n"); } test("engine/ has no DOM or browser-global dependency (stays headless-pure)", () => { const offenders = []; for (const file of purityScanFiles()) { const raw = readFileSync(file, "utf8").split("\n"); stripComments(raw.join("\n")).split("\n").forEach((code, i) => { // Match on the comment-free code, but read the exempt marker off the original line // (the marker lives in a comment, which stripping removed). if (BROWSER_GLOBAL.test(code) && !raw[i].includes("browser-exempt")) { offenders.push(`${label(file)}:${i + 1} ${raw[i].trim()}`); } }); } assert.deepEqual(offenders, [], "browser/DOM reference(s) in engine/ — the engine must stay pure logic (move UI code to a " + "non-engine module), or mark a genuine render-loop seam `browser-exempt`:\n" + offenders.join("\n")); });