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.
Files changed (60) hide show
  1. package/dist/cli.js +16 -0
  2. package/dist/core/acAssertionBinding.js +180 -0
  3. package/dist/core/apiFieldContract.js +90 -0
  4. package/dist/core/apiFieldCoverage.js +335 -0
  5. package/dist/core/componentSubstance.js +172 -0
  6. package/dist/core/demoImplementationCoverage.js +244 -0
  7. package/dist/core/e2eAssertionDepth.js +237 -0
  8. package/dist/core/e2eDemoSignalClicks.js +90 -0
  9. package/dist/core/prdArtifacts.js +12 -1
  10. package/dist/core/prdCoverage.js +52 -36
  11. package/dist/core/prdSemanticDiff.js +7 -4
  12. package/dist/core/snakeCamel.js +93 -0
  13. package/dist/core/syncSourcePrd.js +181 -0
  14. package/dist/core/techDocQuality.js +91 -0
  15. package/dist/core/testAssertionAnalysis.js +213 -0
  16. package/dist/core/visualRegression.js +243 -0
  17. package/dist/core/writeNext.js +1 -1
  18. package/dist/index.js +6 -1
  19. package/dist/steps/runGate.js +49 -5
  20. package/dist/steps/runSyncSourcePrd.js +38 -0
  21. package/dist/steps/runValidate.js +4 -2
  22. package/dist/steps/setupProject.js +3 -0
  23. package/dist/steps/startFlow.js +9 -2
  24. package/package.json +2 -1
  25. package/scripts/assert-active-openspec-change.sh +114 -0
  26. package/scripts/assess-ac-assertion-binding.mjs +104 -0
  27. package/scripts/assess-api-field-contract.mjs +119 -0
  28. package/scripts/assess-api-field-coverage.mjs +88 -0
  29. package/scripts/assess-component-substance.mjs +87 -0
  30. package/scripts/assess-demo-implementation-coverage.mjs +188 -0
  31. package/scripts/assess-e2e-assertion-depth.mjs +162 -0
  32. package/scripts/assess-playwright-e2e-coverage.mjs +244 -0
  33. package/scripts/assess-tech-doc-quality.mjs +98 -0
  34. package/scripts/assess-visual-regression.mjs +92 -0
  35. package/scripts/install-git-hooks.sh +27 -0
  36. package/scripts/lib/ac-assertion-binding.mjs +119 -0
  37. package/scripts/lib/api-field-coverage.mjs +238 -0
  38. package/scripts/lib/component-substance.mjs +117 -0
  39. package/scripts/lib/demo-reference-parse.mjs +55 -0
  40. package/scripts/lib/e2e-demo-signal-clicks.mjs +43 -0
  41. package/scripts/lib/snake-camel.mjs +74 -0
  42. package/scripts/lib/test-assertion-analysis.mjs +148 -0
  43. package/scripts/lib/visual-regression.mjs +191 -0
  44. package/scripts/resolve-playwright-e2e-scope.mjs +248 -0
  45. package/src/templates/artifacts/01-adi-doc-skill-/346/214/207/345/274/225.template.md +6 -2
  46. 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
  47. package/src/templates/artifacts/04-/346/212/200/346/234/257/346/226/207/346/241/243.template.md +22 -0
  48. package/src/templates/artifacts/05-/351/252/214/346/224/266/346/270/205/345/215/225.template.md +4 -3
  49. package/src/templates/artifacts/06-openspec-/346/217/220/346/241/210/350/215/211/347/250/277.template.md +2 -2
  50. package/src/templates/artifacts/07-opsx-auto-chain.template.md +2 -2
  51. 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
  52. package/src/templates/artifacts/design-demo-reference.template.md +24 -0
  53. package/src/templates/artifacts/tests-fixtures-readme.template.md +19 -0
  54. package/src/templates/artifacts/visual-baseline-readme.template.md +28 -0
  55. package/src/templates/prompts/02-sdd-loop-prompt.md +25 -11
  56. package/src/templates/prompts/04-tech-doc-fill-prompt.md +76 -0
  57. package/src/templates/skills/confluence-doc.py.template +64 -25
  58. package/src/templates/skills/demo-reference-mandatory-workflow.md +79 -0
  59. package/src/templates/skills/doc-skill.SKILL.md.template +13 -6
  60. package/src/templates/skills/layered-testing-strategy.md +15 -6
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.e2eExercisesSignal = e2eExercisesSignal;
7
+ exports.checkE2eDemoSignalClicks = checkE2eDemoSignalClicks;
8
+ /**
9
+ * E2E 演示信号点击门禁:design 映射表中的可点击交互信号须在 change 级 e2e 中有 click/getByRole。
10
+ */
11
+ const path_1 = __importDefault(require("path"));
12
+ const demoImplementationCoverage_1 = require("./demoImplementationCoverage");
13
+ const fs_1 = require("./fs");
14
+ /** 须在 E2E 中验证点击的信号特征 */
15
+ const CLICKABLE_SIGNAL_RE = /编辑|保存|删除|新增|导入|拉取|提交|确认|取消|打开|修改|添加/i;
16
+ function escapeRegExp(s) {
17
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
18
+ }
19
+ /**
20
+ * 判断 E2E 源码是否对某交互信号做了点击类操作。
21
+ */
22
+ function e2eExercisesSignal(e2eContent, signal) {
23
+ if (!signal || signal.length < 2)
24
+ return true;
25
+ const stripped = e2eContent.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
26
+ if (!stripped.includes(signal))
27
+ return false;
28
+ const esc = escapeRegExp(signal);
29
+ const clickPatterns = [
30
+ new RegExp(`\\.click\\s*\\([^)]*${esc}`),
31
+ new RegExp(`getByRole\\s*\\(\\s*['"]button['"][^)]*${esc}`),
32
+ new RegExp(`getByRole\\s*\\(\\s*['"][^'"]+['"][^)]*name:\\s*[^)]*${esc}`),
33
+ new RegExp(`getByText\\s*\\(\\s*['"\`]${esc}`),
34
+ new RegExp(`getByText\\s*\\(\\s*[^)]*${esc}`),
35
+ new RegExp(`locator\\s*\\([^)]*${esc}[^)]*\\)\\.click`),
36
+ ];
37
+ return clickPatterns.some((re) => re.test(stripped));
38
+ }
39
+ /**
40
+ * UI 类 change:映射表中可点击信号须在绑定的 e2e spec 中出现 click/getByRole。
41
+ */
42
+ async function checkE2eDemoSignalClicks(pkgRoot, designContent, specPaths) {
43
+ var _a;
44
+ const skip = process.env.POQUAN_SKIP_E2E_DEMO_CLICKS === "1" ||
45
+ process.env.OPSX_SKIP_E2E_DEMO_CLICKS === "1";
46
+ if (skip) {
47
+ return {
48
+ name: "e2e-demo-signal-clicks",
49
+ ok: true,
50
+ detail: "SKIP: OPSX_SKIP_E2E_DEMO_CLICKS=1",
51
+ };
52
+ }
53
+ const parsed = (0, demoImplementationCoverage_1.parseDemoReferenceSection)(designContent !== null && designContent !== void 0 ? designContent : "");
54
+ if (parsed.exempt || parsed.mappings.length === 0) {
55
+ return {
56
+ name: "e2e-demo-signal-clicks",
57
+ ok: true,
58
+ detail: "无演示映射或已豁免,跳过 E2E 点击检查",
59
+ };
60
+ }
61
+ if (specPaths.length === 0) {
62
+ return {
63
+ name: "e2e-demo-signal-clicks",
64
+ ok: false,
65
+ detail: "有演示映射但未绑定 e2e spec,无法校验交互点击",
66
+ };
67
+ }
68
+ let e2eBlob = "";
69
+ for (const rel of specPaths) {
70
+ const content = (_a = (await (0, fs_1.readFileIfExists)(path_1.default.join(pkgRoot, rel)))) !== null && _a !== void 0 ? _a : "";
71
+ e2eBlob += `\n// ${rel}\n${content}`;
72
+ }
73
+ const failures = [];
74
+ for (const m of parsed.mappings) {
75
+ const clickable = m.interactionSignals.filter((s) => CLICKABLE_SIGNAL_RE.test(s));
76
+ if (clickable.length === 0)
77
+ continue;
78
+ const uncovered = clickable.filter((s) => !e2eExercisesSignal(e2eBlob, s));
79
+ if (uncovered.length === clickable.length) {
80
+ failures.push(`${m.moduleName}: E2E 须 click/getByRole/getByText 覆盖信号「${clickable.join("、")}」`);
81
+ }
82
+ }
83
+ return {
84
+ name: "e2e-demo-signal-clicks",
85
+ ok: failures.length === 0,
86
+ detail: failures.length === 0
87
+ ? `E2E 已覆盖演示映射中的可点击信号(${specPaths.join(", ")})`
88
+ : failures.join(" | "),
89
+ };
90
+ }
@@ -11,6 +11,7 @@ exports.isAcceptanceChecklistSigned = isAcceptanceChecklistSigned;
11
11
  const path_1 = __importDefault(require("path"));
