miaoda-game-devkit 0.6.3 → 0.6.5

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.
@@ -12,6 +12,8 @@ var import_node_path2 = require("path");
12
12
  var import_node_fs = require("fs");
13
13
  var import_node_path = require("path");
14
14
  var PRODUCTION_PLAYTHROUGH = "tests/production-playthrough.test.tsx";
15
+ var PRODUCTION_APP = "src/App.tsx";
16
+ var EXAMPLE_IMPORT_PREFIX = "@/game/example/";
15
17
  var REACT_RUNTIME_ENTRY = "miaoda-game-devkit/react";
16
18
  var REACT_TESTING_ENTRY = "miaoda-game-devkit/react/testing";
17
19
  var CLOCK_IMPORTS = /* @__PURE__ */ new Set(["browserGameClock"]);
@@ -20,6 +22,19 @@ var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
20
22
  "useOwnedGameController"
21
23
  ]);
22
24
  var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
25
+ function runnableTestFiles(root, directory = (0, import_node_path.join)(root, "tests")) {
26
+ if (!(0, import_node_fs.existsSync)(directory)) return [];
27
+ const files = [];
28
+ for (const entry of (0, import_node_fs.readdirSync)(directory, { withFileTypes: true })) {
29
+ const path = (0, import_node_path.join)(directory, entry.name);
30
+ if (entry.isDirectory()) {
31
+ files.push(...runnableTestFiles(root, path));
32
+ } else if (entry.isFile() && /\.test\.[cm]?[jt]sx?$/.test(entry.name)) {
33
+ files.push(path);
34
+ }
35
+ }
36
+ return files;
37
+ }
23
38
  function extension(path) {
24
39
  const index = path.lastIndexOf(".");
25
40
  return index < 0 ? "" : path.slice(index);
@@ -109,6 +124,32 @@ function codePositions(source) {
109
124
  }
110
125
  return positions;
111
126
  }
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]);
134
+ }
135
+ return modules;
136
+ }
137
+ function productionFileImportsExample(file, projectRoot2) {
138
+ const exampleRoot = (0, import_node_path.join)(projectRoot2, "src/game/example");
139
+ return importedModuleSpecifiers((0, import_node_fs.readFileSync)(file, "utf8")).some(
140
+ (moduleName) => {
141
+ if (moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)) return true;
142
+ if (!moduleName.startsWith(".")) return false;
143
+ const target = (0, import_node_path.resolve)((0, import_node_path.dirname)(file), moduleName);
144
+ return target === exampleRoot || target.startsWith(`${exampleRoot}${import_node_path.sep}`);
145
+ }
146
+ );
147
+ }
148
+ function importsExampleAlias(source) {
149
+ return importedModuleSpecifiers(source).some(
150
+ (moduleName) => moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)
151
+ );
152
+ }
112
153
  function namedImports(source, moduleName) {
113
154
  const names = /* @__PURE__ */ new Set();
114
155
  const clean = withoutComments(source);
@@ -141,15 +182,39 @@ function declaresObserve(source) {
141
182
  function auditReactAuthoritativePlaythrough(projectRoot2) {
142
183
  const clockFiles = [];
143
184
  const controllerFiles = [];
144
- for (const file of sourceFiles(projectRoot2)) {
145
- const imports = namedImports((0, import_node_fs.readFileSync)(file, "utf8"), REACT_RUNTIME_ENTRY);
185
+ const productionFiles = sourceFiles(projectRoot2);
186
+ for (const file of productionFiles) {
187
+ const imports = namedImports(
188
+ (0, import_node_fs.readFileSync)(file, "utf8"),
189
+ REACT_RUNTIME_ENTRY
190
+ );
146
191
  const projectPath = (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/");
147
192
  if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
148
- if (containsAny(imports, CONTROLLER_IMPORTS)) controllerFiles.push(projectPath);
193
+ if (containsAny(imports, CONTROLLER_IMPORTS))
194
+ controllerFiles.push(projectPath);
149
195
  }
196
+ const appPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_APP);
197
+ const productionEntryExists = (0, import_node_fs.existsSync)(appPath);
198
+ const productionUsesExample = productionFiles.some(
199
+ (file) => productionFileImportsExample(file, projectRoot2)
200
+ );
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("\\", "/"));
150
202
  const issues = [];
203
+ if (staleExampleTestFiles.length > 0) {
204
+ issues.push(
205
+ `Production ${PRODUCTION_APP} no longer imports ${EXAMPLE_IMPORT_PREFIX}*, but runnable product tests still do: ${staleExampleTestFiles.join(", ")}. Replace those CollectGame tests with tests for the production game or delete genuinely inapplicable slots. Teaching examples under tests/examples/**/*.example.* remain allowed.`
206
+ );
207
+ }
151
208
  if (clockFiles.length === 0 && controllerFiles.length === 0) {
152
- return { ok: true, issues, clockFiles, controllerFiles };
209
+ return {
210
+ ok: issues.length === 0,
211
+ issues,
212
+ clockFiles,
213
+ controllerFiles,
214
+ productionEntryExists,
215
+ productionUsesExample,
216
+ staleExampleTestFiles
217
+ };
153
218
  }
154
219
  const testPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_PLAYTHROUGH);
155
220
  const testSource = (0, import_node_fs.existsSync)(testPath) ? (0, import_node_fs.readFileSync)(testPath, "utf8") : "";
@@ -157,15 +222,23 @@ function auditReactAuthoritativePlaythrough(projectRoot2) {
157
222
  const hasObserve = declaresObserve(testSource);
158
223
  if (!hasObserve) {
159
224
  issues.push(
160
- `${PRODUCTION_PLAYTHROUGH} must declare observe because production uses the devkit clock or Controller ownership hook. Observe the same production Controller rendered by <App /> through Telemetry; DOM labels are not an authoritative gameplay boundary.`
225
+ `${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.`
161
226
  );
162
227
  }
163
228
  if (clockFiles.length > 0 && !testingImports.has("ManualGameClock")) {
164
229
  issues.push(
165
- `${PRODUCTION_PLAYTHROUGH} must import ManualGameClock because production owns gameplay time in ${clockFiles.join(", ")}. Inject it through the production <App /> factory and advance it with a non-empty deterministic step.`
230
+ `${PRODUCTION_PLAYTHROUGH} must import ManualGameClock because production owns gameplay time in ${clockFiles.join(", ")}. Add this import to the test: import { ManualGameClock } from "miaoda-game-devkit/react/testing"; Then create one, inject it through the production <App /> factory, and advance it with step: () => clock.stepFrame().`
166
231
  );
167
232
  }
168
- return { ok: issues.length === 0, issues, clockFiles, controllerFiles };
233
+ return {
234
+ ok: issues.length === 0,
235
+ issues,
236
+ clockFiles,
237
+ controllerFiles,
238
+ productionEntryExists,
239
+ productionUsesExample,
240
+ staleExampleTestFiles
241
+ };
169
242
  }
170
243
 
171
244
  // src/cli/lint.ts
@@ -12,6 +12,8 @@ var import_node_path2 = require("path");
12
12
  var import_node_fs = require("fs");
13
13
  var import_node_path = require("path");
14
14
  var PRODUCTION_PLAYTHROUGH = "tests/production-playthrough.test.tsx";
15
+ var PRODUCTION_APP = "src/App.tsx";
16
+ var EXAMPLE_IMPORT_PREFIX = "@/game/example/";
15
17
  var REACT_RUNTIME_ENTRY = "miaoda-game-devkit/react";
16
18
  var REACT_TESTING_ENTRY = "miaoda-game-devkit/react/testing";
17
19
  var CLOCK_IMPORTS = /* @__PURE__ */ new Set(["browserGameClock"]);
@@ -20,6 +22,19 @@ var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
20
22
  "useOwnedGameController"
21
23
  ]);
