miaoda-game-devkit 0.6.6 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/phaser-lint.js +62 -105
- package/dist/cli/react-lint.js +62 -105
- package/dist/react/index.d.mts +3 -0
- package/dist/react/index.d.ts +3 -0
- package/dist/react/testing.d.mts +10 -0
- package/dist/react/testing.d.ts +10 -0
- package/dist/react/testing.js +2 -2
- package/dist/react/testing.mjs +2 -2
- package/dist/react/vitest-config.d.mts +5 -0
- package/dist/react/vitest-config.d.ts +5 -0
- package/dist/react/vitest-config.js +66 -105
- package/dist/react/vitest-config.mjs +66 -105
- package/package.json +2 -1
package/dist/cli/phaser-lint.js
CHANGED
|
@@ -11,6 +11,7 @@ var import_node_path2 = require("path");
|
|
|
11
11
|
// src/cli/react-authoritative-playthrough.ts
|
|
12
12
|
var import_node_fs = require("fs");
|
|
13
13
|
var import_node_path = require("path");
|
|
14
|
+
var import_oxc_parser = require("oxc-parser");
|
|
14
15
|
var PRODUCTION_PLAYTHROUGH = "tests/production-playthrough.test.tsx";
|
|
15
16
|
var PRODUCTION_APP = "src/App.tsx";
|
|
16
17
|
var EXAMPLE_IMPORT_PREFIX = "@/game/example/";
|
|
@@ -54,89 +55,36 @@ function sourceFiles(root, directory = (0, import_node_path.join)(root, "src"))
|
|
|
54
55
|
}
|
|
55
56
|
return files;
|
|
56
57
|
}
|
|
57
|
-
function
|
|
58
|
-
|
|
59
|
-
let state = "code";
|
|
60
|
-
for (let index = 0; index < source.length; index += 1) {
|
|
61
|
-
const char = source[index];
|
|
62
|
-
const next = source[index + 1];
|
|
63
|
-
if (state === "line") {
|
|
64
|
-
if (char === "\n") {
|
|
65
|
-
state = "code";
|
|
66
|
-
output += char;
|
|
67
|
-
} else {
|
|
68
|
-
output += " ";
|
|
69
|
-
}
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
if (state === "block") {
|
|
73
|
-
if (char === "*" && next === "/") {
|
|
74
|
-
output += " ";
|
|
75
|
-
index += 1;
|
|
76
|
-
state = "code";
|
|
77
|
-
} else {
|
|
78
|
-
output += char === "\n" ? "\n" : " ";
|
|
79
|
-
}
|
|
80
|
-
continue;
|
|
81
|
-
}
|
|
82
|
-
if (state === "code" && char === "/" && next === "/") {
|
|
83
|
-
output += " ";
|
|
84
|
-
index += 1;
|
|
85
|
-
state = "line";
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
|
-
if (state === "code" && char === "/" && next === "*") {
|
|
89
|
-
output += " ";
|
|
90
|
-
index += 1;
|
|
91
|
-
state = "block";
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
if (state === "code" && char === "'") state = "single";
|
|
95
|
-
else if (state === "code" && char === '"') state = "double";
|
|
96
|
-
else if (state === "code" && char === "`") state = "template";
|
|
97
|
-
else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
|
|
98
|
-
state = "code";
|
|
99
|
-
} else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
|
|
100
|
-
state = "code";
|
|
101
|
-
} else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
|
|
102
|
-
state = "code";
|
|
103
|
-
}
|
|
104
|
-
output += char;
|
|
105
|
-
}
|
|
106
|
-
return output;
|
|
58
|
+
function parseSource(file, source) {
|
|
59
|
+
return (0, import_oxc_parser.parseSync)(file, source, { sourceType: "module" });
|
|
107
60
|
}
|
|
108
|
-
function
|
|
109
|
-
|
|
110
|
-
let state = "code";
|
|
111
|
-
for (let index = 0; index < source.length; index += 1) {
|
|
112
|
-
const char = source[index];
|
|
113
|
-
if (state === "code") positions[index] = true;
|
|
114
|
-
if (state === "code" && char === "'") state = "single";
|
|
115
|
-
else if (state === "code" && char === '"') state = "double";
|
|
116
|
-
else if (state === "code" && char === "`") state = "template";
|
|
117
|
-
else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
|
|
118
|
-
state = "code";
|
|
119
|
-
} else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
|
|
120
|
-
state = "code";
|
|
121
|
-
} else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
|
|
122
|
-
state = "code";
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
return positions;
|
|
61
|
+
function staticImports(file, source) {
|
|
62
|
+
return parseSource(file, source).module.staticImports;
|
|
126
63
|
}
|
|
127
|
-
function
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const pattern = /\b(?:from\s+|import\s*(?:\(\s*)?)["']([^"']+)["']\s*\)?/g;
|
|
132
|
-
for (const match of clean.matchAll(pattern)) {
|
|
133
|
-
if (positions[match.index]) modules.push(match[1]);
|
|
64
|
+
function collectDynamicImportSpecifiers(value, modules) {
|
|
65
|
+
if (Array.isArray(value)) {
|
|
66
|
+
for (const item of value) collectDynamicImportSpecifiers(item, modules);
|
|
67
|
+
return;
|
|
134
68
|
}
|
|
69
|
+
if (!isRecord(value)) return;
|
|
70
|
+
if (value.type === "ImportExpression" && isRecord(value.source) && value.source.type === "Literal" && typeof value.source.value === "string") {
|
|
71
|
+
modules.push(value.source.value);
|
|
72
|
+
}
|
|
73
|
+
for (const child of Object.values(value)) {
|
|
74
|
+
collectDynamicImportSpecifiers(child, modules);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function importedModuleSpecifiers(file, source) {
|
|
78
|
+
const parsed = parseSource(file, source);
|
|
79
|
+
const modules = parsed.module.staticImports.map(
|
|
80
|
+
({ moduleRequest }) => moduleRequest.value
|
|
81
|
+
);
|
|
82
|
+
collectDynamicImportSpecifiers(parsed.program, modules);
|
|
135
83
|
return modules;
|
|
136
84
|
}
|
|
137
85
|
function productionFileImportsExample(file, projectRoot2) {
|
|
138
86
|
const exampleRoot = (0, import_node_path.join)(projectRoot2, "src/game/example");
|
|
139
|
-
return importedModuleSpecifiers((0, import_node_fs.readFileSync)(file, "utf8")).some(
|
|
87
|
+
return importedModuleSpecifiers(file, (0, import_node_fs.readFileSync)(file, "utf8")).some(
|
|
140
88
|
(moduleName) => {
|
|
141
89
|
if (moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)) return true;
|
|
142
90
|
if (!moduleName.startsWith(".")) return false;
|
|
@@ -145,25 +93,19 @@ function productionFileImportsExample(file, projectRoot2) {
|
|
|
145
93
|
}
|
|
146
94
|
);
|
|
147
95
|
}
|
|
148
|
-
function importsExampleAlias(source) {
|
|
149
|
-
return importedModuleSpecifiers(source).some(
|
|
96
|
+
function importsExampleAlias(file, source) {
|
|
97
|
+
return importedModuleSpecifiers(file, source).some(
|
|
150
98
|
(moduleName) => moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)
|
|
151
99
|
);
|
|
152
100
|
}
|
|
153
|
-
function namedImports(source, moduleName) {
|
|
101
|
+
function namedImports(file, source, moduleName) {
|
|
154
102
|
const names = /* @__PURE__ */ new Set();
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
);
|
|
162
|
-
for (const match of clean.matchAll(pattern)) {
|
|
163
|
-
if (!positions[match.index]) continue;
|
|
164
|
-
for (const specifier of match[1].split(",")) {
|
|
165
|
-
const imported = specifier.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim();
|
|
166
|
-
if (imported) names.add(imported);
|
|
103
|
+
for (const declaration of staticImports(file, source)) {
|
|
104
|
+
if (declaration.moduleRequest.value !== moduleName) continue;
|
|
105
|
+
for (const entry of declaration.entries) {
|
|
106
|
+
if (entry.importName.kind === "Name" && entry.importName.name) {
|
|
107
|
+
names.add(entry.importName.name);
|
|
108
|
+
}
|
|
167
109
|
}
|
|
168
110
|
}
|
|
169
111
|
return names;
|
|
@@ -171,13 +113,20 @@ function namedImports(source, moduleName) {
|
|
|
171
113
|
function containsAny(values, expected) {
|
|
172
114
|
return [...values].some((value) => expected.has(value));
|
|
173
115
|
}
|
|
174
|
-
function
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
116
|
+
function isRecord(value) {
|
|
117
|
+
return typeof value === "object" && value !== null;
|
|
118
|
+
}
|
|
119
|
+
function containsObserveProperty(value) {
|
|
120
|
+
if (Array.isArray(value)) return value.some(containsObserveProperty);
|
|
121
|
+
if (!isRecord(value)) return false;
|
|
122
|
+
if (value.type === "Property" && isRecord(value.key)) {
|
|
123
|
+
if (value.key.type === "Identifier" && value.key.name === "observe") return true;
|
|
124
|
+
if (value.key.type === "Literal" && value.key.value === "observe") return true;
|
|
179
125
|
}
|
|
180
|
-
return
|
|
126
|
+
return Object.values(value).some(containsObserveProperty);
|
|
127
|
+
}
|
|
128
|
+
function declaresObserve(file, source) {
|
|
129
|
+
return containsObserveProperty(parseSource(file, source).program);
|
|
181
130
|
}
|
|
182
131
|
function auditReactAuthoritativePlaythrough(projectRoot2) {
|
|
183
132
|
const clockFiles = [];
|
|
@@ -185,6 +134,7 @@ function auditReactAuthoritativePlaythrough(projectRoot2) {
|
|
|
185
134
|
const productionFiles = sourceFiles(projectRoot2);
|
|
186
135
|
for (const file of productionFiles) {
|
|
187
136
|
const imports = namedImports(
|
|
137
|
+
file,
|
|
188
138
|
(0, import_node_fs.readFileSync)(file, "utf8"),
|
|
189
139
|
REACT_RUNTIME_ENTRY
|
|
190
140
|
);
|
|
@@ -198,7 +148,9 @@ function auditReactAuthoritativePlaythrough(projectRoot2) {
|
|
|
198
148
|
const productionUsesExample = productionFiles.some(
|
|
199
149
|
(file) => productionFileImportsExample(file, projectRoot2)
|
|
200
150
|
);
|
|
201
|
-
const staleExampleTestFiles = productionUsesExample ? [] : runnableTestFiles(projectRoot2).filter(
|
|
151
|
+
const staleExampleTestFiles = productionUsesExample ? [] : runnableTestFiles(projectRoot2).filter(
|
|
152
|
+
(file) => importsExampleAlias(file, (0, import_node_fs.readFileSync)(file, "utf8"))
|
|
153
|
+
).map((file) => (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/"));
|
|
202
154
|
const issues = [];
|
|
203
155
|
if (staleExampleTestFiles.length > 0) {
|
|
204
156
|
issues.push(
|
|
@@ -218,8 +170,12 @@ function auditReactAuthoritativePlaythrough(projectRoot2) {
|
|
|
218
170
|
}
|
|
219
171
|
const testPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_PLAYTHROUGH);
|
|
220
172
|
const testSource = (0, import_node_fs.existsSync)(testPath) ? (0, import_node_fs.readFileSync)(testPath, "utf8") : "";
|
|
221
|
-
const testingImports = namedImports(
|
|
222
|
-
|
|
173
|
+
const testingImports = namedImports(
|
|
174
|
+
testPath,
|
|
175
|
+
testSource,
|
|
176
|
+
REACT_TESTING_ENTRY
|
|
177
|
+
);
|
|
178
|
+
const hasObserve = declaresObserve(testPath, testSource);
|
|
223
179
|
if (!hasObserve) {
|
|
224
180
|
issues.push(
|
|
225
181
|
`${PRODUCTION_PLAYTHROUGH} must declare observe because production uses the devkit clock or Controller ownership hook. Pass { observe: () => telemetry.read.session() } to playthroughTest, using Telemetry backed by the same production Controller rendered by <App />; DOM labels are not an authoritative gameplay boundary.`
|
|
@@ -348,14 +304,15 @@ function checkVitestConfig(target) {
|
|
|
348
304
|
}
|
|
349
305
|
return { name, ok: true };
|
|
350
306
|
}
|
|
351
|
-
function
|
|
352
|
-
const name = "react-
|
|
307
|
+
function checkReactProductTestAlignment() {
|
|
308
|
+
const name = "react-product-test-alignment";
|
|
353
309
|
const audit = auditReactAuthoritativePlaythrough(projectRoot);
|
|
354
|
-
|
|
355
|
-
|
|
310
|
+
if (audit.staleExampleTestFiles.length === 0) return { name, ok: true };
|
|
311
|
+
console.error(`[${name}] ${audit.issues[0]}`);
|
|
312
|
+
return { name, ok: false };
|
|
356
313
|
}
|
|
357
314
|
async function runAllChecks(target) {
|
|
358
|
-
const targetChecks = target === "react" ? [checkVitestConfig("react"),
|
|
315
|
+
const targetChecks = target === "react" ? [checkVitestConfig("react"), checkReactProductTestAlignment()] : [checkPhaser4Dependency(), checkPhaserViteConfig(), checkVitestConfig("phaser")];
|
|
359
316
|
const lintRoots = target === "react" && (0, import_node_fs2.existsSync)((0, import_node_path2.join)(projectRoot, "tests")) ? ["src", "tests"] : ["src"];
|
|
360
317
|
const [tsgo, biome] = await Promise.all([
|
|
361
318
|
run("tsgo", "@typescript/native-preview", "tsgo", ["-p", "tsconfig.json"]),
|
package/dist/cli/react-lint.js
CHANGED
|
@@ -11,6 +11,7 @@ var import_node_path2 = require("path");
|
|
|
11
11
|
// src/cli/react-authoritative-playthrough.ts
|
|
12
12
|
var import_node_fs = require("fs");
|
|
13
13
|
var import_node_path = require("path");
|
|
14
|
+
var import_oxc_parser = require("oxc-parser");
|
|
14
15
|
var PRODUCTION_PLAYTHROUGH = "tests/production-playthrough.test.tsx";
|
|
15
16
|
var PRODUCTION_APP = "src/App.tsx";
|
|
16
17
|
var EXAMPLE_IMPORT_PREFIX = "@/game/example/";
|
|
@@ -54,89 +55,36 @@ function sourceFiles(root, directory = (0, import_node_path.join)(root, "src"))
|
|
|
54
55
|
}
|
|
55
56
|
return files;
|
|
56
57
|
}
|
|
57
|
-
function
|
|
58
|
-
|
|
59
|
-
let state = "code";
|
|
60
|
-
for (let index = 0; index < source.length; index += 1) {
|
|
61
|
-
const char = source[index];
|
|
62
|
-
const next = source[index + 1];
|
|
63
|
-
if (state === "line") {
|
|
64
|
-
if (char === "\n") {
|
|
65
|
-
state = "code";
|
|
66
|
-
output += char;
|
|
67
|
-
} else {
|
|
68
|
-
output += " ";
|
|
69
|
-
}
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
if (state === "block") {
|
|
73
|
-
if (char === "*" && next === "/") {
|
|
74
|
-
output += " ";
|
|
75
|
-
index += 1;
|
|
76
|
-
state = "code";
|
|
77
|
-
} else {
|
|
78
|
-
output += char === "\n" ? "\n" : " ";
|
|
79
|
-
}
|
|
80
|
-
continue;
|
|
81
|
-
}
|
|
82
|
-
if (state === "code" && char === "/" && next === "/") {
|
|
83
|
-
output += " ";
|
|
84
|
-
index += 1;
|
|
85
|
-
state = "line";
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
|
-
if (state === "code" && char === "/" && next === "*") {
|
|
89
|
-
output += " ";
|
|
90
|
-
index += 1;
|
|
91
|
-
state = "block";
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
if (state === "code" && char === "'") state = "single";
|
|
95
|
-
else if (state === "code" && char === '"') state = "double";
|
|
96
|
-
else if (state === "code" && char === "`") state = "template";
|
|
97
|
-
else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
|
|
98
|
-
state = "code";
|
|
99
|
-
} else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
|
|
100
|
-
state = "code";
|
|
101
|
-
} else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
|
|
102
|
-
state = "code";
|
|
103
|
-
}
|
|
104
|
-
output += char;
|
|
105
|
-
}
|
|
106
|
-
return output;
|
|
58
|
+
function parseSource(file, source) {
|
|
59
|
+
return (0, import_oxc_parser.parseSync)(file, source, { sourceType: "module" });
|
|
107
60
|
}
|
|
108
|
-
function
|
|
109
|
-
|
|
110
|
-
let state = "code";
|
|
111
|
-
for (let index = 0; index < source.length; index += 1) {
|
|
112
|
-
const char = source[index];
|
|
113
|
-
if (state === "code") positions[index] = true;
|
|
114
|
-
if (state === "code" && char === "'") state = "single";
|
|
115
|
-
else if (state === "code" && char === '"') state = "double";
|
|
116
|
-
else if (state === "code" && char === "`") state = "template";
|
|
117
|
-
else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
|
|
118
|
-
state = "code";
|
|
119
|
-
} else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
|
|
120
|
-
state = "code";
|
|
121
|
-
} else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
|
|
122
|
-
state = "code";
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
return positions;
|
|
61
|
+
function staticImports(file, source) {
|
|
62
|
+
return parseSource(file, source).module.staticImports;
|
|
126
63
|
}
|
|
127
|
-
function
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const pattern = /\b(?:from\s+|import\s*(?:\(\s*)?)["']([^"']+)["']\s*\)?/g;
|
|
132
|
-
for (const match of clean.matchAll(pattern)) {
|
|
133
|
-
if (positions[match.index]) modules.push(match[1]);
|
|
64
|
+
function collectDynamicImportSpecifiers(value, modules) {
|
|
65
|
+
if (Array.isArray(value)) {
|
|
66
|
+
for (const item of value) collectDynamicImportSpecifiers(item, modules);
|
|
67
|
+
return;
|
|
134
68
|
}
|
|
69
|
+
if (!isRecord(value)) return;
|
|
70
|
+
if (value.type === "ImportExpression" && isRecord(value.source) && value.source.type === "Literal" && typeof value.source.value === "string") {
|
|
71
|
+
modules.push(value.source.value);
|
|
72
|
+
}
|
|
73
|
+
for (const child of Object.values(value)) {
|
|
74
|
+
collectDynamicImportSpecifiers(child, modules);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function importedModuleSpecifiers(file, source) {
|
|
78
|
+
const parsed = parseSource(file, source);
|
|
79
|
+
const modules = parsed.module.staticImports.map(
|
|
80
|
+
({ moduleRequest }) => moduleRequest.value
|
|
81
|
+
);
|
|
82
|
+
collectDynamicImportSpecifiers(parsed.program, modules);
|
|
135
83
|
return modules;
|
|
136
84
|
}
|
|
137
85
|
function productionFileImportsExample(file, projectRoot2) {
|
|
138
86
|
const exampleRoot = (0, import_node_path.join)(projectRoot2, "src/game/example");
|
|
139
|
-
return importedModuleSpecifiers((0, import_node_fs.readFileSync)(file, "utf8")).some(
|
|
87
|
+
return importedModuleSpecifiers(file, (0, import_node_fs.readFileSync)(file, "utf8")).some(
|
|
140
88
|
(moduleName) => {
|
|
141
89
|
if (moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)) return true;
|
|
142
90
|
if (!moduleName.startsWith(".")) return false;
|
|
@@ -145,25 +93,19 @@ function productionFileImportsExample(file, projectRoot2) {
|
|
|
145
93
|
}
|
|
146
94
|
);
|
|
147
95
|
}
|
|
148
|
-
function importsExampleAlias(source) {
|
|
149
|
-
return importedModuleSpecifiers(source).some(
|
|
96
|
+
function importsExampleAlias(file, source) {
|
|
97
|
+
return importedModuleSpecifiers(file, source).some(
|
|
150
98
|
(moduleName) => moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)
|
|
151
99
|
);
|
|
152
100
|
}
|
|
153
|
-
function namedImports(source, moduleName) {
|
|
101
|
+
function namedImports(file, source, moduleName) {
|
|
154
102
|
const names = /* @__PURE__ */ new Set();
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
);
|
|
162
|
-
for (const match of clean.matchAll(pattern)) {
|
|
163
|
-
if (!positions[match.index]) continue;
|
|
164
|
-
for (const specifier of match[1].split(",")) {
|
|
165
|
-
const imported = specifier.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim();
|
|
166
|
-
if (imported) names.add(imported);
|
|
103
|
+
for (const declaration of staticImports(file, source)) {
|
|
104
|
+
if (declaration.moduleRequest.value !== moduleName) continue;
|
|
105
|
+
for (const entry of declaration.entries) {
|
|
106
|
+
if (entry.importName.kind === "Name" && entry.importName.name) {
|
|
107
|
+
names.add(entry.importName.name);
|
|
108
|
+
}
|
|
167
109
|
}
|
|
168
110
|
}
|
|
169
111
|
return names;
|
|
@@ -171,13 +113,20 @@ function namedImports(source, moduleName) {
|
|
|
171
113
|
function containsAny(values, expected) {
|
|
172
114
|
return [...values].some((value) => expected.has(value));
|
|
173
115
|
}
|
|
174
|
-
function
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
116
|
+
function isRecord(value) {
|
|
117
|
+
return typeof value === "object" && value !== null;
|
|
118
|
+
}
|
|
119
|
+
function containsObserveProperty(value) {
|
|
120
|
+
if (Array.isArray(value)) return value.some(containsObserveProperty);
|
|
121
|
+
if (!isRecord(value)) return false;
|
|
122
|
+
if (value.type === "Property" && isRecord(value.key)) {
|
|
123
|
+
if (value.key.type === "Identifier" && value.key.name === "observe") return true;
|
|
124
|
+
if (value.key.type === "Literal" && value.key.value === "observe") return true;
|
|
179
125
|
}
|
|
180
|
-
return
|
|
126
|
+
return Object.values(value).some(containsObserveProperty);
|
|
127
|
+
}
|
|
128
|
+
function declaresObserve(file, source) {
|
|
129
|
+
return containsObserveProperty(parseSource(file, source).program);
|
|
181
130
|
}
|
|
182
131
|
function auditReactAuthoritativePlaythrough(projectRoot2) {
|
|
183
132
|
const clockFiles = [];
|
|
@@ -185,6 +134,7 @@ function auditReactAuthoritativePlaythrough(projectRoot2) {
|
|
|
185
134
|
const productionFiles = sourceFiles(projectRoot2);
|
|
186
135
|
for (const file of productionFiles) {
|
|
187
136
|
const imports = namedImports(
|
|
137
|
+
file,
|
|
188
138
|
(0, import_node_fs.readFileSync)(file, "utf8"),
|
|
189
139
|
REACT_RUNTIME_ENTRY
|
|
190
140
|
);
|
|
@@ -198,7 +148,9 @@ function auditReactAuthoritativePlaythrough(projectRoot2) {
|
|
|
198
148
|
const productionUsesExample = productionFiles.some(
|
|
199
149
|
(file) => productionFileImportsExample(file, projectRoot2)
|
|
200
150
|
);
|
|
201
|
-
const staleExampleTestFiles = productionUsesExample ? [] : runnableTestFiles(projectRoot2).filter(
|
|
151
|
+
const staleExampleTestFiles = productionUsesExample ? [] : runnableTestFiles(projectRoot2).filter(
|
|
152
|
+
(file) => importsExampleAlias(file, (0, import_node_fs.readFileSync)(file, "utf8"))
|
|
153
|
+
).map((file) => (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/"));
|
|
202
154
|
const issues = [];
|
|
203
155
|
if (staleExampleTestFiles.length > 0) {
|
|
204
156
|
issues.push(
|
|
@@ -218,8 +170,12 @@ function auditReactAuthoritativePlaythrough(projectRoot2) {
|
|
|
218
170
|
}
|
|
219
171
|
const testPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_PLAYTHROUGH);
|
|
220
172
|
const testSource = (0, import_node_fs.existsSync)(testPath) ? (0, import_node_fs.readFileSync)(testPath, "utf8") : "";
|
|
221
|
-
const testingImports = namedImports(
|
|
222
|
-
|
|
173
|
+
const testingImports = namedImports(
|
|
174
|
+
testPath,
|
|
175
|
+
testSource,
|
|
176
|
+
REACT_TESTING_ENTRY
|
|
177
|
+
);
|
|
178
|
+
const hasObserve = declaresObserve(testPath, testSource);
|
|
223
179
|
if (!hasObserve) {
|
|
224
180
|
issues.push(
|
|
225
181
|
`${PRODUCTION_PLAYTHROUGH} must declare observe because production uses the devkit clock or Controller ownership hook. Pass { observe: () => telemetry.read.session() } to playthroughTest, using Telemetry backed by the same production Controller rendered by <App />; DOM labels are not an authoritative gameplay boundary.`
|
|
@@ -348,14 +304,15 @@ function checkVitestConfig(target) {
|
|
|
348
304
|
}
|
|
349
305
|
return { name, ok: true };
|
|
350
306
|
}
|
|
351
|
-
function
|
|
352
|
-
const name = "react-
|
|
307
|
+
function checkReactProductTestAlignment() {
|
|
308
|
+
const name = "react-product-test-alignment";
|
|
353
309
|
const audit = auditReactAuthoritativePlaythrough(projectRoot);
|
|
354
|
-
|
|
355
|
-
|
|
310
|
+
if (audit.staleExampleTestFiles.length === 0) return { name, ok: true };
|
|
311
|
+
console.error(`[${name}] ${audit.issues[0]}`);
|
|
312
|
+
return { name, ok: false };
|
|
356
313
|
}
|
|
357
314
|
async function runAllChecks(target) {
|
|
358
|
-
const targetChecks = target === "react" ? [checkVitestConfig("react"),
|
|
315
|
+
const targetChecks = target === "react" ? [checkVitestConfig("react"), checkReactProductTestAlignment()] : [checkPhaser4Dependency(), checkPhaserViteConfig(), checkVitestConfig("phaser")];
|
|
359
316
|
const lintRoots = target === "react" && (0, import_node_fs2.existsSync)((0, import_node_path2.join)(projectRoot, "tests")) ? ["src", "tests"] : ["src"];
|
|
360
317
|
const [tsgo, biome] = await Promise.all([
|
|
361
318
|
run("tsgo", "@typescript/native-preview", "tsgo", ["-p", "tsconfig.json"]),
|
package/dist/react/index.d.mts
CHANGED
|
@@ -18,6 +18,9 @@ interface SubscribableGameController<TState> extends DisposableGameController {
|
|
|
18
18
|
/**
|
|
19
19
|
* 一次调用接管游戏 Controller 的创建、销毁与状态订阅:
|
|
20
20
|
* useOwnedGameController 的 ownership,加上 useSyncExternalStore 的渲染桥。
|
|
21
|
+
* `createController` 是零参数工厂;hook 不会向它传 clock、options 或 props。
|
|
22
|
+
* 需要注入依赖时在调用处闭包捕获,例如:
|
|
23
|
+
* `useGameController(() => gameFactory({ ...options, clock }))`。
|
|
21
24
|
*
|
|
22
25
|
* 附带一道开发期检查:状态内容变了、但 snapshot() 返回的还是原来那个
|
|
23
26
|
* 对象时,React 会认为"什么都没变"而不刷新界面——游戏悄悄卡死,没有
|
package/dist/react/index.d.ts
CHANGED
|
@@ -18,6 +18,9 @@ interface SubscribableGameController<TState> extends DisposableGameController {
|
|
|
18
18
|
/**
|
|
19
19
|
* 一次调用接管游戏 Controller 的创建、销毁与状态订阅:
|
|
20
20
|
* useOwnedGameController 的 ownership,加上 useSyncExternalStore 的渲染桥。
|
|
21
|
+
* `createController` 是零参数工厂;hook 不会向它传 clock、options 或 props。
|
|
22
|
+
* 需要注入依赖时在调用处闭包捕获,例如:
|
|
23
|
+
* `useGameController(() => gameFactory({ ...options, clock }))`。
|
|
21
24
|
*
|
|
22
25
|
* 附带一道开发期检查:状态内容变了、但 snapshot() 返回的还是原来那个
|
|
23
26
|
* 对象时,React 会认为"什么都没变"而不刷新界面——游戏悄悄卡死,没有
|
package/dist/react/testing.d.mts
CHANGED
|
@@ -99,6 +99,10 @@ interface ReactPlaythroughAssertContext {
|
|
|
99
99
|
user: UserEvent;
|
|
100
100
|
view: RenderResult;
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* 每个阶段独立声明完成条件、断言和推进上限。确定性 `step` 属于具体阶段,
|
|
104
|
+
* 不存在 playthrough 顶层的共享 step;这样失败 TRACE 才能归属到准确区间。
|
|
105
|
+
*/
|
|
102
106
|
interface ReactPlaythroughStageShared {
|
|
103
107
|
until: () => boolean;
|
|
104
108
|
assert: (context: ReactPlaythroughAssertContext) => void | Promise<void>;
|
|
@@ -129,7 +133,13 @@ interface ReactPlaythroughArguments {
|
|
|
129
133
|
}
|
|
130
134
|
type ReactPlaythroughRun = (arguments_: ReactPlaythroughArguments) => void | Promise<void>;
|
|
131
135
|
interface ReactPlaythroughTest {
|
|
136
|
+
/** 第一个参数是实际渲染的 ReactNode;简单游戏直接传 `<App />`。 */
|
|
132
137
|
(element: ReactNode, run: ReactPlaythroughRun): void;
|
|
138
|
+
/**
|
|
139
|
+
* Controller/Canvas 游戏仍传生产 `<App gameFactory={factory} />`,通过
|
|
140
|
+
* options.observe 读取同一个 Controller。不要把 `(clock) => <App />`
|
|
141
|
+
* 之类的元素工厂传到这里;clock 应由 App 的 Controller factory 闭包注入。
|
|
142
|
+
*/
|
|
133
143
|
(element: ReactNode, options: ReactPlaythroughOptions, run: ReactPlaythroughRun): void;
|
|
134
144
|
skip(reason: string, element: ReactNode): void;
|
|
135
145
|
}
|
package/dist/react/testing.d.ts
CHANGED
|
@@ -99,6 +99,10 @@ interface ReactPlaythroughAssertContext {
|
|
|
99
99
|
user: UserEvent;
|
|
100
100
|
view: RenderResult;
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* 每个阶段独立声明完成条件、断言和推进上限。确定性 `step` 属于具体阶段,
|
|
104
|
+
* 不存在 playthrough 顶层的共享 step;这样失败 TRACE 才能归属到准确区间。
|
|
105
|
+
*/
|
|
102
106
|
interface ReactPlaythroughStageShared {
|
|
103
107
|
until: () => boolean;
|
|
104
108
|
assert: (context: ReactPlaythroughAssertContext) => void | Promise<void>;
|
|
@@ -129,7 +133,13 @@ interface ReactPlaythroughArguments {
|
|
|
129
133
|
}
|
|
130
134
|
type ReactPlaythroughRun = (arguments_: ReactPlaythroughArguments) => void | Promise<void>;
|
|
131
135
|
interface ReactPlaythroughTest {
|
|
136
|
+
/** 第一个参数是实际渲染的 ReactNode;简单游戏直接传 `<App />`。 */
|
|
132
137
|
(element: ReactNode, run: ReactPlaythroughRun): void;
|
|
138
|
+
/**
|
|
139
|
+
* Controller/Canvas 游戏仍传生产 `<App gameFactory={factory} />`,通过
|
|
140
|
+
* options.observe 读取同一个 Controller。不要把 `(clock) => <App />`
|
|
141
|
+
* 之类的元素工厂传到这里;clock 应由 App 的 Controller factory 闭包注入。
|
|
142
|
+
*/
|
|
133
143
|
(element: ReactNode, options: ReactPlaythroughOptions, run: ReactPlaythroughRun): void;
|
|
134
144
|
skip(reason: string, element: ReactNode): void;
|
|
135
145
|
}
|
package/dist/react/testing.js
CHANGED
|
@@ -341,10 +341,10 @@ async function runBoundedUntil(condition, options = {}) {
|
|
|
341
341
|
}
|
|
342
342
|
}
|
|
343
343
|
const diagnostics = formatDiagnostics(options.diagnostics);
|
|
344
|
-
const guidance = options.step ? "The step callback ran, but the authoritative outcome did not change." : "No step callback was provided,
|
|
344
|
+
const guidance = options.step ? "The step callback ran, but the authoritative outcome did not change." : "No step callback was provided, and the condition did not become true after the stage action. This does not identify a clock problem; inspect the condition, production input wiring, and observed state boundary.";
|
|
345
345
|
const suffix = diagnostics ? ` Last diagnostics: ${diagnostics}` : "";
|
|
346
346
|
throw codedError(
|
|
347
|
-
options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "
|
|
347
|
+
options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "PLAYTHROUGH_OUTCOME_NOT_REACHED",
|
|
348
348
|
`Playthrough outcome was not reached within ${maxSteps} steps. ${guidance}${suffix}`
|
|
349
349
|
);
|
|
350
350
|
}
|
package/dist/react/testing.mjs
CHANGED
|
@@ -303,10 +303,10 @@ async function runBoundedUntil(condition, options = {}) {
|
|
|
303
303
|
}
|
|
304
304
|
}
|
|
305
305
|
const diagnostics = formatDiagnostics(options.diagnostics);
|
|
306
|
-
const guidance = options.step ? "The step callback ran, but the authoritative outcome did not change." : "No step callback was provided,
|
|
306
|
+
const guidance = options.step ? "The step callback ran, but the authoritative outcome did not change." : "No step callback was provided, and the condition did not become true after the stage action. This does not identify a clock problem; inspect the condition, production input wiring, and observed state boundary.";
|
|
307
307
|
const suffix = diagnostics ? ` Last diagnostics: ${diagnostics}` : "";
|
|
308
308
|
throw codedError(
|
|
309
|
-
options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "
|
|
309
|
+
options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "PLAYTHROUGH_OUTCOME_NOT_REACHED",
|
|
310
310
|
`Playthrough outcome was not reached within ${maxSteps} steps. ${guidance}${suffix}`
|
|
311
311
|
);
|
|
312
312
|
}
|
|
@@ -3,6 +3,11 @@ import { ViteUserConfig } from 'vitest/config';
|
|
|
3
3
|
interface ReactGameVitestConfigOptions {
|
|
4
4
|
/** React 模板的绝对根目录,通常传入 `import.meta.dirname`。 */
|
|
5
5
|
projectRoot: string;
|
|
6
|
+
/**
|
|
7
|
+
* Opt in to the legacy structured playthrough gate. Ordinary React game
|
|
8
|
+
* projects should use independent Vitest scenarios and leave this disabled.
|
|
9
|
+
*/
|
|
10
|
+
enablePlaythroughReporter?: boolean;
|
|
6
11
|
aliases?: Record<string, string>;
|
|
7
12
|
additionalSetupFiles?: string[];
|
|
8
13
|
testTimeout?: number;
|
|
@@ -3,6 +3,11 @@ import { ViteUserConfig } from 'vitest/config';
|
|
|
3
3
|
interface ReactGameVitestConfigOptions {
|
|
4
4
|
/** React 模板的绝对根目录,通常传入 `import.meta.dirname`。 */
|
|
5
5
|
projectRoot: string;
|
|
6
|
+
/**
|
|
7
|
+
* Opt in to the legacy structured playthrough gate. Ordinary React game
|
|
8
|
+
* projects should use independent Vitest scenarios and leave this disabled.
|
|
9
|
+
*/
|
|
10
|
+
enablePlaythroughReporter?: boolean;
|
|
6
11
|
aliases?: Record<string, string>;
|
|
7
12
|
additionalSetupFiles?: string[];
|
|
8
13
|
testTimeout?: number;
|
|
@@ -295,10 +295,10 @@ async function runBoundedUntil(condition, options = {}) {
|
|
|
295
295
|
}
|
|
296
296
|
}
|
|
297
297
|
const diagnostics = formatDiagnostics(options.diagnostics);
|
|
298
|
-
const guidance = options.step ? "The step callback ran, but the authoritative outcome did not change." : "No step callback was provided,
|
|
298
|
+
const guidance = options.step ? "The step callback ran, but the authoritative outcome did not change." : "No step callback was provided, and the condition did not become true after the stage action. This does not identify a clock problem; inspect the condition, production input wiring, and observed state boundary.";
|
|
299
299
|
const suffix = diagnostics ? ` Last diagnostics: ${diagnostics}` : "";
|
|
300
300
|
throw codedError(
|
|
301
|
-
options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "
|
|
301
|
+
options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "PLAYTHROUGH_OUTCOME_NOT_REACHED",
|
|
302
302
|
`Playthrough outcome was not reached within ${maxSteps} steps. ${guidance}${suffix}`
|
|
303
303
|
);
|
|
304
304
|
}
|
|
@@ -1047,6 +1047,7 @@ ${lines.join("\n")}`;
|
|
|
1047
1047
|
// src/cli/react-authoritative-playthrough.ts
|
|
1048
1048
|
var import_node_fs = require("fs");
|
|
1049
1049
|
var import_node_path2 = require("path");
|
|
1050
|
+
var import_oxc_parser = require("oxc-parser");
|
|
1050
1051
|
var PRODUCTION_PLAYTHROUGH = "tests/production-playthrough.test.tsx";
|
|
1051
1052
|
var PRODUCTION_APP = "src/App.tsx";
|
|
1052
1053
|
var EXAMPLE_IMPORT_PREFIX = "@/game/example/";
|
|
@@ -1090,89 +1091,36 @@ function sourceFiles(root, directory = (0, import_node_path2.join)(root, "src"))
|
|
|
1090
1091
|
}
|
|
1091
1092
|
return files;
|
|
1092
1093
|
}
|
|
1093
|
-
function
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
const next = source[index + 1];
|
|
1099
|
-
if (state === "line") {
|
|
1100
|
-
if (char === "\n") {
|
|
1101
|
-
state = "code";
|
|
1102
|
-
output += char;
|
|
1103
|
-
} else {
|
|
1104
|
-
output += " ";
|
|
1105
|
-
}
|
|
1106
|
-
continue;
|
|
1107
|
-
}
|
|
1108
|
-
if (state === "block") {
|
|
1109
|
-
if (char === "*" && next === "/") {
|
|
1110
|
-
output += " ";
|
|
1111
|
-
index += 1;
|
|
1112
|
-
state = "code";
|
|
1113
|
-
} else {
|
|
1114
|
-
output += char === "\n" ? "\n" : " ";
|
|
1115
|
-
}
|
|
1116
|
-
continue;
|
|
1117
|
-
}
|
|
1118
|
-
if (state === "code" && char === "/" && next === "/") {
|
|
1119
|
-
output += " ";
|
|
1120
|
-
index += 1;
|
|
1121
|
-
state = "line";
|
|
1122
|
-
continue;
|
|
1123
|
-
}
|
|
1124
|
-
if (state === "code" && char === "/" && next === "*") {
|
|
1125
|
-
output += " ";
|
|
1126
|
-
index += 1;
|
|
1127
|
-
state = "block";
|
|
1128
|
-
continue;
|
|
1129
|
-
}
|
|
1130
|
-
if (state === "code" && char === "'") state = "single";
|
|
1131
|
-
else if (state === "code" && char === '"') state = "double";
|
|
1132
|
-
else if (state === "code" && char === "`") state = "template";
|
|
1133
|
-
else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
|
|
1134
|
-
state = "code";
|
|
1135
|
-
} else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
|
|
1136
|
-
state = "code";
|
|
1137
|
-
} else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
|
|
1138
|
-
state = "code";
|
|
1139
|
-
}
|
|
1140
|
-
output += char;
|
|
1141
|
-
}
|
|
1142
|
-
return output;
|
|
1143
|
-
}
|
|
1144
|
-
function codePositions(source) {
|
|
1145
|
-
const positions = Array.from({ length: source.length }, () => false);
|
|
1146
|
-
let state = "code";
|
|
1147
|
-
for (let index = 0; index < source.length; index += 1) {
|
|
1148
|
-
const char = source[index];
|
|
1149
|
-
if (state === "code") positions[index] = true;
|
|
1150
|
-
if (state === "code" && char === "'") state = "single";
|
|
1151
|
-
else if (state === "code" && char === '"') state = "double";
|
|
1152
|
-
else if (state === "code" && char === "`") state = "template";
|
|
1153
|
-
else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
|
|
1154
|
-
state = "code";
|
|
1155
|
-
} else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
|
|
1156
|
-
state = "code";
|
|
1157
|
-
} else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
|
|
1158
|
-
state = "code";
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
return positions;
|
|
1094
|
+
function parseSource(file, source) {
|
|
1095
|
+
return (0, import_oxc_parser.parseSync)(file, source, { sourceType: "module" });
|
|
1096
|
+
}
|
|
1097
|
+
function staticImports(file, source) {
|
|
1098
|
+
return parseSource(file, source).module.staticImports;
|
|
1162
1099
|
}
|
|
1163
|
-
function
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1100
|
+
function collectDynamicImportSpecifiers(value, modules) {
|
|
1101
|
+
if (Array.isArray(value)) {
|
|
1102
|
+
for (const item of value) collectDynamicImportSpecifiers(item, modules);
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
if (!isRecord(value)) return;
|
|
1106
|
+
if (value.type === "ImportExpression" && isRecord(value.source) && value.source.type === "Literal" && typeof value.source.value === "string") {
|
|
1107
|
+
modules.push(value.source.value);
|
|
1108
|
+
}
|
|
1109
|
+
for (const child of Object.values(value)) {
|
|
1110
|
+
collectDynamicImportSpecifiers(child, modules);
|
|
1170
1111
|
}
|
|
1112
|
+
}
|
|
1113
|
+
function importedModuleSpecifiers(file, source) {
|
|
1114
|
+
const parsed = parseSource(file, source);
|
|
1115
|
+
const modules = parsed.module.staticImports.map(
|
|
1116
|
+
({ moduleRequest }) => moduleRequest.value
|
|
1117
|
+
);
|
|
1118
|
+
collectDynamicImportSpecifiers(parsed.program, modules);
|
|
1171
1119
|
return modules;
|
|
1172
1120
|
}
|
|
1173
1121
|
function productionFileImportsExample(file, projectRoot) {
|
|
1174
1122
|
const exampleRoot = (0, import_node_path2.join)(projectRoot, "src/game/example");
|
|
1175
|
-
return importedModuleSpecifiers((0, import_node_fs.readFileSync)(file, "utf8")).some(
|
|
1123
|
+
return importedModuleSpecifiers(file, (0, import_node_fs.readFileSync)(file, "utf8")).some(
|
|
1176
1124
|
(moduleName) => {
|
|
1177
1125
|
if (moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)) return true;
|
|
1178
1126
|
if (!moduleName.startsWith(".")) return false;
|
|
@@ -1181,25 +1129,19 @@ function productionFileImportsExample(file, projectRoot) {
|
|
|
1181
1129
|
}
|
|
1182
1130
|
);
|
|
1183
1131
|
}
|
|
1184
|
-
function importsExampleAlias(source) {
|
|
1185
|
-
return importedModuleSpecifiers(source).some(
|
|
1132
|
+
function importsExampleAlias(file, source) {
|
|
1133
|
+
return importedModuleSpecifiers(file, source).some(
|
|
1186
1134
|
(moduleName) => moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)
|
|
1187
1135
|
);
|
|
1188
1136
|
}
|
|
1189
|
-
function namedImports(source, moduleName) {
|
|
1137
|
+
function namedImports(file, source, moduleName) {
|
|
1190
1138
|
const names = /* @__PURE__ */ new Set();
|
|
1191
|
-
const
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
);
|
|
1198
|
-
for (const match of clean.matchAll(pattern)) {
|
|
1199
|
-
if (!positions[match.index]) continue;
|
|
1200
|
-
for (const specifier of match[1].split(",")) {
|
|
1201
|
-
const imported = specifier.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim();
|
|
1202
|
-
if (imported) names.add(imported);
|
|
1139
|
+
for (const declaration of staticImports(file, source)) {
|
|
1140
|
+
if (declaration.moduleRequest.value !== moduleName) continue;
|
|
1141
|
+
for (const entry of declaration.entries) {
|
|
1142
|
+
if (entry.importName.kind === "Name" && entry.importName.name) {
|
|
1143
|
+
names.add(entry.importName.name);
|
|
1144
|
+
}
|
|
1203
1145
|
}
|
|
1204
1146
|
}
|
|
1205
1147
|
return names;
|
|
@@ -1207,13 +1149,20 @@ function namedImports(source, moduleName) {
|
|
|
1207
1149
|
function containsAny(values, expected) {
|
|
1208
1150
|
return [...values].some((value) => expected.has(value));
|
|
1209
1151
|
}
|
|
1210
|
-
function
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1152
|
+
function isRecord(value) {
|
|
1153
|
+
return typeof value === "object" && value !== null;
|
|
1154
|
+
}
|
|
1155
|
+
function containsObserveProperty(value) {
|
|
1156
|
+
if (Array.isArray(value)) return value.some(containsObserveProperty);
|
|
1157
|
+
if (!isRecord(value)) return false;
|
|
1158
|
+
if (value.type === "Property" && isRecord(value.key)) {
|
|
1159
|
+
if (value.key.type === "Identifier" && value.key.name === "observe") return true;
|
|
1160
|
+
if (value.key.type === "Literal" && value.key.value === "observe") return true;
|
|
1215
1161
|
}
|
|
1216
|
-
return
|
|
1162
|
+
return Object.values(value).some(containsObserveProperty);
|
|
1163
|
+
}
|
|
1164
|
+
function declaresObserve(file, source) {
|
|
1165
|
+
return containsObserveProperty(parseSource(file, source).program);
|
|
1217
1166
|
}
|
|
1218
1167
|
function auditReactAuthoritativePlaythrough(projectRoot) {
|
|
1219
1168
|
const clockFiles = [];
|
|
@@ -1221,6 +1170,7 @@ function auditReactAuthoritativePlaythrough(projectRoot) {
|
|
|
1221
1170
|
const productionFiles = sourceFiles(projectRoot);
|
|
1222
1171
|
for (const file of productionFiles) {
|
|
1223
1172
|
const imports = namedImports(
|
|
1173
|
+
file,
|
|
1224
1174
|
(0, import_node_fs.readFileSync)(file, "utf8"),
|
|
1225
1175
|
REACT_RUNTIME_ENTRY
|
|
1226
1176
|
);
|
|
@@ -1234,7 +1184,9 @@ function auditReactAuthoritativePlaythrough(projectRoot) {
|
|
|
1234
1184
|
const productionUsesExample = productionFiles.some(
|
|
1235
1185
|
(file) => productionFileImportsExample(file, projectRoot)
|
|
1236
1186
|
);
|
|
1237
|
-
const staleExampleTestFiles = productionUsesExample ? [] : runnableTestFiles(projectRoot).filter(
|
|
1187
|
+
const staleExampleTestFiles = productionUsesExample ? [] : runnableTestFiles(projectRoot).filter(
|
|
1188
|
+
(file) => importsExampleAlias(file, (0, import_node_fs.readFileSync)(file, "utf8"))
|
|
1189
|
+
).map((file) => (0, import_node_path2.relative)(projectRoot, file).replaceAll("\\", "/"));
|
|
1238
1190
|
const issues = [];
|
|
1239
1191
|
if (staleExampleTestFiles.length > 0) {
|
|
1240
1192
|
issues.push(
|
|
@@ -1254,8 +1206,12 @@ function auditReactAuthoritativePlaythrough(projectRoot) {
|
|
|
1254
1206
|
}
|
|
1255
1207
|
const testPath = (0, import_node_path2.join)(projectRoot, PRODUCTION_PLAYTHROUGH);
|
|
1256
1208
|
const testSource = (0, import_node_fs.existsSync)(testPath) ? (0, import_node_fs.readFileSync)(testPath, "utf8") : "";
|
|
1257
|
-
const testingImports = namedImports(
|
|
1258
|
-
|
|
1209
|
+
const testingImports = namedImports(
|
|
1210
|
+
testPath,
|
|
1211
|
+
testSource,
|
|
1212
|
+
REACT_TESTING_ENTRY
|
|
1213
|
+
);
|
|
1214
|
+
const hasObserve = declaresObserve(testPath, testSource);
|
|
1259
1215
|
if (!hasObserve) {
|
|
1260
1216
|
issues.push(
|
|
1261
1217
|
`${PRODUCTION_PLAYTHROUGH} must declare observe because production uses the devkit clock or Controller ownership hook. Pass { observe: () => telemetry.read.session() } to playthroughTest, using Telemetry backed by the same production Controller rendered by <App />; DOM labels are not an authoritative gameplay boundary.`
|
|
@@ -1311,6 +1267,8 @@ function repairGuidance(code) {
|
|
|
1311
1267
|
return "The stage ran and asserted, but its observable state matched the previous milestone. Preserve the intended gameplay result and observe the same production Controller rendered by <App />. Do not substitute arbitrary labels or a weaker state change.";
|
|
1312
1268
|
case "PLAYTHROUGH_BOUND_EXHAUSTED":
|
|
1313
1269
|
return "Keep the intended outcome unchanged. The stage driver ran, but gameplay did not reach it within the bound. Inspect TRACE and Last diagnostics, then confirm that each deterministic step advances the same production Controller rendered by <App />. If state remains unchanged, inject ManualGameClock through the production App factory and observe that Controller through Telemetry. Do not replace the outcome with navigation, an intermediate phase, a no-op step, or a weaker assertion.";
|
|
1270
|
+
case "PLAYTHROUGH_OUTCOME_NOT_REACHED":
|
|
1271
|
+
return "The stage action completed, but its until condition never became true. Inspect TRACE and Last diagnostics, then confirm that the production DOM input reaches the rendered App, until describes the result caused by this stage, and observe reads the matching authoritative state when used. Add a deterministic step only if the gameplay is actually driven by time or frames; do not add a no-op step or weaken the intended outcome.";
|
|
1314
1272
|
case "PLAYTHROUGH_CLOCK_NOT_ADVANCED":
|
|
1315
1273
|
return "This stage is driven by time or frames, but it did not advance the game clock. Inject the devkit GameClock into the production game and pass step: () => clock.stepFrame(). Do not replace deterministic advancement with a real setTimeout.";
|
|
1316
1274
|
case "EXPECTATION_MISMATCH":
|
|
@@ -1528,8 +1486,11 @@ function defineReactGameVitestConfig(options) {
|
|
|
1528
1486
|
sequence: {
|
|
1529
1487
|
setupFiles: "list"
|
|
1530
1488
|
},
|
|
1531
|
-
|
|
1532
|
-
|
|
1489
|
+
...options.enablePlaythroughReporter ? {
|
|
1490
|
+
// The structured reporter remains available to existing Devkit consumers,
|
|
1491
|
+
// but is no longer a default requirement for generated React games.
|
|
1492
|
+
reporters: [new ReactPlaythroughReporter(options.projectRoot)]
|
|
1493
|
+
} : {},
|
|
1533
1494
|
restoreMocks: true,
|
|
1534
1495
|
clearMocks: true,
|
|
1535
1496
|
testTimeout: options.testTimeout,
|
|
@@ -261,10 +261,10 @@ async function runBoundedUntil(condition, options = {}) {
|
|
|
261
261
|
}
|
|
262
262
|
}
|
|
263
263
|
const diagnostics = formatDiagnostics(options.diagnostics);
|
|
264
|
-
const guidance = options.step ? "The step callback ran, but the authoritative outcome did not change." : "No step callback was provided,
|
|
264
|
+
const guidance = options.step ? "The step callback ran, but the authoritative outcome did not change." : "No step callback was provided, and the condition did not become true after the stage action. This does not identify a clock problem; inspect the condition, production input wiring, and observed state boundary.";
|
|
265
265
|
const suffix = diagnostics ? ` Last diagnostics: ${diagnostics}` : "";
|
|
266
266
|
throw codedError(
|
|
267
|
-
options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "
|
|
267
|
+
options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "PLAYTHROUGH_OUTCOME_NOT_REACHED",
|
|
268
268
|
`Playthrough outcome was not reached within ${maxSteps} steps. ${guidance}${suffix}`
|
|
269
269
|
);
|
|
270
270
|
}
|
|
@@ -1013,6 +1013,7 @@ ${lines.join("\n")}`;
|
|
|
1013
1013
|
// src/cli/react-authoritative-playthrough.ts
|
|
1014
1014
|
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
1015
1015
|
import { dirname, join, relative as relative2, resolve, sep } from "path";
|
|
1016
|
+
import { parseSync } from "oxc-parser";
|
|
1016
1017
|
var PRODUCTION_PLAYTHROUGH = "tests/production-playthrough.test.tsx";
|
|
1017
1018
|
var PRODUCTION_APP = "src/App.tsx";
|
|
1018
1019
|
var EXAMPLE_IMPORT_PREFIX = "@/game/example/";
|
|
@@ -1056,89 +1057,36 @@ function sourceFiles(root, directory = join(root, "src")) {
|
|
|
1056
1057
|
}
|
|
1057
1058
|
return files;
|
|
1058
1059
|
}
|
|
1059
|
-
function
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
const next = source[index + 1];
|
|
1065
|
-
if (state === "line") {
|
|
1066
|
-
if (char === "\n") {
|
|
1067
|
-
state = "code";
|
|
1068
|
-
output += char;
|
|
1069
|
-
} else {
|
|
1070
|
-
output += " ";
|
|
1071
|
-
}
|
|
1072
|
-
continue;
|
|
1073
|
-
}
|
|
1074
|
-
if (state === "block") {
|
|
1075
|
-
if (char === "*" && next === "/") {
|
|
1076
|
-
output += " ";
|
|
1077
|
-
index += 1;
|
|
1078
|
-
state = "code";
|
|
1079
|
-
} else {
|
|
1080
|
-
output += char === "\n" ? "\n" : " ";
|
|
1081
|
-
}
|
|
1082
|
-
continue;
|
|
1083
|
-
}
|
|
1084
|
-
if (state === "code" && char === "/" && next === "/") {
|
|
1085
|
-
output += " ";
|
|
1086
|
-
index += 1;
|
|
1087
|
-
state = "line";
|
|
1088
|
-
continue;
|
|
1089
|
-
}
|
|
1090
|
-
if (state === "code" && char === "/" && next === "*") {
|
|
1091
|
-
output += " ";
|
|
1092
|
-
index += 1;
|
|
1093
|
-
state = "block";
|
|
1094
|
-
continue;
|
|
1095
|
-
}
|
|
1096
|
-
if (state === "code" && char === "'") state = "single";
|
|
1097
|
-
else if (state === "code" && char === '"') state = "double";
|
|
1098
|
-
else if (state === "code" && char === "`") state = "template";
|
|
1099
|
-
else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
|
|
1100
|
-
state = "code";
|
|
1101
|
-
} else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
|
|
1102
|
-
state = "code";
|
|
1103
|
-
} else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
|
|
1104
|
-
state = "code";
|
|
1105
|
-
}
|
|
1106
|
-
output += char;
|
|
1107
|
-
}
|
|
1108
|
-
return output;
|
|
1109
|
-
}
|
|
1110
|
-
function codePositions(source) {
|
|
1111
|
-
const positions = Array.from({ length: source.length }, () => false);
|
|
1112
|
-
let state = "code";
|
|
1113
|
-
for (let index = 0; index < source.length; index += 1) {
|
|
1114
|
-
const char = source[index];
|
|
1115
|
-
if (state === "code") positions[index] = true;
|
|
1116
|
-
if (state === "code" && char === "'") state = "single";
|
|
1117
|
-
else if (state === "code" && char === '"') state = "double";
|
|
1118
|
-
else if (state === "code" && char === "`") state = "template";
|
|
1119
|
-
else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
|
|
1120
|
-
state = "code";
|
|
1121
|
-
} else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
|
|
1122
|
-
state = "code";
|
|
1123
|
-
} else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
|
|
1124
|
-
state = "code";
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1127
|
-
return positions;
|
|
1060
|
+
function parseSource(file, source) {
|
|
1061
|
+
return parseSync(file, source, { sourceType: "module" });
|
|
1062
|
+
}
|
|
1063
|
+
function staticImports(file, source) {
|
|
1064
|
+
return parseSource(file, source).module.staticImports;
|
|
1128
1065
|
}
|
|
1129
|
-
function
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1066
|
+
function collectDynamicImportSpecifiers(value, modules) {
|
|
1067
|
+
if (Array.isArray(value)) {
|
|
1068
|
+
for (const item of value) collectDynamicImportSpecifiers(item, modules);
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
if (!isRecord(value)) return;
|
|
1072
|
+
if (value.type === "ImportExpression" && isRecord(value.source) && value.source.type === "Literal" && typeof value.source.value === "string") {
|
|
1073
|
+
modules.push(value.source.value);
|
|
1074
|
+
}
|
|
1075
|
+
for (const child of Object.values(value)) {
|
|
1076
|
+
collectDynamicImportSpecifiers(child, modules);
|
|
1136
1077
|
}
|
|
1078
|
+
}
|
|
1079
|
+
function importedModuleSpecifiers(file, source) {
|
|
1080
|
+
const parsed = parseSource(file, source);
|
|
1081
|
+
const modules = parsed.module.staticImports.map(
|
|
1082
|
+
({ moduleRequest }) => moduleRequest.value
|
|
1083
|
+
);
|
|
1084
|
+
collectDynamicImportSpecifiers(parsed.program, modules);
|
|
1137
1085
|
return modules;
|
|
1138
1086
|
}
|
|
1139
1087
|
function productionFileImportsExample(file, projectRoot) {
|
|
1140
1088
|
const exampleRoot = join(projectRoot, "src/game/example");
|
|
1141
|
-
return importedModuleSpecifiers(readFileSync(file, "utf8")).some(
|
|
1089
|
+
return importedModuleSpecifiers(file, readFileSync(file, "utf8")).some(
|
|
1142
1090
|
(moduleName) => {
|
|
1143
1091
|
if (moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)) return true;
|
|
1144
1092
|
if (!moduleName.startsWith(".")) return false;
|
|
@@ -1147,25 +1095,19 @@ function productionFileImportsExample(file, projectRoot) {
|
|
|
1147
1095
|
}
|
|
1148
1096
|
);
|
|
1149
1097
|
}
|
|
1150
|
-
function importsExampleAlias(source) {
|
|
1151
|
-
return importedModuleSpecifiers(source).some(
|
|
1098
|
+
function importsExampleAlias(file, source) {
|
|
1099
|
+
return importedModuleSpecifiers(file, source).some(
|
|
1152
1100
|
(moduleName) => moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)
|
|
1153
1101
|
);
|
|
1154
1102
|
}
|
|
1155
|
-
function namedImports(source, moduleName) {
|
|
1103
|
+
function namedImports(file, source, moduleName) {
|
|
1156
1104
|
const names = /* @__PURE__ */ new Set();
|
|
1157
|
-
const
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
);
|
|
1164
|
-
for (const match of clean.matchAll(pattern)) {
|
|
1165
|
-
if (!positions[match.index]) continue;
|
|
1166
|
-
for (const specifier of match[1].split(",")) {
|
|
1167
|
-
const imported = specifier.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim();
|
|
1168
|
-
if (imported) names.add(imported);
|
|
1105
|
+
for (const declaration of staticImports(file, source)) {
|
|
1106
|
+
if (declaration.moduleRequest.value !== moduleName) continue;
|
|
1107
|
+
for (const entry of declaration.entries) {
|
|
1108
|
+
if (entry.importName.kind === "Name" && entry.importName.name) {
|
|
1109
|
+
names.add(entry.importName.name);
|
|
1110
|
+
}
|
|
1169
1111
|
}
|
|
1170
1112
|
}
|
|
1171
1113
|
return names;
|
|
@@ -1173,13 +1115,20 @@ function namedImports(source, moduleName) {
|
|
|
1173
1115
|
function containsAny(values, expected) {
|
|
1174
1116
|
return [...values].some((value) => expected.has(value));
|
|
1175
1117
|
}
|
|
1176
|
-
function
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1118
|
+
function isRecord(value) {
|
|
1119
|
+
return typeof value === "object" && value !== null;
|
|
1120
|
+
}
|
|
1121
|
+
function containsObserveProperty(value) {
|
|
1122
|
+
if (Array.isArray(value)) return value.some(containsObserveProperty);
|
|
1123
|
+
if (!isRecord(value)) return false;
|
|
1124
|
+
if (value.type === "Property" && isRecord(value.key)) {
|
|
1125
|
+
if (value.key.type === "Identifier" && value.key.name === "observe") return true;
|
|
1126
|
+
if (value.key.type === "Literal" && value.key.value === "observe") return true;
|
|
1181
1127
|
}
|
|
1182
|
-
return
|
|
1128
|
+
return Object.values(value).some(containsObserveProperty);
|
|
1129
|
+
}
|
|
1130
|
+
function declaresObserve(file, source) {
|
|
1131
|
+
return containsObserveProperty(parseSource(file, source).program);
|
|
1183
1132
|
}
|
|
1184
1133
|
function auditReactAuthoritativePlaythrough(projectRoot) {
|
|
1185
1134
|
const clockFiles = [];
|
|
@@ -1187,6 +1136,7 @@ function auditReactAuthoritativePlaythrough(projectRoot) {
|
|
|
1187
1136
|
const productionFiles = sourceFiles(projectRoot);
|
|
1188
1137
|
for (const file of productionFiles) {
|
|
1189
1138
|
const imports = namedImports(
|
|
1139
|
+
file,
|
|
1190
1140
|
readFileSync(file, "utf8"),
|
|
1191
1141
|
REACT_RUNTIME_ENTRY
|
|
1192
1142
|
);
|
|
@@ -1200,7 +1150,9 @@ function auditReactAuthoritativePlaythrough(projectRoot) {
|
|
|
1200
1150
|
const productionUsesExample = productionFiles.some(
|
|
1201
1151
|
(file) => productionFileImportsExample(file, projectRoot)
|
|
1202
1152
|
);
|
|
1203
|
-
const staleExampleTestFiles = productionUsesExample ? [] : runnableTestFiles(projectRoot).filter(
|
|
1153
|
+
const staleExampleTestFiles = productionUsesExample ? [] : runnableTestFiles(projectRoot).filter(
|
|
1154
|
+
(file) => importsExampleAlias(file, readFileSync(file, "utf8"))
|
|
1155
|
+
).map((file) => relative2(projectRoot, file).replaceAll("\\", "/"));
|
|
1204
1156
|
const issues = [];
|
|
1205
1157
|
if (staleExampleTestFiles.length > 0) {
|
|
1206
1158
|
issues.push(
|
|
@@ -1220,8 +1172,12 @@ function auditReactAuthoritativePlaythrough(projectRoot) {
|
|
|
1220
1172
|
}
|
|
1221
1173
|
const testPath = join(projectRoot, PRODUCTION_PLAYTHROUGH);
|
|
1222
1174
|
const testSource = existsSync(testPath) ? readFileSync(testPath, "utf8") : "";
|
|
1223
|
-
const testingImports = namedImports(
|
|
1224
|
-
|
|
1175
|
+
const testingImports = namedImports(
|
|
1176
|
+
testPath,
|
|
1177
|
+
testSource,
|
|
1178
|
+
REACT_TESTING_ENTRY
|
|
1179
|
+
);
|
|
1180
|
+
const hasObserve = declaresObserve(testPath, testSource);
|
|
1225
1181
|
if (!hasObserve) {
|
|
1226
1182
|
issues.push(
|
|
1227
1183
|
`${PRODUCTION_PLAYTHROUGH} must declare observe because production uses the devkit clock or Controller ownership hook. Pass { observe: () => telemetry.read.session() } to playthroughTest, using Telemetry backed by the same production Controller rendered by <App />; DOM labels are not an authoritative gameplay boundary.`
|
|
@@ -1277,6 +1233,8 @@ function repairGuidance(code) {
|
|
|
1277
1233
|
return "The stage ran and asserted, but its observable state matched the previous milestone. Preserve the intended gameplay result and observe the same production Controller rendered by <App />. Do not substitute arbitrary labels or a weaker state change.";
|
|
1278
1234
|
case "PLAYTHROUGH_BOUND_EXHAUSTED":
|
|
1279
1235
|
return "Keep the intended outcome unchanged. The stage driver ran, but gameplay did not reach it within the bound. Inspect TRACE and Last diagnostics, then confirm that each deterministic step advances the same production Controller rendered by <App />. If state remains unchanged, inject ManualGameClock through the production App factory and observe that Controller through Telemetry. Do not replace the outcome with navigation, an intermediate phase, a no-op step, or a weaker assertion.";
|
|
1236
|
+
case "PLAYTHROUGH_OUTCOME_NOT_REACHED":
|
|
1237
|
+
return "The stage action completed, but its until condition never became true. Inspect TRACE and Last diagnostics, then confirm that the production DOM input reaches the rendered App, until describes the result caused by this stage, and observe reads the matching authoritative state when used. Add a deterministic step only if the gameplay is actually driven by time or frames; do not add a no-op step or weaken the intended outcome.";
|
|
1280
1238
|
case "PLAYTHROUGH_CLOCK_NOT_ADVANCED":
|
|
1281
1239
|
return "This stage is driven by time or frames, but it did not advance the game clock. Inject the devkit GameClock into the production game and pass step: () => clock.stepFrame(). Do not replace deterministic advancement with a real setTimeout.";
|
|
1282
1240
|
case "EXPECTATION_MISMATCH":
|
|
@@ -1494,8 +1452,11 @@ function defineReactGameVitestConfig(options) {
|
|
|
1494
1452
|
sequence: {
|
|
1495
1453
|
setupFiles: "list"
|
|
1496
1454
|
},
|
|
1497
|
-
|
|
1498
|
-
|
|
1455
|
+
...options.enablePlaythroughReporter ? {
|
|
1456
|
+
// The structured reporter remains available to existing Devkit consumers,
|
|
1457
|
+
// but is no longer a default requirement for generated React games.
|
|
1458
|
+
reporters: [new ReactPlaythroughReporter(options.projectRoot)]
|
|
1459
|
+
} : {},
|
|
1499
1460
|
restoreMocks: true,
|
|
1500
1461
|
clearMocks: true,
|
|
1501
1462
|
testTimeout: options.testTimeout,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "miaoda-game-devkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Shared React and Phaser game lint plus deterministic testing tools for Miaoda games",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -119,6 +119,7 @@
|
|
|
119
119
|
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
|
120
120
|
"jsdom": "29.1.1",
|
|
121
121
|
"oxc-resolver": "11.24.2",
|
|
122
|
+
"oxc-parser": "0.144.0",
|
|
122
123
|
"oxlint": "1.76.0",
|
|
123
124
|
"tailwindcss": "3.4.19",
|
|
124
125
|
"vitest": "4.1.10"
|