Skip to content

Commit a6b79c9

Browse files
author
DavidQ
committed
BUILD_PR_GAMES_TEMPLATE_CONTRACT_ENFORCEMENT
Added template contract rules.
1 parent 69874e5 commit a6b79c9

10 files changed

Lines changed: 329 additions & 17 deletions

docs/dev/CODEX_COMMANDS.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ MODEL: GPT-5.4
22
REASONING: high
33

44
COMMAND:
5-
Execute BUILD_PR_GAMES_PACMANLITE_REMOVE_NEXT
5+
Execute BUILD_PR_GAMES_TEMPLATE_CONTRACT_ENFORCEMENT
66

77
Rules:
8-
- Delete ONLY games/PacmanLite_next/**
9-
- Do NOT touch canonical
8+
- Do NOT modify gameplay
9+
- Do NOT break existing games
10+
- Enforce structure and behavior only
11+
- Fail fast on ambiguity

docs/dev/COMMIT_COMMENT.txt

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,2 @@
1-
APPLY_PR_GAMES_PACMANLITE_REMOVE_NEXT
2-
3-
Accepted removal of PacmanLite_next scaffold.
4-
5-
- canonical PacmanLite preserved
6-
- migration pipeline completed
7-
- repo cleaned
1+
BUILD_PR_GAMES_TEMPLATE_CONTRACT_ENFORCEMENT
2+
Added template contract rules.

docs/dev/NEXT_COMMAND.txt

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1 @@
1-
Create BUILD_PR_GAMES_TEMPLATE_CONTRACT_ENFORCEMENT
2-
3-
Scope:
4-
- codify required games template structure and index behavior
5-
- enforce canvas-first shell expectations
6-
- prevent cross-game drift without broad refactor
1+
Create APPLY_PR_GAMES_TEMPLATE_CONTRACT_ENFORCEMENT
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
BUILD_PR_GAMES_TEMPLATE_CONTRACT_ENFORCEMENT validation report
2+
3+
STATUS: PASS
4+
5+
Targets:
6+
- games/_template
7+
- games/PacmanLite
8+
- games/SpaceInvaders
9+
10+
Checks:
11+
- _template: structure and shell contract checks passed.
12+
- PacmanLite: structure and shell contract checks passed.
13+
- SpaceInvaders: structure and shell contract checks passed.
14+
15+
Issues:
16+
- none
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# BUILD PR — Games Template Contract Enforcement
2+
3+
## Purpose
4+
Codify and enforce the required structure and behavior for all games using the validated template system.
5+
6+
## Scope (STRICT)
7+
- Define required folder structure
8+
- Define required index.html behavior
9+
- Enforce canvas-first rendering
10+
- Prevent cross-game imports
11+
- No gameplay changes
12+
13+
## Required Structure
14+
games/<GameName>/
15+
- index.html
16+
- game/
17+
- entities/
18+
- systems/
19+
- ui/
20+
- assets/
21+
22+
## Required Behavior
23+
- must render via canvas
24+
- must use shared engine/theme shell
25+
- must support debug integration
26+
27+
## Prohibited
28+
- DOM gameplay rendering
29+
- cross-game imports
30+
- hardcoded paths to other games
31+
32+
## Allowed Changes
33+
- add validation rules
34+
- add lightweight enforcement checks
35+
- update template if needed
36+
37+
## Non-Goals
38+
- no refactor of existing games
39+
- no gameplay changes
40+
41+
## Acceptance Criteria
42+
- structure rules defined
43+
- behavior rules defined
44+
- no runtime impact
45+
- no existing games broken
46+
47+
## Output
48+
<project folder>/tmp/BUILD_PR_GAMES_TEMPLATE_CONTRACT_ENFORCEMENT_delta.zip
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Games Template Contract
2+
3+
## Purpose
4+
Define enforceable structure and shell behavior for games that opt into the validated template migration system.
5+
6+
## Applicability
7+
This contract applies to:
8+
- `games/_template/`
9+
- any `games/*_next/` migration lane
10+
- canonical games currently migrated through the validated pipeline:
11+
- `games/PacmanLite/`
12+
- `games/SpaceInvaders/`
13+
14+
Games outside this scope are not blocked by this contract until they are migrated through the same validated pipeline.
15+
16+
## Required Structure
17+
Each contract-managed game directory must include:
18+
- `index.html`
19+
- `assets/`
20+
- `game/`
21+
- `entities/`
22+
- `systems/`
23+
- `ui/`
24+
- `debug/`
25+
26+
## Required Shell Behavior
27+
`index.html` must:
28+
- include a visible `<canvas` element
29+
- include shared shell/theme baseline stylesheet usage via `/src/engine/ui/baseLayout.css`
30+
31+
## Prohibited
32+
- DOM-first gameplay rendering as the primary runtime surface
33+
- imports or executable references to other games under `/games/<OtherGame>/`
34+
- hardcoded executable paths into other game directories
35+
36+
## Enforcement
37+
Use:
38+
- `node scripts/validate-games-template-contract.mjs`
39+
40+
The validator writes:
41+
- `docs/dev/reports/games_template_contract_validation.txt`
42+
43+
and exits non-zero on contract violations.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"pretest": "node tools/dev/checkSharedExtractionGuard.mjs",
55
"test": "node ./scripts/run-node-tests.mjs",
66
"build:manifest": "node ./scripts/generate-sample-manifest.mjs",
7-
"check:shared-extraction-guard": "node tools/dev/checkSharedExtractionGuard.mjs"
7+
"check:shared-extraction-guard": "node tools/dev/checkSharedExtractionGuard.mjs",
8+
"check:games-template-contract": "node ./scripts/validate-games-template-contract.mjs"
89
}
910
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import fs from "node:fs/promises";
2+
import path from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
5+
const __filename = fileURLToPath(import.meta.url);
6+
const __dirname = path.dirname(__filename);
7+
const repoRoot = path.resolve(__dirname, "..");
8+
9+
const GAMES_ROOT = path.join(repoRoot, "games");
10+
const REPORT_PATH = path.join(repoRoot, "docs/dev/reports/games_template_contract_validation.txt");
11+
const MANAGED_CANONICAL_GAMES = ["PacmanLite", "SpaceInvaders"];
12+
const REQUIRED_DIRS = ["assets", "game", "entities", "systems", "ui", "debug"];
13+
const REQUIRED_INDEX_PATTERNS = [
14+
{ id: "canvas", test: (text) => /<canvas\b/i.test(text), message: "index.html must include a <canvas> element." },
15+
{
16+
id: "base-layout",
17+
test: (text) => /\/src\/engine\/ui\/baseLayout\.css/i.test(text),
18+
message: "index.html must include /src/engine/ui/baseLayout.css."
19+
}
20+
];
21+
const SOURCE_FILE_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".html"]);
22+
23+
function toRepoRelative(targetPath) {
24+
return path.relative(repoRoot, targetPath).replace(/\\/g, "/");
25+
}
26+
27+
async function resolveContractTargets() {
28+
const entries = await fs.readdir(GAMES_ROOT, { withFileTypes: true });
29+
const entryNames = new Set(entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name));
30+
const targets = [];
31+
const requiredStaticTargets = ["_template", ...MANAGED_CANONICAL_GAMES];
32+
33+
for (const requiredTarget of requiredStaticTargets) {
34+
if (!entryNames.has(requiredTarget)) {
35+
throw new Error(`Required contract target is missing: games/${requiredTarget}`);
36+
}
37+
}
38+
39+
for (const entry of entries) {
40+
if (!entry.isDirectory()) {
41+
continue;
42+
}
43+
const gameName = entry.name;
44+
const gameRoot = path.join(GAMES_ROOT, gameName);
45+
const gameEntries = await fs.readdir(gameRoot, { withFileTypes: true });
46+
const inScope = (
47+
gameName === "_template"
48+
|| gameName.endsWith("_next")
49+
|| MANAGED_CANONICAL_GAMES.includes(gameName)
50+
);
51+
if (inScope) {
52+
targets.push({
53+
gameName,
54+
gameRoot,
55+
gameEntries
56+
});
57+
}
58+
}
59+
60+
return targets.sort((a, b) => a.gameName.localeCompare(b.gameName));
61+
}
62+
63+
async function listFilesRecursively(rootPath) {
64+
const output = [];
65+
const queue = [rootPath];
66+
67+
while (queue.length > 0) {
68+
const current = queue.pop();
69+
const entries = await fs.readdir(current, { withFileTypes: true });
70+
for (const entry of entries) {
71+
const fullPath = path.join(current, entry.name);
72+
if (entry.isDirectory()) {
73+
queue.push(fullPath);
74+
continue;
75+
}
76+
output.push(fullPath);
77+
}
78+
}
79+
80+
return output;
81+
}
82+
83+
function collectQuotedGamePathViolations({ gameName, repoRelativePath, text }) {
84+
const issues = [];
85+
const matches = text.matchAll(/["'`](\/games\/([^\/"'`]+)\/[^"'`]*)["'`]/g);
86+
for (const match of matches) {
87+
const referencedGame = match[2];
88+
const referencedPath = match[1];
89+
if (referencedGame !== gameName) {
90+
issues.push(
91+
`${repoRelativePath} references another game path (${referencedPath}).`
92+
);
93+
}
94+
}
95+
return issues;
96+
}
97+
98+
async function validateTarget(target) {
99+
const issues = [];
100+
const notes = [];
101+
const entryNames = new Set(target.gameEntries.map((entry) => entry.name));
102+
103+
for (const requiredDir of REQUIRED_DIRS) {
104+
if (!entryNames.has(requiredDir)) {
105+
issues.push(`${target.gameName}: missing required directory ${requiredDir}/.`);
106+
}
107+
}
108+
109+
if (!entryNames.has("index.html")) {
110+
issues.push(`${target.gameName}: missing required index.html.`);
111+
} else {
112+
const indexPath = path.join(target.gameRoot, "index.html");
113+
const indexText = await fs.readFile(indexPath, "utf8");
114+
for (const pattern of REQUIRED_INDEX_PATTERNS) {
115+
if (!pattern.test(indexText)) {
116+
issues.push(`${target.gameName}: ${pattern.message}`);
117+
}
118+
}
119+
}
120+
121+
const files = await listFilesRecursively(target.gameRoot);
122+
for (const filePath of files) {
123+
const ext = path.extname(filePath).toLowerCase();
124+
if (!SOURCE_FILE_EXTENSIONS.has(ext)) {
125+
continue;
126+
}
127+
const text = await fs.readFile(filePath, "utf8");
128+
const repoRelativePath = toRepoRelative(filePath);
129+
const pathIssues = collectQuotedGamePathViolations({
130+
gameName: target.gameName,
131+
repoRelativePath,
132+
text
133+
});
134+
issues.push(...pathIssues);
135+
}
136+
137+
if (issues.length === 0) {
138+
notes.push(`${target.gameName}: structure and shell contract checks passed.`);
139+
}
140+
141+
return { issues, notes };
142+
}
143+
144+
async function main() {
145+
const issues = [];
146+
const notes = [];
147+
148+
const targets = await resolveContractTargets();
149+
if (targets.length === 0) {
150+
issues.push("No contract-managed game targets found under games/.");
151+
}
152+
153+
for (const target of targets) {
154+
const result = await validateTarget(target);
155+
issues.push(...result.issues);
156+
notes.push(...result.notes);
157+
}
158+
159+
const reportLines = [
160+
"BUILD_PR_GAMES_TEMPLATE_CONTRACT_ENFORCEMENT validation report",
161+
"",
162+
issues.length === 0 ? "STATUS: PASS" : "STATUS: FAIL",
163+
"",
164+
"Targets:",
165+
...targets.map((target) => `- games/${target.gameName}`),
166+
"",
167+
"Checks:",
168+
...notes.map((note) => `- ${note}`),
169+
"",
170+
"Issues:",
171+
...(issues.length > 0 ? issues.map((issue) => `- ${issue}`) : ["- none"])
172+
];
173+
174+
await fs.writeFile(REPORT_PATH, `${reportLines.join("\n")}\n`, "utf8");
175+
176+
if (issues.length > 0) {
177+
console.error("GAMES_TEMPLATE_CONTRACT_INVALID");
178+
issues.forEach((issue) => console.error(`- ${issue}`));
179+
process.exitCode = 1;
180+
return;
181+
}
182+
183+
console.log("GAMES_TEMPLATE_CONTRACT_VALID");
184+
console.log(`Report: ${toRepoRelative(REPORT_PATH)}`);
185+
}
186+
187+
await main();

tests/run-tests.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ import { run as runPerformanceProfiler } from './tools/PerformanceProfiler.test.
108108
import { run as runAssetMarketplace } from './tools/AssetMarketplace.test.mjs';
109109
import { run as runCloudRuntime } from './tools/CloudRuntime.test.mjs';
110110
import { run as runGameTemplates } from './tools/GameTemplates.test.mjs';
111+
import { run as runGamesTemplateContractEnforcement } from './tools/GamesTemplateContractEnforcement.test.mjs';
111112
import { run as runPublishingPipeline } from './tools/PublishingPipeline.test.mjs';
112113
import { run as runRenderPipelineContractAll4Tools } from './tools/RenderPipelineContractAll4Tools.test.mjs';
113114
import { run as runRuntimeSceneLoaderHotReload } from './tools/RuntimeSceneLoaderHotReload.test.mjs';
@@ -217,6 +218,7 @@ const tests = [
217218
['AssetMarketplace', runAssetMarketplace],
218219
['CloudRuntime', runCloudRuntime],
219220
['GameTemplates', runGameTemplates],
221+
['GamesTemplateContractEnforcement', runGamesTemplateContractEnforcement],
220222
['PublishingPipeline', runPublishingPipeline],
221223
['RenderPipelineContractAll4Tools', runRenderPipelineContractAll4Tools],
222224
['RuntimeSceneLoaderHotReload', runRuntimeSceneLoaderHotReload],
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import assert from "node:assert/strict";
2+
import { spawnSync } from "node:child_process";
3+
import path from "node:path";
4+
import { fileURLToPath } from "node:url";
5+
6+
const __filename = fileURLToPath(import.meta.url);
7+
const __dirname = path.dirname(__filename);
8+
const repoRoot = path.resolve(__dirname, "..", "..");
9+
10+
export async function run() {
11+
const result = spawnSync(
12+
process.execPath,
13+
["scripts/validate-games-template-contract.mjs"],
14+
{
15+
cwd: repoRoot,
16+
encoding: "utf8"
17+
}
18+
);
19+
20+
const output = [result.stdout || "", result.stderr || ""].join("\n");
21+
assert.equal(result.status, 0, `Games template contract validator failed.\n${output}`);
22+
assert.match(output, /GAMES_TEMPLATE_CONTRACT_VALID/, "Validator did not report valid status.");
23+
}

0 commit comments

Comments
 (0)