sdd-flow-kit 1.3.15 → 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.
- package/dist/cli.js +16 -0
- package/dist/core/acAssertionBinding.js +180 -0
- package/dist/core/apiFieldContract.js +90 -0
- package/dist/core/apiFieldCoverage.js +335 -0
- package/dist/core/componentSubstance.js +172 -0
- package/dist/core/demoImplementationCoverage.js +244 -0
- package/dist/core/e2eAssertionDepth.js +237 -0
- package/dist/core/e2eDemoSignalClicks.js +90 -0
- package/dist/core/prdArtifacts.js +12 -1
- package/dist/core/prdCoverage.js +52 -36
- package/dist/core/prdSemanticDiff.js +7 -4
- package/dist/core/snakeCamel.js +93 -0
- package/dist/core/syncSourcePrd.js +181 -0
- package/dist/core/techDocQuality.js +91 -0
- package/dist/core/testAssertionAnalysis.js +213 -0
- package/dist/core/visualRegression.js +243 -0
- package/dist/core/writeNext.js +1 -1
- package/dist/index.js +6 -1
- package/dist/steps/runGate.js +49 -5
- package/dist/steps/runSyncSourcePrd.js +38 -0
- package/dist/steps/runValidate.js +4 -2
- package/dist/steps/setupProject.js +3 -0
- package/dist/steps/startFlow.js +9 -2
- package/package.json +2 -1
- package/scripts/assert-active-openspec-change.sh +114 -0
- package/scripts/assess-ac-assertion-binding.mjs +104 -0
- package/scripts/assess-api-field-contract.mjs +119 -0
- package/scripts/assess-api-field-coverage.mjs +88 -0
- package/scripts/assess-component-substance.mjs +87 -0
- package/scripts/assess-demo-implementation-coverage.mjs +188 -0
- package/scripts/assess-e2e-assertion-depth.mjs +162 -0
- package/scripts/assess-playwright-e2e-coverage.mjs +244 -0
- package/scripts/assess-tech-doc-quality.mjs +98 -0
- package/scripts/assess-visual-regression.mjs +92 -0
- package/scripts/install-git-hooks.sh +27 -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 +148 -0
- package/scripts/lib/visual-regression.mjs +191 -0
- package/scripts/resolve-playwright-e2e-scope.mjs +248 -0
- package/src/templates/artifacts/01-adi-doc-skill-/346/214/207/345/274/225.template.md +6 -2
- package/src/templates/artifacts/02-/351/234/200/346/261/202/345/210/206/346/236/220/346/217/220/347/244/272/350/257/215.template.md +5 -0
- package/src/templates/artifacts/04-/346/212/200/346/234/257/346/226/207/346/241/243.template.md +22 -0
- package/src/templates/artifacts/05-/351/252/214/346/224/266/346/270/205/345/215/225.template.md +4 -3
- package/src/templates/artifacts/06-openspec-/346/217/220/346/241/210/350/215/211/347/250/277.template.md +2 -2
- package/src/templates/artifacts/07-opsx-auto-chain.template.md +2 -2
- package/src/templates/artifacts/08-PRD/344/270/200/350/207/264/346/200/247/345/244/215/346/237/245/346/212/245/345/221/212.template.md +2 -0
- package/src/templates/artifacts/design-demo-reference.template.md +24 -0
- 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/02-sdd-loop-prompt.md +25 -11
- package/src/templates/prompts/04-tech-doc-fill-prompt.md +76 -0
- package/src/templates/skills/confluence-doc.py.template +64 -25
- package/src/templates/skills/demo-reference-mandatory-workflow.md +79 -0
- package/src/templates/skills/doc-skill.SKILL.md.template +13 -6
- package/src/templates/skills/layered-testing-strategy.md +15 -6
package/dist/cli.js
CHANGED
|
@@ -20,6 +20,7 @@ const runDeliver_1 = require("./steps/runDeliver");
|
|
|
20
20
|
const runPrdReview_1 = require("./steps/runPrdReview");
|
|
21
21
|
const runPrdDiff_1 = require("./steps/runPrdDiff");
|
|
22
22
|
const runPrdRemediate_1 = require("./steps/runPrdRemediate");
|
|
23
|
+
const runSyncSourcePrd_1 = require("./steps/runSyncSourcePrd");
|
|
23
24
|
const runChain_1 = require("./steps/runChain");
|
|
24
25
|
const runSessionMode_1 = require("./steps/runSessionMode");
|
|
25
26
|
const runGuard_1 = require("./steps/runGuard");
|
|
@@ -107,6 +108,7 @@ function printHelp() {
|
|
|
107
108
|
" npx sdd-flow-kit gate --project-root . --run-id <runId> --expect prd-coverage --change <name>",
|
|
108
109
|
"",
|
|
109
110
|
"10) PRD 一致性复查(最细粒度,交付前强制):",
|
|
111
|
+
" npx sdd-flow-kit sync-source-prd --project-root . --run-id <runId>",
|
|
110
112
|
" npx sdd-flow-kit prd-review --project-root . --run-id <runId>",
|
|
111
113
|
" # 在 AI 工具执行 openspec/PRD/<runId>/08-PRD一致性复查提示词.md",
|
|
112
114
|
" npx sdd-flow-kit prd-diff --project-root . --run-id <runId>",
|
|
@@ -192,6 +194,7 @@ async function main() {
|
|
|
192
194
|
cmd !== "prd-review" &&
|
|
193
195
|
cmd !== "prd-diff" &&
|
|
194
196
|
cmd !== "prd-remediate" &&
|
|
197
|
+
cmd !== "sync-source-prd" &&
|
|
195
198
|
cmd !== "deliver" &&
|
|
196
199
|
cmd !== "chain" &&
|
|
197
200
|
cmd !== "session") {
|
|
@@ -245,6 +248,7 @@ async function main() {
|
|
|
245
248
|
"prd-review",
|
|
246
249
|
"prd-diff",
|
|
247
250
|
"prd-remediate",
|
|
251
|
+
"sync-source-prd",
|
|
248
252
|
"deliver",
|
|
249
253
|
"chain",
|
|
250
254
|
"session",
|
|
@@ -352,6 +356,18 @@ async function main() {
|
|
|
352
356
|
process_1.default.exitCode = 1;
|
|
353
357
|
return;
|
|
354
358
|
}
|
|
359
|
+
if (cmd === "sync-source-prd") {
|
|
360
|
+
const result = await (0, runSyncSourcePrd_1.runSyncSourcePrd)({
|
|
361
|
+
projectRoot,
|
|
362
|
+
runId,
|
|
363
|
+
dryRun,
|
|
364
|
+
});
|
|
365
|
+
// eslint-disable-next-line no-console
|
|
366
|
+
console.log(result.ok ? `✅ ${result.detail}` : `❌ ${result.detail}`);
|
|
367
|
+
if (!result.ok)
|
|
368
|
+
process_1.default.exitCode = 1;
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
355
371
|
if (cmd === "validate") {
|
|
356
372
|
if (!changeName) {
|
|
357
373
|
// eslint-disable-next-line no-console
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
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.checkApiFieldContract = checkApiFieldContract;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const fs_1 = require("./fs");
|
|
9
|
+
const API_MODULE_DIR_RE = /(^|\/)(src|frontend\/src)\/config\/api\/modules\//;
|
|
10
|
+
const NORMALIZE_MARKERS = /snakeToCamel|camelToSnake|normalizeFinanceApiResult|normalize\w*Api|caseTransform|toCamelCase|toSnakeCase/i;
|
|
11
|
+
const API_CLIENT_CALL_RE = /\b(?:apiClient|request|post|get)\s*[.(]/i;
|
|
12
|
+
/**
|
|
13
|
+
* 扫描 change 关联的 API 模块:对接后端时须在模块层做字段 normalize(防 SNAKE_CASE 漏映射)。
|
|
14
|
+
*/
|
|
15
|
+
async function checkApiFieldContract(pkgRoot, changeName) {
|
|
16
|
+
const checks = [];
|
|
17
|
+
const skip = process.env.POQUAN_SKIP_API_FIELD_CONTRACT === "1" ||
|
|
18
|
+
process.env.OPSX_SKIP_API_FIELD_CONTRACT === "1";
|
|
19
|
+
if (skip) {
|
|
20
|
+
checks.push({
|
|
21
|
+
name: "api-field-contract",
|
|
22
|
+
ok: true,
|
|
23
|
+
detail: "SKIP: OPSX_SKIP_API_FIELD_CONTRACT=1",
|
|
24
|
+
});
|
|
25
|
+
return checks;
|
|
26
|
+
}
|
|
27
|
+
const changeDir = path_1.default.join(pkgRoot, "openspec/changes", changeName);
|
|
28
|
+
const artifactParts = ["tasks.md", "proposal.md", "design.md"];
|
|
29
|
+
let blob = "";
|
|
30
|
+
for (const f of artifactParts) {
|
|
31
|
+
const c = await (0, fs_1.readFileIfExists)(path_1.default.join(changeDir, f));
|
|
32
|
+
if (c)
|
|
33
|
+
blob += c;
|
|
34
|
+
}
|
|
35
|
+
const moduleRefs = new Set();
|
|
36
|
+
const modRe = /config\/api\/modules\/([\w/-]+)/g;
|
|
37
|
+
let m;
|
|
38
|
+
while ((m = modRe.exec(blob)) !== null) {
|
|
39
|
+
moduleRefs.add(m[1].replace(/\/index$/, "").replace(/\/$/, ""));
|
|
40
|
+
}
|
|
41
|
+
if (moduleRefs.size === 0) {
|
|
42
|
+
checks.push({
|
|
43
|
+
name: "api-field-contract",
|
|
44
|
+
ok: true,
|
|
45
|
+
detail: "change 未引用 config/api/modules,跳过 API 字段契约检查",
|
|
46
|
+
});
|
|
47
|
+
return checks;
|
|
48
|
+
}
|
|
49
|
+
const failures = [];
|
|
50
|
+
const passed = [];
|
|
51
|
+
for (const mod of moduleRefs) {
|
|
52
|
+
const candidates = [
|
|
53
|
+
path_1.default.join(pkgRoot, "src/config/api/modules", mod, "index.ts"),
|
|
54
|
+
path_1.default.join(pkgRoot, "frontend/src/config/api/modules", mod, "index.ts"),
|
|
55
|
+
path_1.default.join(pkgRoot, "src/config/api/modules", `${mod}.ts`),
|
|
56
|
+
path_1.default.join(pkgRoot, "frontend/src/config/api/modules", `${mod}.ts`),
|
|
57
|
+
];
|
|
58
|
+
let found = false;
|
|
59
|
+
for (const fp of candidates) {
|
|
60
|
+
const content = await (0, fs_1.readFileIfExists)(fp);
|
|
61
|
+
if (content === null)
|
|
62
|
+
continue;
|
|
63
|
+
found = true;
|
|
64
|
+
const rel = path_1.default.relative(pkgRoot, fp).replace(/\\/g, "/");
|
|
65
|
+
if (!API_MODULE_DIR_RE.test(`/${rel}`))
|
|
66
|
+
continue;
|
|
67
|
+
if (!API_CLIENT_CALL_RE.test(content)) {
|
|
68
|
+
passed.push(`${rel}(无 HTTP 调用,跳过)`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!NORMALIZE_MARKERS.test(content)) {
|
|
72
|
+
failures.push(`${rel} 含 API 调用但未发现 snake/camel normalize(须防后端 SNAKE_CASE 漏映射)`);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
passed.push(rel);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!found) {
|
|
79
|
+
failures.push(`config/api/modules/${mod} 在 change 中引用但文件不存在`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
checks.push({
|
|
83
|
+
name: "api-field-contract",
|
|
84
|
+
ok: failures.length === 0,
|
|
85
|
+
detail: failures.length === 0
|
|
86
|
+
? `API 字段契约通过: ${passed.join(", ") || "(无模块文件)"}`
|
|
87
|
+
: failures.join(" | "),
|
|
88
|
+
});
|
|
89
|
+
return checks;
|
|
90
|
+
}
|
|
@@ -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
|
+
}
|