12
12
  const acMatrix_1 = require("./acMatrix");
13
13
  const fs_1 = require("./fs");
14
+ const techDocQuality_1 = require("./techDocQuality");
14
15
  const PRD_PLACEHOLDER_MARKERS = [
15
16
  "请把《PRD》",
16
17
  "占位",
@@ -81,7 +82,17 @@ async function areAnalysisDocsReady(outputRoot) {
81
82
  return { ok: false, detail: `${name} 仍为模板占位` };
82
83
  }
83
84
  }
84
- return { ok: true, detail: "01/02/04 文档齐全" };
85
+ const techDoc = await (0, fs_1.readFileIfExists)(path_1.default.join(outputRoot, "04-技术文档草稿.md"));
86
+ if (process.env.OPSX_SKIP_TECH_DOC_QUALITY !== "1") {
87
+ const techQuality = (0, techDocQuality_1.checkTechDocStructure)(techDoc !== null && techDoc !== void 0 ? techDoc : "");
88
+ if (!techQuality.ok) {
89
+ return {
90
+ ok: false,
91
+ detail: `tech-doc-structure: ${techQuality.detail}。请读 04-技术文档生成指引.md,在模板原文件上逐节填写`,
92
+ };
93
+ }
94
+ }
95
+ return { ok: true, detail: "01/02/04 文档齐全且 04 符合模板结构" };
85
96
  }
