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,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 演示代码 → 生产实现 覆盖门禁(通用:演示可为 Vue/React)。
|
|
4
|
+
*
|
|
5
|
+
* 用法: node scripts/assess-demo-implementation-coverage.mjs --change <change-name>
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
|
+
|
|
13
|
+
const DEMO_SECTION_RE = /^##\s*(演示代码参照|Demo Reference)\s*$/im;
|
|
14
|
+
const DEMO_EXEMPT_RE = /^DEMO_REFERENCE_EXEMPT:\s*(.+)$/m;
|
|
15
|
+
const MAPPING_HEADER_RE = /演示模块|Demo Module/i;
|
|
16
|
+
const UI_SCENARIO_KEYWORDS =
|
|
17
|
+
/上传|点击|页面|按钮|弹窗|列表|表格|展示|抽屉|详情|编辑|导出|筛选|输入框|对话框|导航|toast|保存|删除|添加/i;
|
|
18
|
+
|
|
19
|
+
function parseArgs(argv) {
|
|
20
|
+
let change = "";
|
|
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.startsWith("-") && !change) change = a;
|
|
25
|
+
}
|
|
26
|
+
return { change };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function stripComments(content) {
|
|
30
|
+
let out = "";
|
|
31
|
+
let i = 0;
|
|
32
|
+
while (i < content.length) {
|
|
33
|
+
if (content[i] === "/" && content[i + 1] === "/") {
|
|
34
|
+
while (i < content.length && content[i] !== "\n") i += 1;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (content[i] === "/" && content[i + 1] === "*") {
|
|
38
|
+
i += 2;
|
|
39
|
+
while (i < content.length && !(content[i] === "*" && content[i + 1] === "/")) i += 1;
|
|
40
|
+
i += 2;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
out += content[i];
|
|
44
|
+
i += 1;
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseDemoReferenceSection(content) {
|
|
50
|
+
const exemptMatch = content.match(DEMO_EXEMPT_RE);
|
|
51
|
+
const exempt = exemptMatch?.[1]?.trim() || null;
|
|
52
|
+
const sectionMatch = content.match(DEMO_SECTION_RE);
|
|
53
|
+
if (!sectionMatch || sectionMatch.index === undefined) {
|
|
54
|
+
return { hasSection: false, exempt, mappings: [] };
|
|
55
|
+
}
|
|
56
|
+
const sectionBody = content.slice(sectionMatch.index);
|
|
57
|
+
const mappings = [];
|
|
58
|
+
const lines = sectionBody.split("\n");
|
|
59
|
+
let inTable = false;
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
const trimmed = line.trim();
|
|
62
|
+
if (trimmed.startsWith("|") && MAPPING_HEADER_RE.test(trimmed)) {
|
|
63
|
+
inTable = true;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!inTable || !trimmed.startsWith("|")) continue;
|
|
67
|
+
if (/^\|\s*[-:]+/.test(trimmed)) continue;
|
|
68
|
+
const cells = trimmed
|
|
69
|
+
.split("|")
|
|
70
|
+
.map((c) => c.trim())
|
|
71
|
+
.filter((_, idx, arr) => idx > 0 && idx < arr.length - 1);
|
|
72
|
+
if (cells.length < 4) continue;
|
|
73
|
+
if (/演示模块|Demo Module/i.test(cells[0])) continue;
|
|
74
|
+
const signals = (cells[3] ?? "")
|
|
75
|
+
.split(/[、,,|;;]/)
|
|
76
|
+
.map((s) => s.trim())
|
|
77
|
+
.filter((s) => s.length >= 2);
|
|
78
|
+
mappings.push({
|
|
79
|
+
moduleName: cells[0],
|
|
80
|
+
productionPath: cells[2].replace(/\\/g, "/"),
|
|
81
|
+
interactionSignals: signals,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return { hasSection: true, exempt, mappings };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function changeHasUiScenarios(changeDir) {
|
|
88
|
+
const specsDir = path.join(changeDir, "specs");
|
|
89
|
+
if (!fs.existsSync(specsDir)) return false;
|
|
90
|
+
for (const cap of fs.readdirSync(specsDir, { withFileTypes: true })) {
|
|
91
|
+
if (!cap.isDirectory()) continue;
|
|
92
|
+
const specPath = path.join(specsDir, cap.name, "spec.md");
|
|
93
|
+
if (!fs.existsSync(specPath)) continue;
|
|
94
|
+
const content = fs.readFileSync(specPath, "utf8");
|
|
95
|
+
const blocks = content.split(/^####\s+Scenario:/m);
|
|
96
|
+
for (let i = 1; i < blocks.length; i += 1) {
|
|
97
|
+
if (UI_SCENARIO_KEYWORDS.test(blocks[i])) return true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function fail(messages) {
|
|
104
|
+
console.error("[assess-demo-implementation-coverage] FAIL");
|
|
105
|
+
for (const msg of messages) console.error(` - ${msg}`);
|
|
106
|
+
console.error(
|
|
107
|
+
" 修复: 在 design.md 填写 ## 演示代码参照 映射表;按演示拆生产组件;tasks 增加「演示模块迁移」",
|
|
108
|
+
);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function main() {
|
|
113
|
+
const skip =
|
|
114
|
+
process.env.POQUAN_SKIP_DEMO_REFERENCE ||
|
|
115
|
+
process.env.OPSX_SKIP_DEMO_REFERENCE;
|
|
116
|
+
if (skip === "1") {
|
|
117
|
+
console.warn("[assess-demo-implementation-coverage] SKIP");
|
|
118
|
+
process.exit(0);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const { change } = parseArgs(process.argv);
|
|
122
|
+
if (!change) {
|
|
123
|
+
console.error("[assess-demo-implementation-coverage] 缺少 --change <name>");
|
|
124
|
+
process.exit(2);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const changeDir = path.join(ROOT, "openspec/changes", change);
|
|
128
|
+
if (!fs.existsSync(changeDir)) {
|
|
129
|
+
console.error(`[assess-demo-implementation-coverage] change 不存在: ${change}`);
|
|
130
|
+
process.exit(2);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const designPath = path.join(changeDir, "design.md");
|
|
134
|
+
if (!fs.existsSync(designPath)) {
|
|
135
|
+
if (!changeHasUiScenarios(changeDir)) {
|
|
136
|
+
console.log("[assess-demo-implementation-coverage] PASS(无 UI)");
|
|
137
|
+
process.exit(0);
|
|
138
|
+
}
|
|
139
|
+
fail(["缺少 design.md"]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const design = fs.readFileSync(designPath, "utf8");
|
|
143
|
+
const parsed = parseDemoReferenceSection(design);
|
|
144
|
+
if (parsed.exempt) {
|
|
145
|
+
console.log(`[assess-demo-implementation-coverage] PASS(豁免) ${parsed.exempt}`);
|
|
146
|
+
process.exit(0);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!changeHasUiScenarios(changeDir)) {
|
|
150
|
+
console.log("[assess-demo-implementation-coverage] PASS(无 UI Scenario)");
|
|
151
|
+
process.exit(0);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const errors = [];
|
|
155
|
+
if (!parsed.hasSection) errors.push("design.md 缺少 ## 演示代码参照");
|
|
156
|
+
if (parsed.mappings.length === 0) errors.push("演示模块映射表为空");
|
|
157
|
+
|
|
158
|
+
const tasksPath = path.join(changeDir, "tasks.md");
|
|
159
|
+
const tasks = fs.existsSync(tasksPath) ? fs.readFileSync(tasksPath, "utf8") : "";
|
|
160
|
+
if (!/演示模块迁移/.test(tasks)) {
|
|
161
|
+
errors.push("tasks.md 缺少「演示模块迁移」段");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const m of parsed.mappings) {
|
|
165
|
+
const rel = m.productionPath.replace(/^\.\//, "");
|
|
166
|
+
const abs = path.join(ROOT, rel);
|
|
167
|
+
if (!fs.existsSync(abs)) {
|
|
168
|
+
errors.push(`${m.moduleName}: 生产文件不存在 ${rel}`);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const code = stripComments(fs.readFileSync(abs, "utf8"));
|
|
172
|
+
const missing = m.interactionSignals.filter((s) => !code.includes(s));
|
|
173
|
+
if (m.interactionSignals.length > 0 && missing.length === m.interactionSignals.length) {
|
|
174
|
+
errors.push(
|
|
175
|
+
`${m.moduleName}: ${rel} 未实现演示中的关键交互(${m.interactionSignals.join("、")})`,
|
|
176
|
+
);
|
|
177
|
+
} else if (missing.length > 0) {
|
|
178
|
+
errors.push(`${m.moduleName}: ${rel} 缺少: ${missing.join("、")}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (errors.length > 0) fail(errors);
|
|
183
|
+
console.log(
|
|
184
|
+
`[assess-demo-implementation-coverage] PASS change=${change} modules=${parsed.mappings.length}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
main();
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* E2E 断言深度门禁:change 级 spec 须含 page.goto + 数据/交互断言;拒绝 prd-atoms 伪覆盖。
|
|
4
|
+
*
|
|
5
|
+
* 用法: node scripts/assess-e2e-assertion-depth.mjs --change <change-name>
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { analyzeE2eSpecDepth } from "./lib/test-assertion-analysis.mjs";
|
|
11
|
+
import { checkE2eDemoSignalClicks } from "./lib/e2e-demo-signal-clicks.mjs";
|
|
12
|
+
import { parseDemoReferenceSection } from "./lib/demo-reference-parse.mjs";
|
|
13
|
+
|
|
14
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
|
+
const CONFIG_PATH = path.join(ROOT, "e2e/e2e-scope.config.json");
|
|
16
|
+
const UI_SCENARIO_KEYWORDS =
|
|
17
|
+
/上传|点击|页面|按钮|弹窗|列表|表格|展示|抽屉|详情|导出|筛选|输入框|对话框|导航|toast|截图/i;
|
|
18
|
+
const E2E_SPEC_RE = /\be2e\/[\w/.-]+\.spec\.(?:ts|js)\b/g;
|
|
19
|
+
|
|
20
|
+
function parseArgs(argv) {
|
|
21
|
+
let change = "";
|
|
22
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
23
|
+
const a = argv[i];
|
|
24
|
+
if (a === "--change" && argv[i + 1]) change = argv[++i];
|
|
25
|
+
else if (!a.startsWith("-") && !change) change = a;
|
|
26
|
+
}
|
|
27
|
+
return { change };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readChangeArtifacts(changeDir) {
|
|
31
|
+
const parts = {};
|
|
32
|
+
let blob = "";
|
|
33
|
+
for (const f of ["tasks.md", "proposal.md", "design.md"]) {
|
|
34
|
+
const fp = path.join(changeDir, f);
|
|
35
|
+
if (!fs.existsSync(fp)) continue;
|
|
36
|
+
const content = fs.readFileSync(fp, "utf8");
|
|
37
|
+
parts[f] = content;
|
|
38
|
+
blob += `${content}\n`;
|
|
39
|
+
}
|
|
40
|
+
return { parts, blob };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function analyzeDeltaSpecs(changeDir) {
|
|
44
|
+
const specsDir = path.join(changeDir, "specs");
|
|
45
|
+
let uiScenarioCount = 0;
|
|
46
|
+
if (!fs.existsSync(specsDir)) return { uiScenarioCount };
|
|
47
|
+
for (const cap of fs.readdirSync(specsDir, { withFileTypes: true })) {
|
|
48
|
+
if (!cap.isDirectory()) continue;
|
|
49
|
+
const specPath = path.join(specsDir, cap.name, "spec.md");
|
|
50
|
+
if (!fs.existsSync(specPath)) continue;
|
|
51
|
+
const content = fs.readFileSync(specPath, "utf8");
|
|
52
|
+
const blocks = content.split(/^####\s+Scenario:/m);
|
|
53
|
+
for (let i = 1; i < blocks.length; i += 1) {
|
|
54
|
+
if (UI_SCENARIO_KEYWORDS.test(blocks[i])) uiScenarioCount += 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { uiScenarioCount };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function collectChangeE2eSpecs(changeName, changeDir) {
|
|
61
|
+
const specs = new Set();
|
|
62
|
+
const { blob } = readChangeArtifacts(changeDir);
|
|
63
|
+
let m;
|
|
64
|
+
const re = new RegExp(E2E_SPEC_RE.source, "g");
|
|
65
|
+
while ((m = re.exec(blob)) !== null) {
|
|
66
|
+
const rel = m[0];
|
|
67
|
+
if (fs.existsSync(path.join(ROOT, rel))) specs.add(rel);
|
|
68
|
+
}
|
|
69
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
70
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
71
|
+
const overrides = config.changeNameToSpecs?.[changeName];
|
|
72
|
+
if (overrides) {
|
|
73
|
+
for (const raw of overrides) {
|
|
74
|
+
const rel = raw.startsWith("e2e/") ? raw : `e2e/${raw}`;
|
|
75
|
+
if (fs.existsSync(path.join(ROOT, rel))) specs.add(rel);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return [...specs].sort();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function fail(messages) {
|
|
83
|
+
console.error("[assess-e2e-assertion-depth] FAIL");
|
|
84
|
+
for (const msg of messages) console.error(` - ${msg}`);
|
|
85
|
+
console.error(
|
|
86
|
+
" 修复: 在 change 级 e2e spec 中补充 page.goto、waitForResponse/getByRole(cell)/toContainText 或 click 等断言;",
|
|
87
|
+
);
|
|
88
|
+
console.error(" 禁止用 prd-atoms-coverage 登记代替真实 E2E;纯后端 change 可 PLAYWRIGHT_E2E_EXEMPT");
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function main() {
|
|
93
|
+
const skip =
|
|
94
|
+
process.env.POQUAN_SKIP_E2E_ASSERTION_DEPTH ||
|
|
95
|
+
process.env.OPSX_SKIP_E2E_ASSERTION_DEPTH;
|
|
96
|
+
if (skip === "1") {
|
|
97
|
+
console.warn("[assess-e2e-assertion-depth] SKIP");
|
|
98
|
+
process.exit(0);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { change } = parseArgs(process.argv);
|
|
102
|
+
if (!change) {
|
|
103
|
+
console.error("[assess-e2e-assertion-depth] 缺少 --change <name>");
|
|
104
|
+
process.exit(2);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const changeDir = path.join(ROOT, "openspec/changes", change);
|
|
108
|
+
if (!fs.existsSync(changeDir)) {
|
|
109
|
+
console.error(`[assess-e2e-assertion-depth] 活跃 change 不存在: openspec/changes/${change}`);
|
|
110
|
+
process.exit(2);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const { parts } = readChangeArtifacts(changeDir);
|
|
114
|
+
const tasksContent = parts["tasks.md"] || "";
|
|
115
|
+
if (/^PLAYWRIGHT_E2E_EXEMPT:/m.test(tasksContent)) {
|
|
116
|
+
console.log(`[assess-e2e-assertion-depth] PASS(豁免) change=${change}`);
|
|
117
|
+
process.exit(0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const { uiScenarioCount } = analyzeDeltaSpecs(changeDir);
|
|
121
|
+
const specPaths = collectChangeE2eSpecs(change, changeDir);
|
|
122
|
+
|
|
123
|
+
if (uiScenarioCount === 0 && specPaths.length === 0) {
|
|
124
|
+
console.log(`[assess-e2e-assertion-depth] PASS(无 UI e2e 要求) change=${change}`);
|
|
125
|
+
process.exit(0);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const errors = [];
|
|
129
|
+
if (uiScenarioCount > 0 && specPaths.length === 0) {
|
|
130
|
+
errors.push(`含 ${uiScenarioCount} 个 UI Scenario 但未绑定 e2e/*.spec.ts`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const rel of specPaths) {
|
|
134
|
+
const content = fs.readFileSync(path.join(ROOT, rel), "utf8");
|
|
135
|
+
const depth = analyzeE2eSpecDepth(rel, content);
|
|
136
|
+
if (!depth.ok) {
|
|
137
|
+
errors.push(`${rel}: ${depth.issues.join("; ")}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const designPath = path.join(changeDir, "design.md");
|
|
142
|
+
if (fs.existsSync(designPath) && specPaths.length > 0) {
|
|
143
|
+
const design = fs.readFileSync(designPath, "utf8");
|
|
144
|
+
const parsed = parseDemoReferenceSection(design);
|
|
145
|
+
if (!parsed.exempt && parsed.mappings.length > 0) {
|
|
146
|
+
let e2eBlob = "";
|
|
147
|
+
for (const rel of specPaths) {
|
|
148
|
+
e2eBlob += fs.readFileSync(path.join(ROOT, rel), "utf8");
|
|
149
|
+
}
|
|
150
|
+
const clickFailures = checkE2eDemoSignalClicks(design, e2eBlob);
|
|
151
|
+
for (const f of clickFailures) errors.push(f);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (errors.length > 0) fail(errors);
|
|
156
|
+
|
|
157
|
+
console.log(
|
|
158
|
+
`[assess-e2e-assertion-depth] PASS change=${change} specs=${specPaths.join(" ")}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
main();
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 校验 OpenSpec change 的 Playwright E2E 覆盖是否足以验收本 change(防 module 回退钻漏洞)。
|
|
4
|
+
*
|
|
5
|
+
* 规则摘要:
|
|
6
|
+
* - delta specs 含 UI 类 Scenario 时,tasks/proposal/design 或 changeNameToSpecs 必须声明可执行的 e2e/*.spec.ts
|
|
7
|
+
* - scope=change 将回退 module 且存在 UI Scenario 时,默认 FAIL(须显式 POQUAN_ALLOW_PLAYWRIGHT_MODULE_FALLBACK=1 豁免)
|
|
8
|
+
*
|
|
9
|
+
* 用法: node scripts/assess-playwright-e2e-coverage.mjs --change <change-name>
|
|
10
|
+
*/
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
16
|
+
const CONFIG_PATH = path.join(ROOT, "e2e/e2e-scope.config.json");
|
|
17
|
+
|
|
18
|
+
/** UI / 浏览器交互类关键词(出现在 delta Scenario 时强制要求 change 级 e2e spec) */
|
|
19
|
+
const UI_SCENARIO_KEYWORDS =
|
|
20
|
+
/上传|点击|页面|按钮|弹窗|拖拽|选择文件|附件|输入框|对话框|导航|展示|卡片|加载|选文件|文件选择|工作台|Composer|toast|截图/i;
|
|
21
|
+
|
|
22
|
+
/** tasks 中「单测代替集成」的典型反模式 */
|
|
23
|
+
const MOCK_COVERS_INTEGRATION_RE =
|
|
24
|
+
/单测.*覆盖|mock.*覆盖|组件单测.*集成|子组件单测|仅靠.*单测/i;
|
|
25
|
+
|
|
26
|
+
function loadConfig() {
|
|
27
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseArgs(argv) {
|
|
31
|
+
let change = "";
|
|
32
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
33
|
+
const a = argv[i];
|
|
34
|
+
if (a === "--change" && argv[i + 1]) {
|
|
35
|
+
change = argv[++i];
|
|
36
|
+
} else if (!a.startsWith("-") && !change) {
|
|
37
|
+
change = a;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { change };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function findChangeDir(changeName) {
|
|
44
|
+
const active = path.join(ROOT, "openspec/changes", changeName);
|
|
45
|
+
if (fs.existsSync(active)) return active;
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readChangeArtifacts(changeDir) {
|
|
50
|
+
const files = ["tasks.md", "proposal.md", "design.md"];
|
|
51
|
+
const parts = {};
|
|
52
|
+
let blob = "";
|
|
53
|
+
for (const f of files) {
|
|
54
|
+
const fp = path.join(changeDir, f);
|
|
55
|
+
if (!fs.existsSync(fp)) continue;
|
|
56
|
+
const content = fs.readFileSync(fp, "utf8");
|
|
57
|
+
parts[f] = content;
|
|
58
|
+
blob += `${content}\n`;
|
|
59
|
+
}
|
|
60
|
+
return { parts, blob };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 统计 delta specs 中的 Scenario 数量,并检测是否含 UI 交互描述 */
|
|
64
|
+
function analyzeDeltaSpecs(changeDir) {
|
|
65
|
+
const specsDir = path.join(changeDir, "specs");
|
|
66
|
+
let scenarioCount = 0;
|
|
67
|
+
let uiScenarioCount = 0;
|
|
68
|
+
let deltaBlob = "";
|
|
69
|
+
|
|
70
|
+
if (!fs.existsSync(specsDir)) {
|
|
71
|
+
return { scenarioCount, uiScenarioCount, deltaBlob };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const cap of fs.readdirSync(specsDir, { withFileTypes: true })) {
|
|
75
|
+
if (!cap.isDirectory()) continue;
|
|
76
|
+
const specPath = path.join(specsDir, cap.name, "spec.md");
|
|
77
|
+
if (!fs.existsSync(specPath)) continue;
|
|
78
|
+
const content = fs.readFileSync(specPath, "utf8");
|
|
79
|
+
deltaBlob += `${content}\n`;
|
|
80
|
+
const blocks = content.split(/^####\s+Scenario:/m);
|
|
81
|
+
for (let i = 1; i < blocks.length; i += 1) {
|
|
82
|
+
scenarioCount += 1;
|
|
83
|
+
if (UI_SCENARIO_KEYWORDS.test(blocks[i])) {
|
|
84
|
+
uiScenarioCount += 1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { scenarioCount, uiScenarioCount, deltaBlob };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 从 change 文档与配置收集本 change 绑定的 e2e spec 路径(须文件存在) */
|
|
92
|
+
function collectChangeE2eSpecs(changeName, changeDir, config) {
|
|
93
|
+
const specs = new Set();
|
|
94
|
+
const specRe = /\be2e\/[\w/.-]+\.spec\.(?:ts|js)\b/g;
|
|
95
|
+
|
|
96
|
+
if (changeDir) {
|
|
97
|
+
for (const f of ["tasks.md", "proposal.md", "design.md"]) {
|
|
98
|
+
const fp = path.join(changeDir, f);
|
|
99
|
+
if (!fs.existsSync(fp)) continue;
|
|
100
|
+
const content = fs.readFileSync(fp, "utf8");
|
|
101
|
+
let m;
|
|
102
|
+
while ((m = specRe.exec(content)) !== null) {
|
|
103
|
+
const rel = m[0];
|
|
104
|
+
if (fs.existsSync(path.join(ROOT, rel))) specs.add(rel);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const overrides = config.changeNameToSpecs?.[changeName];
|
|
110
|
+
if (overrides) {
|
|
111
|
+
for (const raw of overrides) {
|
|
112
|
+
const rel = raw.startsWith("e2e/") ? raw : `e2e/${raw}`;
|
|
113
|
+
if (fs.existsSync(path.join(ROOT, rel))) specs.add(rel);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return [...specs].sort();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function hasExempt(tasksContent) {
|
|
120
|
+
if (!tasksContent) return { tdd: false, playwright: false };
|
|
121
|
+
const tdd = /^TDD_EXEMPT:/m.test(tasksContent);
|
|
122
|
+
const playwright = /^PLAYWRIGHT_E2E_EXEMPT:/m.test(tasksContent);
|
|
123
|
+
return { tdd, playwright };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function wouldModuleFallback(changeName, specPaths, config) {
|
|
127
|
+
if (specPaths.length > 0) return false;
|
|
128
|
+
const fallback = config.changeScopeFallback ?? "module";
|
|
129
|
+
return fallback === "module";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function fail(messages) {
|
|
133
|
+
console.error("[assess-playwright-e2e-coverage] FAIL");
|
|
134
|
+
for (const msg of messages) {
|
|
135
|
+
console.error(` - ${msg}`);
|
|
136
|
+
}
|
|
137
|
+
console.error(
|
|
138
|
+
" 修复: 在 tasks.md 增加 E.1/E.2(写明 e2e/*.spec.ts 路径),或 e2e-scope.config.json → changeNameToSpecs;",
|
|
139
|
+
);
|
|
140
|
+
console.error(
|
|
141
|
+
" 纯无 UI 交互可声明 PLAYWRIGHT_E2E_EXEMPT: <原因>;module 回退须 POQUAN_ALLOW_PLAYWRIGHT_MODULE_FALLBACK=1",
|
|
142
|
+
);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function main() {
|
|
147
|
+
const skip =
|
|
148
|
+
process.env.POQUAN_SKIP_PLAYWRIGHT_CHANGE_COVERAGE ||
|
|
149
|
+
process.env.OPSX_SKIP_PLAYWRIGHT_CHANGE_COVERAGE;
|
|
150
|
+
if (skip === "1") {
|
|
151
|
+
console.warn(
|
|
152
|
+
"[assess-playwright-e2e-coverage] SKIP: POQUAN_SKIP_PLAYWRIGHT_CHANGE_COVERAGE=1",
|
|
153
|
+
);
|
|
154
|
+
process.exit(0);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const { change } = parseArgs(process.argv);
|
|
158
|
+
if (!change) {
|
|
159
|
+
console.error("[assess-playwright-e2e-coverage] 缺少 --change <name>");
|
|
160
|
+
process.exit(2);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const changeDir = findChangeDir(change);
|
|
164
|
+
if (!changeDir) {
|
|
165
|
+
console.error(
|
|
166
|
+
`[assess-playwright-e2e-coverage] 活跃 change 不存在: openspec/changes/${change}`,
|
|
167
|
+
);
|
|
168
|
+
process.exit(2);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const config = loadConfig();
|
|
172
|
+
const { parts, blob: artifactBlob } = readChangeArtifacts(changeDir);
|
|
173
|
+
const { scenarioCount, uiScenarioCount, deltaBlob } = analyzeDeltaSpecs(changeDir);
|
|
174
|
+
const exempt = hasExempt(parts["tasks.md"]);
|
|
175
|
+
const specPaths = collectChangeE2eSpecs(change, changeDir, config);
|
|
176
|
+
const moduleFallback = wouldModuleFallback(change, specPaths, config);
|
|
177
|
+
const allowFallback =
|
|
178
|
+
process.env.POQUAN_ALLOW_PLAYWRIGHT_MODULE_FALLBACK === "1" ||
|
|
179
|
+
process.env.OPSX_ALLOW_PLAYWRIGHT_MODULE_FALLBACK === "1";
|
|
180
|
+
|
|
181
|
+
if (exempt.tdd || exempt.playwright) {
|
|
182
|
+
console.log(
|
|
183
|
+
`[assess-playwright-e2e-coverage] PASS(豁免) change=${change} ` +
|
|
184
|
+
`TDD_EXEMPT=${exempt.tdd} PLAYWRIGHT_E2E_EXEMPT=${exempt.playwright}`,
|
|
185
|
+
);
|
|
186
|
+
process.exit(0);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const errors = [];
|
|
190
|
+
const requiresChangeE2e = uiScenarioCount > 0;
|
|
191
|
+
|
|
192
|
+
if (requiresChangeE2e && specPaths.length === 0) {
|
|
193
|
+
errors.push(
|
|
194
|
+
`delta 含 ${uiScenarioCount} 个 UI/浏览器交互 Scenario,但未在 tasks/proposal/design 或 changeNameToSpecs 中绑定 e2e/*.spec.ts`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (requiresChangeE2e && moduleFallback && !allowFallback) {
|
|
199
|
+
errors.push(
|
|
200
|
+
`scope=change 将回退 module 目录(changeScopeFallback=module),无法验收本 change 的 UI Scenario;禁止用模块 smoke 代替 change 验收`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const tasksContent = parts["tasks.md"] || "";
|
|
205
|
+
if (
|
|
206
|
+
requiresChangeE2e &&
|
|
207
|
+
MOCK_COVERS_INTEGRATION_RE.test(tasksContent) &&
|
|
208
|
+
!/\be2e\/[\w/.-]+\.spec\.(?:ts|js)\b/.test(tasksContent)
|
|
209
|
+
) {
|
|
210
|
+
errors.push(
|
|
211
|
+
"tasks.md 声称「单测/mock 覆盖集成」但未写明 e2e/*.spec.ts;DOM/Network 交互类功能禁止仅用 mock 单测宣布验收",
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (requiresChangeE2e && specPaths.length > 0) {
|
|
216
|
+
const hasE2eTask =
|
|
217
|
+
/E\.1|E2E|playwright test|e2e\/[\w/.-]+\.spec/.test(tasksContent);
|
|
218
|
+
if (!hasE2eTask) {
|
|
219
|
+
errors.push(
|
|
220
|
+
`已配置 e2e spec(${specPaths.join(", ")}),但 tasks.md 缺少 E.1/E.2 Playwright 验收项`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (scenarioCount > 0 && uiScenarioCount === 0 && specPaths.length === 0 && moduleFallback) {
|
|
226
|
+
console.warn(
|
|
227
|
+
`[assess-playwright-e2e-coverage] WARN change=${change}: ` +
|
|
228
|
+
`${scenarioCount} 个 Scenario 未命中 UI 关键词,将允许 module 回退;若含隐式 UI 行为请补 e2e spec`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (errors.length > 0) {
|
|
233
|
+
fail(errors);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
console.log(
|
|
237
|
+
`[assess-playwright-e2e-coverage] PASS change=${change} ` +
|
|
238
|
+
`scenarios=${scenarioCount} uiScenarios=${uiScenarioCount} ` +
|
|
239
|
+
`e2eSpecs=${specPaths.length > 0 ? specPaths.join(" ") : "(none)"} ` +
|
|
240
|
+
`moduleFallback=${moduleFallback}`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
main();
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 校验 04-技术文档草稿.md 是否符合模板结构(gate docs-closed 同款规则)。
|
|
4
|
+
*
|
|
5
|
+
* 用法: node scripts/assess-tech-doc-quality.mjs --run-dir <openspec/PRD/runId>
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
|
+
|
|
13
|
+
const REQUIRED = [
|
|
14
|
+
{ label: "文档元信息", re: /##\s*文档元信息/m },
|
|
15
|
+
{ label: "1. 改造范围", re: /##\s*1\.\s*改造范围/m },
|
|
16
|
+
{ label: "2. 页面与交互设计", re: /##\s*2\.\s*页面与交互设计/m },
|
|
17
|
+
{ label: "2.4 演示代码参照", re: /###\s*2\.4\s*演示代码参照/m },
|
|
18
|
+
{ label: "3. 前端状态与数据模型设计", re: /##\s*3\.\s*前端状态与数据模型设计/m },
|
|
19
|
+
{ label: "4. 接口与数据交互设计", re: /##\s*4\.\s*接口与数据交互设计/m },
|
|
20
|
+
{ label: "5. 表单与校验设计", re: /##\s*5\.\s*表单与校验设计/m },
|
|
21
|
+
{ label: "6. 异常、边界与降级方案", re: /##\s*6\.\s*异常、边界与降级方案/m },
|
|
22
|
+
{ label: "7. 改造清单", re: /##\s*7\.\s*改造清单/m },
|
|
23
|
+
{ label: "8. 风险点与应对策略", re: /##\s*8\.\s*风险点与应对策略/m },
|
|
24
|
+
{ label: "9. 发布、灰度与回滚", re: /##\s*9\.\s*发布、灰度与回滚/m },
|
|
25
|
+
{ label: "10. 测试与验收要点", re: /##\s*10\.\s*测试与验收要点/m },
|
|
26
|
+
{ label: "11. 待确认事项", re: /##\s*11\.\s*待确认事项/m },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const MIN_LINES = 150;
|
|
30
|
+
|
|
31
|
+
function parseArgs(argv) {
|
|
32
|
+
let runDir = "";
|
|
33
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
34
|
+
const a = argv[i];
|
|
35
|
+
if (a === "--run-dir" && argv[i + 1]) runDir = argv[++i];
|
|
36
|
+
else if (!a.startsWith("-") && !runDir) runDir = a;
|
|
37
|
+
}
|
|
38
|
+
return { runDir };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fail(messages) {
|
|
42
|
+
console.error("[assess-tech-doc-quality] FAIL");
|
|
43
|
+
for (const m of messages) console.error(` - ${m}`);
|
|
44
|
+
console.error(" 修复: 在现有 04-技术文档草稿.md 模板上逐节填写,见 04-技术文档生成指引.md");
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function main() {
|
|
49
|
+
const skip = process.env.OPSX_SKIP_TECH_DOC_QUALITY;
|
|
50
|
+
if (skip === "1") {
|
|
51
|
+
console.warn("[assess-tech-doc-quality] SKIP");
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { runDir } = parseArgs(process.argv);
|
|
56
|
+
if (!runDir) {
|
|
57
|
+
console.error("[assess-tech-doc-quality] 缺少 --run-dir <openspec/PRD/runId>");
|
|
58
|
+
process.exit(2);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const absRun = path.isAbsolute(runDir) ? runDir : path.join(ROOT, runDir);
|
|
62
|
+
const docPath = path.join(absRun, "04-技术文档草稿.md");
|
|
63
|
+
if (!fs.existsSync(docPath)) {
|
|
64
|
+
fail(["04-技术文档草稿.md 不存在"]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const content = fs.readFileSync(docPath, "utf8");
|
|
68
|
+
const lines = content.split("\n");
|
|
69
|
+
const errors = [];
|
|
70
|
+
|
|
71
|
+
if (lines.length < MIN_LINES) {
|
|
72
|
+
errors.push(`行数 ${lines.length} < ${MIN_LINES},疑似摘要版而非完整技术文档`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const sec of REQUIRED) {
|
|
76
|
+
if (!sec.re.test(content)) errors.push(`缺少章节: ${sec.label}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!/\|\s*接口\s*\|\s*Method\s*\|/m.test(content)) {
|
|
80
|
+
errors.push("缺少 §4.1 接口清单表格");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (
|
|
84
|
+
/\|\s*接口\s*\|\s*Method\s*\|/m.test(content) &&
|
|
85
|
+
!/\|\s*后端字段\s*\|\s*前端字段\s*\|/m.test(content)
|
|
86
|
+
) {
|
|
87
|
+
errors.push("缺少 §4.3.1 响应字段映射表(| 后端字段 | 前端字段 |)");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!/\|\s*演示模块\s*\|\s*演示文件\/组件\s*\|/m.test(content)) {
|
|
91
|
+
errors.push("缺少 §2.4 演示模块映射表格");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (errors.length > 0) fail(errors);
|
|
95
|
+
console.log(`[assess-tech-doc-quality] PASS lines=${lines.length} path=${docPath}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
main();
|