@gordon.gan/specflow 1.4.4-beta → 1.5.0-beta
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/README.md +2 -2
- package/dist/cli/commands/approval-assemble.d.ts +21 -0
- package/dist/cli/commands/approval-assemble.js +95 -0
- package/dist/cli/index.js +2 -0
- package/dist/core/approval/assemble.d.ts +10 -0
- package/dist/core/approval/assemble.js +391 -0
- package/dist/core/approval/index-schema.d.ts +473 -0
- package/dist/core/approval/index-schema.js +146 -0
- package/dist/core/approval/index.d.ts +4 -0
- package/dist/core/approval/index.js +3 -0
- package/dist/core/approval/paths.d.ts +8 -0
- package/dist/core/approval/paths.js +28 -0
- package/dist/core/approval/types.d.ts +120 -0
- package/dist/core/approval/types.js +1 -0
- package/package.json +1 -1
- package/prompts/approval/api-guidance.md +179 -0
- package/prompts/approval/generate.md +224 -153
- package/prompts/approval/multi-repo-guidance.md +202 -0
- package/prompts/approval/project-conventions-guidance.md +1 -1
- package/prompts/approval/segmented-generation.md +151 -0
- package/skills/GUIDANCE_PACKS.md +3 -1
- package/skills/specflow-approval/SKILL.md +132 -16
- package/templates/approval-index.yaml +72 -0
- package/templates/approval-part.md +15 -0
package/README.md
CHANGED
|
@@ -126,7 +126,7 @@ npm install -g @gordon.gan/specflow
|
|
|
126
126
|
npm install -g github:Gordon-Gan-Jiang/specflow
|
|
127
127
|
|
|
128
128
|
# 验证
|
|
129
|
-
specflow --version # 以 npm / package.json 为准(当前 1.
|
|
129
|
+
specflow --version # 以 npm / package.json 为准(当前 1.5.0-beta)
|
|
130
130
|
specflow --help
|
|
131
131
|
```
|
|
132
132
|
|
|
@@ -282,7 +282,7 @@ specflow init
|
|
|
282
282
|
| **explore** | 读代码、比方案、定边界;产出 `explore.md`,confirmed 后 handoff 到 propose |
|
|
283
283
|
| **propose** | 一次产出 proposal、delta specs、design、tasks(第一轮深度思考,非占位骨架);叙述语言跟 `artifacts.language` |
|
|
284
284
|
| **refine** | 内部多轮循环(≥2 轮,AI 判断收敛);可更新任意 artifact |
|
|
285
|
-
| **approval** | refine 后可选:AI 对四件套做 7 维闭环性检查(含代码落地性、基线对照)+ 设计质量评估 + 可实施性评估,生成 `approval.md`
|
|
285
|
+
| **approval** | refine 后可选:AI 对四件套做 7 维闭环性检查(含代码落地性、基线对照)+ 设计质量评估 + 可实施性评估,生成 `approval.md` 供人工审批;非轻量变更分片写入 `approval/parts/` 后 `specflow approval assemble` 拼接;不改 phase,不阻塞 apply |
|
|
286
286
|
| **apply** | Phase A 重写 tasks.md;Phase B 按语言路由 ECC 审查 + TDD 逐任务执行;可选 `--yes` |
|
|
287
287
|
| **review** | 代码审查,对照 specs 检查回归 |
|
|
288
288
|
| **test** | 单元 + 集成 + E2E + 回归测试 |
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI: specflow approval assemble | check
|
|
3
|
+
*
|
|
4
|
+
* Deterministic Reduce step for segmented approval generation.
|
|
5
|
+
* No LLM — merges approval/parts/*.md per approval/index.yaml.
|
|
6
|
+
*/
|
|
7
|
+
import type { Command } from 'commander';
|
|
8
|
+
export interface ApprovalAssembleCommandOptions {
|
|
9
|
+
readonly store?: string;
|
|
10
|
+
readonly cwd?: string;
|
|
11
|
+
readonly force?: boolean;
|
|
12
|
+
readonly json?: boolean;
|
|
13
|
+
readonly noLazyCheck?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function runApprovalAssemble(changeName: string, options: ApprovalAssembleCommandOptions & {
|
|
16
|
+
checkOnly?: boolean;
|
|
17
|
+
}): Promise<{
|
|
18
|
+
exitCode: number;
|
|
19
|
+
payload: unknown;
|
|
20
|
+
}>;
|
|
21
|
+
export declare function registerApprovalAssembleCommand(program: Command): void;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI: specflow approval assemble | check
|
|
3
|
+
*
|
|
4
|
+
* Deterministic Reduce step for segmented approval generation.
|
|
5
|
+
* No LLM — merges approval/parts/*.md per approval/index.yaml.
|
|
6
|
+
*/
|
|
7
|
+
import { promises as fs } from 'node:fs';
|
|
8
|
+
import { addStoreOption, resolveRootFromCommandOptions } from '../shared/store-option.js';
|
|
9
|
+
import { validateChangeName } from '../../utils/change-utils.js';
|
|
10
|
+
import { assembleApprovalDocument, getChangeDirectory, getApprovalOutputPath, } from '../../core/approval/index.js';
|
|
11
|
+
export async function runApprovalAssemble(changeName, options) {
|
|
12
|
+
validateChangeName(changeName);
|
|
13
|
+
const projectRoot = await resolveRootFromCommandOptions({
|
|
14
|
+
store: options.store,
|
|
15
|
+
cwd: options.cwd,
|
|
16
|
+
});
|
|
17
|
+
const changeDir = getChangeDirectory(projectRoot, changeName);
|
|
18
|
+
try {
|
|
19
|
+
await fs.access(changeDir);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw new Error(`Change "${changeName}" not found at ${changeDir}`);
|
|
23
|
+
}
|
|
24
|
+
const result = await assembleApprovalDocument({
|
|
25
|
+
changeDir,
|
|
26
|
+
checkOnly: options.checkOnly ?? false,
|
|
27
|
+
force: options.force ?? false,
|
|
28
|
+
validateLazy: !options.noLazyCheck,
|
|
29
|
+
});
|
|
30
|
+
const payload = {
|
|
31
|
+
ok: result.ok,
|
|
32
|
+
change: changeName,
|
|
33
|
+
output: result.outputPath ?? getApprovalOutputPath(changeDir),
|
|
34
|
+
check_only: options.checkOnly ?? false,
|
|
35
|
+
diagnostics: result.diagnostics,
|
|
36
|
+
};
|
|
37
|
+
if (options.json) {
|
|
38
|
+
return { exitCode: result.ok ? 0 : 1, payload };
|
|
39
|
+
}
|
|
40
|
+
if (result.diagnostics.length > 0) {
|
|
41
|
+
for (const d of result.diagnostics) {
|
|
42
|
+
const prefix = d.severity === 'error' ? 'ERROR' : 'WARN';
|
|
43
|
+
const part = d.part ? ` [${d.part}]` : '';
|
|
44
|
+
console.error(`${prefix}${part}: ${d.message}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (result.ok) {
|
|
48
|
+
if (options.checkOnly) {
|
|
49
|
+
console.info(`Check passed for change "${changeName}" (parts ready to assemble).`);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
console.info(`Wrote ${result.outputPath}`);
|
|
53
|
+
}
|
|
54
|
+
return { exitCode: 0, payload };
|
|
55
|
+
}
|
|
56
|
+
return { exitCode: 1, payload };
|
|
57
|
+
}
|
|
58
|
+
export function registerApprovalAssembleCommand(program) {
|
|
59
|
+
const approval = program.command('approval').description('Approval document utilities');
|
|
60
|
+
addStoreOption(approval
|
|
61
|
+
.command('assemble <change>')
|
|
62
|
+
.description('Merge approval/parts into approval.md (deterministic, no LLM)')
|
|
63
|
+
.option('--force', 'Overwrite existing approval.md')
|
|
64
|
+
.option('--json', 'Output result as JSON')
|
|
65
|
+
.option('--no-lazy-check', 'Skip stub/lazy content validation on parts')
|
|
66
|
+
.action(async (change, opts) => {
|
|
67
|
+
const { exitCode, payload } = await runApprovalAssemble(change, {
|
|
68
|
+
...opts,
|
|
69
|
+
checkOnly: false,
|
|
70
|
+
});
|
|
71
|
+
if (opts.json) {
|
|
72
|
+
console.info(JSON.stringify(payload, null, 2));
|
|
73
|
+
}
|
|
74
|
+
if (exitCode !== 0) {
|
|
75
|
+
process.exitCode = exitCode;
|
|
76
|
+
}
|
|
77
|
+
}));
|
|
78
|
+
addStoreOption(approval
|
|
79
|
+
.command('check <change>')
|
|
80
|
+
.description('Validate approval parts and index without writing approval.md')
|
|
81
|
+
.option('--json', 'Output result as JSON')
|
|
82
|
+
.option('--no-lazy-check', 'Skip stub/lazy content validation on parts')
|
|
83
|
+
.action(async (change, opts) => {
|
|
84
|
+
const { exitCode, payload } = await runApprovalAssemble(change, {
|
|
85
|
+
...opts,
|
|
86
|
+
checkOnly: true,
|
|
87
|
+
});
|
|
88
|
+
if (opts.json) {
|
|
89
|
+
console.info(JSON.stringify(payload, null, 2));
|
|
90
|
+
}
|
|
91
|
+
if (exitCode !== 0) {
|
|
92
|
+
process.exitCode = exitCode;
|
|
93
|
+
}
|
|
94
|
+
}));
|
|
95
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import { registerStoreCommand } from './commands/store.js';
|
|
|
16
16
|
import { registerShowCommand } from './commands/show.js';
|
|
17
17
|
import { registerContextCommand } from './commands/context.js';
|
|
18
18
|
import { registerWorksetCommand } from './commands/workset.js';
|
|
19
|
+
import { registerApprovalAssembleCommand } from './commands/approval-assemble.js';
|
|
19
20
|
const __filename = fileURLToPath(import.meta.url);
|
|
20
21
|
const __dirname = dirname(__filename);
|
|
21
22
|
const pkg = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf-8'));
|
|
@@ -43,6 +44,7 @@ registerStoreCommand(program);
|
|
|
43
44
|
registerShowCommand(program);
|
|
44
45
|
registerContextCommand(program);
|
|
45
46
|
registerWorksetCommand(program);
|
|
47
|
+
registerApprovalAssembleCommand(program);
|
|
46
48
|
program.exitOverride();
|
|
47
49
|
try {
|
|
48
50
|
await program.parseAsync();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ApprovalAssembleResult, ApprovalIndex, ApprovalManifest } from './types.js';
|
|
2
|
+
export declare function readApprovalIndex(changeDir: string): Promise<ApprovalIndex>;
|
|
3
|
+
export declare function readApprovalManifest(changeDir: string): Promise<ApprovalManifest | null>;
|
|
4
|
+
export interface AssembleApprovalOptions {
|
|
5
|
+
readonly changeDir: string;
|
|
6
|
+
readonly checkOnly?: boolean;
|
|
7
|
+
readonly force?: boolean;
|
|
8
|
+
readonly validateLazy?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function assembleApprovalDocument(options: AssembleApprovalOptions): Promise<ApprovalAssembleResult>;
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import { parseApprovalIndex } from './index-schema.js';
|
|
5
|
+
import { getApprovalIndexPath, getApprovalManifestPath, getApprovalOutputPath, getApprovalPartPath, getApprovalPartsDir, } from './paths.js';
|
|
6
|
+
const OPTIONAL_PREFIXES = {
|
|
7
|
+
s5: ['05-', '05.'],
|
|
8
|
+
s7: ['07-', '07.'],
|
|
9
|
+
s8: ['08-', '08.'],
|
|
10
|
+
};
|
|
11
|
+
const CHAPTER_HEADERS = {
|
|
12
|
+
'01-intro': '## 1. 绪论与边界 (Introduction & Boundaries)',
|
|
13
|
+
'02-design-review': '## 2. 技术方案评估 (Technical Design Review)',
|
|
14
|
+
'03-architecture': '## 3. 架构整体设计 (Architecture Design)',
|
|
15
|
+
'05-acceptance': '## 5. 验收标准 (Acceptance Criteria)',
|
|
16
|
+
'06-test': '## 6. 测试策略 (Test Strategy)',
|
|
17
|
+
'07-deploy': '## 7. 部署/发布/回滚方案 (Deployment & Release)',
|
|
18
|
+
'08-closed-loop': '## 8. 闭环性检查 (Closed-Loop Verification)',
|
|
19
|
+
'09-implementability': '## 9. 可实施性评估 (Implementability Assessment)',
|
|
20
|
+
'10-signoff': '## 10. 审批意见 (Approval Decision)',
|
|
21
|
+
'appendix-a': '## 附录 A: 产物溯源',
|
|
22
|
+
};
|
|
23
|
+
const SECTION4_HEADER = '## 4. 方案详细设计 (Detailed Design)';
|
|
24
|
+
const LAZY_PATTERNS = [
|
|
25
|
+
/\bTODO\b/i,
|
|
26
|
+
/待补充/,
|
|
27
|
+
/待 refine 澄清(?!.*\[)/,
|
|
28
|
+
/此处省略/,
|
|
29
|
+
/详见\s*(?:上文|下文|附件)(?!.*§)/,
|
|
30
|
+
/略\s*[。.]?$/,
|
|
31
|
+
/TBD\s*[。.]?$/,
|
|
32
|
+
/(?:^|\n)\s*\.{3}\s*(?:\n|$)/,
|
|
33
|
+
/(?:^|\n)\s*(?:同上|同前)\s*[。.]?\s*(?:\n|$)/,
|
|
34
|
+
/实现时(?:命名|对齐)/,
|
|
35
|
+
/(?:RPC|rpc).*暂定/,
|
|
36
|
+
/暂定\s*[`']?\w+[`']?/,
|
|
37
|
+
/\b如\s+[A-Z]\w*(?:Result|Request|Response)/,
|
|
38
|
+
/内部(?:经|调用)\s*I\d+[^.\n]{0,40}(?:一行|代替|省略)/,
|
|
39
|
+
];
|
|
40
|
+
const MIN_PART_BYTES = {
|
|
41
|
+
'04.4': 200,
|
|
42
|
+
'04.5': 200,
|
|
43
|
+
'04.6': 150,
|
|
44
|
+
'05-': 100,
|
|
45
|
+
};
|
|
46
|
+
function sha256(content) {
|
|
47
|
+
return createHash('sha256').update(content, 'utf8').digest('hex');
|
|
48
|
+
}
|
|
49
|
+
function isOptionalPart(partId, optional) {
|
|
50
|
+
if (OPTIONAL_PREFIXES.s5.some((p) => partId.startsWith(p))) {
|
|
51
|
+
return !optional.s5;
|
|
52
|
+
}
|
|
53
|
+
if (OPTIONAL_PREFIXES.s7.some((p) => partId.startsWith(p))) {
|
|
54
|
+
return !optional.s7;
|
|
55
|
+
}
|
|
56
|
+
if (OPTIONAL_PREFIXES.s8.some((p) => partId.startsWith(p))) {
|
|
57
|
+
return !optional.s8;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
function isSection4Part(partId) {
|
|
62
|
+
return partId === '04-detail-core' || partId.startsWith('04.');
|
|
63
|
+
}
|
|
64
|
+
function headerForPart(partId) {
|
|
65
|
+
if (isSection4Part(partId)) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
if (CHAPTER_HEADERS[partId]) {
|
|
69
|
+
return CHAPTER_HEADERS[partId];
|
|
70
|
+
}
|
|
71
|
+
for (const [key, header] of Object.entries(CHAPTER_HEADERS)) {
|
|
72
|
+
if (partId.startsWith(key)) {
|
|
73
|
+
return header;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
function detectLazyContent(partId, content) {
|
|
79
|
+
const trimmed = content.trim();
|
|
80
|
+
if (trimmed.length < 20) {
|
|
81
|
+
return 'part content too short (< 20 chars)';
|
|
82
|
+
}
|
|
83
|
+
for (const prefix of Object.keys(MIN_PART_BYTES)) {
|
|
84
|
+
if (partId.startsWith(prefix) && trimmed.length < MIN_PART_BYTES[prefix]) {
|
|
85
|
+
return `part content below minimum for ${prefix} (${trimmed.length} bytes)`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const pattern of LAZY_PATTERNS) {
|
|
89
|
+
if (pattern.test(trimmed)) {
|
|
90
|
+
return `lazy/stub pattern detected: ${pattern.source}`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (/^#+\s*4\.\s/m.test(trimmed)) {
|
|
94
|
+
return 'part must not contain top-level "## 4." heading (assemble injects section 4)';
|
|
95
|
+
}
|
|
96
|
+
if (/^##\s+[1-9]\d?\./m.test(trimmed) && !partId.startsWith('05-c')) {
|
|
97
|
+
const match = trimmed.match(/^##\s+([1-9]\d?)\./m);
|
|
98
|
+
if (match && !partId.startsWith(`${match[1].padStart(2, '0')}`)) {
|
|
99
|
+
return 'part must not inject foreign chapter "## N." headings';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
function buildDocumentHeader(index) {
|
|
105
|
+
const meta = index.meta ?? {};
|
|
106
|
+
const lang = meta.language ?? 'en';
|
|
107
|
+
const stack = meta.tech_stack ?? 'unknown';
|
|
108
|
+
const projectMode = meta.project_mode ?? 'brownfield';
|
|
109
|
+
const modeNote = index.mode === 'segmented' ? 'segmented+assemble' : 'monolithic';
|
|
110
|
+
let multiRepoBlock = '';
|
|
111
|
+
const mr = index.multi_repo;
|
|
112
|
+
if (mr?.enabled && mr.repos.length >= 2) {
|
|
113
|
+
const mode = mr.document_mode ?? 'pending';
|
|
114
|
+
const primary = mr.primary_repo ?? '—';
|
|
115
|
+
const repoLines = mr.repos
|
|
116
|
+
.map((r) => `- ${r.label} \`${r.id}/${r.change}\``)
|
|
117
|
+
.join('\n');
|
|
118
|
+
multiRepoBlock = `
|
|
119
|
+
> **多仓范围** (${mode}${mode === 'unified' ? `, 主仓: ${primary}` : ''}):
|
|
120
|
+
${repoLines}
|
|
121
|
+
`;
|
|
122
|
+
}
|
|
123
|
+
return `# 技术方案审批文档: ${index.change}
|
|
124
|
+
${multiRepoBlock}
|
|
125
|
+
> 本文档由 \`/specflow:approval\` 基于 refine 收敛后的四件套 + 现有代码与 spec 基线生成,
|
|
126
|
+
> 含架构与详细设计等章节,供人工审批使用。AI 闭环/预审结论在对话中反馈,不写入本文。
|
|
127
|
+
> 生成时间: ${index.generated_at} | phase: refined | 产物语言: ${lang} | 技术栈: ${stack} | 项目模式: ${projectMode} | 生成模式: ${modeNote}
|
|
128
|
+
|
|
129
|
+
> **质量红线(必须满足)**:G1 超 5 行流程→图示 · G2 接口须有失败示例 · G3 JSON/新列须有存量填充策略 · G4 须有回滚数据兼容说明 · G5 有 UI 须有页面/路由清单+栈五元组 · G6 关键页须有空/加载/错态。
|
|
130
|
+
> **文风**:通俗中文、缩写首次注解、禁用含糊词;**正文禁止代码腔**(路径/函数名堆砌→可读中文)。
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
`;
|
|
134
|
+
}
|
|
135
|
+
function buildAppendix() {
|
|
136
|
+
return `
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## 附录 A: 产物溯源
|
|
140
|
+
|
|
141
|
+
| 章节 | 数据来源 | 处理方式 |
|
|
142
|
+
|------|---------|---------|
|
|
143
|
+
| 绪论与边界 | proposal + explore(可选) | AI 提炼 |
|
|
144
|
+
| 技术方案评估 | design + 用户确认选型(若有) | 约束 + 技术选型 + 决策/风险/质量 |
|
|
145
|
+
| 架构 / 详细设计 | design + specs + 锚点 + 约定 | 图+要点+数据/接口/前端 |
|
|
146
|
+
| 验收标准 | specs | **可选** |
|
|
147
|
+
| 测试策略 | §5 或 specs 名 | 分层矩阵 |
|
|
148
|
+
| 部署/闭环 | 运行环境 / Pass 1–7 | **可选** |
|
|
149
|
+
| 可实施性 | 四件套+代码 | 写入文档 |
|
|
150
|
+
| 审批意见 | 人工 | 仅签字栏 |
|
|
151
|
+
`;
|
|
152
|
+
}
|
|
153
|
+
export async function readApprovalIndex(changeDir) {
|
|
154
|
+
const raw = yaml.load(await fs.readFile(getApprovalIndexPath(changeDir), 'utf-8'));
|
|
155
|
+
return parseApprovalIndex(raw);
|
|
156
|
+
}
|
|
157
|
+
export async function readApprovalManifest(changeDir) {
|
|
158
|
+
const path = getApprovalManifestPath(changeDir);
|
|
159
|
+
try {
|
|
160
|
+
const raw = JSON.parse(await fs.readFile(path, 'utf-8'));
|
|
161
|
+
return raw;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export async function assembleApprovalDocument(options) {
|
|
168
|
+
const diagnostics = [];
|
|
169
|
+
const { changeDir, checkOnly = false, force = false, validateLazy = true } = options;
|
|
170
|
+
let index;
|
|
171
|
+
try {
|
|
172
|
+
index = await readApprovalIndex(changeDir);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
diagnostics: [
|
|
179
|
+
{
|
|
180
|
+
code: 'missing_index',
|
|
181
|
+
severity: 'error',
|
|
182
|
+
message: `Cannot read approval/index.yaml: ${message}`,
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (index.multi_repo?.enabled) {
|
|
188
|
+
const mr = index.multi_repo;
|
|
189
|
+
if (!mr.document_mode) {
|
|
190
|
+
diagnostics.push({
|
|
191
|
+
code: 'multi_repo_mode_missing',
|
|
192
|
+
severity: 'error',
|
|
193
|
+
message: 'multi_repo.enabled but document_mode not set; ask user unified vs per_repo before assemble',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (mr.document_mode === 'unified') {
|
|
197
|
+
if (!mr.primary_repo) {
|
|
198
|
+
diagnostics.push({
|
|
199
|
+
code: 'multi_repo_primary_missing',
|
|
200
|
+
severity: 'error',
|
|
201
|
+
message: 'multi_repo.document_mode=unified requires primary_repo',
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
const primary = mr.repos.find((r) => r.id === mr.primary_repo);
|
|
206
|
+
if (primary && primary.change !== index.change) {
|
|
207
|
+
diagnostics.push({
|
|
208
|
+
code: 'multi_repo_change_mismatch',
|
|
209
|
+
severity: 'warning',
|
|
210
|
+
message: `index.change "${index.change}" differs from primary repo change "${primary.change}"; assemble in primary planning root`,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (mr.document_mode === 'per_repo' && !mr.outputs?.per_repo?.length) {
|
|
216
|
+
diagnostics.push({
|
|
217
|
+
code: 'multi_repo_outputs_missing',
|
|
218
|
+
severity: 'warning',
|
|
219
|
+
message: 'per_repo mode should list outputs.per_repo[] with each repo approval path',
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const partsDir = getApprovalPartsDir(changeDir);
|
|
224
|
+
try {
|
|
225
|
+
await fs.access(partsDir);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
diagnostics.push({
|
|
229
|
+
code: 'missing_parts_dir',
|
|
230
|
+
severity: 'error',
|
|
231
|
+
message: `Missing approval/parts/ directory at ${partsDir}`,
|
|
232
|
+
});
|
|
233
|
+
return { ok: false, diagnostics };
|
|
234
|
+
}
|
|
235
|
+
const partContents = new Map();
|
|
236
|
+
let section4Opened = false;
|
|
237
|
+
const body = [buildDocumentHeader(index)];
|
|
238
|
+
for (const partId of index.parts_order) {
|
|
239
|
+
if (isOptionalPart(partId, index.optional)) {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const partPath = getApprovalPartPath(changeDir, partId);
|
|
243
|
+
let content;
|
|
244
|
+
try {
|
|
245
|
+
content = await fs.readFile(partPath, 'utf-8');
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
diagnostics.push({
|
|
249
|
+
code: 'missing_part',
|
|
250
|
+
severity: 'error',
|
|
251
|
+
message: `Missing part file for "${partId}" at ${partPath}`,
|
|
252
|
+
part: partId,
|
|
253
|
+
});
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (validateLazy) {
|
|
257
|
+
const lazy = detectLazyContent(partId, content);
|
|
258
|
+
if (lazy) {
|
|
259
|
+
diagnostics.push({
|
|
260
|
+
code: 'lazy_part_content',
|
|
261
|
+
severity: 'error',
|
|
262
|
+
message: `Part "${partId}": ${lazy}`,
|
|
263
|
+
part: partId,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
partContents.set(partId, content);
|
|
268
|
+
if (isSection4Part(partId)) {
|
|
269
|
+
if (!section4Opened) {
|
|
270
|
+
body.push(SECTION4_HEADER, '', '---', '');
|
|
271
|
+
section4Opened = true;
|
|
272
|
+
}
|
|
273
|
+
body.push(content.trim(), '');
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const header = headerForPart(partId);
|
|
277
|
+
if (header) {
|
|
278
|
+
body.push(header, '');
|
|
279
|
+
}
|
|
280
|
+
body.push(content.trim(), '', '---', '');
|
|
281
|
+
}
|
|
282
|
+
if (!index.parts_order.includes('appendix-a')) {
|
|
283
|
+
body.push(buildAppendix().trim());
|
|
284
|
+
}
|
|
285
|
+
for (const table of index.tables) {
|
|
286
|
+
if (!partContents.has(table.part)) {
|
|
287
|
+
diagnostics.push({
|
|
288
|
+
code: 'id_part_mismatch',
|
|
289
|
+
severity: 'error',
|
|
290
|
+
message: `Table "${table.id}" references missing part "${table.part}"`,
|
|
291
|
+
part: table.part,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
for (const iface of index.interfaces) {
|
|
296
|
+
if (!partContents.has(iface.part)) {
|
|
297
|
+
diagnostics.push({
|
|
298
|
+
code: 'id_part_mismatch',
|
|
299
|
+
severity: 'error',
|
|
300
|
+
message: `Interface "${iface.id}" references missing part "${iface.part}"`,
|
|
301
|
+
part: iface.part,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (index.ui_in_scope) {
|
|
306
|
+
for (const page of index.pages) {
|
|
307
|
+
if (!partContents.has(page.part)) {
|
|
308
|
+
diagnostics.push({
|
|
309
|
+
code: 'id_part_mismatch',
|
|
310
|
+
severity: 'error',
|
|
311
|
+
message: `Page "${page.id}" references missing part "${page.part}"`,
|
|
312
|
+
part: page.part,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
for (const partId of index.parts_order) {
|
|
318
|
+
if (isOptionalPart(partId, index.optional)) {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (!partContents.has(partId)) {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const diskParts = await fs.readdir(partsDir);
|
|
326
|
+
for (const file of diskParts) {
|
|
327
|
+
if (!file.endsWith('.md')) {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const id = file.replace(/\.md$/, '');
|
|
331
|
+
const optionalSkipped = isOptionalPart(id, index.optional);
|
|
332
|
+
const inOrder = index.parts_order.includes(id);
|
|
333
|
+
if (!inOrder && !optionalSkipped) {
|
|
334
|
+
diagnostics.push({
|
|
335
|
+
code: 'orphan_part',
|
|
336
|
+
severity: 'warning',
|
|
337
|
+
message: `Part file "${file}" exists but is not listed in index.parts_order`,
|
|
338
|
+
part: id,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
if (optionalSkipped && inOrder) {
|
|
342
|
+
diagnostics.push({
|
|
343
|
+
code: 'optional_present_but_disabled',
|
|
344
|
+
severity: 'warning',
|
|
345
|
+
message: `Optional chapter disabled but part "${id}" is still in parts_order`,
|
|
346
|
+
part: id,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const hasError = diagnostics.some((d) => d.severity === 'error');
|
|
351
|
+
if (hasError) {
|
|
352
|
+
return { ok: false, diagnostics };
|
|
353
|
+
}
|
|
354
|
+
const markdown = body.join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
|
|
355
|
+
const outputPath = getApprovalOutputPath(changeDir);
|
|
356
|
+
if (checkOnly) {
|
|
357
|
+
return { ok: true, markdown, outputPath, diagnostics };
|
|
358
|
+
}
|
|
359
|
+
if (!force) {
|
|
360
|
+
try {
|
|
361
|
+
await fs.access(outputPath);
|
|
362
|
+
diagnostics.push({
|
|
363
|
+
code: 'output_exists',
|
|
364
|
+
severity: 'warning',
|
|
365
|
+
message: `${outputPath} already exists; use --force to overwrite`,
|
|
366
|
+
});
|
|
367
|
+
return { ok: false, diagnostics, markdown, outputPath };
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
// ok to write
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
await fs.writeFile(outputPath, markdown, 'utf-8');
|
|
374
|
+
const manifest = {
|
|
375
|
+
schema: 'specflow.approval.manifest/v1',
|
|
376
|
+
change: index.change,
|
|
377
|
+
parts: index.parts_order
|
|
378
|
+
.filter((id) => partContents.has(id))
|
|
379
|
+
.map((id) => ({
|
|
380
|
+
id,
|
|
381
|
+
path: `approval/parts/${id}.md`,
|
|
382
|
+
status: 'ok',
|
|
383
|
+
sha256: sha256(partContents.get(id)),
|
|
384
|
+
updated_at: index.generated_at,
|
|
385
|
+
})),
|
|
386
|
+
assembled_at: new Date().toISOString(),
|
|
387
|
+
approval_sha256: sha256(markdown),
|
|
388
|
+
};
|
|
389
|
+
await fs.writeFile(getApprovalManifestPath(changeDir), JSON.stringify(manifest, null, 2), 'utf-8');
|
|
390
|
+
return { ok: true, markdown, outputPath, diagnostics };
|
|
391
|
+
}
|