86
97
  /** 05-验收清单是否已从 PRD 定义完成(含至少一条 P0)。 */
87
98
  async function isAcceptanceChecklistDefined(outputRoot) {
@@ -15,6 +15,12 @@ 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 e2eAssertionDepth_1 = require("./e2eAssertionDepth");
19
+ const apiFieldContract_1 = require("./apiFieldContract");
20
+ const apiFieldCoverage_1 = require("./apiFieldCoverage");
21
+ const visualRegression_1 = require("./visualRegression");
22
+ const demoImplementationCoverage_1 = require("./demoImplementationCoverage");
23
+ const acAssertionBinding_1 = require("./acAssertionBinding");
18
24
  const VALID_VERDICTS = ["一致", "不一致-按PRD修复", "待人工确认"];
19
25
  /** 禁止在证据/结论中出现的揣摩或兜底措辞 */
20
26
  const FORBIDDEN_SPECULATION = [
@@ -33,7 +39,6 @@ const FORBIDDEN_SPECULATION = [
33
39
  "或许",
34
40
  ];
35
41
  const TEST_FILE_RE = /\.(spec|test)\.(t|j)sx?$/i;
36
- const E2E_DIR_RE = /(^|\/)(e2e|playwright)(\/|$)/i;
37
42
  async function walkFiles(dir, out) {
38
43
  let entries;
39
44
  try {
@@ -99,6 +104,22 @@ function hasForbiddenSpeculation(text) {
99
104
  }
100
105
  return null;
101
106
  }
107
+ /** 08 报告中的无效/敷衍证据(如 index.vue:1、prd-atoms 注释覆盖) */
108
+ function isWeakCodeEvidence(evidence) {
109
+ const e = evidence.trim();
110
+ if (!e)
111
+ return "空证据";
112
+ if (/:(1|2)\s*(\||$)/.test(e) && !/expect\(|getByText|getByRole|message\.|toast/.test(e)) {
113
+ return "证据仅指向文件首行,无具体交互/断言";
114
+ }
115
+ if (/prd-atoms-coverage|semantic-registry/i.test(e)) {
116
+ return "伪覆盖登记文件不能作为实现证据";
117
+ }
118
+ if (/index\.vue:1|index\.tsx:1/i.test(e) && e.split(/[;;]/).length < 2) {
119
+ return "禁止用列表页首行代替详情子模块证据";
120
+ }
121
+ return null;
122
+ }
102
123
  async function writePrdAtomsManifest(outputRoot, atoms) {
103
124
  await (0, fs_1.writeTextFileEnsuredDir)(path_1.default.join(outputRoot, ".prd-review-atoms.json"), `${JSON.stringify({ count: atoms.length, atoms }, null, 2)}\n`);
104
125
  }
@@ -116,40 +137,12 @@ async function loadPrdAtomsFromManifest(outputRoot) {
116
137
  }
117
138
  }
118
139
  function checkAcTestMapping(items, testContents) {
119
- var _a;
120
- const checks = [];
121
- // P0 全量 + 所有声明 e2e 的 AC(含 P1/P2 边缘场景)
122
- 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
- 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
- const e2eRequired = /e2e/i.test((_a = ac.testType) !== null && _a !== void 0 ? _a : "");
127
- const e2eHits = hits.filter((h) => E2E_DIR_RE.test(h.file.replace(/\\/g, "/")));
128
- if (hits.length === 0) {
129
- checks.push({
130
- name: `ac-test-${ac.id}`,
131
- ok: false,
132
- detail: `${ac.id}(${ac.priority}) 未在测试文件中找到 [${ac.id}] 映射`,
133
- });
134
- continue;
135
- }
136
- if (e2eRequired && e2eHits.length === 0) {
137
- checks.push({
138
- name: `ac-e2e-${ac.id}`,
139
- ok: false,
140
- detail: `${ac.id} 测试类型含 e2e,但 e2e/ 或 playwright/ 下无对应测试`,
141
- });
142
- continue;
143
- }
144
- checks.push({
145
- name: `ac-test-${ac.id}`,
146
- ok: true,
147
- detail: e2eRequired
148
- ? `${ac.id} 已映射(含 e2e: ${e2eHits.map((h) => path_1.default.basename(h.file)).join(", ")})`
149
- : `${ac.id} 已映射: ${hits.map((h) => path_1.default.basename(h.file)).join(", ")}`,
150
- });
151
- }
152
- return checks;
140
+ const bindingChecks = (0, acAssertionBinding_1.checkAcAssertionBinding)(items, testContents.map((t) => ({ rel: t.rel, content: t.content })));
141
+ return bindingChecks.map((c) => ({
142
+ name: c.name,
143
+ ok: c.ok,
144
+ detail: c.detail,
145
+ }));
153
146
  }
154
147
  function checkSemanticDiffItems(semantic) {
155
148
  const checks = [];
@@ -246,7 +239,7 @@ function checkSemanticDiffItems(semantic) {
246
239
  /**
247
240
  * gate prd-coverage:PRD 最细粒度条目全覆盖 + 08 复查报告合规 + P0 AC 测试映射。
248
241
  */
249
- async function checkPrdCoverage(outputRoot, pkgRoot) {
242
+ async function checkPrdCoverage(outputRoot, pkgRoot, options) {
250
243
  const checks = [];
251
244
  const prdContent = await (0, fs_1.readFileIfExists)(path_1.default.join(outputRoot, "source", "PRD.md"));
252
245
  if (!prdContent) {
@@ -314,6 +307,7 @@ async function checkPrdCoverage(outputRoot, pkgRoot) {
314
307
  const needHuman = [];
315
308
  const speculationHits = [];
316
309
  const emptyEvidence = [];
310
+ const weakEvidence = [];
317
311
  for (const atom of atoms) {
318
312
  const row = rowById.get(atom.id);
319
313
  if (!row)
@@ -329,6 +323,11 @@ async function checkPrdCoverage(outputRoot, pkgRoot) {
329
323
  if (!evidence || evidence.length < 8 || /(填写)|待填写|TBD/i.test(evidence)) {
330
324
  emptyEvidence.push(atom.id);
331
325
  }
326
+ if (row.verdict === "一致") {
327
+ const weak = isWeakCodeEvidence(evidence);
328
+ if (weak)
329
+ weakEvidence.push(`${atom.id}(${weak})`);
330
+ }
332
331
  const combined = `${row.codeEvidence} ${row.note} ${row.verdict}`;
333
332
  const forbidden = hasForbiddenSpeculation(combined);
334
333
  if (forbidden)
@@ -355,6 +354,13 @@ async function checkPrdCoverage(outputRoot, pkgRoot) {
355
354
  ? "每条均有可核对代码证据(文件路径+行号/选择器/断言)"
356
355
  : `缺少代码证据: ${emptyEvidence.slice(0, 8).join(", ")}`,
357
356
  });
357
+ checks.push({
358
+ name: "code-evidence-quality",
359
+ ok: weakEvidence.length === 0,
360
+ detail: weakEvidence.length === 0
361
+ ? "未发现敷衍证据(index.vue:1 / prd-atoms 登记等)"
362
+ : `证据质量不足: ${weakEvidence.slice(0, 8).join(", ")}`,
363
+ });
358
364
  checks.push({
359
365
  name: "no-prd-mismatch-open",
360
366
  ok: needFix.length === 0,
@@ -385,10 +391,20 @@ async function checkPrdCoverage(outputRoot, pkgRoot) {
385
391
  var _a;
386
392
  return ({
387
393
  file: f,
394
+ rel: path_1.default.relative(pkgRoot, f).replace(/\\/g, "/"),
388
395
  content: (_a = (await (0, fs_1.readFileIfExists)(f))) !== null && _a !== void 0 ? _a : "",
389
396
  });
390
397
  }));
391
398
  checks.push(...checkAcTestMapping(acParsed.items, testContents));
399
+ if (options === null || options === void 0 ? void 0 : options.changeName) {
400
+ checks.push(...(await (0, visualRegression_1.checkVisualRegression)(pkgRoot, outputRoot, options.changeName, acParsed.items)));
401
+ }
402
+ }
403
+ if (options === null || options === void 0 ? void 0 : options.changeName) {
404
+ checks.push(...(await (0, e2eAssertionDepth_1.checkE2eAssertionDepth)(pkgRoot, outputRoot, options.changeName)));
405
+ checks.push(...(await (0, apiFieldContract_1.checkApiFieldContract)(pkgRoot, options.changeName)));
406
+ checks.push(...(await (0, apiFieldCoverage_1.checkApiFieldCoverage)(pkgRoot, options.changeName, outputRoot)));
407
+ checks.push(...(await (0, demoImplementationCoverage_1.checkDemoImplementationCoverage)(pkgRoot, options.changeName)));
392
408
  }
393
409
  const ok = checks.every((c) => c.ok);
394
410
  return { ok, checks };
@@ -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
- const hasId = entry.content.includes(atomId) || entry.content.includes(`[${atomId}]`);
178
- const hasSignal = signals.some((s) => s.length >= 4 && entry.content.includes(s));
179
- if (hasId || hasSignal) {
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 (entry.isE2e)
184
+ if (fileE2e)
182
185
  hasE2e = true;
183
186
  }
184
187
  }
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ /**
3
+ * snake_case ↔ camelCase 深转换(与前端 caseTransform 语义一致,供门禁脚本使用)。
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.snakeKeyToCamel = snakeKeyToCamel;
7
+ exports.snakeToCamelDeep = snakeToCamelDeep;
8
+ exports.hasSnakeCaseKeys = hasSnakeCaseKeys;
9
+ exports.collectLeafCamelKeys = collectLeafCamelKeys;
10
+ exports.extractFirstListRow = extractFirstListRow;
11
+ function snakeKeyToCamel(key) {
12
+ return key.replace(/_([a-z])/g, (_, char) => char.toUpperCase());
13
+ }
14
+ function snakeToCamelDeep(input) {
15
+ if (input === null || input === undefined) {
16
+ return input;
17
+ }
18
+ if (Array.isArray(input)) {
19
+ return input.map((item) => snakeToCamelDeep(item));
20
+ }
21
+ if (typeof input === "object") {
22
+ const result = {};
23
+ for (const [key, value] of Object.entries(input)) {
24
+ result[snakeKeyToCamel(key)] = snakeToCamelDeep(value);
25
+ }
26
+ return result;
27
+ }
28
+ return input;
29
+ }
30
+ /** 对象是否含 snake_case 键(判定为后端样例 fixture) */
31
+ function hasSnakeCaseKeys(input) {
32
+ if (input === null || typeof input !== "object")
33
+ return false;
34
+ if (Array.isArray(input)) {
35
+ return input.some((item) => hasSnakeCaseKeys(item));
36
+ }
37
+ for (const key of Object.keys(input)) {
38
+ if (key.includes("_"))
39
+ return true;
40
+ if (hasSnakeCaseKeys(input[key]))
41
+ return true;
42
+ }
43
+ return false;
44
+ }
45
+ /** 收集对象所有叶子路径的 camelCase 键名(末级 key) */
46
+ function collectLeafCamelKeys(input, out = new Set()) {
47
+ if (input === null || input === undefined)
48
+ return out;
49
+ if (Array.isArray(input)) {
50
+ for (const item of input)
51
+ collectLeafCamelKeys(item, out);
52
+ return out;
53
+ }
54
+ if (typeof input === "object") {
55
+ const obj = input;
56
+ const keys = Object.keys(obj);
57
+ if (keys.length === 0)
58
+ return out;
59
+ const allPrimitive = keys.every((k) => obj[k] === null || ["string", "number", "boolean"].includes(typeof obj[k]));
60
+ if (allPrimitive) {
61
+ for (const k of keys)
62
+ out.add(k);
63
+ return out;
64
+ }
65
+ for (const k of keys)
66
+ collectLeafCamelKeys(obj[k], out);
67
+ return out;
68
+ }
69
+ return out;
70
+ }
71
+ /** 从常见分页响应结构取首条列表行 */
72
+ function extractFirstListRow(payload) {
73
+ var _a, _b;
74
+ const normalized = snakeToCamelDeep(payload);
75
+ const candidates = [
76
+ normalized,
77
+ normalized.data,
78
+ normalized.records,
79
+ (_a = normalized.data) === null || _a === void 0 ? void 0 : _a.records,
80
+ (_b = normalized.data) === null || _b === void 0 ? void 0 : _b.items,
81
+ normalized.items,
82
+ normalized.list,
83
+ ];
84
+ for (const c of candidates) {
85
+ if (Array.isArray(c) && c.length > 0 && typeof c[0] === "object" && c[0] !== null) {
86
+ return c[0];
87
+ }
88
+ }
89
+ if (typeof normalized === "object" && normalized !== null && !Array.isArray(normalized)) {
90
+ return normalized;
91
+ }
92
+ return null;
93
+ }
@@ -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
+ }
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ /**
3
+ * 04-技术文档草稿 结构质量门禁:与 artifacts/04-技术文档.template.md 对齐,禁止摘要版替换完整模板。
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.REQUIRED_TECH_DOC_SECTIONS = void 0;
7
+ exports.checkTechDocStructure = checkTechDocStructure;
8
+ /** 与模板一致的必备二级章节(允许「## 1. 改造范围」类带后缀标题) */
9
+ exports.REQUIRED_TECH_DOC_SECTIONS = [
10
+ { id: "meta", pattern: /##\s*文档元信息/m, label: "文档元信息" },
11
+ { id: "scope", pattern: /##\s*1\.\s*改造范围/m, label: "1. 改造范围" },
12
+ { id: "ui", pattern: /##\s*2\.\s*页面与交互设计/m, label: "2. 页面与交互设计" },
13
+ { id: "demo", pattern: /###\s*2\.4\s*演示代码参照/m, label: "2.4 演示代码参照" },
14
+ { id: "state", pattern: /##\s*3\.\s*前端状态与数据模型设计/m, label: "3. 前端状态与数据模型设计" },
15
+ { id: "api", pattern: /##\s*4\.\s*接口与数据交互设计/m, label: "4. 接口与数据交互设计" },
16
+ { id: "form", pattern: /##\s*5\.\s*表单与校验设计/m, label: "5. 表单与校验设计" },
17
+ { id: "edge", pattern: /##\s*6\.\s*异常、边界与降级方案/m, label: "6. 异常、边界与降级方案" },
18
+ { id: "files", pattern: /##\s*7\.\s*改造清单/m, label: "7. 改造清单" },
19
+ { id: "risk", pattern: /##\s*8\.\s*风险点与应对策略/m, label: "8. 风险点与应对策略" },
20
+ { id: "release", pattern: /##\s*9\.\s*发布、灰度与回滚/m, label: "9. 发布、灰度与回滚" },
21
+ { id: "test", pattern: /##\s*10\.\s*测试与验收要点/m, label: "10. 测试与验收要点" },
22
+ { id: "pending", pattern: /##\s*11\.\s*待确认事项/m, label: "11. 待确认事项" },
23
+ ];
24
+ const MIN_LINES = 150;
25
+ const MIN_H3_UNDER_SCOPE = 2;
26
+ const API_TABLE_RE = /\|\s*接口\s*\|\s*Method\s*\|/m;
27
+ const FIELD_MAPPING_TABLE_RE = /\|\s*后端字段\s*\|\s*前端字段\s*\|/m;
28
+ const DEMO_TABLE_RE = /\|\s*演示模块\s*\|\s*演示文件\/组件\s*\|/m;
29
+ const AC_REF_RE = /\[AC-\d{2,}\]/g;
30
+ /** 典型「摘要版」反模式:仅若干条顶层 [AC-xx] bullet,无三级标题 */
31
+ const SUMMARY_ANTIPATTERN_RE = /^#\s*04-技术文档草稿\s*\n+##\s*文档元信息[\s\S]{0,800}##\s*7\.\s*权限/m;
32
+ /**
33
+ * 校验 04 技术文档是否按模板填满(非摘要替换版)。
34
+ */
35
+ function checkTechDocStructure(content) {
36
+ var _a, _b, _c, _d;
37
+ const missingSections = [];
38
+ const warnings = [];
39
+ if (!content || content.trim().length < 80) {
40
+ return {
41
+ ok: false,
42
+ detail: "04-技术文档草稿.md 缺失或过短",
43
+ missingSections: exports.REQUIRED_TECH_DOC_SECTIONS.map((s) => s.label),
44
+ warnings: [],
45
+ };
46
+ }
47
+ const lines = content.split("\n");
48
+ if (lines.length < MIN_LINES) {
49
+ warnings.push(`行数 ${lines.length} < ${MIN_LINES}(模板级文档通常 ≥150 行)`);
50
+ }
51
+ for (const sec of exports.REQUIRED_TECH_DOC_SECTIONS) {
52
+ if (!sec.pattern.test(content)) {
53
+ missingSections.push(sec.label);
54
+ }
55
+ }
56
+ const h3InScope = ((_b = (_a = content.match(/##\s*1\.\s*改造范围[\s\S]*?(?=##\s*2\.)/m)) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : "").match(/^###\s+/gm);
57
+ if (!h3InScope || h3InScope.length < MIN_H3_UNDER_SCOPE) {
58
+ missingSections.push("1.x 改造目标/In Scope 等三级小节");
59
+ }
60
+ if (!API_TABLE_RE.test(content)) {
61
+ missingSections.push("4.1 接口清单表格(| 接口 | Method |)");
62
+ }
63
+ if (API_TABLE_RE.test(content) && !FIELD_MAPPING_TABLE_RE.test(content)) {
64
+ missingSections.push("4.3.1 响应字段映射表(| 后端字段 | 前端字段 |)");
65
+ }
66
+ if (!DEMO_TABLE_RE.test(content)) {
67
+ missingSections.push("2.4 演示模块映射表格(| 演示模块 | 演示文件/组件 |)");
68
+ }
69
+ const acRefs = (_c = content.match(AC_REF_RE)) !== null && _c !== void 0 ? _c : [];
70
+ if (acRefs.length < 3) {
71
+ warnings.push("文中 [AC-XX] 引用过少(建议在各章节分散标注)");
72
+ }
73
+ if (SUMMARY_ANTIPATTERN_RE.test(content) && lines.length < 80) {
74
+ missingSections.push("禁止摘要版:须保留模板全部 11 章并逐节填写");
75
+ }
76
+ const placeholderHeavy = ((_d = content.match(/(填写)|待填写|TBD|示例:/g)) !== null && _d !== void 0 ? _d : []).length > 25 &&
77
+ lines.length < MIN_LINES;
78
+ if (placeholderHeavy) {
79
+ warnings.push("占位符过多且篇幅过短,疑似未按 PRD/接口/演示填充");
80
+ }
81
+ const ok = missingSections.length === 0 && lines.length >= MIN_LINES;
82
+ const detail = ok
83
+ ? `04 技术文档结构合格(${lines.length} 行,${exports.REQUIRED_TECH_DOC_SECTIONS.length} 章齐全)`
84
+ : `04 技术文档不符合模板: 缺 ${missingSections.join("、")}${warnings.length ? `;${warnings.join(";")}` : ""}`;
85
+ return {
86
+ ok,
87
+ detail,
88
+ missingSections,
89
+ warnings,
90
+ };
91
+ }