22
24
  var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
25
+ function runnableTestFiles(root, directory = (0, import_node_path.join)(root, "tests")) {
26
+ if (!(0, import_node_fs.existsSync)(directory)) return [];
27
+ const files = [];
28
+ for (const entry of (0, import_node_fs.readdirSync)(directory, { withFileTypes: true })) {
29
+ const path = (0, import_node_path.join)(directory, entry.name);
30
+ if (entry.isDirectory()) {
31
+ files.push(...runnableTestFiles(root, path));
32
+ } else if (entry.isFile() && /\.test\.[cm]?[jt]sx?$/.test(entry.name)) {
33
+ files.push(path);
34
+ }
35
+ }
36
+ return files;
37
+ }
23
38
  function extension(path) {
24
39
  const index = path.lastIndexOf(".");
25
40
  return index < 0 ? "" : path.slice(index);
@@ -109,6 +124,32 @@ function codePositions(source) {
109
124
  }
110
125
  return positions;
111
126
  }
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]);
134
+ }
135
+ return modules;
136
+ }
137
+ function productionFileImportsExample(file, projectRoot2) {
138
+ const exampleRoot = (0, import_node_path.join)(projectRoot2, "src/game/example");
139
+ return importedModuleSpecifiers((0, import_node_fs.readFileSync)(file, "utf8")).some(
140
+ (moduleName) => {
141
+ if (moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)) return true;
142
+ if (!moduleName.startsWith(".")) return false;
143
+ const target = (0, import_node_path.resolve)((0, import_node_path.dirname)(file), moduleName);
144
+ return target === exampleRoot || target.startsWith(`${exampleRoot}${import_node_path.sep}`);
145
+ }
146
+ );
147
+ }
148
+ function importsExampleAlias(source) {
149
+ return importedModuleSpecifiers(source).some(
150
+ (moduleName) => moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)
151
+ );
152
+ }
112
153
  function namedImports(source, moduleName) {
113
154
  const names = /* @__PURE__ */ new Set();
114
155
  const clean = withoutComments(source);
@@ -141,15 +182,39 @@ function declaresObserve(source) {
141
182
  function auditReactAuthoritativePlaythrough(projectRoot2) {
142
183
  const clockFiles = [];
143
184
  const controllerFiles = [];
144
- for (const file of sourceFiles(projectRoot2)) {
145
- const imports = namedImports((0, import_node_fs.readFileSync)(file, "utf8"), REACT_RUNTIME_ENTRY);
185
+ const productionFiles = sourceFiles(projectRoot2);
186
+ for (const file of productionFiles) {
187
+ const imports = namedImports(
188
+ (0, import_node_fs.readFileSync)(file, "utf8"),
189
+ REACT_RUNTIME_ENTRY
190
+ );
146
191
  const projectPath = (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/");
147
192
  if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
148
- if (containsAny(imports, CONTROLLER_IMPORTS)) controllerFiles.push(projectPath);
193
+ if (containsAny(imports, CONTROLLER_IMPORTS))
194
+ controllerFiles.push(projectPath);
149
195
  }
196
+ const appPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_APP);
197
+ const productionEntryExists = (0, import_node_fs.existsSync)(appPath);
198
+ const productionUsesExample = productionFiles.some(
199
+ (file) => productionFileImportsExample(file, projectRoot2)
200
+ );
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("\\", "/"));
150
202
  const issues = [];
203
+ if (staleExampleTestFiles.length > 0) {
204
+ issues.push(
205
+ `Production ${PRODUCTION_APP} no longer imports ${EXAMPLE_IMPORT_PREFIX}*, but runnable product tests still do: ${staleExampleTestFiles.join(", ")}. Replace those CollectGame tests with tests for the production game or delete genuinely inapplicable slots. Teaching examples under tests/examples/**/*.example.* remain allowed.`
206
+ );
207
+ }
151
208
  if (clockFiles.length === 0 && controllerFiles.length === 0) {
152
- return { ok: true, issues, clockFiles, controllerFiles };
209
+ return {
210
+ ok: issues.length === 0,
211
+ issues,
212
+ clockFiles,
213
+ controllerFiles,
214
+ productionEntryExists,
215
+ productionUsesExample,
216
+ staleExampleTestFiles
217
+ };
153
218
  }
154
219
  const testPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_PLAYTHROUGH);
155
220
  const testSource = (0, import_node_fs.existsSync)(testPath) ? (0, import_node_fs.readFileSync)(testPath, "utf8") : "";
@@ -157,15 +222,23 @@ function auditReactAuthoritativePlaythrough(projectRoot2) {
157
222
  const hasObserve = declaresObserve(testSource);
158
223
  if (!hasObserve) {
159
224
  issues.push(
160
- `${PRODUCTION_PLAYTHROUGH} must declare observe because production uses the devkit clock or Controller ownership hook. Observe the same production Controller rendered by <App /> through Telemetry; DOM labels are not an authoritative gameplay boundary.`
225
+ `${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.`
161
226
  );
162
227
  }
163
228
  if (clockFiles.length > 0 && !testingImports.has("ManualGameClock")) {
164
229
  issues.push(
165
- `${PRODUCTION_PLAYTHROUGH} must import ManualGameClock because production owns gameplay time in ${clockFiles.join(", ")}. Inject it through the production <App /> factory and advance it with a non-empty deterministic step.`
230
+ `${PRODUCTION_PLAYTHROUGH} must import ManualGameClock because production owns gameplay time in ${clockFiles.join(", ")}. Add this import to the test: import { ManualGameClock } from "miaoda-game-devkit/react/testing"; Then create one, inject it through the production <App /> factory, and advance it with step: () => clock.stepFrame().`
166
231
  );
167
232
  }
168
- return { ok: issues.length === 0, issues, clockFiles, controllerFiles };
233
+ return {
234
+ ok: issues.length === 0,
235
+ issues,
236
+ clockFiles,
237
+ controllerFiles,
238
+ productionEntryExists,
239
+ productionUsesExample,
240
+ staleExampleTestFiles
241
+ };
169
242
  }
170
243
 
171
244
  // src/cli/lint.ts
@@ -33,14 +33,247 @@ __export(react_vitest_config_exports, {
33
33
  defineReactGameVitestConfig: () => defineReactGameVitestConfig
34
34
  });
35
35
  module.exports = __toCommonJS(react_vitest_config_exports);
36
- var import_node_fs2 = require("fs");
37
- var import_node_path2 = require("path");
36
+ var import_node_fs3 = require("fs");
37
+ var import_node_path3 = require("path");
38
38
  var import_config = require("vitest/config");
39
39
 
40
40
  // src/react/react-playthrough-reporter.ts
41
+ var import_node_fs2 = require("fs");
42
+ var import_node_path2 = require("path");
43
+ var import_node_util = require("util");
44
+
45
+ // src/cli/react-authoritative-playthrough.ts
41
46
  var import_node_fs = require("fs");
42
47
  var import_node_path = require("path");
43
- var import_node_util = require("util");
48
+ var PRODUCTION_PLAYTHROUGH = "tests/production-playthrough.test.tsx";
49
+ var PRODUCTION_APP = "src/App.tsx";
50
+ var EXAMPLE_IMPORT_PREFIX = "@/game/example/";
51
+ var REACT_RUNTIME_ENTRY = "miaoda-game-devkit/react";
52
+ var REACT_TESTING_ENTRY = "miaoda-game-devkit/react/testing";
53
+ var CLOCK_IMPORTS = /* @__PURE__ */ new Set(["browserGameClock"]);
54
+ var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
55
+ "useGameController",
56
+ "useOwnedGameController"
57
+ ]);
58
+ var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
59
+ function runnableTestFiles(root, directory = (0, import_node_path.join)(root, "tests")) {
60
+ if (!(0, import_node_fs.existsSync)(directory)) return [];
61
+ const files = [];
62
+ for (const entry of (0, import_node_fs.readdirSync)(directory, { withFileTypes: true })) {
63
+ const path = (0, import_node_path.join)(directory, entry.name);
64
+ if (entry.isDirectory()) {
65
+ files.push(...runnableTestFiles(root, path));
66
+ } else if (entry.isFile() && /\.test\.[cm]?[jt]sx?$/.test(entry.name)) {
67
+ files.push(path);
68
+ }
69
+ }
70
+ return files;
71
+ }
72
+ function extension(path) {
73
+ const index = path.lastIndexOf(".");
74
+ return index < 0 ? "" : path.slice(index);
75
+ }
76
+ function sourceFiles(root, directory = (0, import_node_path.join)(root, "src")) {
77
+ if (!(0, import_node_fs.existsSync)(directory)) return [];
78
+ const files = [];
79
+ for (const entry of (0, import_node_fs.readdirSync)(directory, { withFileTypes: true })) {
80
+ const path = (0, import_node_path.join)(directory, entry.name);
81
+ const projectPath = (0, import_node_path.relative)(root, path).replaceAll("\\", "/");
82
+ if (entry.isDirectory()) {
83
+ if (projectPath === "src/game/example") continue;
84
+ files.push(...sourceFiles(root, path));
85
+ } else if (entry.isFile() && SOURCE_EXTENSIONS.has(extension(entry.name))) {
86
+ files.push(path);
87
+ }
88
+ }
89
+ return files;
90
+ }
91
+ function withoutComments(source) {
92
+ let output = "";
93
+ let state = "code";
94
+ for (let index = 0; index < source.length; index += 1) {
95
+ const char = source[index];
96
+ const next = source[index + 1];
97
+ if (state === "line") {
98
+ if (char === "\n") {
99
+ state = "code";
100
+ output += char;
101
+ } else {
102
+ output += " ";
103
+ }
104
+ continue;
105
+ }
106
+ if (state === "block") {
107
+ if (char === "*" && next === "/") {
108
+ output += " ";
109
+ index += 1;
110
+ state = "code";
111
+ } else {
112
+ output += char === "\n" ? "\n" : " ";
113
+ }
114
+ continue;
115
+ }
116
+ if (state === "code" && char === "/" && next === "/") {
117
+ output += " ";
118
+ index += 1;
119
+ state = "line";
120
+ continue;
121
+ }
122
+ if (state === "code" && char === "/" && next === "*") {
123
+ output += " ";
124
+ index += 1;
125
+ state = "block";
126
+ continue;
127
+ }
128
+ if (state === "code" && char === "'") state = "single";
129
+ else if (state === "code" && char === '"') state = "double";
130
+ else if (state === "code" && char === "`") state = "template";
131
+ else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
132
+ state = "code";
133
+ } else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
134
+ state = "code";
135
+ } else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
136
+ state = "code";
137
+ }
138
+ output += char;
139
+ }
140
+ return output;
141
+ }
142
+ function codePositions(source) {
143
+ const positions = Array.from({ length: source.length }, () => false);
144
+ let state = "code";
145
+ for (let index = 0; index < source.length; index += 1) {
146
+ const char = source[index];
147
+ if (state === "code") positions[index] = true;
148
+ if (state === "code" && char === "'") state = "single";
149
+ else if (state === "code" && char === '"') state = "double";
150
+ else if (state === "code" && char === "`") state = "template";
151
+ else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
152
+ state = "code";
153
+ } else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
154
+ state = "code";
155
+ } else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
156
+ state = "code";
157
+ }
158
+ }
159
+ return positions;
160
+ }
161
+ function importedModuleSpecifiers(source) {
162
+ const clean = withoutComments(source);
163
+ const positions = codePositions(clean);
164
+ const modules = [];
165
+ const pattern = /\b(?:from\s+|import\s*(?:\(\s*)?)["']([^"']+)["']\s*\)?/g;
166
+ for (const match of clean.matchAll(pattern)) {
167
+ if (positions[match.index]) modules.push(match[1]);
168
+ }
169
+ return modules;
170
+ }
171
+ function productionFileImportsExample(file, projectRoot) {
172
+ const exampleRoot = (0, import_node_path.join)(projectRoot, "src/game/example");
173
+ return importedModuleSpecifiers((0, import_node_fs.readFileSync)(file, "utf8")).some(
174
+ (moduleName) => {
175
+ if (moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)) return true;
176
+ if (!moduleName.startsWith(".")) return false;
177
+ const target = (0, import_node_path.resolve)((0, import_node_path.dirname)(file), moduleName);
178
+ return target === exampleRoot || target.startsWith(`${exampleRoot}${import_node_path.sep}`);
179
+ }
180
+ );
181
+ }
182
+ function importsExampleAlias(source) {
183
+ return importedModuleSpecifiers(source).some(
184
+ (moduleName) => moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)
185
+ );
186
+ }
187
+ function namedImports(source, moduleName) {
188
+ const names = /* @__PURE__ */ new Set();
189
+ const clean = withoutComments(source);
190
+ const positions = codePositions(clean);
191
+ const escapedModule = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
192
+ const pattern = new RegExp(
193
+ `^\\s*import\\s+(?:type\\s+)?\\{([\\s\\S]*?)\\}\\s+from\\s+["']${escapedModule}["']`,
194
+ "gm"
195
+ );
196
+ for (const match of clean.matchAll(pattern)) {
197
+ if (!positions[match.index]) continue;
198
+ for (const specifier of match[1].split(",")) {
199
+ const imported = specifier.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim();
200
+ if (imported) names.add(imported);
201
+ }
202
+ }
203
+ return names;
204
+ }
205
+ function containsAny(values, expected) {
206
+ return [...values].some((value) => expected.has(value));
207
+ }
208
+ function declaresObserve(source) {
209
+ const clean = withoutComments(source);
210
+ const positions = codePositions(clean);
211
+ for (const match of clean.matchAll(/\bobserve\s*:/g)) {
212
+ if (positions[match.index]) return true;
213
+ }
214
+ return false;
215
+ }
216
+ function auditReactAuthoritativePlaythrough(projectRoot) {
217
+ const clockFiles = [];
218
+ const controllerFiles = [];
219
+ const productionFiles = sourceFiles(projectRoot);
220
+ for (const file of productionFiles) {
221
+ const imports = namedImports(
222
+ (0, import_node_fs.readFileSync)(file, "utf8"),
223
+ REACT_RUNTIME_ENTRY
224
+ );
225
+ const projectPath = (0, import_node_path.relative)(projectRoot, file).replaceAll("\\", "/");
226
+ if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
227
+ if (containsAny(imports, CONTROLLER_IMPORTS))
228
+ controllerFiles.push(projectPath);
229
+ }
230
+ const appPath = (0, import_node_path.join)(projectRoot, PRODUCTION_APP);
231
+ const productionEntryExists = (0, import_node_fs.existsSync)(appPath);
232
+ const productionUsesExample = productionFiles.some(
233
+ (file) => productionFileImportsExample(file, projectRoot)
234
+ );
235
+ const staleExampleTestFiles = productionUsesExample ? [] : runnableTestFiles(projectRoot).filter((file) => importsExampleAlias((0, import_node_fs.readFileSync)(file, "utf8"))).map((file) => (0, import_node_path.relative)(projectRoot, file).replaceAll("\\", "/"));
236
+ const issues = [];
237
+ if (staleExampleTestFiles.length > 0) {
238
+ issues.push(
239
+ `Production ${PRODUCTION_APP} no longer imports ${EXAMPLE_IMPORT_PREFIX}*, but runnable product tests still do: ${staleExampleTestFiles.join(", ")}. Replace those CollectGame tests with tests for the production game or delete genuinely inapplicable slots. Teaching examples under tests/examples/**/*.example.* remain allowed.`
240
+ );
241
+ }
242
+ if (clockFiles.length === 0 && controllerFiles.length === 0) {
243
+ return {
244
+ ok: issues.length === 0,
245
+ issues,
246
+ clockFiles,
247
+ controllerFiles,
248
+ productionEntryExists,
249
+ productionUsesExample,
250
+ staleExampleTestFiles
251
+ };
252
+ }
253
+ const testPath = (0, import_node_path.join)(projectRoot, PRODUCTION_PLAYTHROUGH);
254
+ const testSource = (0, import_node_fs.existsSync)(testPath) ? (0, import_node_fs.readFileSync)(testPath, "utf8") : "";
255
+ const testingImports = namedImports(testSource, REACT_TESTING_ENTRY);
256
+ const hasObserve = declaresObserve(testSource);
257
+ if (!hasObserve) {
258
+ issues.push(
259
+ `${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.`
260
+ );
261
+ }
262
+ if (clockFiles.length > 0 && !testingImports.has("ManualGameClock")) {
263
+ issues.push(
264
+ `${PRODUCTION_PLAYTHROUGH} must import ManualGameClock because production owns gameplay time in ${clockFiles.join(", ")}. Add this import to the test: import { ManualGameClock } from "miaoda-game-devkit/react/testing"; Then create one, inject it through the production <App /> factory, and advance it with step: () => clock.stepFrame().`
265
+ );
266
+ }
267
+ return {
268
+ ok: issues.length === 0,
269
+ issues,
270
+ clockFiles,
271
+ controllerFiles,
272
+ productionEntryExists,
273
+ productionUsesExample,
274
+ staleExampleTestFiles
275
+ };
276
+ }
44
277
 
