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.
- package/dist/cli.js +97 -10
- package/dist/core/apiFieldContract.js +90 -0
- package/dist/core/demoImplementationCoverage.js +242 -0
- package/dist/core/e2eAssertionDepth.js +233 -0
- package/dist/core/prdArtifacts.js +12 -1
- package/dist/core/prdCoverage.js +46 -9
- package/dist/core/prdSemanticDiff.js +7 -4
- package/dist/core/syncSourcePrd.js +181 -0
- package/dist/core/techDocQuality.js +87 -0
- package/dist/core/testAssertionAnalysis.js +213 -0
- package/dist/core/versionIntent.js +65 -0
- package/dist/core/writeNext.js +1 -1
- package/dist/index.js +6 -1
- package/dist/steps/invokeFlow.js +5 -19
- package/dist/steps/runGate.js +49 -5
- package/dist/steps/runGuard.js +44 -0
- package/dist/steps/runSyncSourcePrd.js +38 -0
- package/dist/steps/runValidate.js +4 -2
- package/dist/steps/setupProject.js +31 -0
- package/dist/steps/startFlow.js +7 -2
- package/package.json +2 -1
- package/scripts/assert-active-openspec-change.sh +114 -0
- package/scripts/assess-api-field-contract.mjs +119 -0
- package/scripts/assess-demo-implementation-coverage.mjs +188 -0
- package/scripts/assess-e2e-assertion-depth.mjs +146 -0
- package/scripts/assess-playwright-e2e-coverage.mjs +244 -0
- package/scripts/assess-tech-doc-quality.mjs +91 -0
- package/scripts/install-git-hooks.sh +27 -0
- package/scripts/lib/test-assertion-analysis.mjs +134 -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 +14 -0
- package/src/templates/artifacts/05-/351/252/214/346/224/266/346/270/205/345/215/225.template.md +2 -2
- package/src/templates/artifacts/06-agent-skill.template.md +3 -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/prompts/02-sdd-loop-prompt.md +25 -11
- package/src/templates/prompts/04-tech-doc-fill-prompt.md +71 -0
- package/src/templates/rules/sdd-version-trigger.mdc.template +56 -0
- package/src/templates/skills/confluence-doc.py.template +64 -25
- package/src/templates/skills/demo-reference-mandatory-workflow.md +77 -0
- package/src/templates/skills/doc-skill.SKILL.md.template +13 -6
- package/src/templates/skills/layered-testing-strategy.md +13 -6
- package/src/templates/skills/version-trigger.SKILL.md.template +56 -0
|
@@ -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
|
-
|
|
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) {
|
package/dist/core/prdCoverage.js
CHANGED
|
@@ -15,6 +15,10 @@ const acMatrix_1 = require("./acMatrix");
|
|
|
15
15
|
const fs_1 = require("./fs");
|
|
16
16
|
const prdParse_1 = require("./prdParse");
|
|
17
17
|
const prdSemanticDiff_1 = require("./prdSemanticDiff");
|
|
18
|
+
const testAssertionAnalysis_1 = require("./testAssertionAnalysis");
|
|
19
|
+
const e2eAssertionDepth_1 = require("./e2eAssertionDepth");
|
|
20
|
+
const apiFieldContract_1 = require("./apiFieldContract");
|
|
21
|
+
const demoImplementationCoverage_1 = require("./demoImplementationCoverage");
|
|
18
22
|
const VALID_VERDICTS = ["一致", "不一致-按PRD修复", "待人工确认"];
|
|
19
23
|
/** 禁止在证据/结论中出现的揣摩或兜底措辞 */
|
|
20
24
|
const FORBIDDEN_SPECULATION = [
|
|
@@ -99,6 +103,22 @@ function hasForbiddenSpeculation(text) {
|
|
|
99
103
|
}
|
|
100
104
|
return null;
|
|
101
105
|
}
|
|
106
|
+
/** 08 报告中的无效/敷衍证据(如 index.vue:1、prd-atoms 注释覆盖) */
|
|
107
|
+
function isWeakCodeEvidence(evidence) {
|
|
108
|
+
const e = evidence.trim();
|
|
109
|
+
if (!e)
|
|
110
|
+
return "空证据";
|
|
111
|
+
if (/:(1|2)\s*(\||$)/.test(e) && !/expect\(|getByText|getByRole|message\.|toast/.test(e)) {
|
|
112
|
+
return "证据仅指向文件首行,无具体交互/断言";
|
|
113
|
+
}
|
|
114
|
+
if (/prd-atoms-coverage|semantic-registry/i.test(e)) {
|
|
115
|
+
return "伪覆盖登记文件不能作为实现证据";
|
|
116
|
+
}
|
|
117
|
+
if (/index\.vue:1|index\.tsx:1/i.test(e) && e.split(/[;;]/).length < 2) {
|
|
118
|
+
return "禁止用列表页首行代替详情子模块证据";
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
102
122
|
async function writePrdAtomsManifest(outputRoot, atoms) {
|
|
103
123
|
await (0, fs_1.writeTextFileEnsuredDir)(path_1.default.join(outputRoot, ".prd-review-atoms.json"), `${JSON.stringify({ count: atoms.length, atoms }, null, 2)}\n`);
|
|
104
124
|
}
|
|
@@ -118,18 +138,16 @@ async function loadPrdAtomsFromManifest(outputRoot) {
|
|
|
118
138
|
function checkAcTestMapping(items, testContents) {
|
|
119
139
|
var _a;
|
|
120
140
|
const checks = [];
|
|
121
|
-
// P0 全量 + 所有声明 e2e 的 AC(含 P1/P2 边缘场景)
|
|
122
141
|
const mustCover = items.filter((i) => { var _a; return /^P0$/i.test(i.priority.trim()) || /e2e/i.test((_a = i.testType) !== null && _a !== void 0 ? _a : ""); });
|
|
123
142
|
for (const ac of mustCover) {
|
|
124
|
-
const bracket = `[${ac.id}]`;
|
|
125
|
-
const hits = testContents.filter((t) => t.content.includes(bracket) || t.content.includes(ac.id));
|
|
126
143
|
const e2eRequired = /e2e/i.test((_a = ac.testType) !== null && _a !== void 0 ? _a : "");
|
|
127
|
-
const
|
|
144
|
+
const hits = testContents.filter((t) => (0, testAssertionAnalysis_1.hasMeaningfulAcCoverageInFile)(t.rel, t.content, ac.id).covered);
|
|
145
|
+
const e2eHits = hits.filter((h) => E2E_DIR_RE.test(h.rel));
|
|
128
146
|
if (hits.length === 0) {
|
|
129
147
|
checks.push({
|
|
130
148
|
name: `ac-test-${ac.id}`,
|
|
131
149
|
ok: false,
|
|
132
|
-
detail: `${ac.id}(${ac.priority})
|
|
150
|
+
detail: `${ac.id}(${ac.priority}) 未在有效 test/it 块中找到 [${ac.id}] + expect 映射(注释登记不计入)`,
|
|
133
151
|
});
|
|
134
152
|
continue;
|
|
135
153
|
}
|
|
@@ -137,7 +155,7 @@ function checkAcTestMapping(items, testContents) {
|
|
|
137
155
|
checks.push({
|
|
138
156
|
name: `ac-e2e-${ac.id}`,
|
|
139
157
|
ok: false,
|
|
140
|
-
detail: `${ac.id} 测试类型含 e2e,但 e2e/ 或 playwright/
|
|
158
|
+
detail: `${ac.id} 测试类型含 e2e,但 e2e/ 或 playwright/ 下无含 expect 的对应测试`,
|
|
141
159
|
});
|
|
142
160
|
continue;
|
|
143
161
|
}
|
|
@@ -145,8 +163,8 @@ function checkAcTestMapping(items, testContents) {
|
|
|
145
163
|
name: `ac-test-${ac.id}`,
|
|
146
164
|
ok: true,
|
|
147
165
|
detail: e2eRequired
|
|
148
|
-
? `${ac.id} 已映射(含 e2e: ${e2eHits.map((h) => path_1.default.basename(h.
|
|
149
|
-
: `${ac.id} 已映射: ${hits.map((h) => path_1.default.basename(h.
|
|
166
|
+
? `${ac.id} 已映射(含 e2e: ${e2eHits.map((h) => path_1.default.basename(h.rel)).join(", ")})`
|
|
167
|
+
: `${ac.id} 已映射: ${hits.map((h) => path_1.default.basename(h.rel)).join(", ")}`,
|
|
150
168
|
});
|
|
151
169
|
}
|
|
152
170
|
return checks;
|
|
@@ -246,7 +264,7 @@ function checkSemanticDiffItems(semantic) {
|
|
|
246
264
|
/**
|
|
247
265
|
* gate prd-coverage:PRD 最细粒度条目全覆盖 + 08 复查报告合规 + P0 AC 测试映射。
|
|
248
266
|
*/
|
|
249
|
-
async function checkPrdCoverage(outputRoot, pkgRoot) {
|
|
267
|
+
async function checkPrdCoverage(outputRoot, pkgRoot, options) {
|
|
250
268
|
const checks = [];
|
|
251
269
|
const prdContent = await (0, fs_1.readFileIfExists)(path_1.default.join(outputRoot, "source", "PRD.md"));
|
|
252
270
|
if (!prdContent) {
|
|
@@ -314,6 +332,7 @@ async function checkPrdCoverage(outputRoot, pkgRoot) {
|
|
|
314
332
|
const needHuman = [];
|
|
315
333
|
const speculationHits = [];
|
|
316
334
|
const emptyEvidence = [];
|
|
335
|
+
const weakEvidence = [];
|
|
317
336
|
for (const atom of atoms) {
|
|
318
337
|
const row = rowById.get(atom.id);
|
|
319
338
|
if (!row)
|
|
@@ -329,6 +348,11 @@ async function checkPrdCoverage(outputRoot, pkgRoot) {
|
|
|
329
348
|
if (!evidence || evidence.length < 8 || /(填写)|待填写|TBD/i.test(evidence)) {
|
|
330
349
|
emptyEvidence.push(atom.id);
|
|
331
350
|
}
|
|
351
|
+
if (row.verdict === "一致") {
|
|
352
|
+
const weak = isWeakCodeEvidence(evidence);
|
|
353
|
+
if (weak)
|
|
354
|
+
weakEvidence.push(`${atom.id}(${weak})`);
|
|
355
|
+
}
|
|
332
356
|
const combined = `${row.codeEvidence} ${row.note} ${row.verdict}`;
|
|
333
357
|
const forbidden = hasForbiddenSpeculation(combined);
|
|
334
358
|
if (forbidden)
|
|
@@ -355,6 +379,13 @@ async function checkPrdCoverage(outputRoot, pkgRoot) {
|
|
|
355
379
|
? "每条均有可核对代码证据(文件路径+行号/选择器/断言)"
|
|
356
380
|
: `缺少代码证据: ${emptyEvidence.slice(0, 8).join(", ")}`,
|
|
357
381
|
});
|
|
382
|
+
checks.push({
|
|
383
|
+
name: "code-evidence-quality",
|
|
384
|
+
ok: weakEvidence.length === 0,
|
|
385
|
+
detail: weakEvidence.length === 0
|
|
386
|
+
? "未发现敷衍证据(index.vue:1 / prd-atoms 登记等)"
|
|
387
|
+
: `证据质量不足: ${weakEvidence.slice(0, 8).join(", ")}`,
|
|
388
|
+
});
|
|
358
389
|
checks.push({
|
|
359
390
|
name: "no-prd-mismatch-open",
|
|
360
391
|
ok: needFix.length === 0,
|
|
@@ -385,11 +416,17 @@ async function checkPrdCoverage(outputRoot, pkgRoot) {
|
|
|
385
416
|
var _a;
|
|
386
417
|
return ({
|
|
387
418
|
file: f,
|
|
419
|
+
rel: path_1.default.relative(pkgRoot, f).replace(/\\/g, "/"),
|
|
388
420
|
content: (_a = (await (0, fs_1.readFileIfExists)(f))) !== null && _a !== void 0 ? _a : "",
|
|
389
421
|
});
|
|
390
422
|
}));
|
|
391
423
|
checks.push(...checkAcTestMapping(acParsed.items, testContents));
|
|
392
424
|
}
|
|
425
|
+
if (options === null || options === void 0 ? void 0 : options.changeName) {
|
|
426
|
+
checks.push(...(await (0, e2eAssertionDepth_1.checkE2eAssertionDepth)(pkgRoot, outputRoot, options.changeName)));
|
|
427
|
+
checks.push(...(await (0, apiFieldContract_1.checkApiFieldContract)(pkgRoot, options.changeName)));
|
|
428
|
+
checks.push(...(await (0, demoImplementationCoverage_1.checkDemoImplementationCoverage)(pkgRoot, options.changeName)));
|
|
429
|
+
}
|
|
393
430
|
const ok = checks.every((c) => c.ok);
|
|
394
431
|
return { ok, checks };
|
|
395
432
|
}
|
|
@@ -18,6 +18,7 @@ exports.writeSemanticDiffArtifacts = writeSemanticDiffArtifacts;
|
|
|
18
18
|
const promises_1 = __importDefault(require("fs/promises"));
|
|
19
19
|
const path_1 = __importDefault(require("path"));
|
|
20
20
|
const fs_1 = require("./fs");
|
|
21
|
+
const testAssertionAnalysis_1 = require("./testAssertionAnalysis");
|
|
21
22
|
const SOURCE_EXT = /\.(vue|tsx?|jsx?|json|css|less|scss)$/i;
|
|
22
23
|
const TEST_EXT = /\.(spec|test)\.(t|j)sx?$/i;
|
|
23
24
|
const E2E_DIR_RE = /(^|\/)(e2e|playwright)(\/|$)/i;
|
|
@@ -170,15 +171,17 @@ async function verifyEvidenceRefs(pkgRoot, evidence, signals) {
|
|
|
170
171
|
}
|
|
171
172
|
return { ok: true, detail: "证据路径与字面量可核对" };
|
|
172
173
|
}
|
|
174
|
+
/** PRD 原子须有 test/it 块内 expect 断言;禁止注释或 prd-atoms 登记类伪覆盖 */
|
|
173
175
|
function hasTestCoverageForAtom(atomId, signals, testCorpus) {
|
|
174
176
|
let hasTest = false;
|
|
175
177
|
let hasE2e = false;
|
|
176
178
|
for (const entry of testCorpus) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
179
|
+
if (!entry.isTest)
|
|
180
|
+
continue;
|
|
181
|
+
const { covered, hasE2e: fileE2e } = (0, testAssertionAnalysis_1.hasMeaningfulAtomCoverageInFile)(entry.rel, entry.content, atomId, signals);
|
|
182
|
+
if (covered) {
|
|
180
183
|
hasTest = true;
|
|
181
|
-
if (
|
|
184
|
+
if (fileE2e)
|
|
182
185
|
hasE2e = true;
|
|
183
186
|
}
|
|
184
187
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
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.buildVersionSlug = buildVersionSlug;
|
|
7
|
+
exports.resolveDocsVersionDir = resolveDocsVersionDir;
|
|
8
|
+
exports.findMainPrdMarkdown = findMainPrdMarkdown;
|
|
9
|
+
exports.ensurePrdImageDirMetadata = ensurePrdImageDirMetadata;
|
|
10
|
+
exports.syncSourcePrdFromDocs = syncSourcePrdFromDocs;
|
|
11
|
+
exports.tryAutoSyncSourcePrd = tryAutoSyncSourcePrd;
|
|
12
|
+
exports.resolveSyncSourceDirRel = resolveSyncSourceDirRel;
|
|
13
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
14
|
+
const path_1 = __importDefault(require("path"));
|
|
15
|
+
const fs_1 = require("./fs");
|
|
16
|
+
const inferProject_1 = require("./inferProject");
|
|
17
|
+
const IMAGE_SUBDIR = "image";
|
|
18
|
+
const PRD_PLACEHOLDER_MARKERS = ["请把《PRD》", "占位", "placeholder"];
|
|
19
|
+
/**
|
|
20
|
+
* 与 confluence-doc.py 的 version_slug 保持一致:1.2.3 → adi-v123。
|
|
21
|
+
*/
|
|
22
|
+
function buildVersionSlug(productPrefix, version) {
|
|
23
|
+
const normalized = version
|
|
24
|
+
.trim()
|
|
25
|
+
.replace(/^[A-Z]+[-_]?V?/i, "")
|
|
26
|
+
.replace(/^V/i, "");
|
|
27
|
+
return `${productPrefix.toLowerCase()}-v${normalized.replace(/\./g, "")}`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 解析 docs 下当前版本的 PRD 目录(含主文档与 image/)。
|
|
31
|
+
*/
|
|
32
|
+
async function resolveDocsVersionDir(projectRoot, product, version) {
|
|
33
|
+
const kind = (0, inferProject_1.productToKind)(product);
|
|
34
|
+
const prefix = inferProject_1.PRODUCT_PREFIX_BY_KIND[kind];
|
|
35
|
+
const slug = buildVersionSlug(prefix, version);
|
|
36
|
+
const docSkill = await (0, inferProject_1.resolveDocSkillDir)(projectRoot, kind);
|
|
37
|
+
const docsRoot = path_1.default.dirname(docSkill.absPath);
|
|
38
|
+
const versionDir = path_1.default.join(docsRoot, slug);
|
|
39
|
+
try {
|
|
40
|
+
const st = await promises_1.default.stat(versionDir);
|
|
41
|
+
if (!st.isDirectory())
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return { docsRoot, versionDir, slug };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 在版本目录根下选取体积最大的 .md 作为主 PRD(排除子目录)。
|
|
51
|
+
*/
|
|
52
|
+
async function findMainPrdMarkdown(versionDir) {
|
|
53
|
+
let entries;
|
|
54
|
+
try {
|
|
55
|
+
entries = await promises_1.default.readdir(versionDir);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const mdFiles = entries.filter((name) => name.toLowerCase().endsWith(".md"));
|
|
61
|
+
if (mdFiles.length === 0)
|
|
62
|
+
return null;
|
|
63
|
+
let best = null;
|
|
64
|
+
for (const name of mdFiles) {
|
|
65
|
+
const full = path_1.default.join(versionDir, name);
|
|
66
|
+
const st = await promises_1.default.stat(full);
|
|
67
|
+
if (!st.isFile())
|
|
68
|
+
continue;
|
|
69
|
+
if (!best || st.size > best.size) {
|
|
70
|
+
best = { name, size: st.size };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return best ? path_1.default.join(versionDir, best.name) : null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* 在 PRD 头部补充原型图片相对路径,供 OPSX UI 解析使用。
|
|
77
|
+
*/
|
|
78
|
+
function ensurePrdImageDirMetadata(content) {
|
|
79
|
+
if (/原型图片目录/.test(content)) {
|
|
80
|
+
return content;
|
|
81
|
+
}
|
|
82
|
+
const lines = content.split("\n");
|
|
83
|
+
const titleIdx = lines.findIndex((line) => line.trim().startsWith("# "));
|
|
84
|
+
const insertAt = titleIdx >= 0 ? titleIdx + 1 : 0;
|
|
85
|
+
const meta = `> 原型图片目录: ${IMAGE_SUBDIR}/`;
|
|
86
|
+
lines.splice(insertAt, 0, "", meta);
|
|
87
|
+
return lines.join("\n");
|
|
88
|
+
}
|
|
89
|
+
async function copyDirRecursive(srcDir, destDir) {
|
|
90
|
+
await promises_1.default.mkdir(destDir, { recursive: true });
|
|
91
|
+
const entries = await promises_1.default.readdir(srcDir, { withFileTypes: true });
|
|
92
|
+
let count = 0;
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
const srcPath = path_1.default.join(srcDir, entry.name);
|
|
95
|
+
const destPath = path_1.default.join(destDir, entry.name);
|
|
96
|
+
if (entry.isDirectory()) {
|
|
97
|
+
count += await copyDirRecursive(srcPath, destPath);
|
|
98
|
+
}
|
|
99
|
+
else if (entry.isFile()) {
|
|
100
|
+
await promises_1.default.copyFile(srcPath, destPath);
|
|
101
|
+
count += 1;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return count;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* 将 docs/<版本目录> 下的 PRD 主文档与 image/ 同步到 openspec run 的 source/。
|
|
108
|
+
*/
|
|
109
|
+
async function syncSourcePrdFromDocs(options) {
|
|
110
|
+
const resolved = await resolveDocsVersionDir(options.projectRoot, options.product, options.version);
|
|
111
|
+
if (!resolved) {
|
|
112
|
+
const slug = buildVersionSlug(inferProject_1.PRODUCT_PREFIX_BY_KIND[(0, inferProject_1.productToKind)(options.product)], options.version);
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
attempted: true,
|
|
116
|
+
detail: `未找到 docs 版本目录(期望 docs/${slug}/,请先执行 confluence-doc.py)`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const mainMd = await findMainPrdMarkdown(resolved.versionDir);
|
|
120
|
+
if (!mainMd) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
attempted: true,
|
|
124
|
+
detail: `docs/${resolved.slug}/ 下无 PRD 主文档(*.md)`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const sourceDir = path_1.default.join(options.outputRoot, "source");
|
|
128
|
+
const destPrd = path_1.default.join(sourceDir, "PRD.md");
|
|
129
|
+
const raw = await promises_1.default.readFile(mainMd, "utf8");
|
|
130
|
+
if (raw.trim().length < 80) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
attempted: true,
|
|
134
|
+
detail: `docs 主文档过短: ${path_1.default.basename(mainMd)}`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const content = ensurePrdImageDirMetadata(raw);
|
|
138
|
+
await (0, fs_1.writeTextFileEnsuredDir)(destPrd, content);
|
|
139
|
+
const srcImageDir = path_1.default.join(resolved.versionDir, IMAGE_SUBDIR);
|
|
140
|
+
const destImageDir = path_1.default.join(sourceDir, IMAGE_SUBDIR);
|
|
141
|
+
let imageCount = 0;
|
|
142
|
+
try {
|
|
143
|
+
const st = await promises_1.default.stat(srcImageDir);
|
|
144
|
+
if (st.isDirectory()) {
|
|
145
|
+
await promises_1.default.rm(destImageDir, { recursive: true, force: true });
|
|
146
|
+
imageCount = await copyDirRecursive(srcImageDir, destImageDir);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// 无图片目录不阻断同步
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
ok: true,
|
|
154
|
+
attempted: true,
|
|
155
|
+
detail: `已同步 ${path_1.default.basename(mainMd)} → source/PRD.md${imageCount > 0 ? `,${imageCount} 张图片 → source/${IMAGE_SUBDIR}/` : "(无图片)"}`,
|
|
156
|
+
sourcePrdPath: destPrd,
|
|
157
|
+
imageCount,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* gate 前自动同步:source/PRD.md 仍为占位且 docs 版本目录已存在时尝试回填。
|
|
162
|
+
*/
|
|
163
|
+
async function tryAutoSyncSourcePrd(options) {
|
|
164
|
+
const existing = await (0, fs_1.readFileIfExists)(path_1.default.join(options.outputRoot, "source", "PRD.md"));
|
|
165
|
+
if (existing && !PRD_PLACEHOLDER_MARKERS.some((m) => existing.includes(m)) && existing.trim().length >= 80) {
|
|
166
|
+
return { ok: true, attempted: false, detail: "source/PRD.md 已回填,跳过同步" };
|
|
167
|
+
}
|
|
168
|
+
return syncSourcePrdFromDocs(options);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* 从 run 目录相对 doc-skill 脚本目录的 source 路径(供 confluence-doc.py 环境变量)。
|
|
172
|
+
*/
|
|
173
|
+
async function resolveSyncSourceDirRel(projectRoot, sourceDir, product) {
|
|
174
|
+
const kind = (0, inferProject_1.productToKind)(product);
|
|
175
|
+
const docSkill = await (0, inferProject_1.resolveDocSkillDir)(projectRoot, kind);
|
|
176
|
+
const rel = path_1.default.relative(docSkill.absPath, sourceDir).replace(/\\/g, "/");
|
|
177
|
+
if (!rel || rel.startsWith("..")) {
|
|
178
|
+
return rel;
|
|
179
|
+
}
|
|
180
|
+
return `./${rel}`;
|
|
181
|
+
}
|