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.
@@ -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 withoutComments(source) {
58
- let output = "";
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 codePositions(source) {
109
- const positions = Array.from({ length: source.length }, () => false);
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 importedModuleSpecifiers(source) {
128
- const clean = withoutComments(source);
129
- const positions = codePositions(clean);
130
- const modules = [];
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 clean = withoutComments(source);
156
- const positions = codePositions(clean);
157
- const escapedModule = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
158
- const pattern = new RegExp(
159
- `^\\s*import\\s+(?:type\\s+)?\\{([\\s\\S]*?)\\}\\s+from\\s+["']${escapedModule}["']`,
160
- "gm"
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 declaresObserve(source) {
175
- const clean = withoutComments(source);
176
- const positions = codePositions(clean);
177
- for (const match of clean.matchAll(/\bobserve\s*:/g)) {
178
- if (positions[match.index]) return true;
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 false;
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((file) => importsExampleAlias((0, import_node_fs.readFileSync)(file, "utf8"))).map((file) => (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/"));
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(testSource, REACT_TESTING_ENTRY);
222
- const hasObserve = declaresObserve(testSource);
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 checkReactAuthoritativePlaythrough() {
352
- const name = "react-authoritative-playthrough";
307
+ function checkReactProductTestAlignment() {
308
+ const name = "react-product-test-alignment";
353
309
  const audit = auditReactAuthoritativePlaythrough(projectRoot);
354
- for (const issue of audit.issues) console.error(`[${name}] ${issue}`);
355
- return { name, ok: audit.ok };
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"), checkReactAuthoritativePlaythrough()] : [checkPhaser4Dependency(), checkPhaserViteConfig(), checkVitestConfig("phaser")];
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"]),
@@ -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 withoutComments(source) {
58
- let output = "";
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 codePositions(source) {
109
- const positions = Array.from({ length: source.length }, () => false);
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 importedModuleSpecifiers(source) {
128
- const clean = withoutComments(source);
129
- const positions = codePositions(clean);
130
- const modules = [];
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 clean = withoutComments(source);
156
- const positions = codePositions(clean);
157
- const escapedModule = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
158
- const pattern = new RegExp(
159
- `^\\s*import\\s+(?:type\\s+)?\\{([\\s\\S]*?)\\}\\s+from\\s+["']${escapedModule}["']`,
160
- "gm"
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 declaresObserve(source) {
175
- const clean = withoutComments(source);
176
- const positions = codePositions(clean);
177
- for (const match of clean.matchAll(/\bobserve\s*:/g)) {
178
- if (positions[match.index]) return true;
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 false;
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((file) => importsExampleAlias((0, import_node_fs.readFileSync)(file, "utf8"))).map((file) => (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/"));
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(testSource, REACT_TESTING_ENTRY);
222
- const hasObserve = declaresObserve(testSource);
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 checkReactAuthoritativePlaythrough() {
352
- const name = "react-authoritative-playthrough";
307
+ function checkReactProductTestAlignment() {
308
+ const name = "react-product-test-alignment";
353
309
  const audit = auditReactAuthoritativePlaythrough(projectRoot);
354
- for (const issue of audit.issues) console.error(`[${name}] ${issue}`);
355
- return { name, ok: audit.ok };
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"), checkReactAuthoritativePlaythrough()] : [checkPhaser4Dependency(), checkPhaserViteConfig(), checkVitestConfig("phaser")];
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"]),
@@ -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 会认为"什么都没变"而不刷新界面——游戏悄悄卡死,没有
@@ -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 会认为"什么都没变"而不刷新界面——游戏悄悄卡死,没有
@@ -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
  }
@@ -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
  }
@@ -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, so time-driven gameplay was not advanced. Inject a ManualGameClock for this test and pass step: () => clock.stepFrame().";
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" : "PLAYTHROUGH_CLOCK_NOT_ADVANCED",
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
  }
@@ -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, so time-driven gameplay was not advanced. Inject a ManualGameClock for this test and pass step: () => clock.stepFrame().";
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" : "PLAYTHROUGH_CLOCK_NOT_ADVANCED",
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, so time-driven gameplay was not advanced. Inject a ManualGameClock for this test and pass step: () => clock.stepFrame().";
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" : "PLAYTHROUGH_CLOCK_NOT_ADVANCED",
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 withoutComments(source) {
1094
- let output = "";
1095
- let state = "code";
1096
- for (let index = 0; index < source.length; index += 1) {
1097
- const char = source[index];
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 importedModuleSpecifiers(source) {
1164
- const clean = withoutComments(source);
1165
- const positions = codePositions(clean);
1166
- const modules = [];
1167
- const pattern = /\b(?:from\s+|import\s*(?:\(\s*)?)["']([^"']+)["']\s*\)?/g;
1168
- for (const match of clean.matchAll(pattern)) {
1169
- if (positions[match.index]) modules.push(match[1]);
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 clean = withoutComments(source);
1192
- const positions = codePositions(clean);
1193
- const escapedModule = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1194
- const pattern = new RegExp(
1195
- `^\\s*import\\s+(?:type\\s+)?\\{([\\s\\S]*?)\\}\\s+from\\s+["']${escapedModule}["']`,
1196
- "gm"
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 declaresObserve(source) {
1211
- const clean = withoutComments(source);
1212
- const positions = codePositions(clean);
1213
- for (const match of clean.matchAll(/\bobserve\s*:/g)) {
1214
- if (positions[match.index]) return true;
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 false;
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((file) => importsExampleAlias((0, import_node_fs.readFileSync)(file, "utf8"))).map((file) => (0, import_node_path2.relative)(projectRoot, file).replaceAll("\\", "/"));
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(testSource, REACT_TESTING_ENTRY);
1258
- const hasObserve = declaresObserve(testSource);
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
- // 单一 reporter 输出无 ANSI 的紧凑失败摘要和项目级可玩性门禁。
1532
- reporters: [new ReactPlaythroughReporter(options.projectRoot)],
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, so time-driven gameplay was not advanced. Inject a ManualGameClock for this test and pass step: () => clock.stepFrame().";
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" : "PLAYTHROUGH_CLOCK_NOT_ADVANCED",
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 withoutComments(source) {
1060
- let output = "";
1061
- let state = "code";
1062
- for (let index = 0; index < source.length; index += 1) {
1063
- const char = source[index];
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 importedModuleSpecifiers(source) {
1130
- const clean = withoutComments(source);
1131
- const positions = codePositions(clean);
1132
- const modules = [];
1133
- const pattern = /\b(?:from\s+|import\s*(?:\(\s*)?)["']([^"']+)["']\s*\)?/g;
1134
- for (const match of clean.matchAll(pattern)) {
1135
- if (positions[match.index]) modules.push(match[1]);
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 clean = withoutComments(source);
1158
- const positions = codePositions(clean);
1159
- const escapedModule = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1160
- const pattern = new RegExp(
1161
- `^\\s*import\\s+(?:type\\s+)?\\{([\\s\\S]*?)\\}\\s+from\\s+["']${escapedModule}["']`,
1162
- "gm"
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 declaresObserve(source) {
1177
- const clean = withoutComments(source);
1178
- const positions = codePositions(clean);
1179
- for (const match of clean.matchAll(/\bobserve\s*:/g)) {
1180
- if (positions[match.index]) return true;
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 false;
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((file) => importsExampleAlias(readFileSync(file, "utf8"))).map((file) => relative2(projectRoot, file).replaceAll("\\", "/"));
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(testSource, REACT_TESTING_ENTRY);
1224
- const hasObserve = declaresObserve(testSource);
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
- // 单一 reporter 输出无 ANSI 的紧凑失败摘要和项目级可玩性门禁。
1498
- reporters: [new ReactPlaythroughReporter(options.projectRoot)],
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.6.6",
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"