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:
parent
9e31b6d682
commit
2368a24b15
14 changed files with 3435 additions and 0 deletions
|
|
@ -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 }
|
||||
]
|
||||
78
packages/chess/src/__fixtures__/baseline-regression.test.ts
Normal file
78
packages/chess/src/__fixtures__/baseline-regression.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
5
packages/chess/src/__fixtures__/baseline-test-count.json
Normal file
5
packages/chess/src/__fixtures__/baseline-test-count.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"files": 167,
|
||||
"tests": 1961,
|
||||
"timestamp": "2026-04-26T06:29:52Z"
|
||||
}
|
||||
85
packages/chess/src/__fixtures__/determinism/harness.test.ts
Normal file
85
packages/chess/src/__fixtures__/determinism/harness.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
98
packages/chess/src/__fixtures__/determinism/harness.ts
Normal file
98
packages/chess/src/__fixtures__/determinism/harness.ts
Normal 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 };
|
||||
}
|
||||
61
packages/chess/src/util/state-hash.test.ts
Normal file
61
packages/chess/src/util/state-hash.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
50
packages/chess/src/util/state-hash.ts
Normal file
50
packages/chess/src/util/state-hash.ts
Normal 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");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue