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,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* snake_case ↔ camelCase(与 src/core/snakeCamel.ts 语义一致)。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function snakeKeyToCamel(key) {
|
|
6
|
+
return key.replace(/_([a-z])/g, (_, char) => char.toUpperCase());
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function snakeToCamelDeep(input) {
|
|
10
|
+
if (input === null || input === undefined) return input;
|
|
11
|
+
if (Array.isArray(input)) return input.map((item) => snakeToCamelDeep(item));
|
|
12
|
+
if (typeof input === "object") {
|
|
13
|
+
const result = {};
|
|
14
|
+
for (const [key, value] of Object.entries(input)) {
|
|
15
|
+
result[snakeKeyToCamel(key)] = snakeToCamelDeep(value);
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
return input;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function hasSnakeCaseKeys(input) {
|
|
23
|
+
if (input === null || typeof input !== "object") return false;
|
|
24
|
+
if (Array.isArray(input)) return input.some((item) => hasSnakeCaseKeys(item));
|
|
25
|
+
for (const key of Object.keys(input)) {
|
|
26
|
+
if (key.includes("_")) return true;
|
|
27
|
+
if (hasSnakeCaseKeys(input[key])) return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function collectLeafCamelKeys(input, out = new Set()) {
|
|
33
|
+
if (input === null || input === undefined) return out;
|
|
34
|
+
if (Array.isArray(input)) {
|
|
35
|
+
for (const item of input) collectLeafCamelKeys(item, out);
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
if (typeof input === "object") {
|
|
39
|
+
const keys = Object.keys(input);
|
|
40
|
+
if (keys.length === 0) return out;
|
|
41
|
+
const allPrimitive = keys.every(
|
|
42
|
+
(k) => input[k] === null || ["string", "number", "boolean"].includes(typeof input[k]),
|
|
43
|
+
);
|
|
44
|
+
if (allPrimitive) {
|
|
45
|
+
for (const k of keys) out.add(k);
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
for (const k of keys) collectLeafCamelKeys(input[k], out);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function extractFirstListRow(payload) {
|
|
55
|
+
const normalized = snakeToCamelDeep(payload);
|
|
56
|
+
const candidates = [
|
|
57
|
+
normalized,
|
|
58
|
+
normalized.data,
|
|
59
|
+
normalized.records,
|
|
60
|
+
normalized.data?.records,
|
|
61
|
+
normalized.data?.items,
|
|
62
|
+
normalized.items,
|
|
63
|
+
normalized.list,
|
|
64
|
+
];
|
|
65
|
+
for (const c of candidates) {
|
|
66
|
+
if (Array.isArray(c) && c.length > 0 && typeof c[0] === "object" && c[0] !== null) {
|
|
67
|
+
return c[0];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (typeof normalized === "object" && normalized !== null && !Array.isArray(normalized)) {
|
|
71
|
+
return normalized;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
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 testBlockHasExpect(block) {
|
|
81
|
+
return EXPECT_RE.test(block.body);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function testBlockReferencesAc(block, acId) {
|
|
85
|
+
const bracket = `[${acId}]`;
|
|
86
|
+
const upper = acId.toUpperCase();
|
|
87
|
+
return (
|
|
88
|
+
block.title.includes(bracket) ||
|
|
89
|
+
block.title.toUpperCase().includes(upper) ||
|
|
90
|
+
block.body.includes(bracket)
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function analyzeE2eSpecDepth(relPath, content) {
|
|
95
|
+
const issues = [];
|
|
96
|
+
if (isRegistryStubFile(relPath, content)) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
issues: ["伪覆盖登记文件,不能作为 change 级 E2E 验收"],
|
|
100
|
+
stats: { testCount: 0, testsWithExpect: 0, hasGoto: false, hasDataAssertion: false, hasInteraction: false },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const stripped = stripComments(content);
|
|
105
|
+
const blocks = parseTestBlocks(content);
|
|
106
|
+
const hookBlocks = blocks.filter((b) => HOOK_TITLE_RE.test(b.title.trim()));
|
|
107
|
+
const testBlocks = blocks.filter((b) => !HOOK_TITLE_RE.test(b.title.trim()));
|
|
108
|
+
const testsWithExpect = testBlocks.filter((b) => EXPECT_RE.test(b.body)).length;
|
|
109
|
+
const hasGoto = /\bpage\.goto\b/.test(stripped);
|
|
110
|
+
const hasDataInTests = testBlocks.some((b) => DATA_ASSERTION_RE.test(b.body));
|
|
111
|
+
const hasInteractionInTests = testBlocks.some((b) =>
|
|
112
|
+
/\b(?:click|dblclick|fill|press|selectOption)\b/.test(b.body),
|
|
113
|
+
);
|
|
114
|
+
const placeholderOnlyTests = testBlocks.filter((b) => testBlockIsPlaceholderOnly(b));
|
|
115
|
+
|
|
116
|
+
if (testBlocks.length === 0) issues.push("未解析到 test/it 用例(不含 hook)");
|
|
117
|
+
if (testsWithExpect === 0) issues.push("无含 expect( 的 test/it 块");
|
|
118
|
+
if (!hasGoto && !hookBlocks.some((b) => /\bpage\.goto\b/.test(b.body))) {
|
|
119
|
+
issues.push("缺少 page.goto(页面可达性未验证)");
|
|
120
|
+
}
|
|
121
|
+
if (placeholderOnlyTests.length > 0) {
|
|
122
|
+
issues.push(
|
|
123
|
+
`以下用例仅断言 placeholder/label: ${placeholderOnlyTests.map((t) => t.title).join(", ")}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (!hasDataInTests && !hasInteractionInTests) {
|
|
127
|
+
issues.push("所有 test 块均缺少数据展示断言或交互操作");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const ok =
|
|
131
|
+
testBlocks.length > 0 &&
|
|
132
|
+
testsWithExpect > 0 &&
|
|
133
|
+
(hasGoto || hookBlocks.some((b) => /\bpage\.goto\b/.test(b.body))) &&
|
|
134
|
+
placeholderOnlyTests.length === 0 &&
|
|
135
|
+
(hasDataInTests || hasInteractionInTests);
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
ok,
|
|
139
|
+
issues,
|
|
140
|
+
stats: {
|
|
141
|
+
testCount: testBlocks.length,
|
|
142
|
+
testsWithExpect,
|
|
143
|
+
hasGoto,
|
|
144
|
+
hasDataAssertion: hasDataInTests,
|
|
145
|
+
hasInteraction: hasInteractionInTests,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L4 视觉回归分析(与 src/core/visualRegression.ts 语义一致)。
|
|
3
|
+
*/
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { parseTestBlocks, stripComments, testBlockReferencesAc } from "./test-assertion-analysis.mjs";
|
|
7
|
+
|
|
8
|
+
const IMAGE_REF_RE =
|
|
9
|
+
/(?:source\/[\w/.-]+\.(?:png|jpe?g|webp)|visual-baseline\/[\w/.-]+\.(?:png|jpe?g|webp)|image_\d+\.(?:png|jpe?g|webp))/gi;
|
|
10
|
+
const DIFF_PERCENT_RE = /diff\s*[≤<=]\s*(\d+(?:\.\d+)?)\s*%/i;
|
|
11
|
+
const TO_HAVE_SCREENSHOT_RE = /\btoHaveScreenshot\s*\(/;
|
|
12
|
+
const PAGE_SCREENSHOT_ONLY_RE = /\bpage\.screenshot\s*\(/;
|
|
13
|
+
|
|
14
|
+
export function parseAcMatrixFrom05(content) {
|
|
15
|
+
const items = [];
|
|
16
|
+
for (const line of content.split("\n")) {
|
|
17
|
+
const trimmed = line.trim();
|
|
18
|
+
if (!trimmed.startsWith("|")) continue;
|
|
19
|
+
if (/^\|\s*[-:]+/.test(trimmed)) continue;
|
|
20
|
+
if (/^\|\s*ID\s*\|/i.test(trimmed)) continue;
|
|
21
|
+
const cells = trimmed
|
|
22
|
+
.split("|")
|
|
23
|
+
.map((c) => c.trim())
|
|
24
|
+
.filter((_, i, arr) => i > 0 && i < arr.length - 1);
|
|
25
|
+
if (cells.length < 5) continue;
|
|
26
|
+
const id = (cells[0] ?? "").toUpperCase();
|
|
27
|
+
if (!/^AC-\d{2,}/.test(id)) continue;
|
|
28
|
+
items.push({
|
|
29
|
+
id,
|
|
30
|
+
criterion: cells[2] ?? "",
|
|
31
|
+
testType: cells[3] ?? "",
|
|
32
|
+
priority: cells[4] ?? "",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return items;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function parseVisualAcRequirements(items) {
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const item of items) {
|
|
41
|
+
if (!/visual/i.test(item.testType ?? "")) continue;
|
|
42
|
+
const criterion = item.criterion ?? "";
|
|
43
|
+
const baselineRefs = [...new Set((criterion.match(IMAGE_REF_RE) ?? []).map((s) => s.trim()))];
|
|
44
|
+
const diffMatch = criterion.match(DIFF_PERCENT_RE);
|
|
45
|
+
out.push({
|
|
46
|
+
acId: item.id,
|
|
47
|
+
criterion,
|
|
48
|
+
baselineRefs,
|
|
49
|
+
maxDiffPercent: diffMatch ? Number(diffMatch[1]) : null,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function walkImages(dir, out) {
|
|
56
|
+
if (!fs.existsSync(dir)) return;
|
|
57
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
58
|
+
const full = path.join(dir, ent.name);
|
|
59
|
+
if (ent.isDirectory()) walkImages(full, out);
|
|
60
|
+
else if (/\.(png|jpe?g|webp)$/i.test(ent.name)) out.push(full);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function listPrdSourceImages(outputRoot) {
|
|
65
|
+
const images = [];
|
|
66
|
+
walkImages(path.join(outputRoot, "source"), images);
|
|
67
|
+
return images;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function resolveBaselinePath(pkgRoot, outputRoot, ref, prdImages) {
|
|
71
|
+
const normalized = ref.replace(/\\/g, "/");
|
|
72
|
+
const candidates = [];
|
|
73
|
+
if (outputRoot) {
|
|
74
|
+
candidates.push(path.join(outputRoot, normalized));
|
|
75
|
+
candidates.push(path.join(outputRoot, "visual-baseline", path.basename(normalized)));
|
|
76
|
+
}
|
|
77
|
+
candidates.push(path.join(pkgRoot, normalized));
|
|
78
|
+
candidates.push(path.join(pkgRoot, "visual-baseline", path.basename(normalized)));
|
|
79
|
+
for (const c of candidates) {
|
|
80
|
+
if (fs.existsSync(c)) return c;
|
|
81
|
+
}
|
|
82
|
+
if (/^image_\d+\./i.test(normalized)) {
|
|
83
|
+
const hit = prdImages.find((p) => path.basename(p) === path.basename(normalized));
|
|
84
|
+
if (hit) return hit;
|
|
85
|
+
}
|
|
86
|
+
return prdImages.find((p) => p.endsWith(`/${path.basename(normalized)}`)) ?? null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function e2eBlockHasVisualAssertion(body) {
|
|
90
|
+
return TO_HAVE_SCREENSHOT_RE.test(stripComments(body));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function e2eReferencesBaseline(body, baselineRefs) {
|
|
94
|
+
const stripped = stripComments(body);
|
|
95
|
+
if (baselineRefs.length === 0) return TO_HAVE_SCREENSHOT_RE.test(stripped);
|
|
96
|
+
return baselineRefs.some((ref) => {
|
|
97
|
+
const base = path.basename(ref).replace(/\.(png|jpe?g|webp)$/i, "");
|
|
98
|
+
return (
|
|
99
|
+
stripped.includes(ref) ||
|
|
100
|
+
stripped.includes(base) ||
|
|
101
|
+
stripped.includes(`'${base}'`) ||
|
|
102
|
+
stripped.includes(`"${base}"`)
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function collectChangeE2eSpecs(root, changeName) {
|
|
108
|
+
const changeDir = path.join(root, "openspec/changes", changeName);
|
|
109
|
+
const specs = new Set();
|
|
110
|
+
let blob = "";
|
|
111
|
+
for (const f of ["tasks.md", "proposal.md", "design.md"]) {
|
|
112
|
+
const fp = path.join(changeDir, f);
|
|
113
|
+
if (!fs.existsSync(fp)) continue;
|
|
114
|
+
blob += fs.readFileSync(fp, "utf8");
|
|
115
|
+
}
|
|
116
|
+
const re = /\be2e\/[\w/.-]+\.spec\.(?:ts|js)\b/g;
|
|
117
|
+
let m;
|
|
118
|
+
while ((m = re.exec(blob)) !== null) {
|
|
119
|
+
const rel = m[0];
|
|
120
|
+
if (fs.existsSync(path.join(root, rel))) specs.add(rel);
|
|
121
|
+
}
|
|
122
|
+
return [...specs].sort();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function assessVisualRegression(root, outputRoot, changeName, acItems) {
|
|
126
|
+
const errors = [];
|
|
127
|
+
const visualReqs = parseVisualAcRequirements(acItems);
|
|
128
|
+
const prdImages = outputRoot ? listPrdSourceImages(outputRoot) : [];
|
|
129
|
+
const specPaths = collectChangeE2eSpecs(root, changeName);
|
|
130
|
+
|
|
131
|
+
const tasksPath = path.join(root, "openspec/changes", changeName, "tasks.md");
|
|
132
|
+
if (fs.existsSync(tasksPath) && /^VISUAL_REGRESSION_EXEMPT:/m.test(fs.readFileSync(tasksPath, "utf8"))) {
|
|
133
|
+
return { errors: [], visualReqs, exempt: true };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (prdImages.length > 0 && visualReqs.length === 0) {
|
|
137
|
+
errors.push(
|
|
138
|
+
`PRD source 含 ${prdImages.length} 张原型图,但 05 无 visual AC;请补充基线对比验收项`,
|
|
139
|
+
);
|
|
140
|
+
return { errors, visualReqs, exempt: false };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (visualReqs.length === 0) return { errors: [], visualReqs, exempt: false };
|
|
144
|
+
|
|
145
|
+
let e2eBlob = "";
|
|
146
|
+
const e2eBlocks = [];
|
|
147
|
+
for (const rel of specPaths) {
|
|
148
|
+
const content = fs.readFileSync(path.join(root, rel), "utf8");
|
|
149
|
+
e2eBlob += content;
|
|
150
|
+
for (const block of parseTestBlocks(content)) {
|
|
151
|
+
e2eBlocks.push({ spec: rel, title: block.title, body: block.body });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (specPaths.length === 0) errors.push("含 visual AC 但未绑定 e2e spec");
|
|
156
|
+
else if (!TO_HAVE_SCREENSHOT_RE.test(e2eBlob)) {
|
|
157
|
+
errors.push("e2e 须使用 toHaveScreenshot(禁止仅 page.screenshot)");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const req of visualReqs) {
|
|
161
|
+
if (req.baselineRefs.length === 0) {
|
|
162
|
+
errors.push(`${req.acId}: visual AC 须写明基线路径`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
for (const ref of req.baselineRefs) {
|
|
166
|
+
if (!resolveBaselinePath(root, outputRoot, ref, prdImages)) {
|
|
167
|
+
errors.push(`${req.acId}: 基线图不存在 ${ref}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const bound = e2eBlocks.filter(
|
|
171
|
+
(b) =>
|
|
172
|
+
testBlockReferencesAc({ title: b.title, body: b.body, index: 0 }, req.acId) &&
|
|
173
|
+
e2eBlockHasVisualAssertion(b.body),
|
|
174
|
+
);
|
|
175
|
+
if (bound.length === 0) {
|
|
176
|
+
errors.push(`${req.acId}: e2e 须含 [${req.acId}] + toHaveScreenshot`);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (!bound.some((b) => e2eReferencesBaseline(b.body, req.baselineRefs))) {
|
|
180
|
+
errors.push(
|
|
181
|
+
`${req.acId}: toHaveScreenshot 须引用 ${req.baselineRefs.map((r) => path.basename(r)).join("、")}`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (PAGE_SCREENSHOT_ONLY_RE.test(e2eBlob) && !TO_HAVE_SCREENSHOT_RE.test(e2eBlob)) {
|
|
187
|
+
errors.push("仅有 page.screenshot 无 toHaveScreenshot");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { errors, visualReqs, exempt: false };
|
|
191
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 根据 OpenSpec change / 显式模块名解析 Playwright 运行范围。
|
|
4
|
+
*
|
|
5
|
+
* scope 三级(见 e2e/e2e-scope.config.json defaultScope):
|
|
6
|
+
* - change(默认):优先 change 文档中的 e2e/*.spec.ts;未写明则按 changeScopeFallback 回退(默认 module)
|
|
7
|
+
* - module:解析到的 e2e/<module>/ 整个目录
|
|
8
|
+
* - full:输出 FULL,由调用方跑全量 e2e/**
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
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
|
+
|
|
17
|
+
const VALID_SCOPES = new Set(["change", "module", "full"]);
|
|
18
|
+
|
|
19
|
+
function loadConfig() {
|
|
20
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveDefaultScope(config) {
|
|
24
|
+
const fromEnv = process.env.OPSX_PLAYWRIGHT_SCOPE || process.env.POQUAN_PLAYWRIGHT_SCOPE;
|
|
25
|
+
if (fromEnv) return fromEnv;
|
|
26
|
+
return config.defaultScope || "change";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseArgs(argv) {
|
|
30
|
+
const config = loadConfig();
|
|
31
|
+
const out = {
|
|
32
|
+
change: "",
|
|
33
|
+
scope: resolveDefaultScope(config),
|
|
34
|
+
modules: "",
|
|
35
|
+
};
|
|
36
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
37
|
+
const a = argv[i];
|
|
38
|
+
if (a === "--change" && argv[i + 1]) {
|
|
39
|
+
out.change = argv[++i];
|
|
40
|
+
} else if (a === "--scope" && argv[i + 1]) {
|
|
41
|
+
out.scope = argv[++i];
|
|
42
|
+
} else if (a === "--modules" && argv[i + 1]) {
|
|
43
|
+
out.modules = argv[++i];
|
|
44
|
+
} else if (!a.startsWith("-") && !out.change) {
|
|
45
|
+
out.change = a;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const envModules = process.env.OPSX_E2E_MODULES || process.env.POQUAN_E2E_MODULES;
|
|
49
|
+
if (envModules) {
|
|
50
|
+
out.modules = envModules;
|
|
51
|
+
}
|
|
52
|
+
if (!VALID_SCOPES.has(out.scope)) {
|
|
53
|
+
console.error(
|
|
54
|
+
`[resolve-playwright-e2e-scope] 无效 scope="${out.scope}";允许: change | module | full`,
|
|
55
|
+
);
|
|
56
|
+
process.exit(2);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function findChangeDir(changeName) {
|
|
62
|
+
const active = path.join(ROOT, "openspec/changes", changeName);
|
|
63
|
+
if (fs.existsSync(active)) return active;
|
|
64
|
+
const archiveRoot = path.join(ROOT, "openspec/changes/archive");
|
|
65
|
+
if (!fs.existsSync(archiveRoot)) return null;
|
|
66
|
+
const hit = fs
|
|
67
|
+
.readdirSync(archiveRoot)
|
|
68
|
+
.filter((d) => d.endsWith(changeName) || d.includes(changeName))
|
|
69
|
+
.sort()
|
|
70
|
+
.pop();
|
|
71
|
+
return hit ? path.join(archiveRoot, hit) : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 从 change 文档中提取显式声明的 e2e spec 路径(tasks / proposal / design) */
|
|
75
|
+
function collectSpecPathsFromChangeDir(changeDir) {
|
|
76
|
+
const specs = new Set();
|
|
77
|
+
const specRe = /\be2e\/[\w/.-]+\.spec\.(?:ts|js)\b/g;
|
|
78
|
+
const files = ["tasks.md", "proposal.md", "design.md"];
|
|
79
|
+
for (const f of files) {
|
|
80
|
+
const fp = path.join(changeDir, f);
|
|
81
|
+
if (!fs.existsSync(fp)) continue;
|
|
82
|
+
const content = fs.readFileSync(fp, "utf8");
|
|
83
|
+
let m;
|
|
84
|
+
while ((m = specRe.exec(content)) !== null) {
|
|
85
|
+
const rel = m[0];
|
|
86
|
+
const abs = path.join(ROOT, rel);
|
|
87
|
+
if (fs.existsSync(abs)) specs.add(rel);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return [...specs].sort();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function resolveChangeSpecPaths(changeName, config) {
|
|
94
|
+
const paths = new Set();
|
|
95
|
+
const changeDir = findChangeDir(changeName);
|
|
96
|
+
if (changeDir) {
|
|
97
|
+
collectSpecPathsFromChangeDir(changeDir).forEach((p) => paths.add(p));
|
|
98
|
+
}
|
|
99
|
+
const overrides = config.changeNameToSpecs?.[changeName];
|
|
100
|
+
if (overrides) {
|
|
101
|
+
for (const raw of overrides) {
|
|
102
|
+
const rel = raw.startsWith("e2e/") ? raw : `e2e/${raw}`;
|
|
103
|
+
const abs = path.join(ROOT, rel);
|
|
104
|
+
if (fs.existsSync(abs)) paths.add(rel);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return [...paths].sort();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function collectCapabilities(changeDir) {
|
|
111
|
+
const caps = [];
|
|
112
|
+
const specsDir = path.join(changeDir, "specs");
|
|
113
|
+
if (!fs.existsSync(specsDir)) return caps;
|
|
114
|
+
for (const ent of fs.readdirSync(specsDir, { withFileTypes: true })) {
|
|
115
|
+
if (ent.isDirectory()) caps.push(ent.name);
|
|
116
|
+
}
|
|
117
|
+
return caps;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function scanFeatureDirs(text) {
|
|
121
|
+
const found = new Set();
|
|
122
|
+
const re = /src\/features\/([a-z0-9-]+)/gi;
|
|
123
|
+
let m;
|
|
124
|
+
while ((m = re.exec(text)) !== null) {
|
|
125
|
+
found.add(m[1]);
|
|
126
|
+
}
|
|
127
|
+
return [...found];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function matchChangeNameKeywords(changeName, config) {
|
|
131
|
+
const mods = new Set();
|
|
132
|
+
for (const { pattern, modules } of config.changeNameKeywords || []) {
|
|
133
|
+
if (new RegExp(pattern, "i").test(changeName)) {
|
|
134
|
+
modules.forEach((mod) => mods.add(mod));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return [...mods];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function modulesToPaths(moduleIds, config) {
|
|
141
|
+
const paths = new Set();
|
|
142
|
+
for (const id of moduleIds) {
|
|
143
|
+
const dirs = config.modules[id];
|
|
144
|
+
if (!dirs) continue;
|
|
145
|
+
dirs.forEach((p) => {
|
|
146
|
+
const abs = path.join(ROOT, p);
|
|
147
|
+
if (fs.existsSync(abs)) paths.add(p);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return [...paths].sort();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function resolveModules({ change, modules }, config) {
|
|
154
|
+
if (modules) {
|
|
155
|
+
return modules
|
|
156
|
+
.split(",")
|
|
157
|
+
.map((s) => s.trim())
|
|
158
|
+
.filter(Boolean);
|
|
159
|
+
}
|
|
160
|
+
const moduleSet = new Set();
|
|
161
|
+
if (change) {
|
|
162
|
+
const changeDir = findChangeDir(change);
|
|
163
|
+
if (changeDir) {
|
|
164
|
+
for (const cap of collectCapabilities(changeDir)) {
|
|
165
|
+
(config.capabilityToModules[cap] || []).forEach((m) => moduleSet.add(m));
|
|
166
|
+
}
|
|
167
|
+
const textFiles = ["proposal.md", "design.md", "tasks.md"];
|
|
168
|
+
let blob = "";
|
|
169
|
+
for (const f of textFiles) {
|
|
170
|
+
const fp = path.join(changeDir, f);
|
|
171
|
+
if (fs.existsSync(fp)) blob += `${fs.readFileSync(fp, "utf8")}\n`;
|
|
172
|
+
}
|
|
173
|
+
for (const feat of scanFeatureDirs(blob)) {
|
|
174
|
+
(config.featureDirToModules[feat] || []).forEach((m) => moduleSet.add(m));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
matchChangeNameKeywords(change, config).forEach((m) => moduleSet.add(m));
|
|
178
|
+
}
|
|
179
|
+
return [...moduleSet];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 按 change / OPSX_E2E_MODULES / POQUAN_E2E_MODULES 解析模块 id 与 e2e 目录路径 */
|
|
183
|
+
function resolveModuleScope(args, config) {
|
|
184
|
+
const moduleIds = resolveModules(args, config);
|
|
185
|
+
const paths = modulesToPaths(moduleIds, config);
|
|
186
|
+
return { moduleIds, paths };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function emitModulePaths(args, config, logPrefix) {
|
|
190
|
+
const { moduleIds, paths } = resolveModuleScope(args, config);
|
|
191
|
+
if (paths.length === 0) {
|
|
192
|
+
console.error(
|
|
193
|
+
"[resolve-playwright-e2e-scope] 未能解析模块;请设置 OPSX_E2E_MODULES=auth,review / POQUAN_E2E_MODULES=auth,review 或 OPSX_PLAYWRIGHT_SCOPE=full",
|
|
194
|
+
);
|
|
195
|
+
process.exit(2);
|
|
196
|
+
}
|
|
197
|
+
process.stderr.write(
|
|
198
|
+
`${logPrefix} scope=module change=${args.change || "-"} modules=${moduleIds.join(",")} paths=${paths.join(" ")}\n`,
|
|
199
|
+
);
|
|
200
|
+
paths.forEach((p) => process.stdout.write(`${p}\n`));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function main() {
|
|
204
|
+
const config = loadConfig();
|
|
205
|
+
const args = parseArgs(process.argv);
|
|
206
|
+
|
|
207
|
+
if (args.scope === "full") {
|
|
208
|
+
process.stdout.write("FULL\n");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (args.scope === "change") {
|
|
213
|
+
if (!args.change) {
|
|
214
|
+
console.error(
|
|
215
|
+
"[resolve-playwright-e2e-scope] scope=change 需要 change 名;示例: node scripts/resolve-playwright-e2e-scope.mjs <change-name>",
|
|
216
|
+
);
|
|
217
|
+
process.exit(2);
|
|
218
|
+
}
|
|
219
|
+
const specPaths = resolveChangeSpecPaths(args.change, config);
|
|
220
|
+
if (specPaths.length > 0) {
|
|
221
|
+
process.stderr.write(
|
|
222
|
+
`[resolve-playwright-e2e-scope] scope=change(文档 spec) change=${args.change} specs=${specPaths.join(" ")}\n`,
|
|
223
|
+
);
|
|
224
|
+
specPaths.forEach((p) => process.stdout.write(`${p}\n`));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const fallback = config.changeScopeFallback ?? "module";
|
|
229
|
+
if (fallback === "module") {
|
|
230
|
+
process.stderr.write(
|
|
231
|
+
`[resolve-playwright-e2e-scope] scope=change 未在 tasks/proposal/design 中找到 spec,` +
|
|
232
|
+
`已按 changeScopeFallback=module 回退模块目录(change=${args.change})\n`,
|
|
233
|
+
);
|
|
234
|
+
emitModulePaths(args, config, "[resolve-playwright-e2e-scope]");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
console.error(
|
|
239
|
+
`[resolve-playwright-e2e-scope] scope=change 未找到 e2e/*.spec.ts,且 changeScopeFallback=${fallback};` +
|
|
240
|
+
`可配置 changeNameToSpecs、将 changeScopeFallback 设为 module,或 OPSX_PLAYWRIGHT_SCOPE=module|full`,
|
|
241
|
+
);
|
|
242
|
+
process.exit(2);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
emitModulePaths(args, config, "[resolve-playwright-e2e-scope]");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
main();
|
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
```bash
|
|
8
8
|
cd {{DOC_SKILL_REL}}
|
|
9
9
|
export DOC_PRODUCT_PREFIX="{{PRODUCT_PREFIX}}"
|
|
10
|
+
export SDD_SYNC_SOURCE_DIR="{{SYNC_SOURCE_DIR_REL}}"
|
|
10
11
|
python3 confluence-doc.py {{VERSION_EXAMPLE}}
|
|
11
12
|
```
|
|
12
13
|
|
|
14
|
+
脚本成功后会自动将 `docs/<版本目录>/*.md` 与 `image/` 同步到本 run 的 `source/PRD.md` 与 `source/image/`(也可手动执行 `npx sdd-flow-kit sync-source-prd --run-id <runId>`)。
|
|
15
|
+
|
|
13
16
|
版本号可传:`2.3.2`、`V2.3.2`、`ADI_V2.3.2`、`ADI-V2.3.2`(脚本会归一化)。
|
|
14
17
|
|
|
15
18
|
2. 脚本会依次用关键词打开 **Confluence 专用搜索页** `dosearchsite.action?queryString=<关键词>`(非首页顶栏搜索),并依次尝试:`ADI_V*` → `ADI-V*` → `ADI_*` → `ADI-*` → `ADI_v*` → `ADI-v*` → `ADI V*` → `ADI_V*结算` → `V*` 等。
|
|
@@ -24,8 +27,9 @@ python3 confluence-doc.py {{VERSION_EXAMPLE}}
|
|
|
24
27
|
|
|
25
28
|
5. **禁止**在 Confluence 脚本未跑完或未确认失败前,用本地 Word/原型替代 `source/PRD.md`。
|
|
26
29
|
|
|
27
|
-
6.
|
|
28
|
-
- `source/PRD.md
|
|
30
|
+
6. 同步结果(自动,无需手抄):
|
|
31
|
+
- `source/PRD.md`(含 `> 原型图片目录: image/` 元数据行)
|
|
32
|
+
- `source/image/`(正文图片,与 docs 版本目录一致)
|
|
29
33
|
|
|
30
34
|
## 直链兜底(可选,非默认)
|
|
31
35
|
|
|
@@ -23,6 +23,11 @@
|
|
|
23
23
|
- `01-需求分析报告.md`
|
|
24
24
|
- `02-改动点清单.md`
|
|
25
25
|
- `03-待确认问题清单.md`
|
|
26
|
+
- 阶段 B 交付模式另产出:`05-验收清单.md`、**填写完成的** `04-技术文档草稿.md`(须符合 `04-技术文档生成指引.md`,禁止摘要版)
|
|
27
|
+
|
|
28
|
+
## 04 技术文档(阶段 B 必读)
|
|
29
|
+
- 同 run 目录:`04-技术文档生成指引.md`
|
|
30
|
+
- 脚手架已生成完整模板 `04-技术文档草稿.md` → **在原文件上填内容,勿删章节**
|
|
26
31
|
|
|
27
32
|
## 输出话术(建议固定)
|
|
28
33
|
- 提问模式:`SDD 模式:提问模式;矛盾点池状态:未闭合;仅更新 03,等待人工确认。`
|