Spaces:
Paused
Paused
File size: 9,832 Bytes
4f1cb54 9af10ec 4f1cb54 021c865 9af10ec 4f1cb54 9af10ec 4f1cb54 9af10ec 4f1cb54 021c865 9af10ec 021c865 9af10ec 4f1cb54 021c865 4f1cb54 9af10ec 4f1cb54 9af10ec 4f1cb54 302a725 9af10ec 302a725 9af10ec 302a725 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 | 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(/<script[^>]*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"));
});
|