sdd-flow-kit 1.3.18 → 1.3.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +42 -13
- package/dist/core/acAssertionBinding.js +180 -0
- package/dist/core/apiFieldContract.js +1 -1
- package/dist/core/apiFieldCoverage.js +335 -0
- package/dist/core/componentSubstance.js +172 -0
- package/dist/core/demoImplementationCoverage.js +2 -0
- package/dist/core/e2eAssertionDepth.js +4 -0
- package/dist/core/e2eDemoSignalClicks.js +90 -0
- package/dist/core/inferProject.js +26 -0
- package/dist/core/prdCoverage.js +13 -34
- package/dist/core/snakeCamel.js +93 -0
- package/dist/core/techDocQuality.js +4 -0
- package/dist/core/visualRegression.js +243 -0
- package/dist/steps/startFlow.js +2 -0
- package/package.json +1 -1
- package/scripts/assess-ac-assertion-binding.mjs +104 -0
- package/scripts/assess-api-field-contract.mjs +1 -1
- package/scripts/assess-api-field-coverage.mjs +88 -0
- package/scripts/assess-component-substance.mjs +87 -0
- package/scripts/assess-e2e-assertion-depth.mjs +16 -0
- package/scripts/assess-tech-doc-quality.mjs +7 -0
- package/scripts/assess-visual-regression.mjs +92 -0
- package/scripts/lib/ac-assertion-binding.mjs +119 -0
- package/scripts/lib/api-field-coverage.mjs +238 -0
- package/scripts/lib/component-substance.mjs +117 -0
- package/scripts/lib/demo-reference-parse.mjs +55 -0
- package/scripts/lib/e2e-demo-signal-clicks.mjs +43 -0
- package/scripts/lib/snake-camel.mjs +74 -0
- package/scripts/lib/test-assertion-analysis.mjs +14 -0
- package/scripts/lib/visual-regression.mjs +191 -0
- package/src/templates/artifacts/04-/346/212/200/346/234/257/346/226/207/346/241/243.template.md +8 -0
- package/src/templates/artifacts/05-/351/252/214/346/224/266/346/270/205/345/215/225.template.md +3 -2
- package/src/templates/artifacts/design-demo-reference.template.md +5 -5
- package/src/templates/artifacts/tests-fixtures-readme.template.md +19 -0
- package/src/templates/artifacts/visual-baseline-readme.template.md +28 -0
- package/src/templates/prompts/04-tech-doc-fill-prompt.md +7 -2
- package/src/templates/skills/demo-reference-mandatory-workflow.md +3 -1
- package/src/templates/skills/layered-testing-strategy.md +5 -3
package/dist/cli.js
CHANGED
|
@@ -27,15 +27,42 @@ const runGuard_1 = require("./steps/runGuard");
|
|
|
27
27
|
const bootstrap_1 = require("./core/bootstrap");
|
|
28
28
|
const projectRoots_1 = require("./core/projectRoots");
|
|
29
29
|
const versionIntent_1 = require("./core/versionIntent");
|
|
30
|
+
const inferProject_1 = require("./core/inferProject");
|
|
30
31
|
const ALLOWED_AGENTS = ["cursor", "claude-code", "codex", "openclaw", "custom"];
|
|
31
|
-
|
|
32
|
+
/** 读取 flag 后的参数;allowMultiWord 时合并至下一个以 - 开头的 token(支持未加引号的 AD Tools)。 */
|
|
33
|
+
function getArgValue(flag, options) {
|
|
32
34
|
const idx = process_1.default.argv.indexOf(flag);
|
|
33
35
|
if (idx === -1)
|
|
34
36
|
return null;
|
|
35
|
-
const
|
|
36
|
-
if (!
|
|
37
|
+
const first = process_1.default.argv[idx + 1];
|
|
38
|
+
if (!first || first.startsWith("-"))
|
|
37
39
|
return null;
|
|
38
|
-
|
|
40
|
+
if (!(options === null || options === void 0 ? void 0 : options.allowMultiWord))
|
|
41
|
+
return first;
|
|
42
|
+
const parts = [first];
|
|
43
|
+
for (let i = idx + 2; i < process_1.default.argv.length; i += 1) {
|
|
44
|
+
const token = process_1.default.argv[i];
|
|
45
|
+
if (token.startsWith("-"))
|
|
46
|
+
break;
|
|
47
|
+
parts.push(token);
|
|
48
|
+
}
|
|
49
|
+
return parts.join(" ");
|
|
50
|
+
}
|
|
51
|
+
/** 统计 flag 在 argv 中占用的 token 数(含多词值),供 getFreeTextArgs 跳过。 */
|
|
52
|
+
function countFlagArgTokens(flag) {
|
|
53
|
+
const idx = process_1.default.argv.indexOf(flag);
|
|
54
|
+
if (idx === -1)
|
|
55
|
+
return 0;
|
|
56
|
+
if (flag === "--project") {
|
|
57
|
+
const raw = getArgValue("--project", { allowMultiWord: true });
|
|
58
|
+
if (!raw)
|
|
59
|
+
return 1;
|
|
60
|
+
return 1 + raw.split(" ").length;
|
|
61
|
+
}
|
|
62
|
+
const next = process_1.default.argv[idx + 1];
|
|
63
|
+
if (!next || next.startsWith("-"))
|
|
64
|
+
return 1;
|
|
65
|
+
return 2;
|
|
39
66
|
}
|
|
40
67
|
function getFreeTextArgs(startIndex) {
|
|
41
68
|
const flagsWithValue = new Set([
|
|
@@ -64,7 +91,8 @@ function getFreeTextArgs(startIndex) {
|
|
|
64
91
|
continue;
|
|
65
92
|
}
|
|
66
93
|
if (flagsWithValue.has(token)) {
|
|
67
|
-
|
|
94
|
+
const consumed = countFlagArgTokens(token);
|
|
95
|
+
i += Math.max(consumed - 1, 0);
|
|
68
96
|
continue;
|
|
69
97
|
}
|
|
70
98
|
if (token.startsWith("--"))
|
|
@@ -142,7 +170,7 @@ function printHelp() {
|
|
|
142
170
|
" 环境变量 SDD_FLOW_KIT_LEGACY_MONOREPO=1 恢复「仓库根无 package.json 时落到 frontend/」",
|
|
143
171
|
" --agent <name> cursor | claude-code | codex | openclaw | custom(默认 claude-code)",
|
|
144
172
|
" --branch <name> 开发分支;不存在会自动创建",
|
|
145
|
-
" --project <name> OMS | ADI | 欢盟 | AD Tools | BreakX(默认 OMS
|
|
173
|
+
" --project <name> OMS | ADI | 欢盟 | AD Tools | BreakX(默认 OMS;支持 \"AD Tools\" / AD Tools / adtools)",
|
|
146
174
|
" -y 非交互模式(使用默认值)",
|
|
147
175
|
" --dry-run 预演模式:仅检测并输出计划,不执行写入/安装/切分支",
|
|
148
176
|
" --plan-json 输出机器可读 JSON 计划结果",
|
|
@@ -152,7 +180,7 @@ function printHelp() {
|
|
|
152
180
|
].join("\n"));
|
|
153
181
|
}
|
|
154
182
|
async function main() {
|
|
155
|
-
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l
|
|
183
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
|
|
156
184
|
const cmd = process_1.default.argv[2];
|
|
157
185
|
if (!cmd || cmd === "--help" || cmd === "-h") {
|
|
158
186
|
printHelp();
|
|
@@ -216,7 +244,9 @@ async function main() {
|
|
|
216
244
|
? ((_h = getArgValue("--text")) !== null && _h !== void 0 ? _h : getFreeTextArgs(3)).trim()
|
|
217
245
|
: "";
|
|
218
246
|
const guardIntent = rawGuardText ? (0, versionIntent_1.parseVersionIntent)(rawGuardText) : null;
|
|
219
|
-
const
|
|
247
|
+
const rawProjectArg = getArgValue("--project", { allowMultiWord: true });
|
|
248
|
+
const resolvedProject = rawProjectArg ? (0, inferProject_1.resolveProjectKindInput)(rawProjectArg) : null;
|
|
249
|
+
const projectKind = (_j = resolvedProject !== null && resolvedProject !== void 0 ? resolvedProject : ((guardIntent === null || guardIntent === void 0 ? void 0 : guardIntent.product) ? (0, inferProject_1.resolveProjectKindInput)(guardIntent.product) : null)) !== null && _j !== void 0 ? _j : "OMS";
|
|
220
250
|
if (!ALLOWED_AGENTS.includes(agentValue)) {
|
|
221
251
|
// eslint-disable-next-line no-console
|
|
222
252
|
console.error(`不支持的 --agent: ${agentValue}`);
|
|
@@ -225,10 +255,9 @@ async function main() {
|
|
|
225
255
|
return;
|
|
226
256
|
}
|
|
227
257
|
const agent = agentValue;
|
|
228
|
-
|
|
229
|
-
if (!allowedProjects.includes(projectKind)) {
|
|
258
|
+
if (rawProjectArg && !resolvedProject) {
|
|
230
259
|
// eslint-disable-next-line no-console
|
|
231
|
-
console.error(`不支持的 --project: ${
|
|
260
|
+
console.error(`不支持的 --project: ${rawProjectArg}(可选:${inferProject_1.ALLOWED_PROJECTS.join(" / ")};别名示例:adtools)`);
|
|
232
261
|
process_1.default.exitCode = 1;
|
|
233
262
|
return;
|
|
234
263
|
}
|
|
@@ -467,7 +496,7 @@ async function main() {
|
|
|
467
496
|
process_1.default.exitCode = 1;
|
|
468
497
|
return;
|
|
469
498
|
}
|
|
470
|
-
const from = ((
|
|
499
|
+
const from = ((_k = getArgValue("--from")) !== null && _k !== void 0 ? _k : "docs-done");
|
|
471
500
|
if (!CHAIN_FROM.includes(from)) {
|
|
472
501
|
// eslint-disable-next-line no-console
|
|
473
502
|
console.error(`chain 需要 --from ${CHAIN_FROM.join("|")}`);
|
|
@@ -496,7 +525,7 @@ async function main() {
|
|
|
496
525
|
process_1.default.exitCode = 1;
|
|
497
526
|
return;
|
|
498
527
|
}
|
|
499
|
-
const modeArg = (
|
|
528
|
+
const modeArg = (_l = getArgValue("--execution-mode")) !== null && _l !== void 0 ? _l : getArgValue("--executionMode");
|
|
500
529
|
const textArg = getArgValue("--text");
|
|
501
530
|
const strictCommands = process_1.default.argv.includes("--strict-commands");
|
|
502
531
|
const result = await (0, runSessionMode_1.runSessionMode)({
|
|
@@ -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
|
+
}
|