feat(thressgame-coverage): Wave 0-1 foundation (ADR + baseline + harness + audits)

Wave 0:
- T0: Architectural decisions (10 sections, 215 lines) + 5-rule paper exercise

Wave 1 (parallel):
- T1: Backward-compat baseline fixture (1961 tests / 167 files snapshot + regression guard)
- T2: Determinism property-test harness (runDeterminismCheck, N=100 default, 1.7s)
- T3: State-hash util (SHA256 of session.allFacts, insertion-order independent)
- T4: Position-attr caller audit (75 prod callsites classified, 17 fixes seeded for T6/T7)
- T5: $var conflict audit (CLEAN — T12 binding shape safe)

Tests: 1961 -> 1970 (+9). bun run check exits 0. No production source modified.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-26 08:16:26 -06:00
commit 2368a24b15
No known key found for this signature in database
14 changed files with 3435 additions and 0 deletions

View file

@ -0,0 +1,30 @@
[
{ "kind": "absorb-damage-with-attribute", "file": "absorb-damage-with-attribute.test.ts", "testCount": 4, "expectCount": 8 },
{ "kind": "add-aura", "file": "add-aura.test.ts", "testCount": 4, "expectCount": 6 },
{ "kind": "add-direction", "file": "add-direction.test.ts", "testCount": 7, "expectCount": 8 },
{ "kind": "add-to-attribute", "file": "add-to-attribute.test.ts", "testCount": 4, "expectCount": 5 },
{ "kind": "block-move-type", "file": "block-move-type.test.ts", "testCount": 4, "expectCount": 5 },
{ "kind": "conditional", "file": "conditional.test.ts", "testCount": 6, "expectCount": 7 },
{ "kind": "consumer-integration", "file": "consumer-integration.test.ts", "testCount": 6, "expectCount": 11 },
{ "kind": "context", "file": "context.test.ts", "testCount": 13, "expectCount": 20 },
{ "kind": "docs", "file": "docs.test.ts", "testCount": 3, "expectCount": 8 },
{ "kind": "manifest", "file": "manifest.test.ts", "testCount": 5, "expectCount": 14 },
{ "kind": "modify-movement-range", "file": "modify-movement-range.test.ts", "testCount": 4, "expectCount": 5 },
{ "kind": "multiply-attribute", "file": "multiply-attribute.test.ts", "testCount": 4, "expectCount": 5 },
{ "kind": "on-captured", "file": "on-captured.test.ts", "testCount": 10, "expectCount": 23 },
{ "kind": "on-capture", "file": "on-capture.test.ts", "testCount": 4, "expectCount": 5 },
{ "kind": "on-check-delivered", "file": "on-check-delivered.test.ts", "testCount": 8, "expectCount": 10 },
{ "kind": "on-check-received", "file": "on-check-received.test.ts", "testCount": 5, "expectCount": 9 },
{ "kind": "on-damaged", "file": "on-damaged.test.ts", "testCount": 4, "expectCount": 5 },
{ "kind": "on-moved-onto-square", "file": "on-moved-onto-square.test.ts", "testCount": 14, "expectCount": 15 },
{ "kind": "on-move", "file": "on-move.test.ts", "testCount": 7, "expectCount": 10 },
{ "kind": "on-promotion", "file": "on-promotion.test.ts", "testCount": 6, "expectCount": 9 },
{ "kind": "on-turn-end", "file": "on-turn-end.test.ts", "testCount": 6, "expectCount": 7 },
{ "kind": "on-turn-start", "file": "on-turn-start.test.ts", "testCount": 4, "expectCount": 5 },
{ "kind": "override-promotion", "file": "override-promotion.test.ts", "testCount": 4, "expectCount": 7 },
{ "kind": "reflect-damage", "file": "reflect-damage.test.ts", "testCount": 4, "expectCount": 6 },
{ "kind": "registry-count", "file": "registry-count.test.ts", "testCount": 2, "expectCount": 4 },
{ "kind": "registry", "file": "registry.test.ts", "testCount": 6, "expectCount": 8 },
{ "kind": "seed-attribute", "file": "seed-attribute.test.ts", "testCount": 4, "expectCount": 5 },
{ "kind": "set-capture-flag", "file": "set-capture-flag.test.ts", "testCount": 4, "expectCount": 5 }
]

View file

