sdd-flow-kit 1.3.15 → 1.3.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/cli.js +16 -0
  2. package/dist/core/apiFieldContract.js +90 -0
  3. package/dist/core/demoImplementationCoverage.js +242 -0
  4. package/dist/core/e2eAssertionDepth.js +233 -0
  5. package/dist/core/prdArtifacts.js +12 -1
  6. package/dist/core/prdCoverage.js +46 -9
  7. package/dist/core/prdSemanticDiff.js +7 -4
  8. package/dist/core/syncSourcePrd.js +181 -0
  9. package/dist/core/techDocQuality.js +87 -0
  10. package/dist/core/testAssertionAnalysis.js +213 -0
  11. package/dist/core/writeNext.js +1 -1
  12. package/dist/index.js +6 -1
  13. package/dist/steps/runGate.js +49 -5
  14. package/dist/steps/runSyncSourcePrd.js +38 -0
  15. package/dist/steps/runValidate.js +4 -2
  16. package/dist/steps/setupProject.js +3 -0
  17. package/dist/steps/startFlow.js +7 -2
  18. package/package.json +2 -1
  19. package/scripts/assert-active-openspec-change.sh +114 -0
  20. package/scripts/assess-api-field-contract.mjs +119 -0
  21. package/scripts/assess-demo-implementation-coverage.mjs +188 -0
  22. package/scripts/assess-e2e-assertion-depth.mjs +146 -0
  23. package/scripts/assess-playwright-e2e-coverage.mjs +244 -0
  24. package/scripts/assess-tech-doc-quality.mjs +91 -0
  25. package/scripts/install-git-hooks.sh +27 -0
  26. package/scripts/lib/test-assertion-analysis.mjs +134 -0
  27. package/scripts/resolve-playwright-e2e-scope.mjs +248 -0
  28. package/src/templates/artifacts/01-adi-doc-skill-/346/214/207/345/274/225.template.md +6 -2
  29. 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
  30. package/src/templates/artifacts/04-/346/212/200/346/234/257/346/226/207/346/241/243.template.md +14 -0
  31. package/src/templates/artifacts/05-/351/252/214/346/224/266/346/270/205/345/215/225.template.md +2 -2
  32. package/src/templates/artifacts/06-openspec-/346/217/220/346/241/210/350/215/211/347/250/277.template.md +2 -2
  33. package/src/templates/artifacts/07-opsx-auto-chain.template.md +2 -2
  34. 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
  35. package/src/templates/artifacts/design-demo-reference.template.md +24 -0
  36. package/src/templates/prompts/02-sdd-loop-prompt.md +25 -11
  37. package/src/templates/prompts/04-tech-doc-fill-prompt.md +71 -0
  38. package/src/templates/skills/confluence-doc.py.template +64 -25
  39. package/src/templates/skills/demo-reference-mandatory-workflow.md +77 -0
  40. package/src/templates/skills/doc-skill.SKILL.md.template +13 -6
  41. package/src/templates/skills/layered-testing-strategy.md +13 -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,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$/, ""));
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,242 @@
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.parseDemoReferenceSection = parseDemoReferenceSection;
7
+ exports.checkDemoReferenceArtifacts = checkDemoReferenceArtifacts;
8
+ exports.checkDemoImplementationCoverage = checkDemoImplementationCoverage;
9
+ const promises_1 = __importDefault(require("fs/promises"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const fs_1 = require("./fs");
12
+ const testAssertionAnalysis_1 = require("./testAssertionAnalysis");
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 = /上传|点击|页面|按钮|弹窗|列表|表格|展示|抽屉|详情|编辑|导出|筛选|输入框|对话框|导航|toast|保存|删除|添加/i;
17
+ /**
18
+ * 从 Markdown 解析「演示代码参照」章节与模块映射表。
19
+ */
20
+ function parseDemoReferenceSection(content) {
21
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
22
+ const exemptMatch = content.match(DEMO_EXEMPT_RE);
23
+ const exempt = ((_a = exemptMatch === null || exemptMatch === void 0 ? void 0 : exemptMatch[1]) === null || _a === void 0 ? void 0 : _a.trim()) || null;
24
+ const sectionMatch = content.match(DEMO_SECTION_RE);
25
+ if (!sectionMatch || sectionMatch.index === undefined) {
26
+ return { hasSection: false, exempt, demoRoot: null, demoStack: null, mappings: [] };
27
+ }
28
+ const sectionBody = content.slice(sectionMatch.index);
29
+ const demoRoot = ((_c = (_b = sectionBody.match(/演示代码根路径[::]\s*`?([^`\n]+)`?/i)) === null || _b === void 0 ? void 0 : _b[1]) === null || _c === void 0 ? void 0 : _c.trim()) ||
30
+ ((_e = (_d = sectionBody.match(/Demo Root[::]\s*`?([^`\n]+)`?/i)) === null || _d === void 0 ? void 0 : _d[1]) === null || _e === void 0 ? void 0 : _e.trim()) ||
31
+ null;
32
+ const demoStack = ((_g = (_f = sectionBody.match(/技术栈[::]\s*`?(vue|react|其他)[^`\n]*`?/i)) === null || _f === void 0 ? void 0 : _f[1]) === null || _g === void 0 ? void 0 : _g.trim()) ||
33
+ ((_j = (_h = sectionBody.match(/Stack[::]\s*`?(vue|react|other)[^`\n]*`?/i)) === null || _h === void 0 ? void 0 : _h[1]) === null || _j === void 0 ? void 0 : _j.trim()) ||
34
+ null;
35
+ const mappings = [];
36
+ const lines = sectionBody.split("\n");
37
+ let inTable = false;
38
+ for (let i = 0; i < lines.length; i += 1) {
39
+ const line = lines[i].trim();
40
+ if (line.startsWith("|") && MAPPING_HEADER_RE.test(line)) {
41
+ inTable = true;
42
+ continue;
43
+ }
44
+ if (!inTable || !line.startsWith("|"))
45
+ continue;
46
+ if (/^\|\s*[-:]+/.test(line))
47
+ continue;
48
+ const cells = line
49
+ .split("|")
50
+ .map((c) => c.trim())
51
+ .filter((_, idx, arr) => idx > 0 && idx < arr.length - 1);
52
+ if (cells.length < 4)
53
+ continue;
54
+ if (/演示模块|Demo Module/i.test(cells[0]))
55
+ continue;
56
+ const signals = ((_k = cells[3]) !== null && _k !== void 0 ? _k : "")
57
+ .split(/[、,,|;;]/)
58
+ .map((s) => s.trim())
59
+ .filter((s) => s.length >= 2);
60
+ mappings.push({
61
+ moduleName: cells[0],
62
+ demoRef: cells[1],
63
+ productionPath: cells[2].replace(/\\/g, "/"),
64
+ interactionSignals: signals,
65
+ rowLine: i + 1,
66
+ });
67
+ }
68
+ return { hasSection: true, exempt, demoRoot, demoStack, mappings };
69
+ }
70
+ async function readChangeDesignAndTasks(pkgRoot, changeName) {
71
+ const changeDir = path_1.default.join(pkgRoot, "openspec/changes", changeName);
72
+ const design = await (0, fs_1.readFileIfExists)(path_1.default.join(changeDir, "design.md"));
73
+ const tasks = await (0, fs_1.readFileIfExists)(path_1.default.join(changeDir, "tasks.md"));
74
+ return { design, tasks };
75
+ }
76
+ async function changeHasUiScenarios(pkgRoot, changeName) {
77
+ const specsDir = path_1.default.join(pkgRoot, "openspec/changes", changeName, "specs");
78
+ let entries;
79
+ try {
80
+ entries = await promises_1.default.readdir(specsDir, { withFileTypes: true });
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ for (const cap of entries) {
86
+ if (!cap.isDirectory())
87
+ continue;
88
+ const specPath = path_1.default.join(specsDir, cap.name, "spec.md");
89
+ const content = await (0, fs_1.readFileIfExists)(specPath);
90
+ if (!content)
91
+ continue;
92
+ const blocks = content.split(/^####\s+Scenario:/m);
93
+ for (let i = 1; i < blocks.length; i += 1) {
94
+ if (UI_SCENARIO_KEYWORDS.test(blocks[i]))
95
+ return true;
96
+ }
97
+ }
98
+ return false;
99
+ }
100
+ /**
101
+ * Propose / AC 阶段:design 须声明演示参照与模块映射表;tasks 须含演示迁移段。
102
+ */
103
+ async function checkDemoReferenceArtifacts(pkgRoot, changeName) {
104
+ const checks = [];
105
+ const skip = process.env.POQUAN_SKIP_DEMO_REFERENCE === "1" ||
106
+ process.env.OPSX_SKIP_DEMO_REFERENCE === "1";
107
+ if (skip) {
108
+ checks.push({
109
+ name: "demo-reference-artifacts",
110
+ ok: true,
111
+ detail: "SKIP: OPSX_SKIP_DEMO_REFERENCE=1",
112
+ });
113
+ return checks;
114
+ }
115
+ const requiresUi = await changeHasUiScenarios(pkgRoot, changeName);
116
+ const { design, tasks } = await readChangeDesignAndTasks(pkgRoot, changeName);
117
+ if (!design) {
118
+ checks.push({
119
+ name: "demo-reference-design",
120
+ ok: !requiresUi,
121
+ detail: requiresUi
122
+ ? "UI change 缺少 design.md,无法声明演示代码参照"
123
+ : "无 UI Scenario,跳过演示参照",
124
+ });
125
+ return checks;
126
+ }
127
+ const parsed = parseDemoReferenceSection(design);
128
+ if (parsed.exempt) {
129
+ checks.push({
130
+ name: "demo-reference-design",
131
+ ok: true,
132
+ detail: `DEMO_REFERENCE_EXEMPT: ${parsed.exempt}`,
133
+ });
134
+ return checks;
135
+ }
136
+ if (!requiresUi) {
137
+ checks.push({
138
+ name: "demo-reference-design",
139
+ ok: true,
140
+ detail: "无 UI Scenario,不要求演示映射表",
141
+ });
142
+ return checks;
143
+ }
144
+ if (!parsed.hasSection) {
145
+ checks.push({
146
+ name: "demo-reference-design",
147
+ ok: false,
148
+ detail: "design.md 缺少 ## 演示代码参照(须映射演示模块→生产组件→关键交互)",
149
+ });
150
+ return checks;
151
+ }
152
+ if (!parsed.demoRoot) {
153
+ checks.push({
154
+ name: "demo-reference-root",
155
+ ok: false,
156
+ detail: "演示代码参照 未填写「演示代码根路径」(Vue/React 演示仓库或目录)",
157
+ });
158
+ }
159
+ else {
160
+ checks.push({
161
+ name: "demo-reference-root",
162
+ ok: true,
163
+ detail: `演示根路径: ${parsed.demoRoot}`,
164
+ });
165
+ }
166
+ if (parsed.mappings.length === 0) {
167
+ checks.push({
168
+ name: "demo-module-mapping",
169
+ ok: false,
170
+ detail: "演示模块映射表为空(至少须覆盖详情/弹窗等每个 UI 子模块一行)",
171
+ });
172
+ }
173
+ else {
174
+ checks.push({
175
+ name: "demo-module-mapping",
176
+ ok: true,
177
+ detail: `已登记 ${parsed.mappings.length} 个演示模块映射`,
178
+ });
179
+ }
180
+ const tasksText = tasks !== null && tasks !== void 0 ? tasks : "";
181
+ const hasMigrationSection = /##\s*演示模块迁移|演示模块迁移/.test(tasksText);
182
+ const missingInTasks = [];
183
+ for (const m of parsed.mappings) {
184
+ const prodBase = path_1.default.basename(m.productionPath);
185
+ if (!tasksText.includes(m.moduleName) && !tasksText.includes(prodBase)) {
186
+ missingInTasks.push(m.moduleName);
187
+ }
188
+ }
189
+ checks.push({
190
+ name: "demo-tasks-migration",
191
+ ok: hasMigrationSection && missingInTasks.length === 0,
192
+ detail: hasMigrationSection && missingInTasks.length === 0
193
+ ? "tasks.md 含演示模块迁移且覆盖全部映射行"
194
+ : `tasks 缺少演示迁移: section=${hasMigrationSection}, 未覆盖模块=${missingInTasks.join(", ") || "无"}`,
195
+ });
196
+ return checks;
197
+ }
198
+ /**
199
+ * Validate / Deliver:生产代码须实现映射表中的组件与关键交互信号。
200
+ */
201
+ async function checkDemoImplementationCoverage(pkgRoot, changeName) {
202
+ const checks = [];
203
+ const artifactChecks = await checkDemoReferenceArtifacts(pkgRoot, changeName);
204
+ const exempt = artifactChecks.some((c) => c.ok && c.detail.startsWith("DEMO_REFERENCE_EXEMPT"));
205
+ if (exempt || artifactChecks.some((c) => c.name === "demo-reference-design" && c.ok && c.detail.includes("跳过"))) {
206
+ checks.push(...artifactChecks.filter((c) => c.ok));
207
+ return checks;
208
+ }
209
+ const failedArtifact = artifactChecks.filter((c) => !c.ok);
210
+ if (failedArtifact.length > 0) {
211
+ checks.push(...failedArtifact);
212
+ return checks;
213
+ }
214
+ const { design } = await readChangeDesignAndTasks(pkgRoot, changeName);
215
+ const parsed = parseDemoReferenceSection(design !== null && design !== void 0 ? design : "");
216
+ const failures = [];
217
+ for (const m of parsed.mappings) {
218
+ const prodRel = m.productionPath.replace(/^\.\//, "");
219
+ const abs = path_1.default.join(pkgRoot, prodRel);
220
+ const content = await (0, fs_1.readFileIfExists)(abs);
221
+ if (content === null) {
222
+ failures.push(`${m.moduleName}: 生产文件不存在 ${prodRel}`);
223
+ continue;
224
+ }
225
+ const code = (0, testAssertionAnalysis_1.stripComments)(content);
226
+ const missingSignals = m.interactionSignals.filter((s) => !code.includes(s));
227
+ if (m.interactionSignals.length > 0 && missingSignals.length === m.interactionSignals.length) {
228
+ failures.push(`${m.moduleName}: ${prodRel} 未实现任何关键交互(${m.interactionSignals.join("、")})`);
229
+ }
230
+ else if (missingSignals.length > 0) {
231
+ failures.push(`${m.moduleName}: ${prodRel} 缺少交互信号: ${missingSignals.join("、")}`);
232
+ }
233
+ }
234
+ checks.push({
235
+ name: "demo-implementation-coverage",
236
+ ok: failures.length === 0,
237
+ detail: failures.length === 0
238
+ ? `演示模块已全部落地(${parsed.mappings.length} 项)`
239
+ : failures.join(" | "),
240
+ });
241
+ return checks;
242
+ }
@@ -0,0 +1,233 @@
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.collectChangeE2eSpecs = collectChangeE2eSpecs;
7
+ exports.changeHasUiScenarios = changeHasUiScenarios;
8
+ exports.checkE2eAssertionDepth = checkE2eAssertionDepth;
9
+ const promises_1 = __importDefault(require("fs/promises"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const acMatrix_1 = require("./acMatrix");
12
+ const fs_1 = require("./fs");
13
+ const testAssertionAnalysis_1 = require("./testAssertionAnalysis");
14
+ const E2E_SPEC_RE = /\be2e\/[\w/.-]+\.spec\.(?:ts|js)\b/g;
15
+ const UI_SCENARIO_KEYWORDS = /上传|点击|页面|按钮|弹窗|列表|表格|展示|抽屉|详情|导出|筛选|输入框|对话框|导航|toast|截图/i;
16
+ async function readChangeArtifacts(changeDir) {
17
+ const files = ["tasks.md", "proposal.md", "design.md"];
18
+ let blob = "";
19
+ for (const f of files) {
20
+ const content = await (0, fs_1.readFileIfExists)(path_1.default.join(changeDir, f));
21
+ if (content)
22
+ blob += `${content}\n`;
23
+ }
24
+ return blob;
25
+ }
26
+ /** 从 change 文档收集绑定的 e2e spec(须文件存在) */
27
+ async function collectChangeE2eSpecs(pkgRoot, changeName) {
28
+ var _a;
29
+ const changeDir = path_1.default.join(pkgRoot, "openspec/changes", changeName);
30
+ const specs = new Set();
31
+ const artifactBlob = await readChangeArtifacts(changeDir);
32
+ let m;
33
+ const re = new RegExp(E2E_SPEC_RE.source, "g");
34
+ while ((m = re.exec(artifactBlob)) !== null) {
35
+ const rel = m[0];
36
+ const abs = path_1.default.join(pkgRoot, rel);
37
+ const content = await (0, fs_1.readFileIfExists)(abs);
38
+ if (content !== null)
39
+ specs.add(rel);
40
+ }
41
+ const configPath = path_1.default.join(pkgRoot, "e2e/e2e-scope.config.json");
42
+ const configRaw = await (0, fs_1.readFileIfExists)(configPath);
43
+ if (configRaw) {
44
+ try {
45
+ const config = JSON.parse(configRaw);
46
+ const overrides = (_a = config.changeNameToSpecs) === null || _a === void 0 ? void 0 : _a[changeName];
47
+ if (overrides) {
48
+ for (const raw of overrides) {
49
+ const rel = raw.startsWith("e2e/") ? raw : `e2e/${raw}`;
50
+ const content = await (0, fs_1.readFileIfExists)(path_1.default.join(pkgRoot, rel));
51
+ if (content !== null)
52
+ specs.add(rel);
53
+ }
54
+ }
55
+ }
56
+ catch {
57
+ // ignore invalid config
58
+ }
59
+ }
60
+ return [...specs].sort();
61
+ }
62
+ /** delta specs 是否含 UI 交互类 Scenario */
63
+ async function changeHasUiScenarios(pkgRoot, changeName) {
64
+ const specsDir = path_1.default.join(pkgRoot, "openspec/changes", changeName, "specs");
65
+ let entries;
66
+ try {
67
+ entries = await promises_1.default.readdir(specsDir, { withFileTypes: true });
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ for (const cap of entries) {
73
+ if (!cap.isDirectory())
74
+ continue;
75
+ const specPath = path_1.default.join(specsDir, cap.name, "spec.md");
76
+ const content = await (0, fs_1.readFileIfExists)(specPath);
77
+ if (!content)
78
+ continue;
79
+ const blocks = content.split(/^####\s+Scenario:/m);
80
+ for (let i = 1; i < blocks.length; i += 1) {
81
+ if (UI_SCENARIO_KEYWORDS.test(blocks[i]))
82
+ return true;
83
+ }
84
+ }
85
+ return false;
86
+ }
87
+ function hasPlaywrightExempt(tasksContent) {
88
+ return /^PLAYWRIGHT_E2E_EXEMPT:/m.test(tasksContent);
89
+ }
90
+ /**
91
+ * E2E 断言深度门禁:change 级 spec 须含页面可达、数据/交互断言;e2e 类型 AC 须在有效 test 块内映射。
92
+ */
93
+ async function checkE2eAssertionDepth(pkgRoot, outputRoot, changeName) {
94
+ var _a, _b;
95
+ const checks = [];
96
+ const skip = process.env.POQUAN_SKIP_E2E_ASSERTION_DEPTH === "1" ||
97
+ process.env.OPSX_SKIP_E2E_ASSERTION_DEPTH === "1";
98
+ if (skip) {
99
+ checks.push({
100
+ name: "e2e-assertion-depth",
101
+ ok: true,
102
+ detail: "SKIP: OPSX_SKIP_E2E_ASSERTION_DEPTH=1",
103
+ });
104
+ return checks;
105
+ }
106
+ const changeDir = path_1.default.join(pkgRoot, "openspec/changes", changeName);
107
+ const changeExists = await (0, fs_1.readFileIfExists)(path_1.default.join(changeDir, "tasks.md"));
108
+ if (changeExists === null) {
109
+ checks.push({
110
+ name: "e2e-assertion-depth",
111
+ ok: true,
112
+ detail: `活跃 change 不存在,跳过深度检查: ${changeName}`,
113
+ });
114
+ return checks;
115
+ }
116
+ const tasksContent = (_a = (await (0, fs_1.readFileIfExists)(path_1.default.join(changeDir, "tasks.md")))) !== null && _a !== void 0 ? _a : "";
117
+ if (hasPlaywrightExempt(tasksContent)) {
118
+ checks.push({
119
+ name: "e2e-assertion-depth",
120
+ ok: true,
121
+ detail: "PLAYWRIGHT_E2E_EXEMPT 已声明,跳过 E2E 深度检查",
122
+ });
123
+ return checks;
124
+ }
125
+ const requiresUiE2e = await changeHasUiScenarios(pkgRoot, changeName);
126
+ const specPaths = await collectChangeE2eSpecs(pkgRoot, changeName);
127
+ if (!requiresUiE2e && specPaths.length === 0) {
128
+ checks.push({
129
+ name: "e2e-assertion-depth",
130
+ ok: true,
131
+ detail: "无 UI Scenario 且未绑定 change 级 e2e spec,跳过深度检查",
132
+ });
133
+ return checks;
134
+ }
135
+ if (requiresUiE2e && specPaths.length === 0) {
136
+ checks.push({
137
+ name: "e2e-assertion-depth",
138
+ ok: false,
139
+ detail: "含 UI Scenario 但未绑定 e2e/*.spec.ts,无法做断言深度验收",
140
+ });
141
+ return checks;
142
+ }
143
+ const depthFailures = [];
144
+ for (const rel of specPaths) {
145
+ const abs = path_1.default.join(pkgRoot, rel);
146
+ const content = (_b = (await (0, fs_1.readFileIfExists)(abs))) !== null && _b !== void 0 ? _b : "";
147
+ const depth = (0, testAssertionAnalysis_1.analyzeE2eSpecDepth)(rel, content);
148
+ if (!depth.ok) {
149
+ depthFailures.push(`${rel}: ${depth.issues.join("; ")}`);
150
+ }
151
+ }
152
+ checks.push({
153
+ name: "e2e-assertion-depth",
154
+ ok: depthFailures.length === 0,
155
+ detail: depthFailures.length === 0
156
+ ? `change 级 E2E 深度通过(${specPaths.join(", ")})`
157
+ : `E2E 断言过浅: ${depthFailures.join(" | ")}`,
158
+ });
159
+ const acParsed = await (0, acMatrix_1.parseAcMatrix)(outputRoot);
160
+ if (acParsed.ok) {
161
+ const e2eAcs = acParsed.items.filter((i) => { var _a; return /^P0$/i.test(i.priority.trim()) || /e2e/i.test((_a = i.testType) !== null && _a !== void 0 ? _a : ""); });
162
+ await checkE2eAcDepth(pkgRoot, e2eAcs, checks);
163
+ }
164
+ return checks;
165
+ }
166
+ /** 校验声明 e2e 的 AC 是否在有效 test 块内有映射 */
167
+ async function checkE2eAcDepth(pkgRoot, acs, checks) {
168
+ var _a, _b;
169
+ const e2eRoots = ["e2e", "playwright"];
170
+ const e2eFiles = [];
171
+ for (const root of e2eRoots) {
172
+ const dir = path_1.default.join(pkgRoot, root);
173
+ let entries;
174
+ try {
175
+ entries = await promises_1.default.readdir(dir, { withFileTypes: true });
176
+ }
177
+ catch {
178
+ continue;
179
+ }
180
+ for (const ent of entries) {
181
+ if (!ent.isFile() || !/\.spec\.(t|j)sx?$/.test(ent.name))
182
+ continue;
183
+ const abs = path_1.default.join(dir, ent.name);
184
+ const content = (_a = (await (0, fs_1.readFileIfExists)(abs))) !== null && _a !== void 0 ? _a : "";
185
+ e2eFiles.push({ rel: `${root}/${ent.name}`, content });
186
+ }
187
+ await walkE2eSubdirs(dir, root, e2eFiles);
188
+ }
189
+ for (const ac of acs) {
190
+ const e2eRequired = /e2e/i.test((_b = ac.testType) !== null && _b !== void 0 ? _b : "");
191
+ if (!e2eRequired)
192
+ continue;
193
+ const hits = e2eFiles.filter((f) => (0, testAssertionAnalysis_1.hasMeaningfulAcCoverageInFile)(f.rel, f.content, ac.id).covered);
194
+ checks.push({
195
+ name: `ac-e2e-depth-${ac.id}`,
196
+ ok: hits.length > 0,
197
+ detail: hits.length > 0
198
+ ? `${ac.id} 在有效 E2E test 块内有 expect 断言: ${hits.map((h) => h.rel).join(", ")}`
199
+ : `${ac.id} 测试类型含 e2e,但 e2e/ 下无含 [${ac.id}] 且带 expect 的 test/it 块`,
200
+ });
201
+ }
202
+ }
203
+ async function walkE2eSubdirs(dir, relPrefix, out) {
204
+ var _a;
205
+ let entries;
206
+ try {
207
+ entries = await promises_1.default.readdir(dir, { withFileTypes: true });
208
+ }
209
+ catch {
210
+ return;
211
+ }
212
+ for (const ent of entries) {
213
+ if (!ent.isDirectory())
214
+ continue;
215
+ const sub = path_1.default.join(dir, ent.name);
216
+ const subRel = `${relPrefix}/${ent.name}`;
217
+ let files;
218
+ try {
219
+ files = await promises_1.default.readdir(sub, { withFileTypes: true });
220
+ }
221
+ catch {
222
+ continue;
223
+ }
224
+ for (const f of files) {
225
+ if (!f.isFile() || !/\.spec\.(t|j)sx?$/.test(f.name))
226
+ continue;
227
+ const abs = path_1.default.join(sub, f.name);
228
+ const content = (_a = (await (0, fs_1.readFileIfExists)(abs))) !== null && _a !== void 0 ? _a : "";
229
+ out.push({ rel: `${subRel}/${f.name}`, content });
230
+ }
231
+ await walkE2eSubdirs(sub, subRel, out);
232
+ }
233
+ }
@@ -11,6 +11,7 @@ exports.isAcceptanceChecklistSigned = isAcceptanceChecklistSigned;
11
11
  const path_1 = __importDefault(require("path"));
12
12
  const acMatrix_1 = require("./acMatrix");
13
13
  const fs_1 = require("./fs");
14
+ const techDocQuality_1 = require("./techDocQuality");
14
15
  const PRD_PLACEHOLDER_MARKERS = [
15
16
  "请把《PRD》",
16
17
  "占位",
@@ -81,7 +82,17 @@ async function areAnalysisDocsReady(outputRoot) {
81
82
  return { ok: false, detail: `${name} 仍为模板占位` };
82
83
  }
83
84
  }
84
- return { ok: true, detail: "01/02/04 文档齐全" };
85
+ const techDoc = await (0, fs_1.readFileIfExists)(path_1.default.join(outputRoot, "04-技术文档草稿.md"));
86
+ if (process.env.OPSX_SKIP_TECH_DOC_QUALITY !== "1") {
87
+ const techQuality = (0, techDocQuality_1.checkTechDocStructure)(techDoc !== null && techDoc !== void 0 ? techDoc : "");
88
+ if (!techQuality.ok) {
89
+ return {
90
+ ok: false,
91
+ detail: `tech-doc-structure: ${techQuality.detail}。请读 04-技术文档生成指引.md,在模板原文件上逐节填写`,
92
+ };
93
+ }
94
+ }
95
+ return { ok: true, detail: "01/02/04 文档齐全且 04 符合模板结构" };
85
96
  }
86
97
  /** 05-验收清单是否已从 PRD 定义完成(含至少一条 P0)。 */
87
98
  async function isAcceptanceChecklistDefined(outputRoot) {