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
|
@@ -134,6 +134,7 @@ async function installAgentSkill(projectRoot, projectKind) {
|
|
|
134
134
|
return;
|
|
135
135
|
const template = await (0, templates_1.loadTemplateText)("artifacts/06-agent-skill.template.md");
|
|
136
136
|
const layered = await (0, templates_1.loadTemplateText)("skills/layered-testing-strategy.md");
|
|
137
|
+
const demoRef = await (0, templates_1.loadTemplateText)("skills/demo-reference-mandatory-workflow.md");
|
|
137
138
|
for (const target of targets) {
|
|
138
139
|
const agentName = target.includes("/.cursor/")
|
|
139
140
|
? "cursor"
|
|
@@ -150,6 +151,30 @@ async function installAgentSkill(projectRoot, projectKind) {
|
|
|
150
151
|
await (0, fs_1.writeTextFileEnsuredDir)(target, content);
|
|
151
152
|
const layeredPath = path_1.default.join(path_1.default.dirname(target), "LAYERED-TESTING.md");
|
|
152
153
|
await (0, fs_1.writeTextFileEnsuredDir)(layeredPath, layered);
|
|
154
|
+
const demoRefPath = path_1.default.join(path_1.default.dirname(target), "DEMO-REFERENCE-WORKFLOW.md");
|
|
155
|
+
await (0, fs_1.writeTextFileEnsuredDir)(demoRefPath, demoRef);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async function installSddVersionTriggerRule(projectRoot, agent) {
|
|
159
|
+
const template = await (0, templates_1.loadTemplateText)("rules/sdd-version-trigger.mdc.template");
|
|
160
|
+
const content = renderVars(template, {
|
|
161
|
+
AGENT_NAME: agent,
|
|
162
|
+
});
|
|
163
|
+
if (agent === "cursor" || agent === "custom") {
|
|
164
|
+
const rulePath = path_1.default.join(projectRoot, ".cursor", "rules", "00-sdd-flow-kit-version-trigger.mdc");
|
|
165
|
+
await (0, fs_1.writeTextFileEnsuredDir)(rulePath, content);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async function installSddVersionTriggerSkills(projectRoot) {
|
|
169
|
+
const template = await (0, templates_1.loadTemplateText)("skills/version-trigger.SKILL.md.template");
|
|
170
|
+
const targets = [
|
|
171
|
+
{ agent: "cursor", relPath: ".cursor/skills/00-sdd-flow-kit-version-trigger/SKILL.md" },
|
|
172
|
+
{ agent: "claude-code", relPath: ".claude/skills/00-sdd-flow-kit-version-trigger/SKILL.md" },
|
|
173
|
+
{ agent: "codex", relPath: ".codex/skills/00-sdd-flow-kit-version-trigger/SKILL.md" },
|
|
174
|
+
{ agent: "openclaw", relPath: ".openclaw/skills/00-sdd-flow-kit-version-trigger/SKILL.md" },
|
|
175
|
+
];
|
|
176
|
+
for (const target of targets) {
|
|
177
|
+
await (0, fs_1.writeTextFileEnsuredDir)(path_1.default.join(projectRoot, target.relPath), renderVars(template, { AGENT_NAME: target.agent }));
|
|
153
178
|
}
|
|
154
179
|
}
|
|
155
180
|
async function installSddCursorRule(projectRoot) {
|
|
@@ -343,6 +368,8 @@ async function setupProject(options) {
|
|
|
343
368
|
const { dir } = docSkillDirName(selectedKind);
|
|
344
369
|
plannedActions.push(`would install docs skill: ${path_1.default.join(installRoot, "docs", dir)}`);
|
|
345
370
|
plannedActions.push(`would install opsx config: ${path_1.default.join(installRoot, ".opsx", "config.json")}`);
|
|
371
|
+
plannedActions.push(`would install highest-priority version trigger rule: ${path_1.default.join(installRoot, ".cursor", "rules", "00-sdd-flow-kit-version-trigger.mdc")}`);
|
|
372
|
+
plannedActions.push("would install cross-agent version trigger skills: cursor/claude-code/codex/openclaw");
|
|
346
373
|
plannedActions.push(`would install cursor rule: ${path_1.default.join(installRoot, ".cursor", "rules", "sdd-flow-kit-gates.mdc")}`);
|
|
347
374
|
}
|
|
348
375
|
else {
|
|
@@ -352,6 +379,10 @@ async function setupProject(options) {
|
|
|
352
379
|
plannedActions.push("added pnpm.onlyBuiltDependencies: sdd-flow-kit");
|
|
353
380
|
}
|
|
354
381
|
await installAgentSkill(installRoot, selectedKind);
|
|
382
|
+
await installSddVersionTriggerRule(installRoot, selectedAgent);
|
|
383
|
+
await installSddVersionTriggerSkills(installRoot);
|
|
384
|
+
plannedActions.push("installed highest-priority version trigger rule (.cursor/rules/00-sdd-flow-kit-version-trigger.mdc)");
|
|
385
|
+
plannedActions.push("installed cross-agent version trigger skills (cursor/claude-code/codex/openclaw)");
|
|
355
386
|
await installSddCursorRule(installRoot);
|
|
356
387
|
skillInstalled = true;
|
|
357
388
|
await installDocsSkill(installRoot, selectedKind);
|
package/dist/steps/startFlow.js
CHANGED
|
@@ -10,6 +10,7 @@ const utils_1 = require("../core/utils");
|
|
|
10
10
|
const fs_1 = require("../core/fs");
|
|
11
11
|
const templates_1 = require("../core/templates");
|
|
12
12
|
const inferProject_1 = require("../core/inferProject");
|
|
13
|
+
const syncSourcePrd_1 = require("../core/syncSourcePrd");
|
|
13
14
|
const sessionState_1 = require("../core/sessionState");
|
|
14
15
|
const projectRoots_1 = require("../core/projectRoots");
|
|
15
16
|
const writeNext_1 = require("../core/writeNext");
|
|
@@ -30,12 +31,14 @@ async function startFlowScaffold(options) {
|
|
|
30
31
|
// 1) Step1: doc-skill 指引(按产品解析脚本路径,支持 monorepo frontend)
|
|
31
32
|
const kind = (0, inferProject_1.productToKind)(options.product);
|
|
32
33
|
const docSkill = await (0, inferProject_1.resolveDocSkillDir)(options.projectRoot, kind);
|
|
34
|
+
const syncSourceDirRel = await (0, syncSourcePrd_1.resolveSyncSourceDirRel)(options.projectRoot, sourceDir, options.product);
|
|
33
35
|
const step1Template = await (0, templates_1.loadTemplateText)("artifacts/01-adi-doc-skill-指引.template.md");
|
|
34
36
|
const step1 = renderTemplate(step1Template, {
|
|
35
37
|
PRODUCT: options.product,
|
|
36
38
|
PRODUCT_PREFIX: inferProject_1.PRODUCT_PREFIX_BY_KIND[kind],
|
|
37
39
|
DOC_SKILL_REL: docSkill.relFromRoot.replace(/\\/g, "/"),
|
|
38
40
|
VERSION_EXAMPLE: options.version,
|
|
41
|
+
SYNC_SOURCE_DIR_REL: syncSourceDirRel,
|
|
39
42
|
});
|
|
40
43
|
await (0, fs_1.writeTextFileEnsuredDir)(path_1.default.join(outputRoot, "01-获取需求文档指引.md"), step1);
|
|
41
44
|
// source 占位:让第 2 步提示词在任何工具里都能直接读
|
|
@@ -74,9 +77,11 @@ async function startFlowScaffold(options) {
|
|
|
74
77
|
await (0, fs_1.writeTextFileEnsuredDir)(path_1.default.join(outputRoot, "02-改动点清单.md"), file02Template);
|
|
75
78
|
await (0, fs_1.writeTextFileEnsuredDir)(path_1.default.join(outputRoot, "03-待确认问题清单.md"), file03Template);
|
|
76
79
|
await (0, fs_1.writeTextFileEnsuredDir)(path_1.default.join(outputRoot, "05-验收清单.md"), file05Template);
|
|
77
|
-
// 4) Step4
|
|
80
|
+
// 4) Step4 技术文档入口(完整模板 + 填写指引)
|
|
78
81
|
const techDocTemplate = await (0, templates_1.loadTemplateText)("artifacts/04-技术文档.template.md");
|
|
79
82
|
await (0, fs_1.writeTextFileEnsuredDir)(path_1.default.join(outputRoot, "04-技术文档草稿.md"), techDocTemplate);
|
|
83
|
+
const techDocFillGuide = await (0, templates_1.loadTemplateText)("prompts/04-tech-doc-fill-prompt.md");
|
|
84
|
+
await (0, fs_1.writeTextFileEnsuredDir)(path_1.default.join(outputRoot, "04-技术文档生成指引.md"), techDocFillGuide);
|
|
80
85
|
// 5) Step5 openspec 提案入口(草稿)
|
|
81
86
|
const openspecEntryTemplate = await (0, templates_1.loadTemplateText)("artifacts/06-openspec-提案草稿.template.md");
|
|
82
87
|
await (0, fs_1.writeTextFileEnsuredDir)(path_1.default.join(outputRoot, "06-openspec-提案草稿.md"), openspecEntryTemplate);
|
|
@@ -118,7 +123,7 @@ async function startFlowScaffold(options) {
|
|
|
118
123
|
"用户确认后产出(顺序重要):",
|
|
119
124
|
"- `03-待确认问题清单.md`(已闭合)",
|
|
120
125
|
"- `05-验收清单.md`(从 `source/PRD.md` 逐条抽取 AC,标注「定义状态:已定义」)",
|
|
121
|
-
"- `04-技术文档草稿.md
|
|
126
|
+
"- `04-技术文档草稿.md`(**在脚手架模板原文件上逐节填写**,见 `04-技术文档生成指引.md`;禁止摘要替换)",
|
|
122
127
|
"- `01-需求分析报告.md`(业务概述,非验收 SSOT)",
|
|
123
128
|
"- `02-改动点清单.md`",
|
|
124
129
|
"",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sdd-flow-kit",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.18",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Cross-agent SDD automated development workflow kit (ADI/ADI-like).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"files": [
|
|
13
13
|
"dist",
|
|
14
14
|
"src/templates",
|
|
15
|
+
"scripts",
|
|
15
16
|
"README.md"
|
|
16
17
|
],
|
|
17
18
|
"bin": {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# 校验:改动 src/ 或 e2e/ 时必须绑定活跃 OpenSpec change(见 AGENTS.md)
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
6
|
+
cd "$ROOT_DIR"
|
|
7
|
+
|
|
8
|
+
WATCH_PATHS=('src/' 'e2e/')
|
|
9
|
+
CHANGE_DIR_PREFIX="openspec/changes"
|
|
10
|
+
ACTIVE_CHANGE_FILES=('.opsx-active-change' '.poquan-active-change')
|
|
11
|
+
|
|
12
|
+
log() { printf '[opsx:gate] %s\n' "$*"; }
|
|
13
|
+
die() { log "ERROR: $*"; exit 1; }
|
|
14
|
+
|
|
15
|
+
path_is_watched() {
|
|
16
|
+
local path="$1"
|
|
17
|
+
local prefix
|
|
18
|
+
for prefix in "${WATCH_PATHS[@]}"; do
|
|
19
|
+
if [[ "$path" == "$prefix"* ]] || [[ "$path" == */"$prefix"* ]]; then
|
|
20
|
+
return 0
|
|
21
|
+
fi
|
|
22
|
+
done
|
|
23
|
+
return 1
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
collect_candidate_files() {
|
|
27
|
+
CANDIDATE_FILES=()
|
|
28
|
+
if [[ "${1:-}" == "--ci" ]]; then
|
|
29
|
+
local base="${GITHUB_BASE_REF:-main}"
|
|
30
|
+
base="${base#origin/}"
|
|
31
|
+
git fetch origin "$base" --depth=1 2>/dev/null || true
|
|
32
|
+
local merge_base
|
|
33
|
+
merge_base="$(git merge-base "origin/${base}" HEAD 2>/dev/null || git merge-base "${base}" HEAD 2>/dev/null || echo "")"
|
|
34
|
+
[[ -n "$merge_base" ]] || merge_base="HEAD~1"
|
|
35
|
+
while IFS= read -r line; do
|
|
36
|
+
[[ -n "$line" ]] && CANDIDATE_FILES+=("$line")
|
|
37
|
+
done < <(git diff --name-only "$merge_base"...HEAD 2>/dev/null || git diff --name-only HEAD~1 HEAD)
|
|
38
|
+
else
|
|
39
|
+
while IFS= read -r line; do
|
|
40
|
+
[[ -n "$line" ]] && CANDIDATE_FILES+=("$line")
|
|
41
|
+
done < <(git diff --cached --name-only --diff-filter=ACMR 2>/dev/null || true)
|
|
42
|
+
if [[ ${#CANDIDATE_FILES[@]} -eq 0 ]]; then
|
|
43
|
+
while IFS= read -r line; do
|
|
44
|
+
[[ -n "$line" ]] && CANDIDATE_FILES+=("$line")
|
|
45
|
+
done < <(git diff --name-only --diff-filter=ACMR HEAD 2>/dev/null || true)
|
|
46
|
+
fi
|
|
47
|
+
fi
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
has_watched_changes() {
|
|
51
|
+
local file
|
|
52
|
+
for file in "${CANDIDATE_FILES[@]}"; do
|
|
53
|
+
if path_is_watched "$file"; then
|
|
54
|
+
return 0
|
|
55
|
+
fi
|
|
56
|
+
done
|
|
57
|
+
return 1
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
resolve_change_name() {
|
|
61
|
+
if [[ -n "${OPSX_ACTIVE_CHANGE:-}" ]]; then
|
|
62
|
+
echo "$OPSX_ACTIVE_CHANGE"
|
|
63
|
+
return 0
|
|
64
|
+
fi
|
|
65
|
+
if [[ -n "${POQUAN_ACTIVE_CHANGE:-}" ]]; then
|
|
66
|
+
echo "$POQUAN_ACTIVE_CHANGE"
|
|
67
|
+
return 0
|
|
68
|
+
fi
|
|
69
|
+
local file name
|
|
70
|
+
for file in "${ACTIVE_CHANGE_FILES[@]}"; do
|
|
71
|
+
if [[ -f "$file" ]]; then
|
|
72
|
+
name="$(tr -d '[:space:]' <"$file")"
|
|
73
|
+
[[ -n "$name" ]] && { echo "$name"; return 0; }
|
|
74
|
+
fi
|
|
75
|
+
done
|
|
76
|
+
local branch
|
|
77
|
+
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")"
|
|
78
|
+
if [[ "$branch" =~ ^change/([^/]+) ]]; then
|
|
79
|
+
echo "${BASH_REMATCH[1]}"
|
|
80
|
+
return 0
|
|
81
|
+
fi
|
|
82
|
+
return 1
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
validate_change_dir() {
|
|
86
|
+
local name="$1"
|
|
87
|
+
local dir="${CHANGE_DIR_PREFIX}/${name}"
|
|
88
|
+
[[ -d "$dir" ]] || die "change 目录不存在: ${dir}(请先 Propose 或设置 OPSX_ACTIVE_CHANGE)"
|
|
89
|
+
[[ "$dir" != *"/archive/"* ]] || die "change 已归档,请新建 change: ${name}"
|
|
90
|
+
if [[ -f "${dir}/tasks.md" ]] && grep -q '^- \[ \]' "${dir}/tasks.md" 2>/dev/null; then
|
|
91
|
+
log "WARN: tasks.md 仍有未完成项;合码前建议全部勾选并跑 delivery-pipeline"
|
|
92
|
+
fi
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
main() {
|
|
96
|
+
if [[ "${OPSX_SKIP_OPSX:-}" == "1" ]] || [[ "${POQUAN_SKIP_OPSX:-}" == "1" ]]; then
|
|
97
|
+
log "已跳过(OPSX_SKIP_OPSX 或 POQUAN_SKIP_OPSX=1)"
|
|
98
|
+
exit 0
|
|
99
|
+
fi
|
|
100
|
+
|
|
101
|
+
collect_candidate_files "${1:-}"
|
|
102
|
+
if ! has_watched_changes; then
|
|
103
|
+
log "无 src/ 或 e2e/ 改动,跳过"
|
|
104
|
+
exit 0
|
|
105
|
+
fi
|
|
106
|
+
|
|
107
|
+
local change_name
|
|
108
|
+
change_name="$(resolve_change_name)" || die "改动 src/e2e 但未绑定 OpenSpec change。请先 Propose,或设置 OPSX_ACTIVE_CHANGE / .opsx-active-change / 分支 change/<name>。详见 AGENTS.md"
|
|
109
|
+
|
|
110
|
+
validate_change_dir "$change_name"
|
|
111
|
+
log "OK — 活跃 change: ${change_name}"
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
main "$@"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* API 字段契约门禁:change 引用的 config/api/modules 须含 snake/camel normalize(防 SNAKE_CASE 漏映射)。
|
|
4
|
+
*
|
|
5
|
+
* 用法: node scripts/assess-api-field-contract.mjs --change <change-name>
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
|
+
const NORMALIZE_MARKERS =
|
|
13
|
+
/snakeToCamel|camelToSnake|normalizeFinanceApiResult|normalize\w*Api|caseTransform|toCamelCase|toSnakeCase/i;
|
|
14
|
+
const API_CLIENT_CALL_RE = /\b(?:apiClient|request|post|get)\s*[.(]/i;
|
|
15
|
+
|
|
16
|
+
function parseArgs(argv) {
|
|
17
|
+
let change = "";
|
|
18
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
19
|
+
const a = argv[i];
|
|
20
|
+
if (a === "--change" && argv[i + 1]) change = argv[++i];
|
|
21
|
+
else if (!a.startsWith("-") && !change) change = a;
|
|
22
|
+
}
|
|
23
|
+
return { change };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readChangeBlob(changeDir) {
|
|
27
|
+
let blob = "";
|
|
28
|
+
for (const f of ["tasks.md", "proposal.md", "design.md"]) {
|
|
29
|
+
const fp = path.join(changeDir, f);
|
|
30
|
+
if (!fs.existsSync(fp)) continue;
|
|
31
|
+
blob += fs.readFileSync(fp, "utf8");
|
|
32
|
+
}
|
|
33
|
+
return blob;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveModuleFile(mod) {
|
|
37
|
+
const candidates = [
|
|
38
|
+
path.join(ROOT, "src/config/api/modules", mod, "index.ts"),
|
|
39
|
+
path.join(ROOT, "frontend/src/config/api/modules", mod, "index.ts"),
|
|
40
|
+
path.join(ROOT, "src/config/api/modules", `${mod}.ts`),
|
|
41
|
+
path.join(ROOT, "frontend/src/config/api/modules", `${mod}.ts`),
|
|
42
|
+
];
|
|
43
|
+
for (const fp of candidates) {
|
|
44
|
+
if (fs.existsSync(fp)) return fp;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fail(messages) {
|
|
50
|
+
console.error("[assess-api-field-contract] FAIL");
|
|
51
|
+
for (const msg of messages) console.error(` - ${msg}`);
|
|
52
|
+
console.error(
|
|
53
|
+
" 修复: 在 config/api/modules/<domain> 对请求体 camelToSnake、响应 data snakeToCamel;参考 finance/caseTransform.ts",
|
|
54
|
+
);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function main() {
|
|
59
|
+
const skip =
|
|
60
|
+
process.env.POQUAN_SKIP_API_FIELD_CONTRACT ||
|
|
61
|
+
process.env.OPSX_SKIP_API_FIELD_CONTRACT;
|
|
62
|
+
if (skip === "1") {
|
|
63
|
+
console.warn("[assess-api-field-contract] SKIP");
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const { change } = parseArgs(process.argv);
|
|
68
|
+
if (!change) {
|
|
69
|
+
console.error("[assess-api-field-contract] 缺少 --change <name>");
|
|
70
|
+
process.exit(2);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const changeDir = path.join(ROOT, "openspec/changes", change);
|
|
74
|
+
if (!fs.existsSync(changeDir)) {
|
|
75
|
+
console.error(`[assess-api-field-contract] change 不存在: openspec/changes/${change}`);
|
|
76
|
+
process.exit(2);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const blob = readChangeBlob(changeDir);
|
|
80
|
+
const moduleRefs = new Set();
|
|
81
|
+
const modRe = /config\/api\/modules\/([\w/-]+)/g;
|
|
82
|
+
let m;
|
|
83
|
+
while ((m = modRe.exec(blob)) !== null) {
|
|
84
|
+
moduleRefs.add(m[1].replace(/\/index$/, ""));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (moduleRefs.size === 0) {
|
|
88
|
+
console.log(`[assess-api-field-contract] PASS(无 API 模块引用) change=${change}`);
|
|
89
|
+
process.exit(0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const errors = [];
|
|
93
|
+
const passed = [];
|
|
94
|
+
for (const mod of moduleRefs) {
|
|
95
|
+
const fp = resolveModuleFile(mod);
|
|
96
|
+
if (!fp) {
|
|
97
|
+
errors.push(`config/api/modules/${mod} 引用但文件不存在`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const rel = path.relative(ROOT, fp).replace(/\\/g, "/");
|
|
101
|
+
const content = fs.readFileSync(fp, "utf8");
|
|
102
|
+
if (!API_CLIENT_CALL_RE.test(content)) {
|
|
103
|
+
passed.push(`${rel}(无 HTTP 调用)`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (!NORMALIZE_MARKERS.test(content)) {
|
|
107
|
+
errors.push(`${rel} 含 API 调用但未发现字段 normalize`);
|
|
108
|
+
} else {
|
|
109
|
+
passed.push(rel);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (errors.length > 0) fail(errors);
|
|
114
|
+
console.log(
|
|
115
|
+
`[assess-api-field-contract] PASS change=${change} modules=${passed.join(", ")}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
main();
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 演示代码 → 生产实现 覆盖门禁(通用:演示可为 Vue/React)。
|
|
4
|
+
*
|
|
5
|
+
* 用法: node scripts/assess-demo-implementation-coverage.mjs --change <change-name>
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
|
+
|
|
13
|
+
const DEMO_SECTION_RE = /^##\s*(演示代码参照|Demo Reference)\s*$/im;
|
|
14
|
+
const DEMO_EXEMPT_RE = /^DEMO_REFERENCE_EXEMPT:\s*(.+)$/m;
|
|
15
|
+
const MAPPING_HEADER_RE = /演示模块|Demo Module/i;
|
|
16
|
+
const UI_SCENARIO_KEYWORDS =
|
|
17
|
+
/上传|点击|页面|按钮|弹窗|列表|表格|展示|抽屉|详情|编辑|导出|筛选|输入框|对话框|导航|toast|保存|删除|添加/i;
|
|
18
|
+
|
|
19
|
+
function parseArgs(argv) {
|
|
20
|
+
let change = "";
|
|
21
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
22
|
+
const a = argv[i];
|
|
23
|
+
if (a === "--change" && argv[i + 1]) change = argv[++i];
|
|
24
|
+
else if (!a.startsWith("-") && !change) change = a;
|
|
25
|
+
}
|
|
26
|
+
return { change };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function stripComments(content) {
|
|
30
|
+
let out = "";
|
|
31
|
+
let i = 0;
|
|
32
|
+
while (i < content.length) {
|
|
33
|
+
if (content[i] === "/" && content[i + 1] === "/") {
|
|
34
|
+
while (i < content.length && content[i] !== "\n") i += 1;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (content[i] === "/" && content[i + 1] === "*") {
|
|
38
|
+
i += 2;
|
|
39
|
+
while (i < content.length && !(content[i] === "*" && content[i + 1] === "/")) i += 1;
|
|
40
|
+
i += 2;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
out += content[i];
|
|
44
|
+
i += 1;
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseDemoReferenceSection(content) {
|
|
50
|
+
const exemptMatch = content.match(DEMO_EXEMPT_RE);
|
|
51
|
+
const exempt = exemptMatch?.[1]?.trim() || null;
|
|
52
|
+
const sectionMatch = content.match(DEMO_SECTION_RE);
|
|
53
|
+
if (!sectionMatch || sectionMatch.index === undefined) {
|
|
54
|
+
return { hasSection: false, exempt, mappings: [] };
|
|
55
|
+
}
|
|
56
|
+
const sectionBody = content.slice(sectionMatch.index);
|
|
57
|
+
const mappings = [];
|
|
58
|
+
const lines = sectionBody.split("\n");
|
|
59
|
+
let inTable = false;
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
const trimmed = line.trim();
|
|
62
|
+
if (trimmed.startsWith("|") && MAPPING_HEADER_RE.test(trimmed)) {
|
|
63
|
+
inTable = true;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!inTable || !trimmed.startsWith("|")) continue;
|
|
67
|
+
if (/^\|\s*[-:]+/.test(trimmed)) continue;
|
|
68
|
+
const cells = trimmed
|
|
69
|
+
.split("|")
|
|
70
|
+
.map((c) => c.trim())
|
|
71
|
+
.filter((_, idx, arr) => idx > 0 && idx < arr.length - 1);
|
|
72
|
+
if (cells.length < 4) continue;
|
|
73
|
+
if (/演示模块|Demo Module/i.test(cells[0])) continue;
|
|
74
|
+
const signals = (cells[3] ?? "")
|
|
75
|
+
.split(/[、,,|;;]/)
|
|
76
|
+
.map((s) => s.trim())
|
|
77
|
+
.filter((s) => s.length >= 2);
|
|
78
|
+
mappings.push({
|
|
79
|
+
moduleName: cells[0],
|
|
80
|
+
productionPath: cells[2].replace(/\\/g, "/"),
|
|
81
|
+
interactionSignals: signals,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return { hasSection: true, exempt, mappings };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function changeHasUiScenarios(changeDir) {
|
|
88
|
+
const specsDir = path.join(changeDir, "specs");
|
|
89
|
+
if (!fs.existsSync(specsDir)) return false;
|
|
90
|
+
for (const cap of fs.readdirSync(specsDir, { withFileTypes: true })) {
|
|
91
|
+
if (!cap.isDirectory()) continue;
|
|
92
|
+
const specPath = path.join(specsDir, cap.name, "spec.md");
|
|
93
|
+
if (!fs.existsSync(specPath)) continue;
|
|
94
|
+
const content = fs.readFileSync(specPath, "utf8");
|
|
95
|
+
const blocks = content.split(/^####\s+Scenario:/m);
|
|
96
|
+
for (let i = 1; i < blocks.length; i += 1) {
|
|
97
|
+
if (UI_SCENARIO_KEYWORDS.test(blocks[i])) return true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function fail(messages) {
|
|
104
|
+
console.error("[assess-demo-implementation-coverage] FAIL");
|
|
105
|
+
for (const msg of messages) console.error(` - ${msg}`);
|
|
106
|
+
console.error(
|
|
107
|
+
" 修复: 在 design.md 填写 ## 演示代码参照 映射表;按演示拆生产组件;tasks 增加「演示模块迁移」",
|
|
108
|
+
);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function main() {
|
|
113
|
+
const skip =
|
|
114
|
+
process.env.POQUAN_SKIP_DEMO_REFERENCE ||
|
|
115
|
+
process.env.OPSX_SKIP_DEMO_REFERENCE;
|
|
116
|
+
if (skip === "1") {
|
|
117
|
+
console.warn("[assess-demo-implementation-coverage] SKIP");
|
|
118
|
+
process.exit(0);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const { change } = parseArgs(process.argv);
|
|
122
|
+
if (!change) {
|
|
123
|
+
console.error("[assess-demo-implementation-coverage] 缺少 --change <name>");
|
|
124
|
+
process.exit(2);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const changeDir = path.join(ROOT, "openspec/changes", change);
|
|
128
|
+
if (!fs.existsSync(changeDir)) {
|
|
129
|
+
console.error(`[assess-demo-implementation-coverage] change 不存在: ${change}`);
|
|
130
|
+
process.exit(2);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const designPath = path.join(changeDir, "design.md");
|
|
134
|
+
if (!fs.existsSync(designPath)) {
|
|
135
|
+
if (!changeHasUiScenarios(changeDir)) {
|
|
136
|
+
console.log("[assess-demo-implementation-coverage] PASS(无 UI)");
|
|
137
|
+
process.exit(0);
|
|
138
|
+
}
|
|
139
|
+
fail(["缺少 design.md"]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const design = fs.readFileSync(designPath, "utf8");
|
|
143
|
+
const parsed = parseDemoReferenceSection(design);
|
|
144
|
+
if (parsed.exempt) {
|
|
145
|
+
console.log(`[assess-demo-implementation-coverage] PASS(豁免) ${parsed.exempt}`);
|
|
146
|
+
process.exit(0);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!changeHasUiScenarios(changeDir)) {
|
|
150
|
+
console.log("[assess-demo-implementation-coverage] PASS(无 UI Scenario)");
|
|
151
|
+
process.exit(0);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const errors = [];
|
|
155
|
+
if (!parsed.hasSection) errors.push("design.md 缺少 ## 演示代码参照");
|
|
156
|
+
if (parsed.mappings.length === 0) errors.push("演示模块映射表为空");
|
|
157
|
+
|
|
158
|
+
const tasksPath = path.join(changeDir, "tasks.md");
|
|
159
|
+
const tasks = fs.existsSync(tasksPath) ? fs.readFileSync(tasksPath, "utf8") : "";
|
|
160
|
+
if (!/演示模块迁移/.test(tasks)) {
|
|
161
|
+
errors.push("tasks.md 缺少「演示模块迁移」段");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const m of parsed.mappings) {
|
|
165
|
+
const rel = m.productionPath.replace(/^\.\//, "");
|
|
166
|
+
const abs = path.join(ROOT, rel);
|
|
167
|
+
if (!fs.existsSync(abs)) {
|
|
168
|
+
errors.push(`${m.moduleName}: 生产文件不存在 ${rel}`);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const code = stripComments(fs.readFileSync(abs, "utf8"));
|
|
172
|
+
const missing = m.interactionSignals.filter((s) => !code.includes(s));
|
|
173
|
+
if (m.interactionSignals.length > 0 && missing.length === m.interactionSignals.length) {
|
|
174
|
+
errors.push(
|
|
175
|
+
`${m.moduleName}: ${rel} 未实现演示中的关键交互(${m.interactionSignals.join("、")})`,
|
|
176
|
+
);
|
|
177
|
+
} else if (missing.length > 0) {
|
|
178
|
+
errors.push(`${m.moduleName}: ${rel} 缺少: ${missing.join("、")}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (errors.length > 0) fail(errors);
|
|
183
|
+
console.log(
|
|
184
|
+
`[assess-demo-implementation-coverage] PASS change=${change} modules=${parsed.mappings.length}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
main();
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* E2E 断言深度门禁:change 级 spec 须含 page.goto + 数据/交互断言;拒绝 prd-atoms 伪覆盖。
|
|
4
|
+
*
|
|
5
|
+
* 用法: node scripts/assess-e2e-assertion-depth.mjs --change <change-name>
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { analyzeE2eSpecDepth } from "./lib/test-assertion-analysis.mjs";
|
|
11
|
+
|
|
12
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
13
|
+
const CONFIG_PATH = path.join(ROOT, "e2e/e2e-scope.config.json");
|
|
14
|
+
const UI_SCENARIO_KEYWORDS =
|
|
15
|
+
/上传|点击|页面|按钮|弹窗|列表|表格|展示|抽屉|详情|导出|筛选|输入框|对话框|导航|toast|截图/i;
|
|
16
|
+
const E2E_SPEC_RE = /\be2e\/[\w/.-]+\.spec\.(?:ts|js)\b/g;
|
|
17
|
+
|
|
18
|
+
function parseArgs(argv) {
|
|
19
|
+
let change = "";
|
|
20
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
21
|
+
const a = argv[i];
|
|
22
|
+
if (a === "--change" && argv[i + 1]) change = argv[++i];
|
|
23
|
+
else if (!a.startsWith("-") && !change) change = a;
|
|
24
|
+
}
|
|
25
|
+
return { change };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readChangeArtifacts(changeDir) {
|
|
29
|
+
const parts = {};
|
|
30
|
+
let blob = "";
|
|
31
|
+
for (const f of ["tasks.md", "proposal.md", "design.md"]) {
|
|
32
|
+
const fp = path.join(changeDir, f);
|
|
33
|
+
if (!fs.existsSync(fp)) continue;
|
|
34
|
+
const content = fs.readFileSync(fp, "utf8");
|
|
35
|
+
parts[f] = content;
|
|
36
|
+
blob += `${content}\n`;
|
|
37
|
+
}
|
|
38
|
+
return { parts, blob };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function analyzeDeltaSpecs(changeDir) {
|
|
42
|
+
const specsDir = path.join(changeDir, "specs");
|
|
43
|
+
let uiScenarioCount = 0;
|
|
44
|
+
if (!fs.existsSync(specsDir)) return { uiScenarioCount };
|
|
45
|
+
for (const cap of fs.readdirSync(specsDir, { withFileTypes: true })) {
|
|
46
|
+
if (!cap.isDirectory()) continue;
|
|
47
|
+
const specPath = path.join(specsDir, cap.name, "spec.md");
|
|
48
|
+
if (!fs.existsSync(specPath)) continue;
|
|
49
|
+
const content = fs.readFileSync(specPath, "utf8");
|
|
50
|
+
const blocks = content.split(/^####\s+Scenario:/m);
|
|
51
|
+
for (let i = 1; i < blocks.length; i += 1) {
|
|
52
|
+
if (UI_SCENARIO_KEYWORDS.test(blocks[i])) uiScenarioCount += 1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { uiScenarioCount };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function collectChangeE2eSpecs(changeName, changeDir) {
|
|
59
|
+
const specs = new Set();
|
|
60
|
+
const { blob } = readChangeArtifacts(changeDir);
|
|
61
|
+
let m;
|
|
62
|
+
const re = new RegExp(E2E_SPEC_RE.source, "g");
|
|
63
|
+
while ((m = re.exec(blob)) !== null) {
|
|
64
|
+
const rel = m[0];
|
|
65
|
+
if (fs.existsSync(path.join(ROOT, rel))) specs.add(rel);
|
|
66
|
+
}
|
|
67
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
68
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
69
|
+
const overrides = config.changeNameToSpecs?.[changeName];
|
|
70
|
+
if (overrides) {
|
|
71
|
+
for (const raw of overrides) {
|
|
72
|
+
const rel = raw.startsWith("e2e/") ? raw : `e2e/${raw}`;
|
|
73
|
+
if (fs.existsSync(path.join(ROOT, rel))) specs.add(rel);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return [...specs].sort();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function fail(messages) {
|
|
81
|
+
console.error("[assess-e2e-assertion-depth] FAIL");
|
|
82
|
+
for (const msg of messages) console.error(` - ${msg}`);
|
|
83
|
+
console.error(
|
|
84
|
+
" 修复: 在 change 级 e2e spec 中补充 page.goto、waitForResponse/getByRole(cell)/toContainText 或 click 等断言;",
|
|
85
|
+
);
|
|
86
|
+
console.error(" 禁止用 prd-atoms-coverage 登记代替真实 E2E;纯后端 change 可 PLAYWRIGHT_E2E_EXEMPT");
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function main() {
|
|
91
|
+
const skip =
|
|
92
|
+
process.env.POQUAN_SKIP_E2E_ASSERTION_DEPTH ||
|
|
93
|
+
process.env.OPSX_SKIP_E2E_ASSERTION_DEPTH;
|
|
94
|
+
if (skip === "1") {
|
|
95
|
+
console.warn("[assess-e2e-assertion-depth] SKIP");
|
|
96
|
+
process.exit(0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const { change } = parseArgs(process.argv);
|
|
100
|
+
if (!change) {
|
|
101
|
+
console.error("[assess-e2e-assertion-depth] 缺少 --change <name>");
|
|
102
|
+
process.exit(2);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const changeDir = path.join(ROOT, "openspec/changes", change);
|
|
106
|
+
if (!fs.existsSync(changeDir)) {
|
|
107
|
+
console.error(`[assess-e2e-assertion-depth] 活跃 change 不存在: openspec/changes/${change}`);
|
|
108
|
+
process.exit(2);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const { parts } = readChangeArtifacts(changeDir);
|
|
112
|
+
const tasksContent = parts["tasks.md"] || "";
|
|
113
|
+
if (/^PLAYWRIGHT_E2E_EXEMPT:/m.test(tasksContent)) {
|
|
114
|
+
console.log(`[assess-e2e-assertion-depth] PASS(豁免) change=${change}`);
|
|
115
|
+
process.exit(0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { uiScenarioCount } = analyzeDeltaSpecs(changeDir);
|
|
119
|
+
const specPaths = collectChangeE2eSpecs(change, changeDir);
|
|
120
|
+
|
|
121
|
+
if (uiScenarioCount === 0 && specPaths.length === 0) {
|
|
122
|
+
console.log(`[assess-e2e-assertion-depth] PASS(无 UI e2e 要求) change=${change}`);
|
|
123
|
+
process.exit(0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const errors = [];
|
|
127
|
+
if (uiScenarioCount > 0 && specPaths.length === 0) {
|
|
128
|
+
errors.push(`含 ${uiScenarioCount} 个 UI Scenario 但未绑定 e2e/*.spec.ts`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const rel of specPaths) {
|
|
132
|
+
const content = fs.readFileSync(path.join(ROOT, rel), "utf8");
|
|
133
|
+
const depth = analyzeE2eSpecDepth(rel, content);
|
|
134
|
+
if (!depth.ok) {
|
|
135
|
+
errors.push(`${rel}: ${depth.issues.join("; ")}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (errors.length > 0) fail(errors);
|
|
140
|
+
|
|
141
|
+
console.log(
|
|
142
|
+
`[assess-e2e-assertion-depth] PASS change=${change} specs=${specPaths.join(" ")}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
main();
|