45
278
  // src/react/react-playthrough.ts
46
279
  var import_react2 = require("@testing-library/react");
@@ -628,6 +861,25 @@ function auditReactPlaythroughRun(tests) {
628
861
  // src/react/react-playthrough-reporter.ts
629
862
  var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
630
863
  var REPAIR_CONSTRAINT = "Preserve the intended gameplay outcome. Fix the production mechanic, deterministic driver, or authoritative observation that prevents it. Do not make the test pass by weakening or deleting assertions, replacing the outcome with back/menu/exit navigation, observing arbitrary UI text only to change a fingerprint, using a no-op step, or treating an intermediate active/in-flight/running phase as meaningful progress.";
864
+ function assessProductTestAlignment(projectRoot) {
865
+ const audit = auditReactAuthoritativePlaythrough(projectRoot);
866
+ if (audit.staleExampleTestFiles.length > 0) {
867
+ return { status: "FAILED", cause: audit.issues[0] };
868
+ }
869
+ if (!audit.productionEntryExists) {
870
+ return {
871
+ status: "NOT_VERIFIED",
872
+ cause: "The production src/App.tsx entry does not exist, so product-test alignment could not be verified."
873
+ };
874
+ }
875
+ if (audit.productionUsesExample) {
876
+ return {
877
+ status: "NOT_VERIFIED",
878
+ cause: "The production App still uses the replaceable src/game/example teaching game, so replacement-game test alignment is not applicable yet."
879
+ };
880
+ }
881
+ return { status: "PASS" };
882
+ }
631
883
  function isMetadata(value) {
632
884
  if (!value || typeof value !== "object") return false;
633
885
  const metadata = value;
@@ -655,7 +907,7 @@ function toAuditInput(test2) {
655
907
  }
656
908
  function findPendingProductTests(modules, projectRoot) {
657
909
  return modules.flatMap((module2) => {
658
- const file = (0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/");
910
+ const file = (0, import_node_path2.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/");
659
911
  if (file.split("/").includes("examples")) return [];
660
912
  const tests = [...module2.children.allTests()];
661
913
  const pending = tests.filter((test2) => {
@@ -752,7 +1004,10 @@ function truncateReporterLine(value, limit) {
752
1004
  }
753
1005
  function toModuleResult(module2, projectRoot) {
754
1006
  const tests = [...module2.children.allTests()];
755
- const moduleFailure = selectReactFailure(module2.errors(), module2.errors().length);
1007
+ const moduleFailure = selectReactFailure(
1008
+ module2.errors(),
1009
+ module2.errors().length
1010
+ );
756
1011
  const moduleErrors = extractFailureEntries(module2.errors()).map(
757
1012
  (entry) => firstLine(entry.message) ?? entry.message
758
1013
  );
@@ -778,7 +1033,7 @@ function toModuleResult(module2, projectRoot) {
778
1033
  causeCode: selected.code,
779
1034
  cause: selected.cause,
780
1035
  related: selected.related,
781
- location: test2.location ? `${(0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(selected.rawCause),
1036
+ location: test2.location ? `${(0, import_node_path2.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(selected.rawCause),
782
1037
  hint: failureHint(selected.rawCause),
783
1038
  trace: failureTrace(test2)
784
1039
  };
@@ -794,7 +1049,7 @@ function toModuleResult(module2, projectRoot) {
794
1049
  });
795
1050
  }
796
1051
  return {
797
- file: (0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/"),
1052
+ file: (0, import_node_path2.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/"),
798
1053
  state: module2.state(),
799
1054
  errors,
800
1055
  primaryError: moduleErrors[0] ?? failures[0]?.cause,
@@ -932,8 +1187,13 @@ function assessReactPlaythroughReport(input) {
932
1187
  }
933
1188
  return { ...base, status: "PASS", failsRun: false };
934
1189
  }
935
- function formatReactPlaythroughReport(report) {
936
- const lines = [`REACT_PLAYTHROUGH: ${report.status}`, `FILE: ${report.file}`];
1190
+ function formatReactPlaythroughReport(report, alignment) {
1191
+ const lines = [
1192
+ `REACT_PLAYTHROUGH_STRUCTURE: ${report.status}`,
1193
+ `PRODUCT_TEST_ALIGNMENT: ${alignment?.status ?? "NOT_VERIFIED"}`,
1194
+ `FILE: ${report.file}`
1195
+ ];
1196
+ if (alignment?.cause) lines.push(`ALIGNMENT_CAUSE: ${alignment.cause}`);
937
1197
  if (report.causeCode) lines.push(`CAUSE_CODE: ${report.causeCode}`);
938
1198
  if (report.cause) lines.push(`CAUSE: ${report.cause}`);
939
1199
  for (const [index, related] of (report.related ?? []).entries()) {
@@ -956,7 +1216,7 @@ var ReactPlaythroughReporter = class {
956
1216
  /** 绑定模板根目录,以稳定识别生产可玩性测试而不依赖测试名称。 */
957
1217
  constructor(projectRoot) {
958
1218
  this.projectRoot = projectRoot;
959
- this.expectedModuleId = (0, import_node_path.resolve)(projectRoot, this.expectedFile);
1219
+ this.expectedModuleId = (0, import_node_path2.resolve)(projectRoot, this.expectedFile);
960
1220
  }
961
1221
  projectRoot;
962
1222
  expectedFile = PRODUCTION_PLAYTHROUGH_FILE;
@@ -966,7 +1226,7 @@ var ReactPlaythroughReporter = class {
966
1226
  /** 记录本轮是否实际选择了生产流程文件,用于区分聚焦运行与门禁失败。 */
967
1227
  onTestRunStart(specifications) {
968
1228
  this.expectedFileScheduled = specifications.some(
969
- (specification) => (0, import_node_path.resolve)(specification.moduleId) === this.expectedModuleId
1229
+ (specification) => (0, import_node_path2.resolve)(specification.moduleId) === this.expectedModuleId
970
1230
  );
971
1231
  this.focusedSelection = specifications.some(
972
1232
  (specification) => Boolean(specification.project.globalConfig.testNamePattern) || Boolean(specification.testNamePattern) || Boolean(specification.testLines?.length)
@@ -980,7 +1240,7 @@ var ReactPlaythroughReporter = class {
980
1240
  );
981
1241
  const report = assessReactPlaythroughReport({
982
1242
  expectedFile: this.expectedFile,
983
- expectedFileExists: (0, import_node_fs.existsSync)(this.expectedModuleId),
1243
+ expectedFileExists: (0, import_node_fs2.existsSync)(this.expectedModuleId),
984
1244
  expectedFileScheduled: this.expectedFileScheduled,
985
1245
  focusedSelection: this.focusedSelection,
986
1246
  modules: testModules.map(
@@ -990,8 +1250,9 @@ var ReactPlaythroughReporter = class {
990
1250
  (entry) => firstLine(entry.message) ?? entry.message
991
1251
  )
992
1252
  });
993
- const output = formatReactPlaythroughReport(report);
994
- if (report.failsRun) {
1253
+ const alignment = assessProductTestAlignment(this.projectRoot);
1254
+ const output = formatReactPlaythroughReport(report, alignment);
1255
+ if (report.failsRun || alignment.status === "FAILED") {
995
1256
  console.error(output);
996
1257
  process.exitCode = 1;
997
1258
  } else if (report.status === "WAIVED" || report.status === "NOT_CHECKED") {
@@ -1007,7 +1268,7 @@ var ReactPlaythroughReporter = class {
1007
1268
  const summary = formatReactFailureSummary(
1008
1269
  testModules.map((module2) => toModuleResult(module2, this.projectRoot))
1009
1270
  );
1010
- if ((report.failsRun || pendingProductTests.length > 0) && summary[0] === "TEST_RESULT: PASS") {
1271
+ if ((report.failsRun || alignment.status === "FAILED" || pendingProductTests.length > 0) && summary[0] === "TEST_RESULT: PASS") {
1011
1272
  summary[0] = "TEST_RESULT: FAIL";
1012
1273
  }
1013
1274
  console.log(`
@@ -1023,16 +1284,16 @@ function getJSDOMWorkerExecArgv() {
1023
1284
 
1024
1285
  // src/react-vitest-config.ts
1025
1286
  function resolvePhaser3BrowserEntry(projectRoot) {
1026
- const manifestPath = (0, import_node_path2.resolve)(projectRoot, "node_modules/phaser/package.json");
1027
- if (!(0, import_node_fs2.existsSync)(manifestPath)) return void 0;
1287
+ const manifestPath = (0, import_node_path3.resolve)(projectRoot, "node_modules/phaser/package.json");
1288
+ if (!(0, import_node_fs3.existsSync)(manifestPath)) return void 0;
1028
1289
  try {
1029
- const manifest = JSON.parse((0, import_node_fs2.readFileSync)(manifestPath, "utf8"));
1290
+ const manifest = JSON.parse((0, import_node_fs3.readFileSync)(manifestPath, "utf8"));
1030
1291
  if (!manifest.version?.startsWith("3.")) return void 0;
1031
- const browserEntry = (0, import_node_path2.resolve)(
1032
- (0, import_node_path2.dirname)(manifestPath),
1292
+ const browserEntry = (0, import_node_path3.resolve)(
1293
+ (0, import_node_path3.dirname)(manifestPath),
1033
1294
  manifest.browser ?? "dist/phaser.js"
1034
1295
  );
1035
- return (0, import_node_fs2.existsSync)(browserEntry) ? browserEntry : void 0;
1296
+ return (0, import_node_fs3.existsSync)(browserEntry) ? browserEntry : void 0;
1036
1297
  } catch {
1037
1298
  return void 0;
1038
1299
  }
@@ -1047,7 +1308,7 @@ function defineReactGameVitestConfig(options) {
1047
1308
  alias: {
1048
1309
  ...phaser3BrowserEntry ? { phaser: phaser3BrowserEntry } : {},
1049
1310
  ...options.aliases,
1050
- "@": (0, import_node_path2.resolve)(options.projectRoot, "src")
1311
+ "@": (0, import_node_path3.resolve)(options.projectRoot, "src")
1051
1312
  }
1052
1313
  },
1053
1314
  test: {
@@ -1,13 +1,246 @@
1
1
  // src/react-vitest-config.ts
2
- import { existsSync as existsSync2, readFileSync } from "fs";
3
- import { dirname, resolve as resolve2 } from "path";
2
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
3
+ import { dirname as dirname2, resolve as resolve3 } from "path";
4
4
  import { defineConfig } from "vitest/config";
5
5
 
6
6
  // src/react/react-playthrough-reporter.ts
7
- import { existsSync } from "fs";
8
- import { relative, resolve } from "path";
7
+ import { existsSync as existsSync2 } from "fs";
8
+ import { relative as relative2, resolve as resolve2 } from "path";
9
9
  import { stripVTControlCharacters } from "util";
10
10
 
11
+ // src/cli/react-authoritative-playthrough.ts
12
+ import { existsSync, readdirSync, readFileSync } from "fs";
13
+ import { dirname, join, relative, resolve, sep } from "path";
14
+ var PRODUCTION_PLAYTHROUGH = "tests/production-playthrough.test.tsx";
15
+ var PRODUCTION_APP = "src/App.tsx";
16
+ var EXAMPLE_IMPORT_PREFIX = "@/game/example/";
17
+ var REACT_RUNTIME_ENTRY = "miaoda-game-devkit/react";
18
+ var REACT_TESTING_ENTRY = "miaoda-game-devkit/react/testing";
19
+ var CLOCK_IMPORTS = /* @__PURE__ */ new Set(["browserGameClock"]);
20
+ var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
21
+ "useGameController",
22
+ "useOwnedGameController"
23
+ ]);
24
+ var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
25
+ function runnableTestFiles(root, directory = join(root, "tests")) {
26
+ if (!existsSync(directory)) return [];
27
+ const files = [];
28
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
29
+ const path = join(directory, entry.name);
30
+ if (entry.isDirectory()) {
31
+ files.push(...runnableTestFiles(root, path));
32
+ } else if (entry.isFile() && /\.test\.[cm]?[jt]sx?$/.test(entry.name)) {
33
+ files.push(path);
34
+ }
35
+ }
36
+ return files;
37
+ }
38
+ function extension(path) {
39
+ const index = path.lastIndexOf(".");
40
+ return index < 0 ? "" : path.slice(index);
41
+ }
42
+ function sourceFiles(root, directory = join(root, "src")) {
43
+ if (!existsSync(directory)) return [];
44
+ const files = [];
45
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
46
+ const path = join(directory, entry.name);
47
+ const projectPath = relative(root, path).replaceAll("\\", "/");
48
+ if (entry.isDirectory()) {
49
+ if (projectPath === "src/game/example") continue;
50
+ files.push(...sourceFiles(root, path));
51
+ } else if (entry.isFile() && SOURCE_EXTENSIONS.has(extension(entry.name))) {
52
+ files.push(path);
53
+ }
54
+ }
55
+ return files;
56
+ }
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;
107
+ }
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;
126
+ }
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]);
134
+ }
135
+ return modules;
136
+ }
137
+ function productionFileImportsExample(file, projectRoot) {
138
+ const exampleRoot = join(projectRoot, "src/game/example");
139
+ return importedModuleSpecifiers(readFileSync(file, "utf8")).some(
140
+ (moduleName) => {
141
+ if (moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)) return true;
142
+ if (!moduleName.startsWith(".")) return false;
143
+ const target = resolve(dirname(file), moduleName);
144
+ return target === exampleRoot || target.startsWith(`${exampleRoot}${sep}`);
145
+ }
146
+ );
147
+ }
148
+ function importsExampleAlias(source) {
149
+ return importedModuleSpecifiers(source).some(
150
+ (moduleName) => moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)
151
+ );
152
+ }
153
+ function namedImports(source, moduleName) {
154
+ 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);
167
+ }
168
+ }
169
+ return names;
170
+ }
171
+ function containsAny(values, expected) {
172
+ return [...values].some((value) => expected.has(value));
173
+ }
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;
179
+ }
180
+ return false;
181
+ }
182
+ function auditReactAuthoritativePlaythrough(projectRoot) {
183
+ const clockFiles = [];
184
+ const controllerFiles = [];
185
+ const productionFiles = sourceFiles(projectRoot);
186
+ for (const file of productionFiles) {
187
+ const imports = namedImports(
188
+ readFileSync(file, "utf8"),
189
+ REACT_RUNTIME_ENTRY
190
+ );
191
+ const projectPath = relative(projectRoot, file).replaceAll("\\", "/");
192
+ if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
193
+ if (containsAny(imports, CONTROLLER_IMPORTS))
194
+ controllerFiles.push(projectPath);
195
+ }
196
+ const appPath = join(projectRoot, PRODUCTION_APP);
197
+ const productionEntryExists = existsSync(appPath);
198
+ const productionUsesExample = productionFiles.some(
199
+ (file) => productionFileImportsExample(file, projectRoot)
200
+ );
201
+ const staleExampleTestFiles = productionUsesExample ? [] : runnableTestFiles(projectRoot).filter((file) => importsExampleAlias(readFileSync(file, "utf8"))).map((file) => relative(projectRoot, file).replaceAll("\\", "/"));
202
+ const issues = [];
203
+ if (staleExampleTestFiles.length > 0) {
204
+ issues.push(
205
+ `Production ${PRODUCTION_APP} no longer imports ${EXAMPLE_IMPORT_PREFIX}*, but runnable product tests still do: ${staleExampleTestFiles.join(", ")}. Replace those CollectGame tests with tests for the production game or delete genuinely inapplicable slots. Teaching examples under tests/examples/**/*.example.* remain allowed.`
206
+ );
207
+ }
208
+ if (clockFiles.length === 0 && controllerFiles.length === 0) {
209
+ return {
210
+ ok: issues.length === 0,
211
+ issues,
212
+ clockFiles,
213
+ controllerFiles,
214
+ productionEntryExists,
215
+ productionUsesExample,
216
+ staleExampleTestFiles
217
+ };
218
+ }
219
+ const testPath = join(projectRoot, PRODUCTION_PLAYTHROUGH);
220
+ const testSource = existsSync(testPath) ? readFileSync(testPath, "utf8") : "";
221
+ const testingImports = namedImports(testSource, REACT_TESTING_ENTRY);
222
+ const hasObserve = declaresObserve(testSource);
223
+ if (!hasObserve) {
224
+ issues.push(
225
+ `${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.`
226
+ );
227
+ }
228
+ if (clockFiles.length > 0 && !testingImports.has("ManualGameClock")) {
229
+ issues.push(
230
+ `${PRODUCTION_PLAYTHROUGH} must import ManualGameClock because production owns gameplay time in ${clockFiles.join(", ")}. Add this import to the test: import { ManualGameClock } from "miaoda-game-devkit/react/testing"; Then create one, inject it through the production <App /> factory, and advance it with step: () => clock.stepFrame().`
231
+ );
232
+ }
233
+ return {
234
+ ok: issues.length === 0,
235
+ issues,
236
+ clockFiles,
237
+ controllerFiles,
238
+ productionEntryExists,
239
+ productionUsesExample,
240
+ staleExampleTestFiles
241
+ };
242
+ }
243
+
11
244
  // src/react/react-playthrough.ts
12
245
  import { render } from "@testing-library/react";
13
246
  import userEvent from "@testing-library/user-event";
@@ -594,6 +827,25 @@ function auditReactPlaythroughRun(tests) {
594
827
  // src/react/react-playthrough-reporter.ts
595
828
  var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
596
829
  var REPAIR_CONSTRAINT = "Preserve the intended gameplay outcome. Fix the production mechanic, deterministic driver, or authoritative observation that prevents it. Do not make the test pass by weakening or deleting assertions, replacing the outcome with back/menu/exit navigation, observing arbitrary UI text only to change a fingerprint, using a no-op step, or treating an intermediate active/in-flight/running phase as meaningful progress.";
830
+ function assessProductTestAlignment(projectRoot) {
831
+ const audit = auditReactAuthoritativePlaythrough(projectRoot);
832
+ if (audit.staleExampleTestFiles.length > 0) {
833
+ return { status: "FAILED", cause: audit.issues[0] };
834
+ }
835
+ if (!audit.productionEntryExists) {
836
+ return {
837
+ status: "NOT_VERIFIED",
838
+ cause: "The production src/App.tsx entry does not exist, so product-test alignment could not be verified."
839
+ };
840
+ }
841
+ if (audit.productionUsesExample) {
842
+ return {
843
+ status: "NOT_VERIFIED",
844
+ cause: "The production App still uses the replaceable src/game/example teaching game, so replacement-game test alignment is not applicable yet."
845
+ };
846
+ }
847
+ return { status: "PASS" };
848
+ }
597
849
  function isMetadata(value) {
598
850
  if (!value || typeof value !== "object") return false;
599
851
  const metadata = value;
@@ -621,7 +873,7 @@ function toAuditInput(test2) {
621
873
  }
622
874
  function findPendingProductTests(modules, projectRoot) {
623
875
  return modules.flatMap((module) => {
624
- const file = relative(projectRoot, module.moduleId).replaceAll("\\", "/");
876
+ const file = relative2(projectRoot, module.moduleId).replaceAll("\\", "/");
625
877
  if (file.split("/").includes("examples")) return [];
626
878
  const tests = [...module.children.allTests()];
627
879
  const pending = tests.filter((test2) => {
@@ -718,7 +970,10 @@ function truncateReporterLine(value, limit) {
718
970
  }
719
971
  function toModuleResult(module, projectRoot) {
720
972
  const tests = [...module.children.allTests()];
721
- const moduleFailure = selectReactFailure(module.errors(), module.errors().length);
973
+ const moduleFailure = selectReactFailure(
974
+ module.errors(),
975
+ module.errors().length
976
+ );
722
977
  const moduleErrors = extractFailureEntries(module.errors()).map(
723
978
  (entry) => firstLine(entry.message) ?? entry.message
724
979
  );
@@ -744,7 +999,7 @@ function toModuleResult(module, projectRoot) {
744
999
  causeCode: selected.code,
745
1000
  cause: selected.cause,
746
1001
  related: selected.related,
747
- location: test2.location ? `${relative(projectRoot, module.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(selected.rawCause),
1002
+ location: test2.location ? `${relative2(projectRoot, module.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(selected.rawCause),
748
1003
  hint: failureHint(selected.rawCause),
749
1004
  trace: failureTrace(test2)
750
1005
  };
@@ -760,7 +1015,7 @@ function toModuleResult(module, projectRoot) {
760
1015
  });
761
1016
  }
762
1017
  return {
763
- file: relative(projectRoot, module.moduleId).replaceAll("\\", "/"),
1018
+ file: relative2(projectRoot, module.moduleId).replaceAll("\\", "/"),
764
1019
  state: module.state(),
765
1020
  errors,
766
1021
  primaryError: moduleErrors[0] ?? failures[0]?.cause,
@@ -898,8 +1153,13 @@ function assessReactPlaythroughReport(input) {
898
1153
  }
899
1154
  return { ...base, status: "PASS", failsRun: false };
900
1155
  }
901
- function formatReactPlaythroughReport(report) {
902
- const lines = [`REACT_PLAYTHROUGH: ${report.status}`, `FILE: ${report.file}`];
1156
+ function formatReactPlaythroughReport(report, alignment) {
1157
+ const lines = [
1158
+ `REACT_PLAYTHROUGH_STRUCTURE: ${report.status}`,
1159
+ `PRODUCT_TEST_ALIGNMENT: ${alignment?.status ?? "NOT_VERIFIED"}`,
1160
+ `FILE: ${report.file}`
1161
+ ];
1162
+ if (alignment?.cause) lines.push(`ALIGNMENT_CAUSE: ${alignment.cause}`);
903
1163
  if (report.causeCode) lines.push(`CAUSE_CODE: ${report.causeCode}`);
904
1164
  if (report.cause) lines.push(`CAUSE: ${report.cause}`);
905
1165
  for (const [index, related] of (report.related ?? []).entries()) {
@@ -922,7 +1182,7 @@ var ReactPlaythroughReporter = class {
922
1182
  /** 绑定模板根目录,以稳定识别生产可玩性测试而不依赖测试名称。 */
923
1183
  constructor(projectRoot) {
924
1184
  this.projectRoot = projectRoot;
925
- this.expectedModuleId = resolve(projectRoot, this.expectedFile);
1185
+ this.expectedModuleId = resolve2(projectRoot, this.expectedFile);
926
1186
  }
927
1187
  projectRoot;
928
1188
  expectedFile = PRODUCTION_PLAYTHROUGH_FILE;
@@ -932,7 +1192,7 @@ var ReactPlaythroughReporter = class {
932
1192
  /** 记录本轮是否实际选择了生产流程文件,用于区分聚焦运行与门禁失败。 */
933
1193
  onTestRunStart(specifications) {
934
1194
  this.expectedFileScheduled = specifications.some(
935
- (specification) => resolve(specification.moduleId) === this.expectedModuleId
1195
+ (specification) => resolve2(specification.moduleId) === this.expectedModuleId
936
1196
  );
937
1197
  this.focusedSelection = specifications.some(
938
1198
  (specification) => Boolean(specification.project.globalConfig.testNamePattern) || Boolean(specification.testNamePattern) || Boolean(specification.testLines?.length)
@@ -946,7 +1206,7 @@ var ReactPlaythroughReporter = class {
946
1206
  );
947
1207
  const report = assessReactPlaythroughReport({
948
1208
  expectedFile: this.expectedFile,
949
- expectedFileExists: existsSync(this.expectedModuleId),
1209
+ expectedFileExists: existsSync2(this.expectedModuleId),
950
1210
  expectedFileScheduled: this.expectedFileScheduled,
951
1211
  focusedSelection: this.focusedSelection,
952
1212
  modules: testModules.map(
@@ -956,8 +1216,9 @@ var ReactPlaythroughReporter = class {
956
1216
  (entry) => firstLine(entry.message) ?? entry.message
957
1217
  )
958
1218
  });
959
- const output = formatReactPlaythroughReport(report);
960
- if (report.failsRun) {
1219
+ const alignment = assessProductTestAlignment(this.projectRoot);
1220
+ const output = formatReactPlaythroughReport(report, alignment);
1221
+ if (report.failsRun || alignment.status === "FAILED") {
961
1222
  console.error(output);
962
1223
  process.exitCode = 1;
963
1224
  } else if (report.status === "WAIVED" || report.status === "NOT_CHECKED") {
@@ -973,7 +1234,7 @@ var ReactPlaythroughReporter = class {
973
1234
  const summary = formatReactFailureSummary(
974
1235
  testModules.map((module) => toModuleResult(module, this.projectRoot))
975
1236
  );
976
- if ((report.failsRun || pendingProductTests.length > 0) && summary[0] === "TEST_RESULT: PASS") {
1237
+ if ((report.failsRun || alignment.status === "FAILED" || pendingProductTests.length > 0) && summary[0] === "TEST_RESULT: PASS") {
977
1238
  summary[0] = "TEST_RESULT: FAIL";
978
1239
  }
979
1240
  console.log(`
@@ -989,16 +1250,16 @@ function getJSDOMWorkerExecArgv() {
989
1250
 
990
1251
  // src/react-vitest-config.ts
991
1252
  function resolvePhaser3BrowserEntry(projectRoot) {
992
- const manifestPath = resolve2(projectRoot, "node_modules/phaser/package.json");
993
- if (!existsSync2(manifestPath)) return void 0;
1253
+ const manifestPath = resolve3(projectRoot, "node_modules/phaser/package.json");
1254
+ if (!existsSync3(manifestPath)) return void 0;
994
1255
  try {
995
- const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
1256
+ const manifest = JSON.parse(readFileSync2(manifestPath, "utf8"));
996
1257
  if (!manifest.version?.startsWith("3.")) return void 0;
997
- const browserEntry = resolve2(
998
- dirname(manifestPath),
1258
+ const browserEntry = resolve3(
1259
+ dirname2(manifestPath),
999
1260
  manifest.browser ?? "dist/phaser.js"
1000
1261
  );
1001
- return existsSync2(browserEntry) ? browserEntry : void 0;
1262
+ return existsSync3(browserEntry) ? browserEntry : void 0;
1002
1263
  } catch {
1003
1264
  return void 0;
1004
1265
  }
@@ -1013,7 +1274,7 @@ function defineReactGameVitestConfig(options) {
1013
1274
  alias: {
1014
1275
  ...phaser3BrowserEntry ? { phaser: phaser3BrowserEntry } : {},
1015
1276
  ...options.aliases,
1016
- "@": resolve2(options.projectRoot, "src")
1277
+ "@": resolve3(options.projectRoot, "src")
1017
1278
  }
1018
1279
  },
1019
1280
  test: {
@@ -57,7 +57,7 @@ var rule = {
57
57
  providedUser: "Use the user provided by playthroughTest; do not import or create another userEvent instance in the production playthrough.",
58
58
  productionTestingImport: "Production source must not import miaoda-game-devkit/react/testing. Inject test clocks and observers through the production App factory boundary.",
59
59
  productionEntry: "Render <App /> from the production playthrough. Import Controller and Telemetry helpers when needed, but do not render an internal game component directly.",
60
- emptyStep: "A deterministic step must advance production-owned time, frames, or queued work. An empty step is not gameplay evidence. Inject ManualGameClock through <App /> and call clock.stepFrame(...)."
60
+ emptyStep: 'A deterministic step must advance production-owned time, frames, or queued work. An empty step is not gameplay evidence. Add import { ManualGameClock } from "miaoda-game-devkit/react/testing"; inject it through the production <App /> factory, and use step: () => clock.stepFrame().'
61
61
  },
62
62
  schema: []
63
63
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miaoda-game-devkit",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
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",