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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* L4 视觉回归门禁:05 visual AC + 基线图 + E2E toHaveScreenshot。
|
|
4
|
+
*
|
|
5
|
+
* 用法:
|
|
6
|
+
* node scripts/assess-visual-regression.mjs --change <name> --run-dir openspec/PRD/<runId>
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import {
|
|
12
|
+
assessVisualRegression,
|
|
13
|
+
parseAcMatrixFrom05,
|
|
14
|
+
} from "./lib/visual-regression.mjs";
|
|
15
|
+
|
|
16
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
+
|
|
18
|
+
function parseArgs(argv) {
|
|
19
|
+
let change = "";
|
|
20
|
+
let runDir = "";
|
|
21
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
22
|
+
const a = argv[i];
|
|
23
|
+
if (a === "--change" && argv[i + 1]) change = argv[++i];
|
|
24
|
+
else if (a === "--run-dir" && argv[i + 1]) runDir = argv[++i];
|
|
25
|
+
else if (!a.startsWith("-") && !change) change = a;
|
|
26
|
+
}
|
|
27
|
+
return { change, runDir };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveRunDir(arg) {
|
|
31
|
+
if (!arg) return "";
|
|
32
|
+
const candidates = [
|
|
33
|
+
path.isAbsolute(arg) ? arg : path.join(ROOT, arg),
|
|
34
|
+
path.join(ROOT, "openspec/PRD", arg),
|
|
35
|
+
path.join(ROOT, "frontend/openspec/PRD", arg),
|
|
36
|
+
];
|
|
37
|
+
for (const c of candidates) {
|
|
38
|
+
if (fs.existsSync(path.join(c, "05-验收清单.md"))) return c;
|
|
39
|
+
}
|
|
40
|
+
return "";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fail(messages) {
|
|
44
|
+
console.error("[assess-visual-regression] FAIL");
|
|
45
|
+
for (const m of messages) console.error(` - ${m}`);
|
|
46
|
+
console.error(
|
|
47
|
+
" 修复: 05 增加 visual AC(含基线路径 + diff≤N%);e2e 使用 toHaveScreenshot;基线图放 source/ 或 visual-baseline/",
|
|
48
|
+
);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function main() {
|
|
53
|
+
const skip = process.env.OPSX_SKIP_VISUAL_REGRESSION;
|
|
54
|
+
if (skip === "1") {
|
|
55
|
+
console.warn("[assess-visual-regression] SKIP");
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { change, runDir: runDirArg } = parseArgs(process.argv);
|
|
60
|
+
if (!change) {
|
|
61
|
+
console.error("[assess-visual-regression] 缺少 --change <name>");
|
|
62
|
+
process.exit(2);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const changeDir = path.join(ROOT, "openspec/changes", change);
|
|
66
|
+
if (!fs.existsSync(changeDir)) {
|
|
67
|
+
console.error(`[assess-visual-regression] change 不存在: openspec/changes/${change}`);
|
|
68
|
+
process.exit(2);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const runDir = resolveRunDir(runDirArg);
|
|
72
|
+
if (!runDir) {
|
|
73
|
+
console.error("[assess-visual-regression] 缺少 --run-dir(须含 05-验收清单.md)");
|
|
74
|
+
process.exit(2);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const acItems = parseAcMatrixFrom05(fs.readFileSync(path.join(runDir, "05-验收清单.md"), "utf8"));
|
|
78
|
+
const { errors, visualReqs, exempt } = assessVisualRegression(ROOT, runDir, change, acItems);
|
|
79
|
+
|
|
80
|
+
if (exempt) {
|
|
81
|
+
console.log("[assess-visual-regression] PASS(豁免)");
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (errors.length > 0) fail(errors);
|
|
86
|
+
|
|
87
|
+
console.log(
|
|
88
|
+
`[assess-visual-regression] PASS change=${change} visualAc=${visualReqs.length}`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
main();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# 安装 pre-commit:提交前执行 opsx:gate(见 AGENTS.md)
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
6
|
+
HOOK_PATH="$(git -C "$ROOT_DIR" rev-parse --git-dir 2>/dev/null)/hooks/pre-commit"
|
|
7
|
+
|
|
8
|
+
if [[ ! -d "$(dirname "$HOOK_PATH")" ]]; then
|
|
9
|
+
echo "[opsx:install-hooks] ERROR: 不在 git 仓库内" >&2
|
|
10
|
+
exit 1
|
|
11
|
+
fi
|
|
12
|
+
|
|
13
|
+
cat >"$HOOK_PATH" <<'HOOK'
|
|
14
|
+
#!/usr/bin/env bash
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
ROOT="$(git rev-parse --show-toplevel)"
|
|
17
|
+
cd "$ROOT"
|
|
18
|
+
if command -v pnpm >/dev/null 2>&1; then
|
|
19
|
+
pnpm run opsx:gate
|
|
20
|
+
else
|
|
21
|
+
bash scripts/assert-active-openspec-change.sh
|
|
22
|
+
fi
|
|
23
|
+
HOOK
|
|
24
|
+
|
|
25
|
+
chmod +x "$HOOK_PATH"
|
|
26
|
+
echo "[opsx:install-hooks] 已安装 pre-commit -> $HOOK_PATH"
|
|
27
|
+
echo "[opsx:install-hooks] 提交含 src/ 或 e2e/ 改动时将校验 OpenSpec change 绑定"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AC 断言绑定(与 src/core/acAssertionBinding.ts 语义一致)。
|
|
3
|
+
*/
|
|
4
|
+
import {
|
|
5
|
+
parseTestBlocks,
|
|
6
|
+
stripComments,
|
|
7
|
+
testBlockHasExpect,
|
|
8
|
+
testBlockReferencesAc,
|
|
9
|
+
} from "./test-assertion-analysis.mjs";
|
|
10
|
+
|
|
11
|
+
const E2E_DIR_RE = /(^|\/)(e2e|playwright)(\/|$)/i;
|
|
12
|
+
const MIN_LITERAL_LEN = 4;
|
|
13
|
+
const FUZZY_ONLY_ASSERT_RE =
|
|
14
|
+
/expect\s*\([^)]*\)\s*\.toContain(?:Text)?\s*\(\s*['"](?:成功|失败|错误|保存|完成)['"]\s*\)/;
|
|
15
|
+
|
|
16
|
+
export function extractCriterionLiterals(criterion) {
|
|
17
|
+
const out = new Set();
|
|
18
|
+
const text = criterion ?? "";
|
|
19
|
+
const quotedPatterns = [/「([^」]{3,})」/g, /"([^"]{3,})"/g, /'([^']{3,})'/g, /`([^`]{3,})`/g];
|
|
20
|
+
for (const re of quotedPatterns) {
|
|
21
|
+
let m;
|
|
22
|
+
const r = new RegExp(re.source, re.flags);
|
|
23
|
+
while ((m = r.exec(text)) !== null) {
|
|
24
|
+
const v = m[1]?.trim();
|
|
25
|
+
if (v && v.length >= MIN_LITERAL_LEN) out.add(v);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const placeholder = text.match(/请输入[^\s,,。;;]+/);
|
|
29
|
+
if (placeholder?.[0] && placeholder[0].length >= MIN_LITERAL_LEN) out.add(placeholder[0]);
|
|
30
|
+
return [...out];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function testBlockBindsCriterionLiterals(blockBody, literals) {
|
|
34
|
+
if (literals.length === 0) return true;
|
|
35
|
+
const body = stripComments(blockBody);
|
|
36
|
+
if (!/\bexpect\s*\(/.test(body)) return false;
|
|
37
|
+
if (literals.some((lit) => body.includes(lit))) return true;
|
|
38
|
+
return literals.some((lit) => lit.length >= 8 && body.includes(lit.slice(0, 8)));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function testBlockIsFuzzyOnly(blockBody, literals) {
|
|
42
|
+
if (literals.length === 0) return false;
|
|
43
|
+
const body = stripComments(blockBody);
|
|
44
|
+
if (!FUZZY_ONLY_ASSERT_RE.test(body)) return false;
|
|
45
|
+
return !testBlockBindsCriterionLiterals(blockBody, literals);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function checkAcAssertionBinding(items, testFiles) {
|
|
49
|
+
const errors = [];
|
|
50
|
+
const mustCover = items.filter(
|
|
51
|
+
(i) => /^P0$/i.test(i.priority.trim()) || /e2e/i.test(i.testType ?? ""),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
for (const ac of mustCover) {
|
|
55
|
+
const isP0 = /^P0$/i.test(ac.priority.trim());
|
|
56
|
+
const testType = (ac.testType ?? "").trim();
|
|
57
|
+
const literals = extractCriterionLiterals(ac.criterion ?? "");
|
|
58
|
+
|
|
59
|
+
if (isP0 && /^manual$/i.test(testType)) {
|
|
60
|
+
errors.push(`${ac.id}: P0 禁止纯 manual,须 unit/e2e`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const e2eRequired = /e2e/i.test(testType);
|
|
65
|
+
let bound = false;
|
|
66
|
+
let e2eBound = false;
|
|
67
|
+
|
|
68
|
+
for (const tf of testFiles) {
|
|
69
|
+
const blocks = parseTestBlocks(tf.content);
|
|
70
|
+
for (const block of blocks) {
|
|
71
|
+
if (!testBlockReferencesAc(block, ac.id)) continue;
|
|
72
|
+
if (!testBlockHasExpect(block)) continue;
|
|
73
|
+
const literalOk = testBlockBindsCriterionLiterals(block.body, literals);
|
|
74
|
+
const fuzzy = testBlockIsFuzzyOnly(block.body, literals);
|
|
75
|
+
if ((literalOk && !fuzzy) || literals.length === 0) {
|
|
76
|
+
bound = true;
|
|
77
|
+
if (E2E_DIR_RE.test(tf.rel)) e2eBound = true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!bound) {
|
|
83
|
+
const hint =
|
|
84
|
+
literals.length > 0
|
|
85
|
+
? `expect 须含: ${literals.slice(0, 2).join("、")}`
|
|
86
|
+
: "须有 expect";
|
|
87
|
+
errors.push(`${ac.id}: 未绑定验收字面量;${hint}`);
|
|
88
|
+
} else if (e2eRequired && !e2eBound) {
|
|
89
|
+
errors.push(`${ac.id}: 测试类型含 e2e 但 e2e/ 无绑定字面量的 test`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return errors;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 解析 05-验收清单 AC 表格 */
|
|
97
|
+
export function parseAcMatrixFrom05(content) {
|
|
98
|
+
const items = [];
|
|
99
|
+
for (const line of content.split("\n")) {
|
|
100
|
+
const trimmed = line.trim();
|
|
101
|
+
if (!trimmed.startsWith("|")) continue;
|
|
102
|
+
if (/^\|\s*[-:]+/.test(trimmed)) continue;
|
|
103
|
+
if (/^\|\s*ID\s*\|/i.test(trimmed)) continue;
|
|
104
|
+
const cells = trimmed
|
|
105
|
+
.split("|")
|
|
106
|
+
.map((c) => c.trim())
|
|
107
|
+
.filter((_, i, arr) => i > 0 && i < arr.length - 1);
|
|
108
|
+
if (cells.length < 5) continue;
|
|
109
|
+
const id = (cells[0] ?? "").toUpperCase();
|
|
110
|
+
if (!/^AC-\d{2,}$/.test(id)) continue;
|
|
111
|
+
items.push({
|
|
112
|
+
id,
|
|
113
|
+
priority: cells[4] ?? "",
|
|
114
|
+
criterion: cells[2] ?? "",
|
|
115
|
+
testType: cells[3] ?? "",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return items;
|
|
119
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API 字段级覆盖(与 src/core/apiFieldCoverage.ts 语义一致)。
|
|
3
|
+
*/
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
collectLeafCamelKeys,
|
|
8
|
+
extractFirstListRow,
|
|
9
|
+
hasSnakeCaseKeys,
|
|
10
|
+
snakeToCamelDeep,
|
|
11
|
+
} from "./snake-camel.mjs";
|
|
12
|
+
|
|
13
|
+
const FIELD_TABLE_HEADER_RE = /\|\s*后端字段\s*\|\s*前端字段\s*\|/m;
|
|
14
|
+
const NORMALIZE_TEST_RE = /caseTransform|snakeToCamel|normalize\w*Api/i;
|
|
15
|
+
const FIXTURE_LOAD_RE = /fixtures\/|readFileSync|import\s+\w+\s+from\s+['"].*\.json['"]/;
|
|
16
|
+
|
|
17
|
+
export function parseFieldMappingTable(content) {
|
|
18
|
+
const rows = [];
|
|
19
|
+
if (!FIELD_TABLE_HEADER_RE.test(content)) return rows;
|
|
20
|
+
const section =
|
|
21
|
+
content.match(/###\s*4\.3[\s\S]*?(?=###\s*4\.4|##\s*5\.)/m)?.[0] ??
|
|
22
|
+
content.match(/##\s*API\s*字段契约[\s\S]*/im)?.[0] ??
|
|
23
|
+
content;
|
|
24
|
+
const lines = section.split("\n");
|
|
25
|
+
let inTable = false;
|
|
26
|
+
for (const line of lines) {
|
|
27
|
+
const trimmed = line.trim();
|
|
28
|
+
if (trimmed.startsWith("|") && FIELD_TABLE_HEADER_RE.test(trimmed)) {
|
|
29
|
+
inTable = true;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (!inTable || !trimmed.startsWith("|")) continue;
|
|
33
|
+
if (/^\|\s*[-:]+/.test(trimmed)) continue;
|
|
34
|
+
const cells = trimmed
|
|
35
|
+
.split("|")
|
|
36
|
+
.map((c) => c.trim())
|
|
37
|
+
.filter((_, i, arr) => i > 0 && i < arr.length - 1);
|
|
38
|
+
if (cells.length < 2) continue;
|
|
39
|
+
if (/后端字段|backend/i.test(cells[0])) continue;
|
|
40
|
+
const backendField = cells[0].replace(/`/g, "").trim();
|
|
41
|
+
const frontendField = cells[1].replace(/`/g, "").trim();
|
|
42
|
+
if (!backendField || !frontendField || /填写/.test(backendField)) continue;
|
|
43
|
+
rows.push({ backendField, frontendField, displayLocation: cells[2] });
|
|
44
|
+
}
|
|
45
|
+
return rows;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function readChangeBlob(changeDir) {
|
|
49
|
+
let blob = "";
|
|
50
|
+
for (const f of ["tasks.md", "proposal.md", "design.md"]) {
|
|
51
|
+
const fp = path.join(changeDir, f);
|
|
52
|
+
if (!fs.existsSync(fp)) continue;
|
|
53
|
+
blob += fs.readFileSync(fp, "utf8");
|
|
54
|
+
}
|
|
55
|
+
return blob;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function collectApiModules(blob) {
|
|
59
|
+
const mods = new Set();
|
|
60
|
+
const re = /config\/api\/modules\/([\w/-]+)/g;
|
|
61
|
+
let m;
|
|
62
|
+
while ((m = re.exec(blob)) !== null) {
|
|
63
|
+
mods.add(m[1].replace(/\/index$/, "").replace(/\/$/, ""));
|
|
64
|
+
}
|
|
65
|
+
return mods;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function listFixtureFiles(root, moduleName) {
|
|
69
|
+
const dirs = [
|
|
70
|
+
path.join(root, "tests/fixtures", moduleName),
|
|
71
|
+
path.join(root, "frontend/tests/fixtures", moduleName),
|
|
72
|
+
path.join(root, "tests/fixtures"),
|
|
73
|
+
path.join(root, "frontend/tests/fixtures"),
|
|
74
|
+
];
|
|
75
|
+
const files = [];
|
|
76
|
+
for (const dir of dirs) {
|
|
77
|
+
if (!fs.existsSync(dir)) continue;
|
|
78
|
+
for (const name of fs.readdirSync(dir)) {
|
|
79
|
+
if (name.endsWith(".json")) files.push(path.join(dir, name));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return [...new Set(files)];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function walkSource(dir, acc) {
|
|
86
|
+
if (!fs.existsSync(dir)) return acc;
|
|
87
|
+
let out = acc;
|
|
88
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
89
|
+
if (ent.name === "node_modules") continue;
|
|
90
|
+
const full = path.join(dir, ent.name);
|
|
91
|
+
if (ent.isDirectory()) out = walkSource(full, out);
|
|
92
|
+
else if (/\.(vue|tsx?|ts)$/.test(ent.name)) {
|
|
93
|
+
out += fs.readFileSync(full, "utf8");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function collectSourceCorpus(root, changeDir) {
|
|
100
|
+
let blob = readChangeBlob(changeDir);
|
|
101
|
+
const fileRefs = new Set();
|
|
102
|
+
const patterns = [
|
|
103
|
+
/(?:src|frontend\/src)\/[\w/.-]+\.(vue|tsx?|ts)/g,
|
|
104
|
+
/views\/[\w/.-]+\.(vue|tsx?)/g,
|
|
105
|
+
];
|
|
106
|
+
for (const re of patterns) {
|
|
107
|
+
let m;
|
|
108
|
+
const r = new RegExp(re.source, "g");
|
|
109
|
+
while ((m = r.exec(blob)) !== null) {
|
|
110
|
+
fileRefs.add(m[0].replace(/^frontend\//, ""));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (const ref of fileRefs) {
|
|
114
|
+
for (const fp of [
|
|
115
|
+
path.join(root, ref),
|
|
116
|
+
path.join(root, "frontend", ref),
|
|
117
|
+
path.join(root, "src", ref.replace(/^src\//, "")),
|
|
118
|
+
]) {
|
|
119
|
+
if (fs.existsSync(fp)) {
|
|
120
|
+
blob += fs.readFileSync(fp, "utf8");
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (const vr of [
|
|
126
|
+
path.join(root, "src/views"),
|
|
127
|
+
path.join(root, "frontend/src/views"),
|
|
128
|
+
]) {
|
|
129
|
+
blob = walkSource(vr, blob);
|
|
130
|
+
}
|
|
131
|
+
return blob;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function walkTests(dir, moduleName, hits) {
|
|
135
|
+
if (!fs.existsSync(dir)) return;
|
|
136
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
137
|
+
const full = path.join(dir, ent.name);
|
|
138
|
+
if (ent.isDirectory()) walkTests(full, moduleName, hits);
|
|
139
|
+
else if (/\.(spec|test)\.(t|j)sx?$/.test(ent.name)) {
|
|
140
|
+
const content = fs.readFileSync(full, "utf8");
|
|
141
|
+
if (NORMALIZE_TEST_RE.test(content) && /snakeToCamel|caseTransform/.test(content)) {
|
|
142
|
+
hits.push(full);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function findNormalizeTests(root, moduleName) {
|
|
149
|
+
const hits = [];
|
|
150
|
+
for (const r of [path.join(root, "tests"), path.join(root, "frontend/tests")]) {
|
|
151
|
+
walkTests(r, moduleName, hits);
|
|
152
|
+
}
|
|
153
|
+
return hits;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function corpusContainsField(corpus, field) {
|
|
157
|
+
if (!field || field.length < 2) return true;
|
|
158
|
+
const snake = field.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
159
|
+
return corpus.includes(field) || corpus.includes(`'${field}'`) || corpus.includes(snake);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function assessApiFieldCoverage(root, changeName, fieldDoc) {
|
|
163
|
+
const errors = [];
|
|
164
|
+
const passed = [];
|
|
165
|
+
const changeDir = path.join(root, "openspec/changes", changeName);
|
|
166
|
+
const blob = readChangeBlob(changeDir);
|
|
167
|
+
const modules = collectApiModules(blob);
|
|
168
|
+
if (modules.size === 0) return { errors, passed, mappingRows: [] };
|
|
169
|
+
|
|
170
|
+
const mappingRows = parseFieldMappingTable(fieldDoc);
|
|
171
|
+
const corpus = collectSourceCorpus(root, changeDir);
|
|
172
|
+
|
|
173
|
+
if (mappingRows.length === 0) {
|
|
174
|
+
errors.push("04/design 缺少 §4.3.1 字段映射表(| 后端字段 | 前端字段 |)");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const mod of modules) {
|
|
178
|
+
const fixtures = listFixtureFiles(root, mod);
|
|
179
|
+
const validFixtures = [];
|
|
180
|
+
for (const fp of fixtures) {
|
|
181
|
+
try {
|
|
182
|
+
const payload = JSON.parse(fs.readFileSync(fp, "utf8"));
|
|
183
|
+
if (hasSnakeCaseKeys(payload)) validFixtures.push({ path: fp, payload });
|
|
184
|
+
} catch {
|
|
185
|
+
// skip
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (validFixtures.length === 0) {
|
|
189
|
+
errors.push(
|
|
190
|
+
`${mod}: 缺少 tests/fixtures/${mod}/*.json(须含 snake_case,来自联调抓包)`,
|
|
191
|
+
);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const normalizeTests = findNormalizeTests(root, mod);
|
|
196
|
+
const strongTest = normalizeTests.some((tp) => {
|
|
197
|
+
const t = fs.readFileSync(tp, "utf8");
|
|
198
|
+
return (
|
|
199
|
+
/expect\s*\(/.test(t) &&
|
|
200
|
+
/snakeToCamel|normalize/.test(t) &&
|
|
201
|
+
(FIXTURE_LOAD_RE.test(t) || /subject_id|_[a-z]/.test(t))
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
if (!strongTest) {
|
|
205
|
+
errors.push(
|
|
206
|
+
`${mod}: 缺少 caseTransform 单测(expect 断言 camelCase;建议加载 fixtures/${mod}/*.json)`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const fixtureKeys = new Set();
|
|
211
|
+
for (const f of validFixtures) {
|
|
212
|
+
const row = extractFirstListRow(f.payload);
|
|
213
|
+
const normalized = snakeToCamelDeep(f.payload);
|
|
214
|
+
if (row) Object.keys(row).forEach((k) => fixtureKeys.add(k));
|
|
215
|
+
for (const k of collectLeafCamelKeys(normalized)) fixtureKeys.add(k);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const keysToCheck =
|
|
219
|
+
mappingRows.length > 0
|
|
220
|
+
? mappingRows.map((r) => r.frontendField)
|
|
221
|
+
: [...fixtureKeys].filter((k) => k.length >= 3).slice(0, 12);
|
|
222
|
+
|
|
223
|
+
const missing = keysToCheck.filter((k) => !corpusContainsField(corpus, k));
|
|
224
|
+
if (keysToCheck.length > 0 && missing.length === keysToCheck.length) {
|
|
225
|
+
errors.push(
|
|
226
|
+
`${mod}: 字段 [${keysToCheck.slice(0, 5).join(", ")}] 在源码中无消费点`,
|
|
227
|
+
);
|
|
228
|
+
} else if (missing.length > 0 && mappingRows.length > 0) {
|
|
229
|
+
errors.push(`${mod}: 映射表字段未在源码出现: ${missing.slice(0, 6).join(", ")}`);
|
|
230
|
+
} else {
|
|
231
|
+
passed.push(
|
|
232
|
+
`${mod}(fixtures=${validFixtures.length}, ok=${keysToCheck.length - missing.length}/${keysToCheck.length})`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { errors, passed, mappingRows };
|
|
238
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 组件实质分析(与 src/core/componentSubstance.ts 语义一致)。
|
|
3
|
+
*/
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
const MIN_EFFECTIVE_LINES = 40;
|
|
8
|
+
const VUE_TEMPLATE_RE = /<template[^>]*>([\s\S]*?)<\/template>/i;
|
|
9
|
+
const INTERACTIVE_CONTROL_RE =
|
|
10
|
+
/<(?:n-button|a-button|el-button|NButton|AButton|ElButton|button|n-input|a-input|el-input|input|n-data-table|a-table|el-table|NDataTable|ATable|n-drawer|a-drawer)[\s/>]/i;
|
|
11
|
+
const API_BEHAVIOR_RE =
|
|
12
|
+
/from\s+['"][^'"]*(?:config\/api|api\/modules)|useMutation|useQuery|Api\s*\(|financePost|financeGet/i;
|
|
13
|
+
const EMIT_HANDLER_RE = /defineEmits|emit\s*\(|@\w+\s*=\s*["']|onClick|onSubmit/i;
|
|
14
|
+
const CHILD_COMPONENT_RE = /(?:Section|Modal|Panel|Form)\.vue$/i;
|
|
15
|
+
const PARENT_CONTAINER_RE = /(?:Drawer|index)\.vue$/i;
|
|
16
|
+
|
|
17
|
+
export function stripComments(content) {
|
|
18
|
+
let out = "";
|
|
19
|
+
let i = 0;
|
|
20
|
+
while (i < content.length) {
|
|
21
|
+
if (content[i] === "/" && content[i + 1] === "/") {
|
|
22
|
+
while (i < content.length && content[i] !== "\n") i += 1;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (content[i] === "/" && content[i + 1] === "*") {
|
|
26
|
+
i += 2;
|
|
27
|
+
while (i < content.length && !(content[i] === "*" && content[i + 1] === "/")) i += 1;
|
|
28
|
+
i += 2;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
out += content[i];
|
|
32
|
+
i += 1;
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function countEffectiveLines(content) {
|
|
38
|
+
return stripComments(content)
|
|
39
|
+
.split("\n")
|
|
40
|
+
.filter((l) => l.trim().length > 0).length;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function signalPresentInComponent(content, signal) {
|
|
44
|
+
if (!signal || signal.length < 2) return true;
|
|
45
|
+
const stripped = stripComments(content);
|
|
46
|
+
const template = stripped.match(VUE_TEMPLATE_RE)?.[1] ?? "";
|
|
47
|
+
const script = stripped.replace(VUE_TEMPLATE_RE, "");
|
|
48
|
+
return template.includes(signal) || script.includes(signal);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function analyzeComponentSubstance(relPath, content, interactionSignals) {
|
|
52
|
+
const issues = [];
|
|
53
|
+
const stripped = stripComments(content);
|
|
54
|
+
const effectiveLines = countEffectiveLines(content);
|
|
55
|
+
|
|
56
|
+
if (effectiveLines < MIN_EFFECTIVE_LINES) {
|
|
57
|
+
issues.push(`有效代码行 ${effectiveLines} < ${MIN_EFFECTIVE_LINES}(疑似空壳)`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (/\.vue$/i.test(relPath)) {
|
|
61
|
+
const template = content.match(VUE_TEMPLATE_RE)?.[1] ?? "";
|
|
62
|
+
if (!template.trim()) issues.push("缺少 <template> 区块");
|
|
63
|
+
else if (!INTERACTIVE_CONTROL_RE.test(template)) {
|
|
64
|
+
issues.push("template 中未发现 button/input/table 等可交互控件");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const hasBehavior = API_BEHAVIOR_RE.test(stripped) || EMIT_HANDLER_RE.test(stripped);
|
|
69
|
+
const meaningfulSignals = interactionSignals.filter((s) => s.length >= 2);
|
|
70
|
+
if (!hasBehavior && meaningfulSignals.length > 0) {
|
|
71
|
+
issues.push("script 中未发现 API 调用或 emit/事件处理(疑似只读骨架)");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const missingSignals = meaningfulSignals.filter((s) => !signalPresentInComponent(content, s));
|
|
75
|
+
if (meaningfulSignals.length > 0 && missingSignals.length === meaningfulSignals.length) {
|
|
76
|
+
issues.push(`交互信号均未出现在 template/script: ${meaningfulSignals.join("、")}`);
|
|
77
|
+
} else if (missingSignals.length > 0) {
|
|
78
|
+
issues.push(`缺少交互信号: ${missingSignals.join("、")}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { ok: issues.length === 0, issues };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function checkChildComponentsImported(pkgRoot, mappings) {
|
|
85
|
+
const failures = [];
|
|
86
|
+
const paths = mappings.map((m) => m.productionPath.replace(/^\.\//, ""));
|
|
87
|
+
|
|
88
|
+
for (const m of mappings) {
|
|
89
|
+
const rel = m.productionPath.replace(/^\.\//, "");
|
|
90
|
+
if (!CHILD_COMPONENT_RE.test(rel)) continue;
|
|
91
|
+
if (PARENT_CONTAINER_RE.test(rel)) continue;
|
|
92
|
+
|
|
93
|
+
const base = path.basename(rel, path.extname(rel));
|
|
94
|
+
let imported = false;
|
|
95
|
+
|
|
96
|
+
for (const otherRel of paths) {
|
|
97
|
+
if (otherRel === rel) continue;
|
|
98
|
+
const fp = path.join(pkgRoot, otherRel);
|
|
99
|
+
if (!fs.existsSync(fp)) continue;
|
|
100
|
+
const content = fs.readFileSync(fp, "utf8");
|
|
101
|
+
if (
|
|
102
|
+
new RegExp(`import\\s+\\w+\\s+from\\s+['"][^'"]*${base}['"]`).test(content) ||
|
|
103
|
+
content.includes(`<${base}`) ||
|
|
104
|
+
content.includes(`<${base} `)
|
|
105
|
+
) {
|
|
106
|
+
imported = true;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!imported) {
|
|
112
|
+
failures.push(`${m.moduleName}: ${rel} 未被父组件 import/挂载`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return failures;
|
|
117
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 演示参照章节解析(与 src/core/demoImplementationCoverage.ts 语义一致)。
|
|
3
|
+
*/
|
|
4
|
+
const DEMO_SECTION_RE = /^##\s*(演示代码参照|Demo Reference)\s*$/im;
|
|
5
|
+
const DEMO_EXEMPT_RE = /^DEMO_REFERENCE_EXEMPT:\s*(.+)$/m;
|
|
6
|
+
const MAPPING_HEADER_RE = /演示模块|Demo Module/i;
|
|
7
|
+
|
|
8
|
+
export function parseDemoReferenceSection(content) {
|
|
9
|
+
const exemptMatch = content.match(DEMO_EXEMPT_RE);
|
|
10
|
+
const exempt = exemptMatch?.[1]?.trim() || null;
|
|
11
|
+
|
|
12
|
+
const sectionMatch = content.match(DEMO_SECTION_RE);
|
|
13
|
+
if (!sectionMatch || sectionMatch.index === undefined) {
|
|
14
|
+
return { hasSection: false, exempt, demoRoot: null, demoStack: null, mappings: [] };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const sectionBody = content.slice(sectionMatch.index);
|
|
18
|
+
const demoRoot =
|
|
19
|
+
sectionBody.match(/演示代码根路径[::]\s*`?([^`\n]+)`?/i)?.[1]?.trim() || null;
|
|
20
|
+
const demoStack =
|
|
21
|
+
sectionBody.match(/技术栈[::]\s*`?(vue|react|其他)[^`\n]*`?/i)?.[1]?.trim() || null;
|
|
22
|
+
|
|
23
|
+
const mappings = [];
|
|
24
|
+
const lines = sectionBody.split("\n");
|
|
25
|
+
let inTable = false;
|
|
26
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
27
|
+
const line = lines[i].trim();
|
|
28
|
+
if (line.startsWith("|") && MAPPING_HEADER_RE.test(line)) {
|
|
29
|
+
inTable = true;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (!inTable || !line.startsWith("|")) continue;
|
|
33
|
+
if (/^\|\s*[-:]+/.test(line)) continue;
|
|
34
|
+
const cells = line
|
|
35
|
+
.split("|")
|
|
36
|
+
.map((c) => c.trim())
|
|
37
|
+
.filter((_, idx, arr) => idx > 0 && idx < arr.length - 1);
|
|
38
|
+
if (cells.length < 4) continue;
|
|
39
|
+
if (/演示模块|Demo Module/i.test(cells[0])) continue;
|
|
40
|
+
|
|
41
|
+
const signals = (cells[3] ?? "")
|
|
42
|
+
.split(/[、,,|;;]/)
|
|
43
|
+
.map((s) => s.trim())
|
|
44
|
+
.filter((s) => s.length >= 2);
|
|
45
|
+
|
|
46
|
+
mappings.push({
|
|
47
|
+
moduleName: cells[0],
|
|
48
|
+
demoRef: cells[1],
|
|
49
|
+
productionPath: cells[2].replace(/\\/g, "/"),
|
|
50
|
+
interactionSignals: signals,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { hasSection: true, exempt, demoRoot, demoStack, mappings };
|
|
55
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E 演示信号点击(与 src/core/e2eDemoSignalClicks.ts 语义一致)。
|
|
3
|
+
*/
|
|
4
|
+
import { parseDemoReferenceSection } from "./demo-reference-parse.mjs";
|
|
5
|
+
|
|
6
|
+
const CLICKABLE_SIGNAL_RE = /编辑|保存|删除|新增|导入|拉取|提交|确认|取消|打开|修改|添加/i;
|
|
7
|
+
|
|
8
|
+
function escapeRegExp(s) {
|
|
9
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function e2eExercisesSignal(e2eContent, signal) {
|
|
13
|
+
if (!signal || signal.length < 2) return true;
|
|
14
|
+
const stripped = e2eContent.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
15
|
+
if (!stripped.includes(signal)) return false;
|
|
16
|
+
const esc = escapeRegExp(signal);
|
|
17
|
+
const clickPatterns = [
|
|
18
|
+
new RegExp(`\\.click\\s*\\([^)]*${esc}`),
|
|
19
|
+
new RegExp(`getByRole\\s*\\(\\s*['"]button['"][^)]*${esc}`),
|
|
20
|
+
new RegExp(`getByText\\s*\\(\\s*['"\`]${esc}`),
|
|
21
|
+
new RegExp(`getByText\\s*\\(\\s*[^)]*${esc}`),
|
|
22
|
+
new RegExp(`locator\\s*\\([^)]*${esc}[^)]*\\)\\.click`),
|
|
23
|
+
];
|
|
24
|
+
return clickPatterns.some((re) => re.test(stripped));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function checkE2eDemoSignalClicks(designContent, e2eBlob) {
|
|
28
|
+
const parsed = parseDemoReferenceSection(designContent ?? "");
|
|
29
|
+
if (parsed.exempt || parsed.mappings.length === 0) return [];
|
|
30
|
+
|
|
31
|
+
const failures = [];
|
|
32
|
+
for (const m of parsed.mappings) {
|
|
33
|
+
const clickable = m.interactionSignals.filter((s) => CLICKABLE_SIGNAL_RE.test(s));
|
|
34
|
+
if (clickable.length === 0) continue;
|
|
35
|
+
const uncovered = clickable.filter((s) => !e2eExercisesSignal(e2eBlob, s));
|
|
36
|
+
if (uncovered.length === clickable.length) {
|
|
37
|
+
failures.push(
|
|
38
|
+
`${m.moduleName}: E2E 须 click/getByText 覆盖「${clickable.join("、")}」`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return failures;
|
|
43
|
+
}
|