sdd-flow-kit 1.3.18 → 1.3.21

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.
Files changed (36) hide show
  1. package/dist/core/acAssertionBinding.js +180 -0
  2. package/dist/core/apiFieldContract.js +1 -1
  3. package/dist/core/apiFieldCoverage.js +335 -0
  4. package/dist/core/componentSubstance.js +172 -0
  5. package/dist/core/demoImplementationCoverage.js +2 -0
  6. package/dist/core/e2eAssertionDepth.js +4 -0
  7. package/dist/core/e2eDemoSignalClicks.js +90 -0
  8. package/dist/core/prdCoverage.js +13 -34
  9. package/dist/core/snakeCamel.js +93 -0
  10. package/dist/core/techDocQuality.js +4 -0
  11. package/dist/core/visualRegression.js +243 -0
  12. package/dist/steps/startFlow.js +2 -0
  13. package/package.json +1 -1
  14. package/scripts/assess-ac-assertion-binding.mjs +104 -0
  15. package/scripts/assess-api-field-contract.mjs +1 -1
  16. package/scripts/assess-api-field-coverage.mjs +88 -0
  17. package/scripts/assess-component-substance.mjs +87 -0
  18. package/scripts/assess-e2e-assertion-depth.mjs +16 -0
  19. package/scripts/assess-tech-doc-quality.mjs +7 -0
  20. package/scripts/assess-visual-regression.mjs +92 -0
  21. package/scripts/lib/ac-assertion-binding.mjs +119 -0
  22. package/scripts/lib/api-field-coverage.mjs +238 -0
  23. package/scripts/lib/component-substance.mjs +117 -0
  24. package/scripts/lib/demo-reference-parse.mjs +55 -0
  25. package/scripts/lib/e2e-demo-signal-clicks.mjs +43 -0
  26. package/scripts/lib/snake-camel.mjs +74 -0
  27. package/scripts/lib/test-assertion-analysis.mjs +14 -0
  28. package/scripts/lib/visual-regression.mjs +191 -0
  29. package/src/templates/artifacts/04-/346/212/200/346/234/257/346/226/207/346/241/243.template.md +8 -0
  30. package/src/templates/artifacts/05-/351/252/214/346/224/266/346/270/205/345/215/225.template.md +3 -2
  31. package/src/templates/artifacts/design-demo-reference.template.md +5 -5
  32. package/src/templates/artifacts/tests-fixtures-readme.template.md +19 -0
  33. package/src/templates/artifacts/visual-baseline-readme.template.md +28 -0
  34. package/src/templates/prompts/04-tech-doc-fill-prompt.md +7 -2
  35. package/src/templates/skills/demo-reference-mandatory-workflow.md +3 -1
  36. package/src/templates/skills/layered-testing-strategy.md +5 -3
