Files
f3365d2613 feat: close CCGS parity gaps (#12)
* docs: remove implemented OpenSpec change

Remove the completed eval framework OpenSpec change documents and add a CCGS gap task reference based on the parity reports and upstream CCGS surfaces.

* feat: generate CCGS gap report

Add an OpenSpec-backed implementation for deterministic remaining-gap reporting from the CCGS parity matrix, including tests and regenerated references.

* feat: deepen accessibility role parity

Add OpenSpec-backed CCGS-depth coverage for the accessibility specialist role, including role contract tests, parity regression tests, and regenerated gap reports.

* feat: close direct role depth gaps

Add OpenSpec-backed CCGS-depth contracts for the remaining direct role package gaps, plus parity tests and regenerated remaining-gap reports. Stop before engine sub-specialist, workflow, template, and rule work.

* feat: add engine sub-specialist roles

Add OpenSpec-backed CCGS engine sub-specialist parity, selected-engine role sets, wrong-engine gating, focused engine references, generated custom-agent surfaces, tests, and regenerated parity reports.

* feat: close first workflow parity batch

* feat: close remaining workflow parity gaps

* feat: close final CCGS parity gaps

* chore: remove implemented OpenSpec changes

---------

Co-authored-by: MerlinH <merlinh221@gmail.com>
2026-07-07 14:09:47 +10:00

314 lines
18 KiB
TypeScript

import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, test } from "node:test";
import { expect } from "expect";
import { projectRolePromptSourceInput } from "../src/agents.js";
import { packageAssetPath } from "../src/paths.js";
import { freezeProject, initProject } from "../src/projects.js";
import { workflowSourceInput } from "../src/projects.js";
import { runValidation, validateProject } from "../src/validation.js";
import { stableHash } from "../src/generated-surfaces.js";
import { loadEngineConfigs } from "../src/engines.js";
import { engineReferenceRegistry } from "../src/engine-reference.js";
import { rolePackages } from "../src/roles.js";
import { behavioralEvaluationScenarios } from "../src/behavioral-evaluation.js";
function seedTemplateRoot(root: string): void {
for (const entry of ["AGENTS.md", ".codex/agents", ".codex/workflows", ".agents/skills"]) {
cpSync(path.join(process.cwd(), entry), path.join(root, entry), { recursive: true });
}
}
function initTemplateProject(options: Parameters<typeof initProject>[0], cwd = mkdtempSync(path.join(tmpdir(), "ogs-template-project-"))): ReturnType<typeof initProject> {
seedTemplateRoot(cwd);
return initProject(options, cwd);
}
const validApprovalRecord = {
id: "appr_test",
stage: "approved",
role: "gameplay-programmer",
objectiveSha256: "0".repeat(64),
approvedGlobs: ["src/**/*.ts"],
source: "draft-workflow",
approvedBy: "designer",
approvedAt: "2026-06-13T00:00:00.000Z"
};
describe("validation", () => {
test("package exposes codex-game-studio as the canonical installed CLI", () => {
const pkg = JSON.parse(readFileSync(path.join(process.cwd(), "package.json"), "utf8")) as { name?: string; bin?: Record<string, string> };
expect(pkg.name).toBe("codex-game-studio");
expect(pkg.bin?.["codex-game-studio"]).toBe("./dist/cli.js");
expect(Object.keys(pkg.bin ?? {})).toEqual(["codex-game-studio"]);
});
test("source checkout wrapper uses built dist output instead of a generated bundle", () => {
const wrapper = readFileSync(path.join(process.cwd(), "codex-game-studio"), "utf8");
expect(wrapper).toContain("dist/cli.js");
expect(wrapper).toContain("npm install && npm run build");
expect(wrapper).not.toContain("bin/codex-game-studio.mjs");
});
test("fresh initialized projects validate and failures are explicit", () => {
for (const [name, engine, mode] of [
["Godot Val", "godot", "prototype"],
["Unity Val", "unity", "design"],
["Unreal Val", "ue5", "development"]
] as const) {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-"));
const { projectRoot } = initTemplateProject({ name, engine, mode, nonInteractive: true }, cwd);
expect(validateProject(projectRoot).filter((c) => c.status === "fail")).toEqual([]);
}
});
test("missing required project artifacts fail", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-"));
const { projectRoot } = initTemplateProject({ name: "Broken Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
rmSync(path.join(projectRoot, "AGENTS.md"));
rmSync(path.join(projectRoot, "src", "project.godot"));
const failures = validateProject(projectRoot).filter((c) => c.status === "fail");
expect(failures.map((f) => f.id)).toContain("project.agents_md");
expect(failures.map((f) => f.id)).toContain("project.engine_file");
});
test("malformed AGENTS contract and custom agent files fail validation", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-"));
const { projectRoot } = initTemplateProject({ name: "Prompt Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
writeFileSync(path.join(projectRoot, "AGENTS.md"), "# Broken\n");
writeFileSync(path.join(projectRoot, ".codex", "agents", "producer.toml"), "");
const failures = validateProject(projectRoot).filter((c) => c.status === "fail");
expect(failures.map((f) => f.id)).toContain("codex.project.AGENTS.md.## Project Goal");
expect(failures.map((f) => f.id)).toContain("codex.agent.producer.name");
});
test("empty timeline sections fail validation", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-"));
const { projectRoot } = initTemplateProject({ name: "Timeline Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
writeFileSync(
path.join(projectRoot, "production", "timeline.md"),
"# Timeline\n\nTBD\n\n# Milestones\n\n# Risks\n\n- Scope risk.\n\n# Next Validation Gate\n\nRun validation.\n"
);
const failures = validateProject(projectRoot).filter((c) => c.status === "fail");
expect(failures.map((f) => f.id)).toContain("project.timeline.# Milestones");
});
test("Unity validation fails when ProjectSettings marker is missing", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-"));
const { projectRoot } = initTemplateProject({ name: "Broken Unity", engine: "unity", mode: "design", nonInteractive: true }, cwd);
rmSync(path.join(projectRoot, "src", "ProjectSettings", "ProjectSettings.asset"));
const failures = validateProject(projectRoot).filter((c) => c.status === "fail");
expect(failures.map((f) => f.id)).toContain("project.engine_settings");
});
test("invalid studio json fails", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-"));
const { projectRoot } = initTemplateProject({ name: "Stale Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
writeFileSync(path.join(projectRoot, ".codex", "studio.json"), "{ invalid json");
expect(validateProject(projectRoot)[0].status).toBe("fail");
});
test("invalid studio mode fails project validation", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-"));
const { projectRoot } = initTemplateProject({ name: "Studio Mode Val", engine: "godot", mode: "prototype", studioMode: "strict-studio", nonInteractive: true }, cwd);
const studioPath = path.join(projectRoot, ".codex", "studio.json");
const studio = JSON.parse(readFileSync(studioPath, "utf8"));
studio.studioMode = "ceremony-platform";
writeFileSync(studioPath, `${JSON.stringify(studio, null, 2)}\n`);
expect(validateProject(projectRoot)[0]).toEqual(
expect.objectContaining({
id: "codex.project.studio",
status: "fail",
message: expect.stringMatching(/studioMode/i)
})
);
});
test("malformed approval store fails project validation", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-"));
const { projectRoot } = initTemplateProject({ name: "Approval Val", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
writeFileSync(
path.join(projectRoot, ".codex", "approvals.json"),
`${JSON.stringify({ schemaVersion: 1, product: "codex-game-studio", records: [{ id: "bad", stage: "approved", approvedGlobs: ["../escape.ts"] }] }, null, 2)}\n`
);
const failures = validateProject(projectRoot).filter((c) => c.status === "fail");
expect(failures).toContainEqual(
expect.objectContaining({
id: "codex.project.approvals",
message: expect.stringMatching(/approval store invalid/i)
})
);
});
test("context manifest schema and stale freshness metadata fail project validation", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-"));
const { projectRoot } = initTemplateProject({ name: "Manifest Val", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
writeFileSync(path.join(projectRoot, ".codex", "context-manifest.json"), "{ invalid json");
expect(validateProject(projectRoot).filter((c) => c.status === "fail")).toContainEqual(
expect.objectContaining({ id: "codex.project.context_manifest", message: expect.stringMatching(/invalid JSON/i) })
);
const { projectRoot: staleProject } = initTemplateProject({ name: "Stale Manifest Val", engine: "godot", mode: "prototype", studioMode: "strict-studio", nonInteractive: true }, mkdtempSync(path.join(tmpdir(), "ogs-val-")));
const metaPath = path.join(staleProject, ".codex", "context-manifest.meta.json");
const meta = JSON.parse(readFileSync(metaPath, "utf8"));
meta.studioMode = "guided-studio";
meta.manifestSha256 = "0".repeat(64);
writeFileSync(metaPath, `${JSON.stringify(meta, null, 2)}\n`);
const failures = validateProject(staleProject).filter((c) => c.status === "fail");
expect(failures.map((f) => f.id)).toContain("codex.project.context_manifest.freshness");
});
test("approval store symlink escape fails project validation", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-"));
const { projectRoot } = initTemplateProject({ name: "Approval Symlink Val", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const outside = mkdtempSync(path.join(tmpdir(), "ogs-outside-"));
mkdirSync(path.join(projectRoot, "source"), { recursive: true });
symlinkSync(outside, path.join(projectRoot, "source", "outside-link"));
writeFileSync(
path.join(projectRoot, ".codex", "approvals.json"),
`${JSON.stringify({ schemaVersion: 1, product: "codex-game-studio", records: [{ ...validApprovalRecord, approvedGlobs: ["source/outside-link/file.ts"] }] }, null, 2)}\n`
);
const failures = validateProject(projectRoot).filter((c) => c.status === "fail");
expect(failures).toContainEqual(
expect.objectContaining({
id: "codex.project.approvals",
message: expect.stringMatching(/symlink outside project/i)
})
);
});
test("freeze status-only changes keep project validation green", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-"));
const { projectRoot } = initTemplateProject({ name: "Freeze Valid", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
freezeProject(projectRoot, cwd);
expect(validateProject(projectRoot).filter((c) => c.status === "fail")).toEqual([]);
});
test("engine reference validation covers package metadata and project materialization", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-engine-reference-"));
const { projectRoot } = initTemplateProject({ name: "Unity Reference Valid", engine: "unity", mode: "prototype", nonInteractive: true }, cwd);
for (const file of [...engineReferenceRegistry.unity.requiredFiles, ...engineReferenceRegistry.unity.moduleFiles, ...engineReferenceRegistry.unity.pluginFiles]) {
expect(existsSync(path.join(projectRoot, engineReferenceRegistry.unity.projectPath(file)))).toBe(true);
}
expect(existsSync(path.join(projectRoot, "docs", "engine-reference", "unreal", "VERSION.md"))).toBe(false);
const checks = validateProject(projectRoot);
expect(checks.filter((c) => c.id.startsWith("engine_reference.") && c.status === "fail")).toEqual([]);
expect(checks).toContainEqual(expect.objectContaining({ id: "engine_reference.package.unity.VERSION.md.metadata", status: "pass" }));
expect(checks).toContainEqual(expect.objectContaining({ id: "engine_reference.package.unity.modules/networking.md.metadata", status: "pass" }));
expect(checks).toContainEqual(expect.objectContaining({ id: "engine_reference.project.unity.VERSION.md", status: "pass" }));
expect(checks).toContainEqual(expect.objectContaining({ id: "engine_reference.project.unity.modules/networking.md", status: "pass" }));
});
test("missing materialized engine reference fails project validation", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-engine-reference-"));
const { projectRoot } = initTemplateProject({ name: "Broken Reference Valid", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
rmSync(path.join(projectRoot, "docs", "engine-reference", "godot", "plugins.md"));
expect(validateProject(projectRoot).filter((c) => c.status === "fail")).toContainEqual(
expect.objectContaining({ id: "engine_reference.project.godot.plugins.md" })
);
});
test("tracked template custom agents are validated across engine specialists", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-specialist-"));
const { projectRoot } = initTemplateProject({ name: "Godot Specialist Valid", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
expect(validateProject(projectRoot).filter((c) => c.status === "fail")).toEqual([]);
rmSync(path.join(projectRoot, ".codex", "agents", "godot-specialist.toml"));
rmSync(path.join(projectRoot, ".codex", "agents", "godot-gdscript-specialist.toml"));
const failures = validateProject(projectRoot).filter((c) => c.status === "fail");
expect(failures.map((f) => f.id)).toContain("codex.agent.godot-specialist.exists");
expect(failures.map((f) => f.id)).toContain("codex.agent.godot-gdscript-specialist.exists");
expect(failures.map((f) => f.id)).not.toContain("codex.agent.unity-specialist.absent");
expect(failures.map((f) => f.id)).not.toContain("codex.agent.unity-dots-specialist.exists");
});
test("template surface validation detects malformed tracked agents and workflows", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-template-shape-"));
const { projectRoot } = initTemplateProject({ name: "Template Shape Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const agent = path.join(projectRoot, ".codex", "agents", "producer.toml");
writeFileSync(agent, readFileSync(agent, "utf8").replace(/developer_instructions\s*=\s*"""[\s\S]*?"""/, "developer_instructions_missing = true"));
expect(validateProject(projectRoot).filter((c) => c.status === "fail")).toContainEqual(
expect.objectContaining({ id: "codex.agent.producer.developer_instructions" })
);
const workflowProject = initTemplateProject({ name: "Template Workflow Shape Game", engine: "godot", mode: "prototype", nonInteractive: true }, mkdtempSync(path.join(tmpdir(), "ogs-val-template-shape-"))).projectRoot;
const workflow = path.join(workflowProject, ".codex", "workflows", "ui-ux-review.md");
writeFileSync(workflow, readFileSync(workflow, "utf8").replace("## Purpose", "## Removed Purpose"));
expect(validateProject(workflowProject).filter((c) => c.status === "fail")).toContainEqual(
expect.objectContaining({ id: "codex.workflow.ui-ux-review.sections" })
);
});
test("template bodies do not require generated freshness metadata", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-template-metadata-"));
const { projectRoot } = initTemplateProject({ name: "Template Metadata Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const failures = validateProject(projectRoot).filter((c) => c.status === "fail").map((c) => c.id);
expect(failures).not.toContain("codex.role.market-analyst.prompt.freshness");
expect(failures).not.toContain("codex.workflow.ui-ux-review.freshness");
expect(failures).not.toContain("codex.workflow.ui-ux-review.body");
});
test("template skill content markers are validated", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-skill-depth-"));
const { projectRoot } = initTemplateProject({ name: "Skill Marker Val", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const file = path.join(projectRoot, ".agents", "skills", "cgs-vertical-slice", "SKILL.md");
const body = readFileSync(file, "utf8");
const qualityGateStart = body.indexOf("## Quality Gates");
writeFileSync(file, body.slice(0, qualityGateStart) + body.slice(qualityGateStart).replace("- Validation Question", "- Removed Question"));
expect(validateProject(projectRoot).filter((c) => c.status === "fail")).toContainEqual(
expect.objectContaining({ id: "codex.skill.cgs-vertical-slice.marker.validation-question" })
);
});
test("template helper source input covers rendered engine and role display fields", () => {
const engines = loadEngineConfigs(packageAssetPath("engine_configs"));
const config = initTemplateProject({ name: "Hash Coverage Game", engine: "godot", mode: "prototype", nonInteractive: true }, mkdtempSync(path.join(tmpdir(), "ogs-val-"))).config;
const baseRoleHash = stableHash(projectRolePromptSourceInput("market-analyst", config, engines));
const renamedEngines = { ...engines, godot: { ...engines.godot, display_name: "Renamed Godot" } };
expect(stableHash(projectRolePromptSourceInput("market-analyst", config, renamedEngines))).not.toBe(baseRoleHash);
const baseWorkflowHash = stableHash(workflowSourceInput("ui-ux-review"));
const originalName = rolePackages["ui-ux-designer"].displayName;
rolePackages["ui-ux-designer"].displayName = "Renamed UI UX Designer";
try {
expect(stableHash(workflowSourceInput("ui-ux-review"))).not.toBe(baseWorkflowHash);
} finally {
rolePackages["ui-ux-designer"].displayName = originalName;
}
});
test("repo validation includes deterministic behavioral evaluation subchecks", async () => {
const result = await runValidation();
const checks = result.checks.filter((check) => check.id.startsWith("behavioral.scenario."));
expect(checks.map((check) => check.id)).toEqual(behavioralEvaluationScenarios.map((scenario) => `behavioral.scenario.${scenario.id}`));
expect(checks.every((check) => check.status === "pass")).toBe(true);
});
test("repo validation reports Codex readiness hard failure when unavailable", async () => {
const old = process.env.CODEX_BIN;
process.env.CODEX_BIN = "/missing/codex";
try {
const result = await runValidation();
expect(result.checks.some((c) => c.id === "codex.cli" && c.status === "fail")).toBe(true);
expect(result.failed).toBe(true);
} finally {
if (old === undefined) delete process.env.CODEX_BIN;
else process.env.CODEX_BIN = old;
}
});
});