sdd-flow-kit 1.3.14 → 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 (47) hide show
  1. package/dist/cli.js +97 -10
  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/versionIntent.js +65 -0
  12. package/dist/core/writeNext.js +1 -1
  13. package/dist/index.js +6 -1
  14. package/dist/steps/invokeFlow.js +5 -19
  15. package/dist/steps/runGate.js +49 -5
  16. package/dist/steps/runGuard.js +44 -0
  17. package/dist/steps/runSyncSourcePrd.js +38 -0
  18. package/dist/steps/runValidate.js +4 -2
  19. package/dist/steps/setupProject.js +31 -0
  20. package/dist/steps/startFlow.js +7 -2
  21. package/package.json +2 -1
  22. package/scripts/assert-active-openspec-change.sh +114 -0
  23. package/scripts/assess-api-field-contract.mjs +119 -0
  24. package/scripts/assess-demo-implementation-coverage.mjs +188 -0
  25. package/scripts/assess-e2e-assertion-depth.mjs +146 -0
  26. package/scripts/assess-playwright-e2e-coverage.mjs +244 -0
  27. package/scripts/assess-tech-doc-quality.mjs +91 -0
  28. package/scripts/install-git-hooks.sh +27 -0
  29. package/scripts/lib/test-assertion-analysis.mjs +134 -0
  30. package/scripts/resolve-playwright-e2e-scope.mjs +248 -0
  31. package/src/templates/artifacts/01-adi-doc-skill-/346/214/207/345/274/225.template.md +6 -2
  32. 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
  33. package/src/templates/artifacts/04-/346/212/200/346/234/257/346/226/207/346/241/243.template.md +14 -0
  34. package/src/templates/artifacts/05-/351/252/214/346/224/266/346/270/205/345/215/225.template.md +2 -2
  35. package/src/templates/artifacts/06-agent-skill.template.md +3 -3
  36. package/src/templates/artifacts/06-openspec-/346/217/220/346/241/210/350/215/211/347/250/277.template.md +2 -2
  37. package/src/templates/artifacts/07-opsx-auto-chain.template.md +2 -2
  38. 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
  39. package/src/templates/artifacts/design-demo-reference.template.md +24 -0
  40. package/src/templates/prompts/02-sdd-loop-prompt.md +25 -11
  41. package/src/templates/prompts/04-tech-doc-fill-prompt.md +71 -0
  42. package/src/templates/rules/sdd-version-trigger.mdc.template +56 -0
  43. package/src/templates/skills/confluence-doc.py.template +64 -25
  44. package/src/templates/skills/demo-reference-mandatory-workflow.md +77 -0
  45. package/src/templates/skills/doc-skill.SKILL.md.template +13 -6
  46. package/src/templates/skills/layered-testing-strategy.md +13 -6
  47. package/src/templates/skills/version-trigger.SKILL.md.template +56 -0
