@gordon.gan/specflow 1.8.0-alpha → 1.8.1-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 +5 -3
- package/dist/cli/commands/document-run.d.ts +14 -1
- package/dist/cli/commands/document-run.js +518 -212
- package/dist/core/document/chapters.js +18 -3
- package/dist/core/document/digests.d.ts +0 -1
- package/dist/core/document/digests.js +18 -10
- package/dist/core/document/engine.d.ts +9 -0
- package/dist/core/document/engine.js +273 -38
- package/dist/core/document/extract.d.ts +61 -0
- package/dist/core/document/extract.js +437 -0
- package/dist/core/document/forbidden-patterns.js +7 -4
- package/dist/core/document/gates.d.ts +13 -1
- package/dist/core/document/gates.js +48 -6
- package/dist/core/document/input-digest.d.ts +8 -0
- package/dist/core/document/input-digest.js +93 -14
- package/dist/core/document/input-features.d.ts +23 -4
- package/dist/core/document/input-features.js +51 -2
- package/dist/core/document/lint.d.ts +17 -0
- package/dist/core/document/lint.js +118 -0
- package/dist/core/document/llm.d.ts +3 -10
- package/dist/core/document/llm.js +3 -8
- package/dist/core/document/map.d.ts +6 -0
- package/dist/core/document/map.js +114 -30
- package/dist/core/document/outline.d.ts +3 -0
- package/dist/core/document/outline.js +52 -9
- package/dist/core/document/paths.d.ts +5 -5
- package/dist/core/document/paths.js +11 -6
- package/dist/core/document/profile-validator.js +33 -5
- package/dist/core/document/profiles.js +5 -0
- package/dist/core/document/render.d.ts +26 -0
- package/dist/core/document/render.js +110 -14
- package/dist/core/document/review.d.ts +19 -4
- package/dist/core/document/review.js +65 -20
- package/dist/core/document/scene-detect.d.ts +10 -3
- package/dist/core/document/scene-detect.js +138 -22
- package/dist/core/document/schemas.d.ts +188 -34
- package/dist/core/document/schemas.js +39 -26
- package/dist/integrations/shared/capability-evidence.js +4 -4
- package/dist/integrations/shared/command-catalog.js +2 -1
- package/dist/integrations/shared/parity-manifest.js +4 -4
- package/package.json +2 -1
- package/prompts/document/map/acceptance.md +1 -0
- package/prompts/document/map/anti-ai.md +29 -0
- package/prompts/document/map/api-design.md +13 -4
- package/prompts/document/map/architecture.md +21 -1
- package/prompts/document/map/benchmark.md +26 -0
- package/prompts/document/map/closed-loop.md +1 -0
- package/prompts/document/map/compat-migration.md +24 -1
- package/prompts/document/map/component-design.md +30 -0
- package/prompts/document/map/config-runtime.md +1 -0
- package/prompts/document/map/core-flow.md +62 -0
- package/prompts/document/map/core-logic.md +1 -0
- package/prompts/document/map/data-model.md +1 -0
- package/prompts/document/map/deploy.md +20 -2
- package/prompts/document/map/fix.md +1 -0
- package/prompts/document/map/frontend-architecture.md +35 -0
- package/prompts/document/map/goal.md +1 -0
- package/prompts/document/map/impact.md +1 -0
- package/prompts/document/map/implementability.md +1 -0
- package/prompts/document/map/migration-guide.md +36 -0
- package/prompts/document/map/mvp-boundary.md +1 -0
- package/prompts/document/map/non-goals.md +1 -0
- package/prompts/document/map/ops.md +33 -0
- package/prompts/document/map/performance.md +32 -0
- package/prompts/document/map/poc-demo.md +25 -0
- package/prompts/document/map/regression.md +1 -0
- package/prompts/document/map/reproduce.md +1 -0
- package/prompts/document/map/requirement.md +1 -0
- package/prompts/document/map/research.md +25 -0
- package/prompts/document/map/root-cause.md +1 -0
- package/prompts/document/map/signoff.md +1 -0
- package/prompts/document/map/state-management.md +23 -0
- package/prompts/document/map/tech-selection.md +13 -1
- package/prompts/document/map/test-strategy.md +18 -1
- package/prompts/document/map/ui-design.md +10 -7
- package/prompts/document/outline/general.md +9 -0
- package/prompts/document/review/ai-review.md +2 -1
- package/prompts/document/shared/grounding.md +84 -0
- package/skills/specflow-techdoc/SKILL.md +143 -0
- package/skills/specflow-techdoc-synth/SKILL.md +99 -0
- package/templates/document/chapters/api-design.yaml +6 -1
- package/templates/document/chapters/architecture.yaml +7 -4
- package/templates/document/chapters/benchmark.yaml +20 -0
- package/templates/document/chapters/compat-migration.yaml +7 -3
- package/templates/document/chapters/component-design.yaml +22 -0
- package/templates/document/chapters/core-flow.yaml +27 -0
- package/templates/document/chapters/core-logic.yaml +1 -1
- package/templates/document/chapters/deploy.yaml +11 -7
- package/templates/document/chapters/frontend-architecture.yaml +22 -0
- package/templates/document/chapters/migration-guide.yaml +21 -0
- package/templates/document/chapters/ops.yaml +25 -0
- package/templates/document/chapters/performance.yaml +21 -0
- package/templates/document/chapters/poc-demo.yaml +22 -0
- package/templates/document/chapters/research.yaml +22 -0
- package/templates/document/chapters/state-management.yaml +22 -0
- package/templates/document/chapters/tech-selection.yaml +6 -3
- package/templates/document/chapters/test-strategy.yaml +5 -2
- package/templates/document/chapters/ui-design.yaml +7 -1
- package/templates/document/profiles/0to1.yaml +41 -9
- package/templates/document/profiles/bugfix.yaml +8 -3
- package/templates/document/profiles/feature.yaml +19 -7
- package/templates/document/profiles/frontend-0to1.yaml +47 -0
- package/templates/document/profiles/migration.yaml +42 -0
- package/templates/document/profiles/poc.yaml +46 -0
- package/dist/core/document/index.d.ts +0 -7
- package/dist/core/document/index.js +0 -7
- package/skills/specflow-document/SKILL.md +0 -124
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Output budget enforced per chapter (decision 6/P6). fill_once falls back to per-chapter when
|
|
12
12
|
* the estimated input context exceeds the budget (decision 9).
|
|
13
13
|
*/
|
|
14
|
-
import { parseEntities } from './schemas.js';
|
|
14
|
+
import { parseEntities, parseInterfaceEntity, parseTableEntity, parseDecisionEntity } from './schemas.js';
|
|
15
15
|
import { estimateTokens } from './llm.js';
|
|
16
16
|
import { fetchSegments } from './input-digest.js';
|
|
17
17
|
const MAP_SYSTEM = '你是方案文档撰写员。按给定章节要点"填空题"式展开:逐要点填充,不自由发挥。' +
|
|
@@ -19,6 +19,12 @@ const MAP_SYSTEM = '你是方案文档撰写员。按给定章节要点"填空
|
|
|
19
19
|
'契约实体必须满足字段约束(接口须含 failure_examples,表须含 ddl/migration/rollback)。';
|
|
20
20
|
// ============= Four-layer context build (T6.2) =============
|
|
21
21
|
export async function buildChapterContext(chapter, ctx) {
|
|
22
|
+
// 缓存命中直接返回(P2:同一章节上下文在 fill_once 决策/执行/回退/review 重填中会被多次构建)
|
|
23
|
+
if (ctx.contextCache) {
|
|
24
|
+
const cached = ctx.contextCache.get(chapter.id);
|
|
25
|
+
if (cached)
|
|
26
|
+
return cached;
|
|
27
|
+
}
|
|
22
28
|
const { outline, digests, upstreamEntities, workRoot } = ctx;
|
|
23
29
|
// ① outline points + global entities/decisions
|
|
24
30
|
const chapterPoints = chapter.points.map((p) => `${p.id} [${p.kind}] ${p.text}${(p.references ?? []).length ? ` refs=${(p.references ?? []).join(',')}` : ''}`).join('\n');
|
|
@@ -65,7 +71,10 @@ export async function buildChapterContext(chapter, ctx) {
|
|
|
65
71
|
system: MAP_SYSTEM,
|
|
66
72
|
messages: [{ role: 'user', content }],
|
|
67
73
|
};
|
|
68
|
-
|
|
74
|
+
const built = { input, estimatedTokens: estimateTokens(content) + estimateTokens(MAP_SYSTEM) };
|
|
75
|
+
if (ctx.contextCache)
|
|
76
|
+
ctx.contextCache.set(chapter.id, built);
|
|
77
|
+
return built;
|
|
69
78
|
}
|
|
70
79
|
export async function fillChapter(llm, chapter, ctx, budgetTokens) {
|
|
71
80
|
let input;
|
|
@@ -78,6 +87,12 @@ export async function fillChapter(llm, chapter, ctx, budgetTokens) {
|
|
|
78
87
|
catch (e) {
|
|
79
88
|
return { ok: false, error: `Chapter "${chapter.id}" context build failed: ${e instanceof Error ? e.message : String(e)}` };
|
|
80
89
|
}
|
|
90
|
+
// Input-side budget guard(P2-adjacent):在 LLM 调用之前拒绝明显超预算输入,
|
|
91
|
+
// 避免昂贵调用已发出后才失败(decision 6/P6 hard gate)。
|
|
92
|
+
const maxInputTokens = budgetTokens ? budgetTokens * 4 : undefined;
|
|
93
|
+
if (maxInputTokens !== undefined && estimatedTokens > maxInputTokens) {
|
|
94
|
+
return { ok: false, error: `Chapter "${chapter.id}" input context ${estimatedTokens} tokens exceeds 4× output budget` };
|
|
95
|
+
}
|
|
81
96
|
let res;
|
|
82
97
|
try {
|
|
83
98
|
res = await llm.complete(input);
|
|
@@ -91,27 +106,84 @@ export async function fillChapter(llm, chapter, ctx, budgetTokens) {
|
|
|
91
106
|
// silently produce a contract-less chapter (decision 1 layered source of truth).
|
|
92
107
|
let entities = { interfaces: [], tables: [], decisions: [] };
|
|
93
108
|
const jsonMatch = narrative.match(/```json\s*([\s\S]*?)```/g);
|
|
109
|
+
// 记录成功解析为实体块的围栏文本:清洗时只移除这些块,保留叙述中的 JSON 示例(P2)。
|
|
110
|
+
const parsedEntityBlocks = [];
|
|
94
111
|
if (jsonMatch) {
|
|
112
|
+
// 按 id 去重合并(P1-9):同一实体跨块/跨引用重复输出时只保留最后出现的版本,
|
|
113
|
+
// 防止共享实体在 entities.json 与最终文档中重复渲染。
|
|
95
114
|
const merged = { interfaces: [], tables: [], decisions: [] };
|
|
115
|
+
const seenInterface = new Set();
|
|
116
|
+
const seenTable = new Set();
|
|
117
|
+
const seenDecision = new Set();
|
|
96
118
|
for (const block of jsonMatch) {
|
|
119
|
+
let parsed;
|
|
97
120
|
try {
|
|
98
|
-
|
|
99
|
-
if (parsed.entities) {
|
|
100
|
-
const e = parseEntities(parsed.entities);
|
|
101
|
-
merged.interfaces.push(...e.interfaces);
|
|
102
|
-
merged.tables.push(...e.tables);
|
|
103
|
-
merged.decisions.push(...e.decisions);
|
|
104
|
-
}
|
|
121
|
+
parsed = JSON.parse(block.replace(/```json\s*/, '').replace(/```$/, '').trim());
|
|
105
122
|
}
|
|
106
123
|
catch {
|
|
107
|
-
//
|
|
124
|
+
continue; // 非法 JSON → 非实体块,忽略
|
|
125
|
+
}
|
|
126
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.entities)
|
|
127
|
+
continue;
|
|
128
|
+
const rawEntities = parsed.entities;
|
|
129
|
+
// 逐实体容错(P2):单个实体字段不合 zod(如接口缺 failure_examples)不再丢弃同块其它
|
|
130
|
+
// 合法实体;非法项跳过并继续,尽量保留有效契约。
|
|
131
|
+
const e = { interfaces: [], tables: [], decisions: [] };
|
|
132
|
+
for (const it of rawEntities.interfaces ?? []) {
|
|
133
|
+
try {
|
|
134
|
+
e.interfaces.push(parseInterfaceEntity(it));
|
|
135
|
+
}
|
|
136
|
+
catch { /* 跳过非法接口 */ }
|
|
137
|
+
}
|
|
138
|
+
for (const tb of rawEntities.tables ?? []) {
|
|
139
|
+
try {
|
|
140
|
+
e.tables.push(parseTableEntity(tb));
|
|
141
|
+
}
|
|
142
|
+
catch { /* 跳过非法表 */ }
|
|
143
|
+
}
|
|
144
|
+
for (const d of rawEntities.decisions ?? []) {
|
|
145
|
+
try {
|
|
146
|
+
e.decisions.push(parseDecisionEntity(d));
|
|
147
|
+
}
|
|
148
|
+
catch { /* 跳过非法决策 */ }
|
|
149
|
+
}
|
|
150
|
+
if (e.interfaces.length + e.tables.length + e.decisions.length === 0)
|
|
151
|
+
continue;
|
|
152
|
+
parsedEntityBlocks.push(block);
|
|
153
|
+
for (const it of e.interfaces) {
|
|
154
|
+
if (!seenInterface.has(it.id)) {
|
|
155
|
+
seenInterface.add(it.id);
|
|
156
|
+
merged.interfaces.push(it);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (const tb of e.tables) {
|
|
160
|
+
if (!seenTable.has(tb.id)) {
|
|
161
|
+
seenTable.add(tb.id);
|
|
162
|
+
merged.tables.push(tb);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
for (const d of e.decisions) {
|
|
166
|
+
if (!seenDecision.has(d.id)) {
|
|
167
|
+
seenDecision.add(d.id);
|
|
168
|
+
merged.decisions.push(d);
|
|
169
|
+
}
|
|
108
170
|
}
|
|
109
171
|
}
|
|
110
172
|
entities = merged;
|
|
111
173
|
}
|
|
112
|
-
// Clean the narrative: remove the
|
|
113
|
-
// verbatim inside chapters
|
|
114
|
-
|
|
174
|
+
// Clean the narrative: remove ONLY the successfully-parsed ```json entity blocks so they are
|
|
175
|
+
// not rendered verbatim inside chapters; narrative JSON examples (e.g. API response samples)
|
|
176
|
+
// are preserved (P2). Contract entities are rendered deterministically in the 契约 section.
|
|
177
|
+
let cleanNarrative = narrative;
|
|
178
|
+
for (const block of parsedEntityBlocks) {
|
|
179
|
+
cleanNarrative = cleanNarrative.replace(block, '');
|
|
180
|
+
}
|
|
181
|
+
cleanNarrative = cleanNarrative.replace(/\n{3,}/g, '\n\n').trim();
|
|
182
|
+
// 空叙述早失败(P2):LLM 只输出实体块未产出叙述时立即报错,与 fillOnce 的检查对齐,
|
|
183
|
+
// 避免拖到管线末尾 gates-fail 才暴露。
|
|
184
|
+
if (cleanNarrative.length === 0) {
|
|
185
|
+
return { ok: false, error: `Chapter "${chapter.id}" produced no narrative (LLM returned only entity JSON or empty content)` };
|
|
186
|
+
}
|
|
115
187
|
// Strict: entity-requiring points must have their referenced entities filled.
|
|
116
188
|
const requiredEntityIds = new Set();
|
|
117
189
|
for (const p of chapter.points) {
|
|
@@ -131,11 +203,6 @@ export async function fillChapter(llm, chapter, ctx, budgetTokens) {
|
|
|
131
203
|
}
|
|
132
204
|
}
|
|
133
205
|
const tokens = estimateTokens(cleanNarrative) + estimateTokens(JSON.stringify(entities));
|
|
134
|
-
// Input-side budget guard: reject clearly-over-budget inputs (decision 6/P6 hard gate).
|
|
135
|
-
const maxInputTokens = budgetTokens ? budgetTokens * 4 : undefined;
|
|
136
|
-
if (maxInputTokens !== undefined && estimatedTokens > maxInputTokens) {
|
|
137
|
-
return { ok: false, error: `Chapter "${chapter.id}" input context ${estimatedTokens} tokens exceeds 4× output budget` };
|
|
138
|
-
}
|
|
139
206
|
if (budgetTokens !== undefined && tokens > budgetTokens) {
|
|
140
207
|
return { ok: false, error: `Chapter "${chapter.id}" output ${tokens} tokens exceeds budget ${budgetTokens}` };
|
|
141
208
|
}
|
|
@@ -150,9 +217,14 @@ export async function shouldFillOnce(outline, ctx) {
|
|
|
150
217
|
return false;
|
|
151
218
|
const budget = outline.fill_once.input_budget_tokens ?? 8000;
|
|
152
219
|
let total = 0;
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
220
|
+
try {
|
|
221
|
+
for (const ch of outline.chapters) {
|
|
222
|
+
const { estimatedTokens } = await buildChapterContext(ch, ctx);
|
|
223
|
+
total += estimatedTokens;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return false; // 上下文构建失败 → 回退 per-chapter(P2)
|
|
156
228
|
}
|
|
157
229
|
return total <= budget;
|
|
158
230
|
}
|
|
@@ -161,15 +233,27 @@ const FILL_ONCE_SYSTEM = '你是方案文档撰写员。一次填完全部章节
|
|
|
161
233
|
'每章内容必须差异化(针对该章要点),禁止复制同一段到所有章节。契约实体须含 failure_examples(接口)/ddl(表)。';
|
|
162
234
|
export async function fillOnce(llm, outline, ctx) {
|
|
163
235
|
// Build a single combined context (all chapters) — bounded by shouldFillOnce pre-check.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
sections
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
236
|
+
// try/catch(P2):上下文构建与 LLM 调用任一抛错都应回退 per-chapter,而不是穿透崩溃。
|
|
237
|
+
let res;
|
|
238
|
+
try {
|
|
239
|
+
const sections = [];
|
|
240
|
+
for (const ch of outline.chapters) {
|
|
241
|
+
const { input } = await buildChapterContext(ch, ctx);
|
|
242
|
+
sections.push(input.messages[0].content);
|
|
243
|
+
}
|
|
244
|
+
res = await llm.complete({
|
|
245
|
+
system: FILL_ONCE_SYSTEM,
|
|
246
|
+
messages: [{ role: 'user', content: sections.join('\n\n=====\n\n') }],
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
catch (e) {
|
|
250
|
+
return {
|
|
251
|
+
ok: false,
|
|
252
|
+
chapters: [],
|
|
253
|
+
fellBackToPerChapter: true,
|
|
254
|
+
error: `fill_once: context build or LLM call failed (${e instanceof Error ? e.message : String(e)})`,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
173
257
|
const content = res.content.trim();
|
|
174
258
|
// Parse structured per-chapter output; fall back to per-chapter fill on parse failure.
|
|
175
259
|
try {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { Outline, ScenarioProfile } from './schemas.js';
|
|
9
9
|
import type { DocumentLLM } from './llm.js';
|
|
10
10
|
import { GlobalInputDigest } from './input-digest.js';
|
|
11
|
+
import { ExtractedContracts } from './extract.js';
|
|
11
12
|
export interface OutlineDiagnostic {
|
|
12
13
|
code: string;
|
|
13
14
|
severity: 'error' | 'warning';
|
|
@@ -18,6 +19,8 @@ export interface GenerateOutlineOptions {
|
|
|
18
19
|
profile: ScenarioProfile;
|
|
19
20
|
inputDigest: GlobalInputDigest;
|
|
20
21
|
prompt?: string;
|
|
22
|
+
/** 工程契约候选(grounding P0 确定性提取):冻结实体名供大纲注册表引用,禁止改名/编造。 */
|
|
23
|
+
extractedContracts?: ExtractedContracts;
|
|
21
24
|
}
|
|
22
25
|
export declare function generateOutline(llm: DocumentLLM, options: GenerateOutlineOptions): Promise<Outline>;
|
|
23
26
|
export declare function lintOutline(outline: Outline, profile: ScenarioProfile): OutlineDiagnostic[];
|
|
@@ -11,9 +11,34 @@ import { parseOutline } from './schemas.js';
|
|
|
11
11
|
const DEFAULT_PROMPT = '你是文档大纲设计专家。基于全局输入摘要与场景 Profile 约束,生成结构化文档大纲。见 prompts/document/outline/general.md。';
|
|
12
12
|
const OUTLINE_SYSTEM = '你是文档大纲设计专家。严格按输入要求输出 JSON 大纲,满足所有硬规则(章节约束/要点 kind/references 完整性/source_segments)。';
|
|
13
13
|
// ============= Generation (T5.2) =============
|
|
14
|
+
/** 剥离 LLM 输出中的 ```json 围栏与前后缀文本(P2:与 map.ts 的围栏处理一致)。 */
|
|
15
|
+
function extractJsonBlock(content) {
|
|
16
|
+
const trimmed = content.trim();
|
|
17
|
+
const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
18
|
+
if (fence)
|
|
19
|
+
return fence[1].trim();
|
|
20
|
+
// 无围栏:尝试取首个平衡 {...} 块(容忍前后缀说明文字)。
|
|
21
|
+
const start = trimmed.indexOf('{');
|
|
22
|
+
const end = trimmed.lastIndexOf('}');
|
|
23
|
+
if (start >= 0 && end > start)
|
|
24
|
+
return trimmed.slice(start, end + 1);
|
|
25
|
+
return trimmed;
|
|
26
|
+
}
|
|
14
27
|
export async function generateOutline(llm, options) {
|
|
15
28
|
const { profile, inputDigest } = options;
|
|
16
29
|
const prompt = options.prompt ?? DEFAULT_PROMPT;
|
|
30
|
+
const extracted = options.extractedContracts;
|
|
31
|
+
// Grounding 注入:工程契约候选(事实)。大纲注册表必须原样引用这些 frozen 名,
|
|
32
|
+
// 禁止改名/编造;无提取时该段省略(行为与旧版一致)。
|
|
33
|
+
const extractedBlock = extracted && (extracted.interfaces.length > 0 || extracted.tables.length > 0)
|
|
34
|
+
? [
|
|
35
|
+
`## 工程契约候选(确定性提取,事实)`,
|
|
36
|
+
`以下实体来自工程数据源(OpenAPI/proto/DDL),是已确认的事实:`,
|
|
37
|
+
...extracted.interfaces.map((it) => `- ${it.id} (${it.method} ${it.path}) name=${it.name}${it.request_fields?.length ? ` request=[${it.request_fields.map((f) => `${f.name}:${f.type}`).join(', ')}]` : ''}${it.response_fields?.length ? ` response=[${it.response_fields.map((f) => `${f.name}:${f.type}`).join(', ')}]` : ''}`),
|
|
38
|
+
...extracted.tables.map((tb) => `- ${tb.id} (table) name=${tb.name} fields=[${tb.fields?.map((f) => `${f.name}:${f.type}`).join(', ') ?? ''}]`),
|
|
39
|
+
`规则:大纲 entities 注册表引用这些实体时,frozen 必须使用上述 name;references 用上述 id;禁止新增同名异义实体。`,
|
|
40
|
+
].join('\n')
|
|
41
|
+
: '';
|
|
17
42
|
const input = {
|
|
18
43
|
system: OUTLINE_SYSTEM,
|
|
19
44
|
messages: [
|
|
@@ -24,23 +49,31 @@ export async function generateOutline(llm, options) {
|
|
|
24
49
|
`Profile: ${JSON.stringify(profile)}`,
|
|
25
50
|
`全局输入摘要: ${inputDigest.summary}`,
|
|
26
51
|
`段摘要: ${inputDigest.segments.map((s) => `[${s.id}] ${s.summary}`).join('\n')}`,
|
|
27
|
-
|
|
52
|
+
extractedBlock,
|
|
53
|
+
].filter(Boolean).join('\n\n'),
|
|
28
54
|
},
|
|
29
55
|
],
|
|
30
56
|
};
|
|
31
|
-
// Retry up to 2 times on
|
|
32
|
-
|
|
57
|
+
// Retry up to 2 times on PARSE failure only(P2):LLM 服务/传输错误(限流/5xx)立即抛出,
|
|
58
|
+
// 不背靠背重试放大延迟与成本;确定性解析错误才重试。
|
|
59
|
+
let lastParseErr;
|
|
33
60
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
61
|
+
let res;
|
|
34
62
|
try {
|
|
35
|
-
|
|
36
|
-
|
|
63
|
+
res = await llm.complete(input);
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
throw new Error(`generateOutline: LLM call failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const outline = parseOutline(JSON.parse(extractJsonBlock(res.content)));
|
|
37
70
|
return outline;
|
|
38
71
|
}
|
|
39
72
|
catch (e) {
|
|
40
|
-
|
|
73
|
+
lastParseErr = e;
|
|
41
74
|
}
|
|
42
75
|
}
|
|
43
|
-
throw new Error(`generateOutline: failed to parse outline after retries: ${
|
|
76
|
+
throw new Error(`generateOutline: failed to parse outline after retries: ${lastParseErr}`);
|
|
44
77
|
}
|
|
45
78
|
// ============= Lint (T5.3) =============
|
|
46
79
|
export function lintOutline(outline, profile) {
|
|
@@ -73,15 +106,25 @@ export function lintOutline(outline, profile) {
|
|
|
73
106
|
seenP.add(p.id);
|
|
74
107
|
}
|
|
75
108
|
}
|
|
109
|
+
// 4b. Duplicate entity ids in registry(P2):重复注册会被 Set 静默折叠,正向引用校验失效。
|
|
110
|
+
const seenEntity = new Set();
|
|
111
|
+
for (const e of outline.entities) {
|
|
112
|
+
if (seenEntity.has(e.id))
|
|
113
|
+
diags.push({ code: 'duplicate_entity', severity: 'error', message: `Duplicate entity "${e.id}" in registry` });
|
|
114
|
+
seenEntity.add(e.id);
|
|
115
|
+
}
|
|
76
116
|
const entityIds = new Set(outline.entities.map((e) => e.id));
|
|
77
117
|
// 5. References forward: point references must exist in entity registry.
|
|
78
|
-
// 6. Kind consistency: references present → kind must not be narrative; ≤3 refs per point.
|
|
118
|
+
// 6. Kind consistency: references present → kind must not be narrative; ≤3 unique refs per point.
|
|
79
119
|
for (const ch of outline.chapters) {
|
|
80
120
|
for (const p of ch.points) {
|
|
81
|
-
const refs = p.references ?? [];
|
|
121
|
+
const refs = [...new Set(p.references ?? [])]; // 去重(P2:重复引用不误触 ≤3 门禁)
|
|
82
122
|
if (refs.length > 3) {
|
|
83
123
|
diags.push({ code: 'too_many_references', severity: 'error', chapter: ch.id, message: `Point "${p.id}" references >3 entities` });
|
|
84
124
|
}
|
|
125
|
+
if ((p.references ?? []).length !== refs.length) {
|
|
126
|
+
diags.push({ code: 'duplicate_reference', severity: 'warning', chapter: ch.id, message: `Point "${p.id}" has duplicate references` });
|
|
127
|
+
}
|
|
85
128
|
for (const ref of refs) {
|
|
86
129
|
if (!entityIds.has(ref)) {
|
|
87
130
|
diags.push({ code: 'reference_unknown', severity: 'error', chapter: ch.id, message: `Point "${p.id}" references unknown entity "${ref}"` });
|
|
@@ -9,15 +9,15 @@ export interface DocumentPaths {
|
|
|
9
9
|
outline: string;
|
|
10
10
|
entities: string;
|
|
11
11
|
chaptersDir: string;
|
|
12
|
-
segmentsDir: string;
|
|
13
|
-
digestsDir: string;
|
|
14
|
-
hashes: string;
|
|
15
|
-
reviewResult: string;
|
|
16
12
|
documentMd: string;
|
|
17
13
|
documentHtml: string;
|
|
18
14
|
}
|
|
19
15
|
/** Resolve all engine artifact paths under a caller-provided work root. */
|
|
20
16
|
export declare function resolvePaths(workRoot: string): DocumentPaths;
|
|
17
|
+
/** 章节 id 白名单:仅允许安全标识符,拒绝路径分隔符/`..`/空串(防路径穿越,P0-3)。 */
|
|
18
|
+
export declare const CHAPTER_ID_RE: RegExp;
|
|
19
|
+
export declare function assertChapterId(chapterId: string): void;
|
|
21
20
|
export declare function chapterPath(workRoot: string, chapterId: string): string;
|
|
22
|
-
|
|
21
|
+
/** Multi-repo synthesize marker: list of repo names (written by document synthesize). */
|
|
22
|
+
export declare function reposPath(workRoot: string): string;
|
|
23
23
|
export declare function segmentIndexPath(workRoot: string): string;
|
|
@@ -12,19 +12,24 @@ export function resolvePaths(workRoot) {
|
|
|
12
12
|
outline: join(workRoot, 'outline.yaml'),
|
|
13
13
|
entities: join(workRoot, 'entities.json'),
|
|
14
14
|
chaptersDir: join(workRoot, 'chapters'),
|
|
15
|
-
segmentsDir: join(workRoot, 'segments'),
|
|
16
|
-
digestsDir: join(workRoot, 'digests'),
|
|
17
|
-
hashes: join(workRoot, 'hashes.json'),
|
|
18
|
-
reviewResult: join(workRoot, 'review-result.json'),
|
|
19
15
|
documentMd: join(workRoot, 'document.md'),
|
|
20
16
|
documentHtml: join(workRoot, 'document.html'),
|
|
21
17
|
};
|
|
22
18
|
}
|
|
19
|
+
/** 章节 id 白名单:仅允许安全标识符,拒绝路径分隔符/`..`/空串(防路径穿越,P0-3)。 */
|
|
20
|
+
export const CHAPTER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
|
|
21
|
+
export function assertChapterId(chapterId) {
|
|
22
|
+
if (!CHAPTER_ID_RE.test(chapterId)) {
|
|
23
|
+
throw new Error(`Invalid chapter id "${chapterId}": must match ${CHAPTER_ID_RE}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
23
26
|
export function chapterPath(workRoot, chapterId) {
|
|
27
|
+
assertChapterId(chapterId);
|
|
24
28
|
return join(workRoot, 'chapters', `${chapterId}.md`);
|
|
25
29
|
}
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
/** Multi-repo synthesize marker: list of repo names (written by document synthesize). */
|
|
31
|
+
export function reposPath(workRoot) {
|
|
32
|
+
return join(workRoot, 'repos.json');
|
|
28
33
|
}
|
|
29
34
|
export function segmentIndexPath(workRoot) {
|
|
30
35
|
return join(workRoot, 'segments', 'index.json');
|
|
@@ -36,11 +36,37 @@ export function validateProfile(options) {
|
|
|
36
36
|
message: `Profile "${profile.id}" must declare at least one required chapter`,
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
|
+
// 2b. 分类冲突(P2):同一章节出现在 required∩forbidden / optional∩forbidden 时,
|
|
40
|
+
// expandProfile 会静默从 selected 删除,必需章节凭空消失。加载期即报错。
|
|
41
|
+
const requiredSet = new Set(profile.required);
|
|
42
|
+
const optionalSet = new Set(profile.optional_candidates);
|
|
43
|
+
for (const id of profile.forbidden) {
|
|
44
|
+
if (requiredSet.has(id)) {
|
|
45
|
+
diags.push({ code: 'category_conflict', severity: 'error', chapter: id, message: `Chapter "${id}" is in both required and forbidden` });
|
|
46
|
+
}
|
|
47
|
+
if (optionalSet.has(id)) {
|
|
48
|
+
diags.push({ code: 'category_conflict', severity: 'error', chapter: id, message: `Chapter "${id}" is in both optional_candidates and forbidden` });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// 2c. 数组内重复 id。
|
|
52
|
+
for (const [label, list] of [['required', profile.required], ['optional_candidates', profile.optional_candidates], ['forbidden', profile.forbidden], ['shared', profile.shared]]) {
|
|
53
|
+
const seenIds = new Set();
|
|
54
|
+
for (const id of list) {
|
|
55
|
+
if (seenIds.has(id)) {
|
|
56
|
+
diags.push({ code: 'duplicate_id', severity: 'warning', chapter: id, message: `Chapter "${id}" appears more than once in ${label}` });
|
|
57
|
+
}
|
|
58
|
+
seenIds.add(id);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
39
61
|
// 3+4. Dependency closure + acyclic over the selected chapters.
|
|
40
62
|
// A chapter may depend on: another selected chapter, a shared chapter, or a chapter in
|
|
41
63
|
// required/optional that we include in the closure graph.
|
|
64
|
+
// closure 检查覆盖 required + optional_candidates(P2):可选章节是 LLM 可能选中的
|
|
65
|
+
// 渲染章节,其悬空依赖必须报错;shared 章节仅作依赖闭合、不渲染,其 depends_on
|
|
66
|
+
// 不需要闭包(引擎不会填充 shared 章节)。
|
|
42
67
|
const closureNodes = new Set([...profile.required, ...profile.optional_candidates, ...profile.shared]);
|
|
43
|
-
|
|
68
|
+
const renderedIds = [...profile.required, ...profile.optional_candidates];
|
|
69
|
+
for (const id of renderedIds) {
|
|
44
70
|
const ch = library.get(id);
|
|
45
71
|
if (!ch)
|
|
46
72
|
continue;
|
|
@@ -56,6 +82,7 @@ export function validateProfile(options) {
|
|
|
56
82
|
}
|
|
57
83
|
}
|
|
58
84
|
// Acyclic check via Kahn's algorithm on selected + shared nodes.
|
|
85
|
+
// 注意:不再排除自依赖(dep !== n 过滤已删除,P2)——自环必须被正确判为环。
|
|
59
86
|
const nodes = [...closureNodes].filter((n) => library.has(n));
|
|
60
87
|
const indegree = new Map();
|
|
61
88
|
const adj = new Map();
|
|
@@ -66,7 +93,7 @@ export function validateProfile(options) {
|
|
|
66
93
|
for (const n of nodes) {
|
|
67
94
|
const ch = library.get(n);
|
|
68
95
|
for (const dep of ch.depends_on) {
|
|
69
|
-
if (closureNodes.has(dep) && library.has(dep)
|
|
96
|
+
if (closureNodes.has(dep) && library.has(dep)) {
|
|
70
97
|
adj.get(dep).push(n);
|
|
71
98
|
indegree.set(n, (indegree.get(n) ?? 0) + 1);
|
|
72
99
|
}
|
|
@@ -93,8 +120,9 @@ export function validateProfile(options) {
|
|
|
93
120
|
});
|
|
94
121
|
}
|
|
95
122
|
}
|
|
96
|
-
// 5. When-usable: every when expression must parse and reference known features.
|
|
97
|
-
|
|
123
|
+
// 5. When-usable: every when expression must parse and reference known input features.
|
|
124
|
+
// (覆盖 shared 章节,P2;未知 feature 提级为 error——拼错 feature 会静默丢章节)
|
|
125
|
+
for (const id of [...profile.required, ...profile.optional_candidates, ...profile.shared]) {
|
|
98
126
|
const ch = library.get(id);
|
|
99
127
|
if (!ch?.when)
|
|
100
128
|
continue;
|
|
@@ -104,7 +132,7 @@ export function validateProfile(options) {
|
|
|
104
132
|
if (!isKnownFeature(f)) {
|
|
105
133
|
diags.push({
|
|
106
134
|
code: 'when_unknown_feature',
|
|
107
|
-
severity: '
|
|
135
|
+
severity: 'error',
|
|
108
136
|
chapter: id,
|
|
109
137
|
message: `when expression of chapter "${id}" references unknown input feature "${f}"`,
|
|
110
138
|
});
|
|
@@ -10,7 +10,12 @@ import { parseScenarioProfile } from './schemas.js';
|
|
|
10
10
|
import { validateProfile, hasErrors } from './profile-validator.js';
|
|
11
11
|
import { evaluateWhen } from './input-features.js';
|
|
12
12
|
const DEFAULT_PROFILES_DIR = join(process.cwd(), 'templates', 'document', 'profiles');
|
|
13
|
+
/** profile id 白名单(P2):拒绝路径穿越(../、绝对路径)。 */
|
|
14
|
+
const PROFILE_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
|
|
13
15
|
export async function loadProfile(profileId, dir = DEFAULT_PROFILES_DIR) {
|
|
16
|
+
if (!PROFILE_ID_RE.test(profileId)) {
|
|
17
|
+
throw new Error(`Invalid profile id "${profileId}": must match ${PROFILE_ID_RE}`);
|
|
18
|
+
}
|
|
14
19
|
const raw = yaml.load(await fs.readFile(join(dir, `${profileId}.yaml`), 'utf-8'));
|
|
15
20
|
return parseScenarioProfile(raw);
|
|
16
21
|
}
|
|
@@ -6,14 +6,40 @@
|
|
|
6
6
|
* Optional HTML render reuses `marked`.
|
|
7
7
|
*/
|
|
8
8
|
import { Outline, Entities, InterfaceEntity, TableEntity } from './schemas.js';
|
|
9
|
+
/** HTML 转义:用于 <title>/<h1> 插值(防存储型 XSS)。 */
|
|
10
|
+
export declare function escapeHtml(s: string): string;
|
|
11
|
+
/** Markdown 表格单元格转义:转义 | 与换行(防破坏列对齐/拆行)。 */
|
|
12
|
+
export declare function escapeTableCell(s: string): string;
|
|
13
|
+
/** 代码围栏内容转义:把可能提前终止 ``` 围栏的行替换为安全形式。 */
|
|
14
|
+
export declare function escapeFenceContent(s: string): string;
|
|
15
|
+
/** 单行内联文本:转义换行,避免注入新标题/新行。 */
|
|
16
|
+
export declare function escapeInline(s: string): string;
|
|
9
17
|
export declare function renderInterface(i: InterfaceEntity): string;
|
|
10
18
|
export declare function renderTable(t: TableEntity): string;
|
|
19
|
+
export declare function renderDecisions(decisions: Array<{
|
|
20
|
+
id: string;
|
|
21
|
+
text: string;
|
|
22
|
+
}>): string;
|
|
11
23
|
export declare function renderEntities(entities: Entities): string;
|
|
12
24
|
export interface RenderDocumentInput {
|
|
13
25
|
outline: Outline;
|
|
14
26
|
entities: Entities;
|
|
15
27
|
/** chapterId → narrative markdown */
|
|
16
28
|
narratives: Map<string, string>;
|
|
29
|
+
/** Scenario profile — drives appendices rendering (profile.appendices, §7.2/7.3). */
|
|
30
|
+
profile?: {
|
|
31
|
+
appendices?: string[];
|
|
32
|
+
};
|
|
33
|
+
/** LLM-written anti-AI self-check items (from appendices/anti-ai.md, optional). */
|
|
34
|
+
appendixAntiAI?: string;
|
|
17
35
|
}
|
|
36
|
+
/** Strip engine repair notes (`<!-- review-fix ... -->`) from the FINAL render. The audit trail
|
|
37
|
+
* stays in `chapters/<id>.md` and `review-result.json`; the deliverable document must not show
|
|
38
|
+
* internal fix notes. */
|
|
39
|
+
export declare function stripReviewNotes(text: string): string;
|
|
18
40
|
export declare function renderDocument(input: RenderDocumentInput): string;
|
|
41
|
+
/** 附录 B · 去AI味自检(frontend-dev-guide §十一 → README §1.6 五维表)。 */
|
|
42
|
+
export declare function renderAppendixAntiAI(content?: string): string;
|
|
43
|
+
/** 附录 C · 下游交付衔接(frontend-dev-guide §八 → README §1.5 矩阵 + 三步)。 */
|
|
44
|
+
export declare function renderAppendixHandoff(): string;
|
|
19
45
|
export declare function renderHtml(markdown: string, title?: string): Promise<string>;
|