@gordon.gan/specflow 1.4.6-beta → 1.7.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 +48 -5
- package/dist/cli/commands/approval-assemble.js +347 -34
- package/dist/core/approval/assemble.js +64 -17
- package/dist/core/approval/bundle.d.ts +9 -0
- package/dist/core/approval/bundle.js +172 -0
- package/dist/core/approval/forbidden-patterns.d.ts +6 -0
- package/dist/core/approval/forbidden-patterns.js +37 -0
- package/dist/core/approval/index-schema.d.ts +432 -0
- package/dist/core/approval/index-schema.js +103 -0
- package/dist/core/approval/index.d.ts +10 -2
- package/dist/core/approval/index.js +7 -1
- package/dist/core/approval/lint.d.ts +10 -0
- package/dist/core/approval/lint.js +302 -0
- package/dist/core/approval/paths.d.ts +5 -0
- package/dist/core/approval/paths.js +15 -0
- package/dist/core/approval/pipeline.d.ts +28 -0
- package/dist/core/approval/pipeline.js +146 -0
- package/dist/core/approval/playbook-schema.d.ts +182 -0
- package/dist/core/approval/playbook-schema.js +51 -0
- package/dist/core/approval/render.d.ts +20 -0
- package/dist/core/approval/render.js +210 -0
- package/dist/core/approval/review-pack.d.ts +26 -0
- package/dist/core/approval/review-pack.js +205 -0
- package/dist/core/approval/types.d.ts +131 -0
- package/dist/integrations/shared/capability-evidence.js +2 -0
- package/dist/integrations/shared/parity-manifest.js +2 -0
- package/package.json +2 -1
- package/prompts/approval/acp-pipeline.md +104 -0
- package/prompts/approval/ai-review.md +145 -0
- package/prompts/approval/api-guidance.md +179 -0
- package/prompts/approval/generate.md +164 -13
- package/prompts/approval/multi-repo-guidance.md +238 -0
- package/prompts/approval/project-conventions-guidance.md +1 -1
- package/prompts/approval/runtime-guidance.md +64 -0
- package/prompts/approval/segmented-generation.md +23 -11
- package/skills/GUIDANCE_PACKS.md +2 -2
- package/skills/specflow-approval/SKILL.md +80 -18
- package/templates/approval-index.yaml +41 -0
- package/templates/approval-part.md +1 -1
- package/templates/approval-playbook.yaml +28 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ApprovalPlaybook } from './types.js';
|
|
3
|
+
export declare const approvalPlaybookSchema: z.ZodObject<{
|
|
4
|
+
schema: z.ZodLiteral<"specflow.approval.playbook/v1">;
|
|
5
|
+
change: z.ZodString;
|
|
6
|
+
title: z.ZodString;
|
|
7
|
+
primary_repo: z.ZodString;
|
|
8
|
+
sources: z.ZodArray<z.ZodObject<{
|
|
9
|
+
repo: z.ZodString;
|
|
10
|
+
change: z.ZodString;
|
|
11
|
+
include_parts: z.ZodOptional<z.ZodUnion<[z.ZodLiteral<"all">, z.ZodArray<z.ZodString, "many">]>>;
|
|
12
|
+
root: z.ZodOptional<z.ZodString>;
|
|
13
|
+
}, "strip", z.ZodTypeAny, {
|
|
14
|
+
repo: string;
|
|
15
|
+
change: string;
|
|
16
|
+
root?: string | undefined;
|
|
17
|
+
include_parts?: string[] | "all" | undefined;
|
|
18
|
+
}, {
|
|
19
|
+
repo: string;
|
|
20
|
+
change: string;
|
|
21
|
+
root?: string | undefined;
|
|
22
|
+
include_parts?: string[] | "all" | undefined;
|
|
23
|
+
}>, "many">;
|
|
24
|
+
overlay: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
25
|
+
assemble: z.ZodObject<{
|
|
26
|
+
inject_headers: z.ZodOptional<z.ZodBoolean>;
|
|
27
|
+
dedupe_appendix: z.ZodOptional<z.ZodBoolean>;
|
|
28
|
+
output: z.ZodString;
|
|
29
|
+
readonly: z.ZodOptional<z.ZodBoolean>;
|
|
30
|
+
}, "strip", z.ZodTypeAny, {
|
|
31
|
+
output: string;
|
|
32
|
+
readonly?: boolean | undefined;
|
|
33
|
+
inject_headers?: boolean | undefined;
|
|
34
|
+
dedupe_appendix?: boolean | undefined;
|
|
35
|
+
}, {
|
|
36
|
+
output: string;
|
|
37
|
+
readonly?: boolean | undefined;
|
|
38
|
+
inject_headers?: boolean | undefined;
|
|
39
|
+
dedupe_appendix?: boolean | undefined;
|
|
40
|
+
}>;
|
|
41
|
+
}, "strip", z.ZodTypeAny, {
|
|
42
|
+
schema: "specflow.approval.playbook/v1";
|
|
43
|
+
change: string;
|
|
44
|
+
primary_repo: string;
|
|
45
|
+
title: string;
|
|
46
|
+
sources: {
|
|
47
|
+
repo: string;
|
|
48
|
+
change: string;
|
|
49
|
+
root?: string | undefined;
|
|
50
|
+
include_parts?: string[] | "all" | undefined;
|
|
51
|
+
}[];
|
|
52
|
+
assemble: {
|
|
53
|
+
output: string;
|
|
54
|
+
readonly?: boolean | undefined;
|
|
55
|
+
inject_headers?: boolean | undefined;
|
|
56
|
+
dedupe_appendix?: boolean | undefined;
|
|
57
|
+
};
|
|
58
|
+
overlay?: string[] | undefined;
|
|
59
|
+
}, {
|
|
60
|
+
schema: "specflow.approval.playbook/v1";
|
|
61
|
+
change: string;
|
|
62
|
+
primary_repo: string;
|
|
63
|
+
title: string;
|
|
64
|
+
sources: {
|
|
65
|
+
repo: string;
|
|
66
|
+
change: string;
|
|
67
|
+
root?: string | undefined;
|
|
68
|
+
include_parts?: string[] | "all" | undefined;
|
|
69
|
+
}[];
|
|
70
|
+
assemble: {
|
|
71
|
+
output: string;
|
|
72
|
+
readonly?: boolean | undefined;
|
|
73
|
+
inject_headers?: boolean | undefined;
|
|
74
|
+
dedupe_appendix?: boolean | undefined;
|
|
75
|
+
};
|
|
76
|
+
overlay?: string[] | undefined;
|
|
77
|
+
}>;
|
|
78
|
+
export declare const approvalReviewResultSchema: z.ZodObject<{
|
|
79
|
+
schema: z.ZodLiteral<"specflow.approval.review/v1">;
|
|
80
|
+
change: z.ZodString;
|
|
81
|
+
reviewed_at: z.ZodString;
|
|
82
|
+
reviewer: z.ZodEnum<["ai", "human"]>;
|
|
83
|
+
findings: z.ZodArray<z.ZodObject<{
|
|
84
|
+
id: z.ZodString;
|
|
85
|
+
severity: z.ZodEnum<["critical", "important", "minor"]>;
|
|
86
|
+
category: z.ZodString;
|
|
87
|
+
message: z.ZodString;
|
|
88
|
+
part: z.ZodOptional<z.ZodString>;
|
|
89
|
+
status: z.ZodEnum<["open", "fixed", "waived"]>;
|
|
90
|
+
waiver_reason: z.ZodOptional<z.ZodString>;
|
|
91
|
+
}, "strip", z.ZodTypeAny, {
|
|
92
|
+
status: "fixed" | "open" | "waived";
|
|
93
|
+
message: string;
|
|
94
|
+
id: string;
|
|
95
|
+
severity: "critical" | "important" | "minor";
|
|
96
|
+
category: string;
|
|
97
|
+
part?: string | undefined;
|
|
98
|
+
waiver_reason?: string | undefined;
|
|
99
|
+
}, {
|
|
100
|
+
status: "fixed" | "open" | "waived";
|
|
101
|
+
message: string;
|
|
102
|
+
id: string;
|
|
103
|
+
severity: "critical" | "important" | "minor";
|
|
104
|
+
category: string;
|
|
105
|
+
part?: string | undefined;
|
|
106
|
+
waiver_reason?: string | undefined;
|
|
107
|
+
}>, "many">;
|
|
108
|
+
verdict: z.ZodEnum<["pass", "fail", "pass_with_waivers"]>;
|
|
109
|
+
summary: z.ZodOptional<z.ZodString>;
|
|
110
|
+
part_hashes: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
111
|
+
id: z.ZodString;
|
|
112
|
+
sha256: z.ZodString;
|
|
113
|
+
}, "strip", z.ZodTypeAny, {
|
|
114
|
+
id: string;
|
|
115
|
+
sha256: string;
|
|
116
|
+
}, {
|
|
117
|
+
id: string;
|
|
118
|
+
sha256: string;
|
|
119
|
+
}>, "many">>;
|
|
120
|
+
}, "strip", z.ZodTypeAny, {
|
|
121
|
+
schema: "specflow.approval.review/v1";
|
|
122
|
+
change: string;
|
|
123
|
+
reviewed_at: string;
|
|
124
|
+
reviewer: "ai" | "human";
|
|
125
|
+
findings: {
|
|
126
|
+
status: "fixed" | "open" | "waived";
|
|
127
|
+
message: string;
|
|
128
|
+
id: string;
|
|
129
|
+
severity: "critical" | "important" | "minor";
|
|
130
|
+
category: string;
|
|
131
|
+
part?: string | undefined;
|
|
132
|
+
waiver_reason?: string | undefined;
|
|
133
|
+
}[];
|
|
134
|
+
verdict: "fail" | "pass" | "pass_with_waivers";
|
|
135
|
+
part_hashes?: {
|
|
136
|
+
id: string;
|
|
137
|
+
sha256: string;
|
|
138
|
+
}[] | undefined;
|
|
139
|
+
summary?: string | undefined;
|
|
140
|
+
}, {
|
|
141
|
+
schema: "specflow.approval.review/v1";
|
|
142
|
+
change: string;
|
|
143
|
+
reviewed_at: string;
|
|
144
|
+
reviewer: "ai" | "human";
|
|
145
|
+
findings: {
|
|
146
|
+
status: "fixed" | "open" | "waived";
|
|
147
|
+
message: string;
|
|
148
|
+
id: string;
|
|
149
|
+
severity: "critical" | "important" | "minor";
|
|
150
|
+
category: string;
|
|
151
|
+
part?: string | undefined;
|
|
152
|
+
waiver_reason?: string | undefined;
|
|
153
|
+
}[];
|
|
154
|
+
verdict: "fail" | "pass" | "pass_with_waivers";
|
|
155
|
+
part_hashes?: {
|
|
156
|
+
id: string;
|
|
157
|
+
sha256: string;
|
|
158
|
+
}[] | undefined;
|
|
159
|
+
summary?: string | undefined;
|
|
160
|
+
}>;
|
|
161
|
+
export declare function parseApprovalPlaybook(raw: unknown): ApprovalPlaybook;
|
|
162
|
+
export declare function parseApprovalReviewResult(raw: unknown): {
|
|
163
|
+
schema: "specflow.approval.review/v1";
|
|
164
|
+
change: string;
|
|
165
|
+
reviewed_at: string;
|
|
166
|
+
reviewer: "ai" | "human";
|
|
167
|
+
findings: {
|
|
168
|
+
status: "fixed" | "open" | "waived";
|
|
169
|
+
message: string;
|
|
170
|
+
id: string;
|
|
171
|
+
severity: "critical" | "important" | "minor";
|
|
172
|
+
category: string;
|
|
173
|
+
part?: string | undefined;
|
|
174
|
+
waiver_reason?: string | undefined;
|
|
175
|
+
}[];
|
|
176
|
+
verdict: "fail" | "pass" | "pass_with_waivers";
|
|
177
|
+
part_hashes?: {
|
|
178
|
+
id: string;
|
|
179
|
+
sha256: string;
|
|
180
|
+
}[] | undefined;
|
|
181
|
+
summary?: string | undefined;
|
|
182
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const approvalPlaybookSchema = z.object({
|
|
3
|
+
schema: z.literal('specflow.approval.playbook/v1'),
|
|
4
|
+
change: z.string().min(1),
|
|
5
|
+
title: z.string().min(1),
|
|
6
|
+
primary_repo: z.string().min(1),
|
|
7
|
+
sources: z
|
|
8
|
+
.array(z.object({
|
|
9
|
+
repo: z.string().min(1),
|
|
10
|
+
change: z.string().min(1),
|
|
11
|
+
include_parts: z.union([z.literal('all'), z.array(z.string().min(1))]).optional(),
|
|
12
|
+
root: z.string().min(1).optional(),
|
|
13
|
+
}))
|
|
14
|
+
.min(1),
|
|
15
|
+
overlay: z.array(z.string().min(1)).optional(),
|
|
16
|
+
assemble: z.object({
|
|
17
|
+
inject_headers: z.boolean().optional(),
|
|
18
|
+
dedupe_appendix: z.boolean().optional(),
|
|
19
|
+
output: z.string().min(1),
|
|
20
|
+
readonly: z.boolean().optional(),
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
23
|
+
export const approvalReviewResultSchema = z.object({
|
|
24
|
+
schema: z.literal('specflow.approval.review/v1'),
|
|
25
|
+
change: z.string().min(1),
|
|
26
|
+
reviewed_at: z.string().min(1),
|
|
27
|
+
reviewer: z.enum(['ai', 'human']),
|
|
28
|
+
findings: z.array(z.object({
|
|
29
|
+
id: z.string().min(1),
|
|
30
|
+
severity: z.enum(['critical', 'important', 'minor']),
|
|
31
|
+
category: z.string().min(1),
|
|
32
|
+
message: z.string().min(1),
|
|
33
|
+
part: z.string().optional(),
|
|
34
|
+
status: z.enum(['open', 'fixed', 'waived']),
|
|
35
|
+
waiver_reason: z.string().optional(),
|
|
36
|
+
})),
|
|
37
|
+
verdict: z.enum(['pass', 'fail', 'pass_with_waivers']),
|
|
38
|
+
summary: z.string().optional(),
|
|
39
|
+
part_hashes: z
|
|
40
|
+
.array(z.object({
|
|
41
|
+
id: z.string().min(1),
|
|
42
|
+
sha256: z.string().min(64).max(64),
|
|
43
|
+
}))
|
|
44
|
+
.optional(),
|
|
45
|
+
});
|
|
46
|
+
export function parseApprovalPlaybook(raw) {
|
|
47
|
+
return approvalPlaybookSchema.parse(raw);
|
|
48
|
+
}
|
|
49
|
+
export function parseApprovalReviewResult(raw) {
|
|
50
|
+
return approvalReviewResultSchema.parse(raw);
|
|
51
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ApprovalDiagnostic } from './types.js';
|
|
2
|
+
export interface RenderApprovalOptions {
|
|
3
|
+
readonly changeDir: string;
|
|
4
|
+
readonly inputPath?: string;
|
|
5
|
+
readonly outputPath?: string;
|
|
6
|
+
readonly title?: string;
|
|
7
|
+
readonly write?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface RenderApprovalResult {
|
|
10
|
+
readonly ok: boolean;
|
|
11
|
+
readonly outputPath?: string;
|
|
12
|
+
readonly html?: string;
|
|
13
|
+
readonly sha256?: string;
|
|
14
|
+
readonly diagnostics: readonly ApprovalDiagnostic[];
|
|
15
|
+
}
|
|
16
|
+
export declare function renderApprovalDocument(options: RenderApprovalOptions): Promise<RenderApprovalResult>;
|
|
17
|
+
export declare function resolveApprovalRenderPaths(changeDir: string, input?: string, output?: string): {
|
|
18
|
+
inputPath: string;
|
|
19
|
+
outputPath: string;
|
|
20
|
+
};
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { marked } from 'marked';
|
|
5
|
+
import { getApprovalOutputPath } from './paths.js';
|
|
6
|
+
const APPROVAL_HTML_STYLES = `
|
|
7
|
+
:root {
|
|
8
|
+
color-scheme: light dark;
|
|
9
|
+
--text: #1a1a1a;
|
|
10
|
+
--muted: #555;
|
|
11
|
+
--border: #ddd;
|
|
12
|
+
--code-bg: #f6f8fa;
|
|
13
|
+
--quote-bg: #f0f4ff;
|
|
14
|
+
}
|
|
15
|
+
@media (prefers-color-scheme: dark) {
|
|
16
|
+
:root {
|
|
17
|
+
--text: #e6edf3;
|
|
18
|
+
--muted: #9da7b3;
|
|
19
|
+
--border: #30363d;
|
|
20
|
+
--code-bg: #161b22;
|
|
21
|
+
--quote-bg: #1c2333;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
* { box-sizing: border-box; }
|
|
25
|
+
body {
|
|
26
|
+
margin: 0;
|
|
27
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC",
|
|
28
|
+
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
|
29
|
+
line-height: 1.65;
|
|
30
|
+
color: var(--text);
|
|
31
|
+
background: #fff;
|
|
32
|
+
}
|
|
33
|
+
@media (prefers-color-scheme: dark) {
|
|
34
|
+
body { background: #0d1117; }
|
|
35
|
+
}
|
|
36
|
+
.page {
|
|
37
|
+
max-width: 920px;
|
|
38
|
+
margin: 0 auto;
|
|
39
|
+
padding: 2rem 1.5rem 4rem;
|
|
40
|
+
}
|
|
41
|
+
.meta {
|
|
42
|
+
font-size: 0.875rem;
|
|
43
|
+
color: var(--muted);
|
|
44
|
+
margin-bottom: 2rem;
|
|
45
|
+
padding-bottom: 1rem;
|
|
46
|
+
border-bottom: 1px solid var(--border);
|
|
47
|
+
}
|
|
48
|
+
article h1 { font-size: 1.75rem; margin-top: 0; }
|
|
49
|
+
article h2 { font-size: 1.35rem; margin-top: 2rem; border-bottom: 1px solid var(--border); padding-bottom: 0.35rem; }
|
|
50
|
+
article h3 { font-size: 1.15rem; margin-top: 1.5rem; }
|
|
51
|
+
article h4, article h5 { font-size: 1rem; margin-top: 1.25rem; }
|
|
52
|
+
article p, article li { margin: 0.5rem 0; }
|
|
53
|
+
article blockquote {
|
|
54
|
+
margin: 1rem 0;
|
|
55
|
+
padding: 0.75rem 1rem;
|
|
56
|
+
border-left: 4px solid #0969da;
|
|
57
|
+
background: var(--quote-bg);
|
|
58
|
+
color: var(--muted);
|
|
59
|
+
}
|
|
60
|
+
article table {
|
|
61
|
+
width: 100%;
|
|
62
|
+
border-collapse: collapse;
|
|
63
|
+
margin: 1rem 0;
|
|
64
|
+
font-size: 0.92rem;
|
|
65
|
+
}
|
|
66
|
+
article th, article td {
|
|
67
|
+
border: 1px solid var(--border);
|
|
68
|
+
padding: 0.45rem 0.65rem;
|
|
69
|
+
text-align: left;
|
|
70
|
+
}
|
|
71
|
+
article th { background: var(--code-bg); }
|
|
72
|
+
article code {
|
|
73
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
74
|
+
font-size: 0.9em;
|
|
75
|
+
background: var(--code-bg);
|
|
76
|
+
padding: 0.15em 0.35em;
|
|
77
|
+
border-radius: 4px;
|
|
78
|
+
}
|
|
79
|
+
article pre {
|
|
80
|
+
overflow-x: auto;
|
|
81
|
+
padding: 1rem;
|
|
82
|
+
background: var(--code-bg);
|
|
83
|
+
border: 1px solid var(--border);
|
|
84
|
+
border-radius: 6px;
|
|
85
|
+
}
|
|
86
|
+
article pre code { background: none; padding: 0; }
|
|
87
|
+
article hr { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }
|
|
88
|
+
.mermaid {
|
|
89
|
+
margin: 1rem 0;
|
|
90
|
+
text-align: center;
|
|
91
|
+
}
|
|
92
|
+
`.trim();
|
|
93
|
+
function sha256(content) {
|
|
94
|
+
return createHash('sha256').update(content, 'utf8').digest('hex');
|
|
95
|
+
}
|
|
96
|
+
function defaultHtmlPath(markdownPath) {
|
|
97
|
+
if (markdownPath.endsWith('.md')) {
|
|
98
|
+
return markdownPath.slice(0, -3) + '.html';
|
|
99
|
+
}
|
|
100
|
+
return `${markdownPath}.html`;
|
|
101
|
+
}
|
|
102
|
+
function extractTitle(markdown, fallback) {
|
|
103
|
+
const match = markdown.match(/^#\s+(.+)$/m);
|
|
104
|
+
return match?.[1]?.trim() ?? fallback;
|
|
105
|
+
}
|
|
106
|
+
function configureMarked() {
|
|
107
|
+
marked.use({
|
|
108
|
+
gfm: true,
|
|
109
|
+
breaks: false,
|
|
110
|
+
});
|
|
111
|
+
marked.use({
|
|
112
|
+
renderer: {
|
|
113
|
+
code({ text, lang }) {
|
|
114
|
+
if (lang === 'mermaid') {
|
|
115
|
+
return `<pre class="mermaid">${escapeHtml(text)}</pre>`;
|
|
116
|
+
}
|
|
117
|
+
const language = lang ? ` class="language-${lang}"` : '';
|
|
118
|
+
return `<pre><code${language}>${escapeHtml(text)}</code></pre>`;
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
function escapeHtml(value) {
|
|
124
|
+
return value
|
|
125
|
+
.replace(/&/g, '&')
|
|
126
|
+
.replace(/</g, '<')
|
|
127
|
+
.replace(/>/g, '>')
|
|
128
|
+
.replace(/"/g, '"');
|
|
129
|
+
}
|
|
130
|
+
function buildHtmlDocument(title, bodyHtml, sourcePath) {
|
|
131
|
+
const hasMermaid = bodyHtml.includes('class="mermaid"');
|
|
132
|
+
const mermaidScript = hasMermaid
|
|
133
|
+
? `<script type="module">
|
|
134
|
+
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
|
135
|
+
mermaid.initialize({ startOnLoad: true, theme: 'neutral' });
|
|
136
|
+
</script>`
|
|
137
|
+
: '';
|
|
138
|
+
return `<!DOCTYPE html>
|
|
139
|
+
<html lang="zh-CN">
|
|
140
|
+
<head>
|
|
141
|
+
<meta charset="utf-8" />
|
|
142
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
143
|
+
<title>${escapeHtml(title)}</title>
|
|
144
|
+
<style>${APPROVAL_HTML_STYLES}</style>
|
|
145
|
+
</head>
|
|
146
|
+
<body>
|
|
147
|
+
<div class="page">
|
|
148
|
+
<div class="meta">SpecFlow approval render · source: ${escapeHtml(sourcePath)} · generated by <code>specflow approval render</code></div>
|
|
149
|
+
<article>${bodyHtml}</article>
|
|
150
|
+
</div>
|
|
151
|
+
${mermaidScript}
|
|
152
|
+
</body>
|
|
153
|
+
</html>
|
|
154
|
+
`;
|
|
155
|
+
}
|
|
156
|
+
export async function renderApprovalDocument(options) {
|
|
157
|
+
const diagnostics = [];
|
|
158
|
+
const { changeDir, write = true } = options;
|
|
159
|
+
const inputPath = options.inputPath ?? getApprovalOutputPath(changeDir);
|
|
160
|
+
const outputPath = options.outputPath ?? defaultHtmlPath(inputPath);
|
|
161
|
+
let markdown;
|
|
162
|
+
try {
|
|
163
|
+
markdown = await fs.readFile(inputPath, 'utf-8');
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
diagnostics: [
|
|
170
|
+
{
|
|
171
|
+
code: 'render_input_missing',
|
|
172
|
+
severity: 'error',
|
|
173
|
+
message: `Cannot read markdown: ${inputPath} (${message})`,
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
if (markdown.trim().length < 20) {
|
|
179
|
+
diagnostics.push({
|
|
180
|
+
code: 'render_input_empty',
|
|
181
|
+
severity: 'error',
|
|
182
|
+
message: `Markdown input too short or empty: ${inputPath}`,
|
|
183
|
+
});
|
|
184
|
+
return { ok: false, diagnostics };
|
|
185
|
+
}
|
|
186
|
+
configureMarked();
|
|
187
|
+
const bodyHtml = await marked.parse(markdown);
|
|
188
|
+
const title = options.title ?? extractTitle(markdown, 'Approval Document');
|
|
189
|
+
const html = buildHtmlDocument(title, bodyHtml, inputPath);
|
|
190
|
+
const hash = sha256(html);
|
|
191
|
+
if (!write) {
|
|
192
|
+
return { ok: true, outputPath, html, sha256: hash, diagnostics };
|
|
193
|
+
}
|
|
194
|
+
await fs.mkdir(dirname(outputPath), { recursive: true });
|
|
195
|
+
await fs.writeFile(outputPath, html, 'utf-8');
|
|
196
|
+
return { ok: true, outputPath, html, sha256: hash, diagnostics };
|
|
197
|
+
}
|
|
198
|
+
export function resolveApprovalRenderPaths(changeDir, input, output) {
|
|
199
|
+
const inputPath = input
|
|
200
|
+
? input.startsWith('/')
|
|
201
|
+
? input
|
|
202
|
+
: join(changeDir, input)
|
|
203
|
+
: getApprovalOutputPath(changeDir);
|
|
204
|
+
const outputPath = output
|
|
205
|
+
? output.startsWith('/')
|
|
206
|
+
? output
|
|
207
|
+
: join(changeDir, output)
|
|
208
|
+
: defaultHtmlPath(inputPath);
|
|
209
|
+
return { inputPath, outputPath };
|
|
210
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ApprovalDiagnostic, ApprovalReviewPacket, ApprovalReviewResult } from './types.js';
|
|
2
|
+
export declare function computeReviewPartHashes(changeDir: string, partIds: readonly string[]): Promise<{
|
|
3
|
+
id: string;
|
|
4
|
+
sha256: string;
|
|
5
|
+
}[]>;
|
|
6
|
+
export interface ReviewPackOptions {
|
|
7
|
+
readonly changeDir: string;
|
|
8
|
+
}
|
|
9
|
+
export interface ReviewPackResult {
|
|
10
|
+
readonly ok: boolean;
|
|
11
|
+
readonly packet?: ApprovalReviewPacket;
|
|
12
|
+
readonly packetPath?: string;
|
|
13
|
+
readonly diagnostics: readonly ApprovalDiagnostic[];
|
|
14
|
+
}
|
|
15
|
+
export declare function generateReviewPacket(options: ReviewPackOptions): Promise<ReviewPackResult>;
|
|
16
|
+
export interface ReviewCheckOptions {
|
|
17
|
+
readonly changeDir: string;
|
|
18
|
+
readonly requirePass?: boolean;
|
|
19
|
+
readonly forceRequire?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface ReviewCheckResult {
|
|
22
|
+
readonly ok: boolean;
|
|
23
|
+
readonly review?: ApprovalReviewResult;
|
|
24
|
+
readonly diagnostics: readonly ApprovalDiagnostic[];
|
|
25
|
+
}
|
|
26
|
+
export declare function checkApprovalReview(options: ReviewCheckOptions): Promise<ReviewCheckResult>;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { parseApprovalReviewResult } from './playbook-schema.js';
|
|
4
|
+
import { readApprovalIndex } from './assemble.js';
|
|
5
|
+
import { lintApprovalDocument } from './lint.js';
|
|
6
|
+
import { getApprovalPartPath, getApprovalReviewPath, getApprovalReviewPacketPath, } from './paths.js';
|
|
7
|
+
const AI_REVIEW_CHECKLIST = [
|
|
8
|
+
'Cross-part naming: same entity/id canonical across §2, §4, diagrams, index.yaml (no 暂定/TBD for listed ids)',
|
|
9
|
+
'Decision traceability: each §2/decisions[] row reflected with concrete behavior in §4',
|
|
10
|
+
'Inventory ↔ body: every index table/interface/page/design_point has substantive content in its part',
|
|
11
|
+
'Depth parity: sibling parts / feature_slices have comparable depth (no one-line stubs next to full sections)',
|
|
12
|
+
'Cross-reference integrity: refs use stable ids (§4.5 In, P*, Page*, T*); targets exist',
|
|
13
|
+
'Diagram ↔ prose: sequence/flow diagrams match adjacent text (actors, messages, errors)',
|
|
14
|
+
'Contract completeness: listed interfaces/page-apis meet Part E minimum (incl. failure example when api_in_scope)',
|
|
15
|
+
'Enum/state closure: status/enum sets explicit and closed where defined (no open-ended 「等」)',
|
|
16
|
+
'Unclosed branches: every mentioned retry/cancel/timeout/idempotency path has outcome or [待 refine 澄清]',
|
|
17
|
+
'UI ↔ API alignment: §4.6 fields and api refs match §4.5 when ui_in_scope',
|
|
18
|
+
'Multi-repo parity: each in-scope repo slice has proportionate depth when multi_repo.enabled',
|
|
19
|
+
'Stub purge: no TODO/待补充/placeholder-only parts',
|
|
20
|
+
'Verdict coherence: §9 readiness matches §4 depth',
|
|
21
|
+
];
|
|
22
|
+
function sha256(content) {
|
|
23
|
+
return createHash('sha256').update(content, 'utf8').digest('hex');
|
|
24
|
+
}
|
|
25
|
+
export async function computeReviewPartHashes(changeDir, partIds) {
|
|
26
|
+
const hashes = [];
|
|
27
|
+
for (const partId of partIds) {
|
|
28
|
+
try {
|
|
29
|
+
const content = await fs.readFile(getApprovalPartPath(changeDir, partId), 'utf-8');
|
|
30
|
+
hashes.push({ id: partId, sha256: sha256(content) });
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// missing part handled elsewhere
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return hashes;
|
|
37
|
+
}
|
|
38
|
+
export async function generateReviewPacket(options) {
|
|
39
|
+
const diagnostics = [];
|
|
40
|
+
const { changeDir } = options;
|
|
41
|
+
let index;
|
|
42
|
+
try {
|
|
43
|
+
index = await readApprovalIndex(changeDir);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
diagnostics: [
|
|
50
|
+
{ code: 'missing_index', severity: 'error', message: `Cannot read index: ${message}` },
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const lintResult = await lintApprovalDocument({ changeDir, strict: false });
|
|
55
|
+
const parts = [];
|
|
56
|
+
for (const partId of index.parts_order) {
|
|
57
|
+
try {
|
|
58
|
+
const content = await fs.readFile(getApprovalPartPath(changeDir, partId), 'utf-8');
|
|
59
|
+
parts.push({
|
|
60
|
+
id: partId,
|
|
61
|
+
sha256: sha256(content),
|
|
62
|
+
bytes: Buffer.byteLength(content, 'utf8'),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
diagnostics.push({
|
|
67
|
+
code: 'review_part_missing',
|
|
68
|
+
severity: 'warning',
|
|
69
|
+
message: `Part "${partId}" missing for review packet`,
|
|
70
|
+
part: partId,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const packet = {
|
|
75
|
+
schema: 'specflow.approval.review-packet/v1',
|
|
76
|
+
change: index.change,
|
|
77
|
+
generated_at: new Date().toISOString(),
|
|
78
|
+
index_summary: {
|
|
79
|
+
interfaces: index.interfaces.length,
|
|
80
|
+
pages: index.pages.length,
|
|
81
|
+
tables: index.tables.length,
|
|
82
|
+
parts: parts.length,
|
|
83
|
+
},
|
|
84
|
+
lint_diagnostics: lintResult.diagnostics,
|
|
85
|
+
parts,
|
|
86
|
+
checklist: [...AI_REVIEW_CHECKLIST],
|
|
87
|
+
instructions: [
|
|
88
|
+
'Stage 12e AI Review: Read prompts/approval/ai-review.md',
|
|
89
|
+
'1. Read approval/review-packet.json and all parts listed',
|
|
90
|
+
'2. Fix issues in parts (not in assembled approval.md)',
|
|
91
|
+
'3. Write approval/review-result.json with findings + part_hashes (specflow approval review-hashes <change> --json)',
|
|
92
|
+
'4. Re-run: specflow approval finalize <change>',
|
|
93
|
+
].join('\n'),
|
|
94
|
+
};
|
|
95
|
+
const packetPath = getApprovalReviewPacketPath(changeDir);
|
|
96
|
+
await fs.writeFile(packetPath, JSON.stringify(packet, null, 2), 'utf-8');
|
|
97
|
+
return { ok: true, packet, packetPath, diagnostics };
|
|
98
|
+
}
|
|
99
|
+
export async function checkApprovalReview(options) {
|
|
100
|
+
const diagnostics = [];
|
|
101
|
+
const { changeDir, requirePass = true } = options;
|
|
102
|
+
let index;
|
|
103
|
+
try {
|
|
104
|
+
index = await readApprovalIndex(changeDir);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
diagnostics: [
|
|
111
|
+
{ code: 'missing_index', severity: 'error', message: `Cannot read index: ${message}` },
|
|
112
|
+
],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const reviewRequired = options.forceRequire === true ||
|
|
116
|
+
index.acp?.review?.required_pass === true ||
|
|
117
|
+
index.acp?.review?.enabled === true;
|
|
118
|
+
let review;
|
|
119
|
+
try {
|
|
120
|
+
const raw = JSON.parse(await fs.readFile(getApprovalReviewPath(changeDir), 'utf-8'));
|
|
121
|
+
review = parseApprovalReviewResult(raw);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
if (reviewRequired && requirePass) {
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
diagnostics: [
|
|
128
|
+
{
|
|
129
|
+
code: 'review_missing',
|
|
130
|
+
severity: 'error',
|
|
131
|
+
message: 'approval/review-result.json missing; run AI review (Stage 12e) and write review-result.json',
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return { ok: true, diagnostics: [] };
|
|
137
|
+
}
|
|
138
|
+
if (review.change !== index.change) {
|
|
139
|
+
diagnostics.push({
|
|
140
|
+
code: 'review_change_mismatch',
|
|
141
|
+
severity: 'error',
|
|
142
|
+
message: `review-result change "${review.change}" != index change "${index.change}"`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
const openFindings = review.findings.filter((f) => f.status === 'open');
|
|
146
|
+
for (const f of openFindings) {
|
|
147
|
+
diagnostics.push({
|
|
148
|
+
code: 'review_finding_open',
|
|
149
|
+
severity: f.severity === 'critical' ? 'error' : 'warning',
|
|
150
|
+
message: `[${f.id}] ${f.message}`,
|
|
151
|
+
part: f.part,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const waivedWithoutReason = review.findings.filter((f) => f.status === 'waived' && !f.waiver_reason?.trim());
|
|
155
|
+
for (const f of waivedWithoutReason) {
|
|
156
|
+
diagnostics.push({
|
|
157
|
+
code: 'review_waiver_missing_reason',
|
|
158
|
+
severity: 'error',
|
|
159
|
+
message: `[${f.id}] waived finding must include waiver_reason`,
|
|
160
|
+
part: f.part,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
if (review.verdict === 'fail') {
|
|
164
|
+
diagnostics.push({
|
|
165
|
+
code: 'review_verdict_fail',
|
|
166
|
+
severity: 'error',
|
|
167
|
+
message: 'review-result verdict is fail',
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (review.part_hashes && review.part_hashes.length > 0) {
|
|
171
|
+
for (const entry of review.part_hashes) {
|
|
172
|
+
try {
|
|
173
|
+
const content = await fs.readFile(getApprovalPartPath(changeDir, entry.id), 'utf-8');
|
|
174
|
+
const current = sha256(content);
|
|
175
|
+
if (current !== entry.sha256) {
|
|
176
|
+
diagnostics.push({
|
|
177
|
+
code: 'review_part_stale',
|
|
178
|
+
severity: 'error',
|
|
179
|
+
message: `Part "${entry.id}" changed after AI review; re-run Stage 12e and update review-result.json part_hashes`,
|
|
180
|
+
part: entry.id,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
diagnostics.push({
|
|
186
|
+
code: 'review_part_missing',
|
|
187
|
+
severity: 'error',
|
|
188
|
+
message: `Part "${entry.id}" in review-result.part_hashes not found on disk`,
|
|
189
|
+
part: entry.id,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else if (reviewRequired) {
|
|
195
|
+
diagnostics.push({
|
|
196
|
+
code: 'review_part_hashes_missing',
|
|
197
|
+
severity: 'error',
|
|
198
|
+
message: 'review-result.json must include part_hashes[] (sha256 of each part after fixes); see ai-review.md §4',
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
const lintResult = await lintApprovalDocument({ changeDir, strict: true });
|
|
202
|
+
diagnostics.push(...lintResult.diagnostics);
|
|
203
|
+
const hasError = diagnostics.some((d) => d.severity === 'error');
|
|
204
|
+
return { ok: !hasError, review, diagnostics };
|
|
205
|
+
}
|