@@ -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,91 @@
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 (!/\|\s*演示模块\s*\|\s*演示文件\/组件\s*\|/m.test(content)) {
84
+ errors.push("缺少 §2.4 演示模块映射表格");
85
+ }
86
+
87
+ if (errors.length > 0) fail(errors);
88
+ console.log(`[assess-tech-doc-quality] PASS lines=${lines.length} path=${docPath}`);
89
+ }
90
+
91
+ 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,134 @@
1
+ /**
2
+ * 测试断言分析(与 src/core/testAssertionAnalysis.ts 保持语义一致,供 scripts 独立运行)。
3
+ */
4
+
5
+ const TEST_TITLE_RE = /(?:^|\n)\s*(?:test|it)(?:\.(?:only|skip))?\s*\(\s*(?:async\s*)?[`'"]([^`'"]*)[`'"]/g;
6
+ const EXPECT_RE = /\bexpect\s*\(/;
7
+ const E2E_DIR_RE = /(^|\/)(e2e|playwright)(\/|$)/i;
8
+ const REGISTRY_STUB_FILE_RE = /prd-atoms-coverage|prd-atoms-registry|semantic-registry/i;
9
+ const PLACEHOLDER_ONLY_RE = /getByPlaceholder|getByLabel|getByTestId/i;
10
+ const DATA_ASSERTION_RE =
11
+ /getByRole\s*\(\s*['"]cell|getByRole\s*\(\s*['"]row|toContainText|waitForResponse|\.n-data-table|data-table-tbody|data-table-body/i;
12
+ const HOOK_TITLE_RE = /^(?:beforeEach|afterEach|beforeAll|afterAll)$/i;
13
+
14
+ export function stripComments(content) {
15
+ let out = "";
16
+ let i = 0;
17
+ while (i < content.length) {
18
+ if (content[i] === "/" && content[i + 1] === "/") {
19
+ while (i < content.length && content[i] !== "\n") i += 1;
20
+ continue;
21
+ }
22
+ if (content[i] === "/" && content[i + 1] === "*") {
23
+ i += 2;
24
+ while (i < content.length && !(content[i] === "*" && content[i + 1] === "/")) i += 1;
25
+ i += 2;
26
+ continue;
27
+ }
28
+ out += content[i];
29
+ i += 1;
30
+ }
31
+ return out;
32
+ }
33
+
34
+ export function parseTestBlocks(content) {
35
+ const stripped = stripComments(content);
36
+ const blocks = [];
37
+ const re = new RegExp(TEST_TITLE_RE.source, "g");
38
+ let m;
39
+ const indices = [];
40
+ let idx = 0;
41
+ while ((m = re.exec(stripped)) !== null) {
42
+ indices.push({ title: m[1] ?? "", start: m.index, index: idx });
43
+ idx += 1;
44
+ }
45
+ for (let i = 0; i < indices.length; i += 1) {
46
+ const cur = indices[i];
47
+ const nextStart = indices[i + 1]?.start ?? stripped.length;
48
+ const slice = stripped.slice(cur.start, Math.min(cur.start + 4000, nextStart));
49
+ blocks.push({ title: cur.title, body: slice, index: cur.index });
50
+ }
51
+ return blocks;
52
+ }
53
+
54
+ export function isRegistryStubFile(relPath, content) {
55
+ const norm = relPath.replace(/\\/g, "/");
56
+ if (REGISTRY_STUB_FILE_RE.test(norm)) return true;
57
+ if (!E2E_DIR_RE.test(norm)) return false;
58
+ const blocks = parseTestBlocks(content);
59
+ if (blocks.length === 0) return false;
60
+ const allShallow = blocks.every((b) => {
61
+ const hasExpect = EXPECT_RE.test(b.body);
62
+ const onlyCount =
63
+ /expect\s*\([^)]*\)\s*\.toBeGreaterThan\s*\(\s*0\s*\)/.test(b.body) &&
64
+ !DATA_ASSERTION_RE.test(b.body) &&
65
+ !/\bpage\.goto\b/.test(b.body);
66
+ return !hasExpect || onlyCount;
67
+ });
68
+ return allShallow && blocks.length >= 3;
69
+ }
70
+
71
+ export function testBlockIsPlaceholderOnly(block) {
72
+ if (HOOK_TITLE_RE.test(block.title.trim())) return false;
73
+ if (!EXPECT_RE.test(block.body)) return false;
74
+ const hasPlaceholder = PLACEHOLDER_ONLY_RE.test(block.body);
75
+ const hasData = DATA_ASSERTION_RE.test(block.body);
76
+ const hasInteraction = /\b(?:click|dblclick|fill|press|selectOption)\b/.test(block.body);
77
+ return hasPlaceholder && !hasData && !hasInteraction;
78
+ }
79
+
80
+ export function analyzeE2eSpecDepth(relPath, content) {
81
+ const issues = [];
82
+ if (isRegistryStubFile(relPath, content)) {
83
+ return {
84
+ ok: false,
85
+ issues: ["伪覆盖登记文件,不能作为 change 级 E2E 验收"],
86
+ stats: { testCount: 0, testsWithExpect: 0, hasGoto: false, hasDataAssertion: false, hasInteraction: false },
87
+ };
88
+ }
89
+
90
+ const stripped = stripComments(content);
91
+ const blocks = parseTestBlocks(content);
92
+ const hookBlocks = blocks.filter((b) => HOOK_TITLE_RE.test(b.title.trim()));
93
+ const testBlocks = blocks.filter((b) => !HOOK_TITLE_RE.test(b.title.trim()));
94
+ const testsWithExpect = testBlocks.filter((b) => EXPECT_RE.test(b.body)).length;
95
+ const hasGoto = /\bpage\.goto\b/.test(stripped);
96
+ const hasDataInTests = testBlocks.some((b) => DATA_ASSERTION_RE.test(b.body));
97
+ const hasInteractionInTests = testBlocks.some((b) =>
98
+ /\b(?:click|dblclick|fill|press|selectOption)\b/.test(b.body),
99
+ );
100
+ const placeholderOnlyTests = testBlocks.filter((b) => testBlockIsPlaceholderOnly(b));
101
+
102
+ if (testBlocks.length === 0) issues.push("未解析到 test/it 用例(不含 hook)");
103
+ if (testsWithExpect === 0) issues.push("无含 expect( 的 test/it 块");
104
+ if (!hasGoto && !hookBlocks.some((b) => /\bpage\.goto\b/.test(b.body))) {
105
+ issues.push("缺少 page.goto(页面可达性未验证)");
106
+ }
107
+ if (placeholderOnlyTests.length > 0) {
108
+ issues.push(
109
+ `以下用例仅断言 placeholder/label: ${placeholderOnlyTests.map((t) => t.title).join(", ")}`,
110
+ );
111
+ }
112
+ if (!hasDataInTests && !hasInteractionInTests) {
113
+ issues.push("所有 test 块均缺少数据展示断言或交互操作");
114
+ }
115
+
116
+ const ok =
117
+ testBlocks.length > 0 &&
118
+ testsWithExpect > 0 &&
119
+ (hasGoto || hookBlocks.some((b) => /\bpage\.goto\b/.test(b.body))) &&
120
+ placeholderOnlyTests.length === 0 &&
121
+ (hasDataInTests || hasInteractionInTests);
122
+
123
+ return {
124
+ ok,
125
+ issues,
126
+ stats: {
127
+ testCount: testBlocks.length,
128
+ testsWithExpect,
129
+ hasGoto,
130
+ hasDataAssertion: hasDataInTests,
131
+ hasInteraction: hasInteractionInTests,
132
+ },
133
+ };
134
+ }