@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs/promises";
import path from "node:path";
describe("backward-compat baseline regression", () => {
let baseline: { files: number; tests: number; timestamp: string };
let primitiveBaseline: Array<{
kind: string;
file: string;
testCount: number;
expectCount: number;
}>;
it("loads baseline JSON files", async () => {
const baselineDir = path.dirname(import.meta.url.replace("file://", ""));
const baselinePath = path.join(baselineDir, "baseline-test-count.json");
const primitivePath = path.join(baselineDir, "baseline-primitive-tests.json");
const baselineData = await fs.readFile(baselinePath, "utf8");
const primitiveData = await fs.readFile(primitivePath, "utf8");
baseline = JSON.parse(baselineData);
primitiveBaseline = JSON.parse(primitiveData);
expect(baseline).toBeDefined();
expect(primitiveBaseline).toBeDefined();
});
it("primitive baseline JSON shape is well-formed", async () => {
const baselineDir = path.dirname(import.meta.url.replace("file://", ""));
const primitivePath = path.join(baselineDir, "baseline-primitive-tests.json");
const primitiveData = await fs.readFile(primitivePath, "utf8");
primitiveBaseline = JSON.parse(primitiveData);
expect(Array.isArray(primitiveBaseline)).toBe(true);
expect(primitiveBaseline.length).toBeGreaterThanOrEqual(1);
for (const entry of primitiveBaseline) {
expect(typeof entry.kind).toBe("string");
expect(typeof entry.file).toBe("string");
expect(entry.testCount).toBeGreaterThanOrEqual(1);
expect(entry.expectCount).toBeGreaterThanOrEqual(1);
}
});
it("baseline-test-count.json has expected shape", async () => {
const baselineDir = path.dirname(import.meta.url.replace("file://", ""));
const baselinePath = path.join(baselineDir, "baseline-test-count.json");
const baselineData = await fs.readFile(baselinePath, "utf8");
baseline = JSON.parse(baselineData);
expect(baseline.tests).toBeGreaterThanOrEqual(1324);
expect(baseline.files).toBeGreaterThanOrEqual(1);
expect(typeof baseline.timestamp).toBe("string");
});
it("each primitive .test.ts file still meets-or-exceeds its baseline", async () => {
const baselineDir = path.dirname(import.meta.url.replace("file://", ""));
const primitivePath = path.join(baselineDir, "baseline-primitive-tests.json");
const primitiveData = await fs.readFile(primitivePath, "utf8");
primitiveBaseline = JSON.parse(primitiveData);
const dir = path.resolve(baselineDir, "..", "modifiers", "primitives");
for (const entry of primitiveBaseline) {
const filePath = path.join(dir, entry.file);
const content = await fs.readFile(filePath, "utf8");
const itMatches = content.match(/^\s*(it|test)\(/gm) ?? [];
const expectMatches = content.match(/expect\(/g) ?? [];
expect(
itMatches.length,
`${entry.file} test count regressed: expected >= ${entry.testCount}, got ${itMatches.length}`
).toBeGreaterThanOrEqual(entry.testCount);
expect(
expectMatches.length,
`${entry.file} expect count regressed: expected >= ${entry.expectCount}, got ${expectMatches.length}`
).toBeGreaterThanOrEqual(entry.expectCount);
}
});
});

View file

@ -0,0 +1,5 @@
{
"files": 167,
"tests": 1961,
"timestamp": "2026-04-26T06:29:52Z"
}

View file

@ -0,0 +1,85 @@
import { describe, it, expect } from "vitest";
import { ChessEngine } from "../../engine";
import { runDeterminismCheck } from "./harness";
describe("runDeterminismCheck — standard chess sanity", () => {
it("standard chess is deterministic across 100 iterations (e2-e4, e7-e5)", () => {
const setup = () => new ChessEngine();
const moves = [
(engine: ChessEngine) => {
// White e2 (square 12) → e4 (square 28).
const legal = engine.getAllLegalMoves();
const m = legal.find((mv) => mv.from === 12 && mv.to === 28);
if (m === undefined) throw new Error("e2-e4 not found in legal moves");
engine.applyMove(m);
},
(engine: ChessEngine) => {
// Black e7 (square 52) → e5 (square 36).
const legal = engine.getAllLegalMoves();
const m = legal.find((mv) => mv.from === 52 && mv.to === 36);
if (m === undefined) throw new Error("e7-e5 not found in legal moves");
engine.applyMove(m);
},
];
const start = Date.now();
const result = runDeterminismCheck(setup, moves, 100);
const elapsed = Date.now() - start;
expect(result.matches).toBe(true);
expect(result.iterations).toBe(100);
expect(result.hash).toMatch(/^[0-9a-f]{64}$/);
expect(result.mismatchAt).toBeUndefined();
expect(result.mismatchHash).toBeUndefined();
// Plan acceptance: 100 iterations must complete in < 5000ms.
expect(elapsed).toBeLessThan(5000);
});
it("default iterations = 100 when not specified", () => {
const setup = () => new ChessEngine();
const result = runDeterminismCheck(setup, []);
expect(result.iterations).toBe(100);
expect(result.matches).toBe(true);
});
it("empty moves list still hashes the fresh engine deterministically", () => {
const setup = () => new ChessEngine();
const result = runDeterminismCheck(setup, [], 10);
expect(result.matches).toBe(true);
expect(result.iterations).toBe(10);
expect(result.hash).toMatch(/^[0-9a-f]{64}$/);
});
it("rejects iterations < 1", () => {
expect(() =>
runDeterminismCheck(() => new ChessEngine(), [], 0),
).toThrow(/iterations must be >= 1/);
expect(() =>
runDeterminismCheck(() => new ChessEngine(), [], -1),
).toThrow(/iterations must be >= 1/);
});
it("detects non-determinism (negative test) and reports first divergent iteration", () => {
// Synthetic mismatch: iteration 0 builds a plain engine; iteration 1+
// spawns an extra pawn. The harness must catch the divergence on
// iteration 1 and return mismatchAt=1 plus the divergent hash.
let callCount = 0;
const setup = () => {
const engine = new ChessEngine();
if (callCount++ > 0) {
// Inject one extra fact set on iteration 1+; iteration 0 is the
// clean reference.
engine.spawnPiece("pawn", "white", 20);
}
return engine;
};
const result = runDeterminismCheck(setup, [], 5);
expect(result.matches).toBe(false);
expect(result.iterations).toBe(5);
expect(result.mismatchAt).toBe(1);
expect(result.hash).toMatch(/^[0-9a-f]{64}$/);
expect(result.mismatchHash).toMatch(/^[0-9a-f]{64}$/);
expect(result.mismatchHash).not.toBe(result.hash);
});
});

View file

@ -0,0 +1,98 @@
import type { ChessEngine } from "../../engine";
import { hashEngineState } from "../../util/state-hash";
/**
* Build a fresh ChessEngine in initial state.
*
* Called once per iteration so that iteration N cannot leak any state
* (closures, caches, RNG cursors, accumulated facts) into iteration N+1.
* If the setup function captures mutable closure state, the harness will
* detect non-determinism and report it via {@link DeterminismResult.matches}.
*/
export type SetupFn = () => ChessEngine;
/**
* Apply a single deterministic mutation to the given engine.
*
* Whatever the engine's actual move-application API is — typically
* `engine.applyMove(legalMove)` after looking the move up via
* `engine.getAllLegalMoves()`. The MoveFn closes over its own input
* lookups (e.g. a from/to pair) and is responsible for resolving them
* fresh against the engine on every iteration.
*/
export type MoveFn = (engine: ChessEngine) => void;
export interface DeterminismResult {
/** SHA256 hex of the final state from iteration 0 (the reference). */
readonly hash: string;
/** True iff every iteration produced the same hash. */
readonly matches: boolean;
/** Number of iterations the harness was asked to run. */
readonly iterations: number;
/** First iteration index that diverged. Set only when `matches === false`. */
readonly mismatchAt?: number;
/** Hash from the first divergent iteration. Set only when `matches === false`. */
readonly mismatchHash?: string;
}
/**
* Run the same `setup → moves → hash` pipeline N times and assert the final
* state hash is byte-identical across every iteration.
*
* The harness contract:
* 1. Call `setup()` to build a fresh engine.
* 2. For each `move` in `moves`, invoke `move(engine)`.
* 3. Compute `hashEngineState(engine)`.
* 4. Compare to the reference hash from iteration 0.
* 5. On any mismatch, return immediately with `matches: false` plus the
* iteration index and divergent hash for diagnostic surfacing.
*
* No `Math.random` / `Date.now` / unsorted iteration is used internally —
* the harness itself is deterministic by construction. Any non-determinism
* MUST come from the setup or moves (which is exactly what we're testing).
*
* Default iterations = 100, matching the plan's N=100 byte-identical invariant
* (`.sisyphus/notepads/thressgame-coverage/decisions.md` "Seeded RNG").
*
* @param setup Fresh-engine factory; called once per iteration.
* @param moves Sequence of mutations applied in order to each fresh engine.
* Empty array is legal — produces a "fresh-engine-only" hash.
* @param iterations Number of iterations to run (default 100; must be ≥ 1).
* @returns Result with reference hash and match status.
* @throws If `iterations < 1`.
*/
export function runDeterminismCheck(
setup: SetupFn,
moves: readonly MoveFn[],
iterations = 100,
): DeterminismResult {
if (iterations < 1) {
throw new Error(
`runDeterminismCheck: iterations must be >= 1, got ${iterations}`,
);
}
let referenceHash: string | undefined;
for (let i = 0; i < iterations; i++) {
const engine = setup();
for (const move of moves) move(engine);
const hash = hashEngineState(engine);
if (referenceHash === undefined) {
referenceHash = hash;
continue;
}
if (hash !== referenceHash) {
return {
hash: referenceHash,
matches: false,
iterations,
mismatchAt: i,
mismatchHash: hash,
};
}
}
// referenceHash is defined here because iterations >= 1 means the loop
// ran at least once and assigned it.
return { hash: referenceHash as string, matches: true, iterations };
}

View file

@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { ChessEngine } from "../engine";
import { hashEngineState } from "./state-hash";
describe("hashEngineState", () => {
it("returns 64-char lowercase hex", () => {
const engine = new ChessEngine();
const hash = hashEngineState(engine);
expect(hash).toMatch(/^[0-9a-f]{64}$/);
});
it("identical state produces identical hash", () => {
const a = new ChessEngine();
const b = new ChessEngine();
expect(hashEngineState(a)).toBe(hashEngineState(b));
});
it("one fact difference produces different hash", () => {
const a = new ChessEngine();
const b = new ChessEngine();
// Spawn a pawn on b only, introducing one fact difference.
b.spawnPiece("pawn", "white", 12);
const hashA = hashEngineState(a);
const hashB = hashEngineState(b);
expect(hashA).not.toBe(hashB);
});
it("insertion-order independence (same entity, different attr insertion order)", () => {
// Test that inserting attributes for the same entity in different orders
// produces the same hash. Since allFacts() sorts by [id, attr], the order
// of insertion doesn't affect the final state representation.
const e1 = new ChessEngine();
const id1 = e1.session.nextId();
e1.session.insert(id1, "Color", "white");
e1.session.insert(id1, "PieceType", "pawn");
e1.session.insert(id1, "Position", 12);
const hash1 = hashEngineState(e1);
// Replay with different insertion order
const e2 = new ChessEngine();
const id2 = e2.session.nextId();
e2.session.insert(id2, "Position", 12);
e2.session.insert(id2, "Color", "white");
e2.session.insert(id2, "PieceType", "pawn");
const hash2 = hashEngineState(e2);
// Yet another order
const e3 = new ChessEngine();
const id3 = e3.session.nextId();
e3.session.insert(id3, "PieceType", "pawn");
e3.session.insert(id3, "Position", 12);
e3.session.insert(id3, "Color", "white");
const hash3 = hashEngineState(e3);
expect(hash1).toBe(hash2);
expect(hash2).toBe(hash3);
});
});

View file

@ -0,0 +1,50 @@
import { createHash } from "node:crypto";
import type { ChessEngine } from "../engine";
/**
* Deterministic SHA256 hash of a ChessEngine's full session state.
*
* Insertion order independent: same facts hash equally regardless of order.
* Two engines with identical facts MUST produce identical hashes; any single
* fact difference MUST produce a different hash.
*
* Algorithm:
* 1. Enumerate all facts via session.allFacts() (already sorted: id asc, attr asc).
* 2. Group facts by entity id.
* 3. For each entity id (in sorted order): stringify { id, facts: [[attr, value], ...] }
* where facts are sorted by attr name (already sorted by session).
* 4. Concatenate all stringified entries with newlines.
* 5. SHA256 the concatenation, return hex digest.
*
* Usage: determinism property tests (T2), parity tests (T59+), debugging.
*/
export function hashEngineState(engine: ChessEngine): string {
const session = engine.session;
const facts = session.allFacts();
// Group facts by entity id. Since allFacts() returns facts sorted by
// [id asc, attr asc], we can stream through once and accumulate.
const factsByEntity = new Map<number, Array<[string, unknown]>>();
for (const fact of facts) {
const id = fact.id as number;
if (!factsByEntity.has(id)) {
factsByEntity.set(id, []);
}
factsByEntity.get(id)!.push([fact.attr, fact.value]);
}
// Sort entity ids numerically (ascending) and build deterministic strings.
const entityIds = Array.from(factsByEntity.keys()).sort((a, b) => a - b);
const parts: string[] = [];
for (const id of entityIds) {
const facts = factsByEntity.get(id)!;
// Facts are already sorted by attr due to session.allFacts() guarantees.
// Stringify each entity record as { id, facts: [[attr, val], ...] }.
parts.push(JSON.stringify({ id, facts }));
}
// Concatenate and hash.
const blob = parts.join("\n");
return createHash("sha256").update(blob).digest("hex");
}