@@ -0,0 +1,180 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.extractCriterionLiterals = extractCriterionLiterals;
7
+ exports.testBlockBindsCriterionLiterals = testBlockBindsCriterionLiterals;
8
+ exports.testBlockIsFuzzyOnly = testBlockIsFuzzyOnly;
9
+ exports.checkAcAssertionBinding = checkAcAssertionBinding;
10
+ /**
11
+ * AC 断言绑定门禁:P0 AC 的验收标准字面量须出现在对应 test/it 块的 expect 中。
12
+ */
13
+ const path_1 = __importDefault(require("path"));
14
+ const testAssertionAnalysis_1 = require("./testAssertionAnalysis");
15
+ const E2E_DIR_RE = /(^|\/)(e2e|playwright)(\/|$)/i;
16
+ const MIN_LITERAL_LEN = 4;
17
+ /** 模糊断言:仅匹配过短的成功/失败类文案 */
18
+ const FUZZY_ONLY_ASSERT_RE = /expect\s*\([^)]*\)\s*\.toContain(?:Text)?\s*\(\s*['"](?:成功|失败|错误|保存|完成)['"]\s*\)/;
19
+ /**
20
+ * 从 05「验收标准」列提取可机械比对的字面量(引号、placeholder、toast 等)。
21
+ */
22
+ function extractCriterionLiterals(criterion) {
23
+ var _a;
24
+ const out = new Set();
25
+ const text = criterion !== null && criterion !== void 0 ? criterion : "";
26
+ const quotedPatterns = [
27
+ /「([^」]{3,})」/g,
28
+ /"([^"]{3,})"/g,
29
+ /'([^']{3,})'/g,
30
+ /`([^`]{3,})`/g,
31
+ ];
32
+ for (const re of quotedPatterns) {
33
+ let m;
34
+ const r = new RegExp(re.source, re.flags);
35
+ while ((m = r.exec(text)) !== null) {
36
+ const v = (_a = m[1]) === null || _a === void 0 ? void 0 : _a.trim();
37
+ if (v && v.length >= MIN_LITERAL_LEN)
38
+ out.add(v);
39
+ }
40
+ }
41
+ const placeholder = text.match(/请输入[^\s,,。;;]+/);
42
+ if ((placeholder === null || placeholder === void 0 ? void 0 : placeholder[0]) && placeholder[0].length >= MIN_LITERAL_LEN) {
43
+ out.add(placeholder[0]);
44
+ }
45
+ const tooltip = text.match(/tooltip[^,,。]*[「"]([^」"]{3,})[」"]/i);
46
+ if (tooltip === null || tooltip === void 0 ? void 0 : tooltip[1])
47
+ out.add(tooltip[1].trim());
48
+ return [...out];
49
+ }
50
+ /**
51
+ * test 块内 expect 是否包含验收标准中的至少一条字面量。
52
+ */
53
+ function testBlockBindsCriterionLiterals(blockBody, literals) {
54
+ if (literals.length === 0)
55
+ return true;
56
+ const body = (0, testAssertionAnalysis_1.stripComments)(blockBody);
57
+ if (!/\bexpect\s*\(/.test(body))
58
+ return false;
59
+ const hasLiteral = literals.some((lit) => body.includes(lit));
60
+ if (hasLiteral)
61
+ return true;
62
+ // 允许较长文案的部分匹配(≥8 字取前 8 字)
63
+ return literals.some((lit) => lit.length >= 8 && body.includes(lit.slice(0, 8)));
64
+ }
65
+ /**
66
+ * 是否为仅模糊断言、未绑定 PRD 原文的 test 块。
67
+ */
68
+ function testBlockIsFuzzyOnly(blockBody, literals) {
69
+ if (literals.length === 0)
70
+ return false;
71
+ const body = (0, testAssertionAnalysis_1.stripComments)(blockBody);
72
+ if (!FUZZY_ONLY_ASSERT_RE.test(body))
73
+ return false;
74
+ return !testBlockBindsCriterionLiterals(blockBody, literals);
75
+ }
76
+ /**
77
+ * P0 / e2e 类 AC 须在有效 test 块内绑定验收标准字面量;P0 禁止纯 manual。
78
+ */
79
+ function checkAcAssertionBinding(items, testFiles) {
80
+ var _a, _b;
81
+ const checks = [];
82
+ const skip = process.env.POQUAN_SKIP_AC_ASSERTION_BINDING === "1" ||
83
+ process.env.OPSX_SKIP_AC_ASSERTION_BINDING === "1";
84
+ if (skip) {
85
+ checks.push({
86
+ name: "ac-assertion-binding",
87
+ ok: true,
88
+ detail: "SKIP: OPSX_SKIP_AC_ASSERTION_BINDING=1",
89
+ });
90
+ return checks;
91
+ }
92
+ const mustCover = items.filter((i) => { var _a; return /^P0$/i.test(i.priority.trim()) || /e2e/i.test((_a = i.testType) !== null && _a !== void 0 ? _a : ""); });
93
+ for (const ac of mustCover) {
94
+ const isP0 = /^P0$/i.test(ac.priority.trim());
95
+ const testType = ((_a = ac.testType) !== null && _a !== void 0 ? _a : "").trim();
96
+ const criterion = (_b = ac.criterion) !== null && _b !== void 0 ? _b : "";
97
+ const literals = extractCriterionLiterals(criterion);
98
+ if (isP0 && /^manual$/i.test(testType)) {
99
+ checks.push({
100
+ name: `ac-assertion-${ac.id}`,
101
+ ok: false,
102
+ detail: `${ac.id} 为 P0 但测试类型为纯 manual,须补充 unit/e2e 自动化`,
103
+ });
104
+ continue;
105
+ }
106
+ const e2eRequired = /e2e/i.test(testType);
107
+ let bound = false;
108
+ let fuzzyOnly = false;
109
+ let e2eBound = false;
110
+ const hitFiles = [];
111
+ for (const tf of testFiles) {
112
+ const blocks = (0, testAssertionAnalysis_1.parseTestBlocks)(tf.content);
113
+ for (const block of blocks) {
114
+ if (!(0, testAssertionAnalysis_1.testBlockReferencesAc)(block, ac.id))
115
+ continue;
116
+ if (!(0, testAssertionAnalysis_1.testBlockHasExpect)(block))
117
+ continue;
118
+ const literalOk = testBlockBindsCriterionLiterals(block.body, literals);
119
+ const fuzzy = testBlockIsFuzzyOnly(block.body, literals);
120
+ if (literalOk && !fuzzy) {
121
+ bound = true;
122
+ hitFiles.push(path_1.default.basename(tf.rel));
123
+ if (E2E_DIR_RE.test(tf.rel))
124
+ e2eBound = true;
125
+ }
126
+ else if (fuzzy) {
127
+ fuzzyOnly = true;
128
+ }
129
+ else if (literals.length === 0) {
130
+ bound = true;
131
+ hitFiles.push(path_1.default.basename(tf.rel));
132
+ if (E2E_DIR_RE.test(tf.rel))
133
+ e2eBound = true;
134
+ }
135
+ }
136
+ }
137
+ if (!bound) {
138
+ const hint = literals.length > 0
139
+ ? `须在含 [${ac.id}] 的 test 块 expect 中出现验收标准字面量之一: ${literals.slice(0, 3).join("、")}`
140
+ : `须在含 [${ac.id}] 的 test 块内有 expect`;
141
+ checks.push({
142
+ name: `ac-assertion-${ac.id}`,
143
+ ok: false,
144
+ detail: `${ac.id}(${ac.priority}) 未绑定验收断言${fuzzyOnly ? "(仅有模糊 toContain 成功/失败)" : ""};${hint}`,
145
+ });
146
+ continue;
147
+ }
148
+ if (e2eRequired && !e2eBound) {
149
+ checks.push({
150
+ name: `ac-assertion-${ac.id}`,
151
+ ok: false,
152
+ detail: `${ac.id} 测试类型含 e2e,但 e2e/ 下无绑定验收字面量的 test 块`,
153
+ });
154
+ continue;
155
+ }
156
+ checks.push({
157
+ name: `ac-assertion-${ac.id}`,
158
+ ok: true,
159
+ detail: `${ac.id} 已绑定验收字面量: ${[...new Set(hitFiles)].join(", ")}`,
160
+ });
161
+ }
162
+ if (mustCover.length === 0) {
163
+ checks.push({
164
+ name: "ac-assertion-binding",
165
+ ok: true,
166
+ detail: "无 P0/e2e AC,跳过断言绑定检查",
167
+ });
168
+ }
169
+ else {
170
+ const allOk = checks.every((c) => c.ok);
171
+ checks.unshift({
172
+ name: "ac-assertion-binding",
173
+ ok: allOk,
174
+ detail: allOk
175
+ ? `P0/e2e AC 断言绑定通过(${mustCover.length} 条)`
176
+ : `部分 AC 未绑定验收字面量`,
177
+ });
178
+ }
179
+ return checks;
180
+ }
@@ -36,7 +36,7 @@ async function checkApiFieldContract(pkgRoot, changeName) {
36
36
  const modRe = /config\/api\/modules\/([\w/-]+)/g;
37
37
  let m;
38
38
  while ((m = modRe.exec(blob)) !== null) {
39
- moduleRefs.add(m[1].replace(/\/index$/, ""));
39
+ moduleRefs.add(m[1].replace(/\/index$/, "").replace(/\/$/, ""));
40
40
  }
41
41
  if (moduleRefs.size === 0) {
42
42
  checks.push({
@@ -0,0 +1,335 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.parseFieldMappingTable = parseFieldMappingTable;
7
+ exports.checkApiFieldCoverage = checkApiFieldCoverage;
8
+ /**
9
+ * API 字段级覆盖门禁(v1.3.20):fixture 样例 + 04 字段映射表 + 源码消费点三角校验。
10
+ */
11
+ const promises_1 = __importDefault(require("fs/promises"));
12
+ const path_1 = __importDefault(require("path"));
13
+ const fs_1 = require("./fs");
14
+ const snakeCamel_1 = require("./snakeCamel");
15
+ const FIELD_TABLE_HEADER_RE = /\|\s*后端字段\s*\|\s*前端字段\s*\|/m;
16
+ const NORMALIZE_TEST_RE = /caseTransform|snakeToCamel|normalize\w*Api/i;
17
+ const FIXTURE_LOAD_RE = /fixtures\/|readFileSync|import\s+\w+\s+from\s+['"].*\.json['"]/;
18
+ /**
19
+ * 从 04 / design 解析 §4.3 响应字段映射表。
20
+ */
21
+ function parseFieldMappingTable(content) {
22
+ var _a, _b, _c, _d;
23
+ const rows = [];
24
+ if (!FIELD_TABLE_HEADER_RE.test(content))
25
+ return rows;
26
+ const section = (_d = (_b = (_a = content.match(/###\s*4\.3[\s\S]*?(?=###\s*4\.4|##\s*5\.)/m)) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : (_c = content.match(/##\s*API\s*字段契约[\s\S]*/im)) === null || _c === void 0 ? void 0 : _c[0]) !== null && _d !== void 0 ? _d : content;
27
+ const lines = section.split("\n");
28
+ let inTable = false;
29
+ for (const line of lines) {
30
+ const trimmed = line.trim();
31
+ if (trimmed.startsWith("|") && FIELD_TABLE_HEADER_RE.test(trimmed)) {
32
+ inTable = true;
33
+ continue;
34
+ }
35
+ if (!inTable || !trimmed.startsWith("|"))
36
+ continue;
37
+ if (/^\|\s*[-:]+/.test(trimmed))
38
+ continue;
39
+ const cells = trimmed
40
+ .split("|")
41
+ .map((c) => c.trim())
42
+ .filter((_, i, arr) => i > 0 && i < arr.length - 1);
43
+ if (cells.length < 2)
44
+ continue;
45
+ if (/后端字段|backend/i.test(cells[0]))
46
+ continue;
47
+ const backendField = cells[0].replace(/`/g, "").trim();
48
+ const frontendField = cells[1].replace(/`/g, "").trim();
49
+ if (!backendField || !frontendField)
50
+ continue;
51
+ rows.push({
52
+ backendField,
53
+ frontendField,
54
+ displayLocation: cells[2],
55
+ });
56
+ }
57
+ return rows;
58
+ }
59
+ async function collectApiModulesFromChange(pkgRoot, changeName) {
60
+ const changeDir = path_1.default.join(pkgRoot, "openspec/changes", changeName);
61
+ const mods = new Set();
62
+ for (const f of ["tasks.md", "proposal.md", "design.md"]) {
63
+ const c = await (0, fs_1.readFileIfExists)(path_1.default.join(changeDir, f));
64
+ if (!c)
65
+ continue;
66
+ const re = /config\/api\/modules\/([\w/-]+)/g;
67
+ let m;
68
+ while ((m = re.exec(c)) !== null) {
69
+ mods.add(m[1].replace(/\/index$/, "").replace(/\/$/, ""));
70
+ }
71
+ }
72
+ return mods;
73
+ }
74
+ async function listFixtureFiles(pkgRoot, moduleName) {
75
+ const dirs = [
76
+ path_1.default.join(pkgRoot, "tests/fixtures", moduleName),
77
+ path_1.default.join(pkgRoot, "frontend/tests/fixtures", moduleName),
78
+ path_1.default.join(pkgRoot, "tests/fixtures"),
79
+ path_1.default.join(pkgRoot, "frontend/tests/fixtures"),
80
+ ];
81
+ const files = [];
82
+ for (const dir of dirs) {
83
+ let entries;
84
+ try {
85
+ entries = await promises_1.default.readdir(dir, { withFileTypes: true });
86
+ }
87
+ catch {
88
+ continue;
89
+ }
90
+ for (const ent of entries) {
91
+ if (ent.isFile() && ent.name.endsWith(".json")) {
92
+ files.push(path_1.default.join(dir, ent.name));
93
+ }
94
+ }
95
+ }
96
+ return [...new Set(files)];
97
+ }
98
+ async function collectChangeSourceCorpus(pkgRoot, changeName) {
99
+ const changeDir = path_1.default.join(pkgRoot, "openspec/changes", changeName);
100
+ let blob = "";
101
+ const fileRefs = new Set();
102
+ for (const f of ["tasks.md", "proposal.md", "design.md"]) {
103
+ const c = await (0, fs_1.readFileIfExists)(path_1.default.join(changeDir, f));
104
+ if (c)
105
+ blob += `${c}\n`;
106
+ }
107
+ const patterns = [
108
+ /(?:src|frontend\/src)\/[\w/.-]+\.(vue|tsx?|ts)/g,
109
+ /views\/[\w/.-]+\.(vue|tsx?)/g,
110
+ /config\/api\/modules\/[\w/.-]+/g,
111
+ ];
112
+ for (const re of patterns) {
113
+ let m;
114
+ const r = new RegExp(re.source, "g");
115
+ while ((m = r.exec(blob)) !== null) {
116
+ fileRefs.add(m[0].replace(/^frontend\//, ""));
117
+ }
118
+ }
119
+ for (const ref of fileRefs) {
120
+ const candidates = [
121
+ path_1.default.join(pkgRoot, ref),
122
+ path_1.default.join(pkgRoot, "frontend", ref),
123
+ path_1.default.join(pkgRoot, "src", ref.replace(/^src\//, "")),
124
+ ];
125
+ for (const fp of candidates) {
126
+ const content = await (0, fs_1.readFileIfExists)(fp);
127
+ if (content) {
128
+ blob += `\n${content}`;
129
+ break;
130
+ }
131
+ }
132
+ }
133
+ const viewRoots = [
134
+ path_1.default.join(pkgRoot, "src/views"),
135
+ path_1.default.join(pkgRoot, "frontend/src/views"),
136
+ ];
137
+ // 兜底:合并 views 下源码
138
+ for (const vr of viewRoots) {
139
+ blob = await walkSourceDir(vr, blob);
140
+ }
141
+ return blob;
142
+ }
143
+ async function walkSourceDir(dir, acc) {
144
+ let entries;
145
+ try {
146
+ entries = await promises_1.default.readdir(dir, { withFileTypes: true });
147
+ }
148
+ catch {
149
+ return acc;
150
+ }
151
+ let out = acc;
152
+ for (const ent of entries) {
153
+ if (ent.name === "node_modules")
154
+ continue;
155
+ const full = path_1.default.join(dir, ent.name);
156
+ if (ent.isDirectory()) {
157
+ out = await walkSourceDir(full, out);
158
+ }
159
+ else if (/\.(vue|tsx?|ts)$/.test(ent.name)) {
160
+ const c = await (0, fs_1.readFileIfExists)(full);
161
+ if (c)
162
+ out += `\n${c}`;
163
+ }
164
+ }
165
+ return out;
166
+ }
167
+ async function findNormalizeTests(pkgRoot, moduleName) {
168
+ const roots = [
169
+ path_1.default.join(pkgRoot, "tests"),
170
+ path_1.default.join(pkgRoot, "frontend/tests"),
171
+ ];
172
+ const hits = [];
173
+ for (const root of roots) {
174
+ await walkTests(root, moduleName, hits);
175
+ }
176
+ return hits;
177
+ }
178
+ async function walkTests(dir, moduleName, hits) {
179
+ let entries;
180
+ try {
181
+ entries = await promises_1.default.readdir(dir, { withFileTypes: true });
182
+ }
183
+ catch {
184
+ return;
185
+ }
186
+ for (const ent of entries) {
187
+ const full = path_1.default.join(dir, ent.name);
188
+ if (ent.isDirectory()) {
189
+ await walkTests(full, moduleName, hits);
190
+ }
191
+ else if (/\.(spec|test)\.(t|j)sx?$/.test(ent.name)) {
192
+ const content = await (0, fs_1.readFileIfExists)(full);
193
+ if (!content)
194
+ continue;
195
+ if (!NORMALIZE_TEST_RE.test(content))
196
+ continue;
197
+ if (content.includes(moduleName) ||
198
+ content.includes("caseTransform") ||
199
+ content.includes("snakeToCamel")) {
200
+ hits.push(full);
201
+ }
202
+ }
203
+ }
204
+ }
205
+ function corpusContainsField(corpus, field) {
206
+ if (!field || field.length < 2)
207
+ return true;
208
+ const camel = field;
209
+ const snake = field.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
210
+ return (corpus.includes(camel) ||
211
+ corpus.includes(`'${camel}'`) ||
212
+ corpus.includes(`"${camel}"`) ||
213
+ corpus.includes(snake));
214
+ }
215
+ /**
216
+ * 字段级 API 覆盖:fixture(snake 样例)+ normalize 单测 + 04 映射表字段在源码可找到。
217
+ */
218
+ async function checkApiFieldCoverage(pkgRoot, changeName, outputRoot) {
219
+ var _a;
220
+ const checks = [];
221
+ const skip = process.env.POQUAN_SKIP_API_FIELD_COVERAGE === "1" ||
222
+ process.env.OPSX_SKIP_API_FIELD_COVERAGE === "1";
223
+ if (skip) {
224
+ checks.push({
225
+ name: "api-field-coverage",
226
+ ok: true,
227
+ detail: "SKIP: OPSX_SKIP_API_FIELD_COVERAGE=1",
228
+ });
229
+ return checks;
230
+ }
231
+ const modules = await collectApiModulesFromChange(pkgRoot, changeName);
232
+ if (modules.size === 0) {
233
+ checks.push({
234
+ name: "api-field-coverage",
235
+ ok: true,
236
+ detail: "change 未引用 API 模块,跳过字段级覆盖",
237
+ });
238
+ return checks;
239
+ }
240
+ let fieldDoc = "";
241
+ if (outputRoot) {
242
+ const tech04 = await (0, fs_1.readFileIfExists)(path_1.default.join(outputRoot, "04-技术文档草稿.md"));
243
+ if (tech04)
244
+ fieldDoc += tech04;
245
+ }
246
+ const design = await (0, fs_1.readFileIfExists)(path_1.default.join(pkgRoot, "openspec/changes", changeName, "design.md"));
247
+ if (design)
248
+ fieldDoc += `\n${design}`;
249
+ const mappingRows = parseFieldMappingTable(fieldDoc);
250
+ const corpus = await collectChangeSourceCorpus(pkgRoot, changeName);
251
+ const failures = [];
252
+ const passed = [];
253
+ for (const mod of modules) {
254
+ const fixtures = await listFixtureFiles(pkgRoot, mod);
255
+ const validFixtures = [];
256
+ for (const fp of fixtures) {
257
+ const raw = await (0, fs_1.readFileIfExists)(fp);
258
+ if (!raw)
259
+ continue;
260
+ try {
261
+ const payload = JSON.parse(raw);
262
+ if ((0, snakeCamel_1.hasSnakeCaseKeys)(payload)) {
263
+ validFixtures.push({ path: fp, payload });
264
+ }
265
+ }
266
+ catch {
267
+ // ignore invalid json
268
+ }
269
+ }
270
+ if (validFixtures.length === 0) {
271
+ failures.push(`${mod}: 缺少 tests/fixtures/${mod}/*.json 后端样例(须含 snake_case 字段,来自联调抓包)`);
272
+ continue;
273
+ }
274
+ const normalizeTests = await findNormalizeTests(pkgRoot, mod);
275
+ let strongTest = false;
276
+ for (const tp of normalizeTests) {
277
+ const t = (_a = (await (0, fs_1.readFileIfExists)(tp))) !== null && _a !== void 0 ? _a : "";
278
+ if (/expect\s*\(/.test(t) &&
279
+ /snakeToCamel|normalize/.test(t) &&
280
+ (FIXTURE_LOAD_RE.test(t) || /subject_id|_[a-z]/.test(t))) {
281
+ strongTest = true;
282
+ break;
283
+ }
284
+ }
285
+ if (!strongTest) {
286
+ failures.push(`${mod}: 缺少 caseTransform/normalize 单测(须 expect 断言 camelCase 字段,建议加载 fixtures/${mod}/*.json)`);
287
+ }
288
+ const fixtureKeys = new Set();
289
+ for (const f of validFixtures) {
290
+ const row = (0, snakeCamel_1.extractFirstListRow)(f.payload);
291
+ const normalized = (0, snakeCamel_1.snakeToCamelDeep)(f.payload);
292
+ if (row) {
293
+ for (const k of Object.keys(row))
294
+ fixtureKeys.add(k);
295
+ }
296
+ for (const k of (0, snakeCamel_1.collectLeafCamelKeys)(normalized))
297
+ fixtureKeys.add(k);
298
+ }
299
+ const keysToCheck = mappingRows.length > 0
300
+ ? mappingRows.map((r) => r.frontendField)
301
+ : [...fixtureKeys].filter((k) => k.length >= 3).slice(0, 12);
302
+ const missingInCorpus = keysToCheck.filter((k) => !corpusContainsField(corpus, k));
303
+ if (keysToCheck.length > 0 && missingInCorpus.length === keysToCheck.length) {
304
+ failures.push(`${mod}: normalize 后字段 [${keysToCheck.slice(0, 5).join(", ")}] 在 change 源码中均无消费点`);
305
+ }
306
+ else if (missingInCorpus.length > 0 && mappingRows.length > 0) {
307
+ failures.push(`${mod}: 04 映射表前端字段未在源码出现: ${missingInCorpus.slice(0, 6).join(", ")}`);
308
+ }
309
+ else {
310
+ passed.push(`${mod}(fixtures=${validFixtures.length}, fields=${keysToCheck.length - missingInCorpus.length}/${keysToCheck.length})`);
311
+ }
312
+ }
313
+ if (mappingRows.length === 0 && modules.size > 0) {
314
+ checks.push({
315
+ name: "api-field-mapping-doc",
316
+ ok: false,
317
+ detail: "04/design 缺少 §4.3 字段映射表(| 后端字段 | 前端字段 |);请在 04-技术文档草稿.md 补充",
318
+ });
319
+ }
320
+ else {
321
+ checks.push({
322
+ name: "api-field-mapping-doc",
323
+ ok: true,
324
+ detail: `已登记 ${mappingRows.length} 条字段映射`,
325
+ });
326
+ }
327
+ checks.push({
328
+ name: "api-field-coverage",
329
+ ok: failures.length === 0,
330
+ detail: failures.length === 0
331
+ ? `API 字段级覆盖通过: ${passed.join("; ") || "(无模块)"}`
332
+ : failures.join(" | "),
333
+ });
334
+ return checks;
335
+ }