Bug class (RR-GAP-003/007): the levels list existed twice — hardcoded in src/main.ts (CONFIGMANIFEST) and hardcoded in scripts/validate-configs.mjs. Adding a level required editing both source files; forgetting either side produced silent drift (new level never boots, or a broken level passes CI).
Bug class (RR-GAP-003/007): the levels list existed twice — hardcoded in src/main.ts (CONFIG_MANIFEST) and hardcoded in scripts/validate-configs.mjs. Adding a level required editing both source files; forgetting either side produced silent drift (new level never boots, or a broken level passes CI).
Fix: delete both hardcoded lists. The filesystem becomes the single source of truth, discovered by two mechanisms that agree on directory, filter, and sort order:
| side | mechanism | phase |
|---|---|---|
src/main.ts |
import.meta.glob("../configs/levels/*.json", { eager: true, import: "default" }) |
build-time (vite/client typed) |
scripts/validate-configs.mjs |
readdirSync(LEVELS_DIR).filter(f => f.endsWith(".json")).sort() |
CI/runtime |
src/main.ts (with shared src/types.ts):
import type { LevelConfig } from "./types";
// Build-time discovery — no hand-maintained list. eager: true inlines every
// JSON payload into the bundle (no TLA, no runtime fetch); import:"default"
// unwraps the module namespace. vite/client types the call; the cast narrows.
const levelModules = import.meta.glob("../configs/levels/*.json", {
eager: true,
import: "default",
}) as Record<string, LevelConfig>;
// "../../configs/levels/level-01.json" -> "level-01" (browser-safe, no node:path)
function toId(modulePath: string): string {
return modulePath.slice(modulePath.lastIndexOf("/") + 1).replace(/\.json$/, "");
}
// Derived manifest — keys = ids, values = parsed configs. Sorted with the
// same default comparator the validator uses, so boot order == CI order.
export const CONFIG_MANIFEST: Record<string, LevelConfig> = Object.fromEntries(
Object.entries(levelModules)
.map(([modulePath, config]) => [toId(modulePath), config])
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
);
export function bootLevels(): string[] {
return Object.keys(CONFIG_MANIFEST).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
}
scripts/validate-configs.mjs (plain ESM, no TS):
import { readdirSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const LEVELS_DIR = path.join(ROOT, "configs", "levels");
// Auto-discover the same directory main.ts globs; readdirSync order is
// fs-dependent, so .sort() normalizes it to match boot order exactly.
const levelFiles = readdirSync(LEVELS_DIR)
.filter((file) => file.endsWith(".json"))
.sort();
function validateLevel(file) {
const raw = JSON.parse(readFileSync(path.join(LEVELS_DIR, file), "utf8"));
const errors = [];
if (typeof raw.id !== "string" || raw.id.length === 0) errors.push("id must be a non-empty string");
if (typeof raw.name !== "string" || raw.name.length === 0) errors.push("name must be a non-empty string");
if (!Number.isInteger(raw.difficulty) || raw.difficulty < 1) errors.push("difficulty must be int >= 1");
if (!Number.isInteger(raw.steps) || raw.steps < 1) errors.push("steps must be int >= 1");
const rectangular = Array.isArray(raw.grid) && raw.grid.length > 0 &&
raw.grid.every((row) => Array.isArray(row) && row.length === raw.grid[0].length);
if (!rectangular) errors.push("grid must be a non-empty rectangular matrix");
return { raw, errors };
}
if (levelFiles.length === 0) {
console.error(`✗ no level files found in ${LEVELS_DIR}`);
process.exit(1);
}
let failed = 0;
for (const file of levelFiles) {
try {
const { raw, errors } = validateLevel(file);
if (errors.length) { failed++; console.error(`✗ ${file}: ${errors.join("; ")}`); }
else console.log(`✓ ${file} (${raw.name})`);
} catch (err) {
failed++; console.error(`✗ ${file}: ${err.message}`);
}
}
console.log(`\nvalidated ${levelFiles.length} discovered level(s); ${failed} failed`);
console.log(`order: ${levelFiles.map((f) => f.replace(/\.json$/, "")).join(", ")}`);
process.exit(failed ? 1 : 0);
Sort parity is exact: all glob keys and readdirSync entries share the .json suffix, so default .sort() on full filenames ≡ the same comparator on bare ids. Both sides therefore derive identity from filename, never from file content.
I built a faithful reproduction (/tmp/rr-gap-009, vite 5.4 + TS 5.9, types: ["vite/client"]) with both buggy and fixed versions, then probed:
Drift demo (buggy, before fix): validator checked only the 3 hardcoded entries and exited 0 while a broken level-99-broken.json sat on disk; the bundle contained only 3 levels.
Probe (fixed, zero source edits): dropped configs/levels/level-03.json, deleted the broken file, touched nothing under src/ or scripts/:
- node scripts/validate-configs.mjs → ✓ level-01, 02, 03, 10 … 0 failed, exit 0
- npx vite build → eager glob inlined all 4 payloads (grep Spiral dist/assets/*.js = 1); executing the bundle logged [boot] loaded 4 levels: level-01, level-02, level-03, level-10
- boot order == validator order (order: level-01, level-02, level-03, level-10), byte-for-byte
- npx tsc --noEmit passes (vite/client types the glob)
Edge cases (12 tests total):
1. Broken level on disk (bad difficulty/steps/ragged grid) → validator now discovers and fails it: exit 1 (buggy version had passed)
2. Non-JSON file inside levels/ (README.md) → ignored by both (validator filter; bundle grep 0 hits)
3. Decoy configs/decoy.json outside levels/ → ignored by both
4. Empty levels/ dir → ✗ no level files found, exit 1 (no silent pass)
5. Malformed JSON → reported with parse message, exit 1; clean state exit 0
6. Order stability: 5 consecutive runs → identical sorted order (immune to fs-dependent readdirSync order)
7. Add/remove cycles (level-99 re-added → fail; removed → pass) with no source edits
8–12. Repeated build+validate passes, type-check, bundle-content greps, exit-code verification, package.json untouched.
{"problem_class":"typescript-config-manifest-duplication","model":"deepseek-v4-flash","result":"passed","tests":12}
Solved by Pi Agent (deepseek-v4-flash).