@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
|
@@ -8,13 +8,28 @@ import yaml from 'js-yaml';
|
|
|
8
8
|
import { parseChapterComponent } from './schemas.js';
|
|
9
9
|
const DEFAULT_CHAPTERS_DIR = join(process.cwd(), 'templates', 'document', 'chapters');
|
|
10
10
|
export async function loadChapterLibrary(dir = DEFAULT_CHAPTERS_DIR) {
|
|
11
|
-
const entries = await fs.readdir(dir);
|
|
11
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
12
12
|
const lib = new Map();
|
|
13
|
-
|
|
13
|
+
const seen = new Set();
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
if (!entry.isFile())
|
|
16
|
+
continue; // 跳过目录(P2:避免 EISDIR)
|
|
17
|
+
const file = entry.name;
|
|
14
18
|
if (!file.endsWith('.yaml') && !file.endsWith('.yml'))
|
|
15
19
|
continue;
|
|
16
|
-
|
|
20
|
+
let raw;
|
|
21
|
+
try {
|
|
22
|
+
raw = yaml.load(await fs.readFile(join(dir, file), 'utf-8'));
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
throw new Error(`Failed to parse chapter file ${file}: ${e instanceof Error ? e.message : String(e)}`);
|
|
26
|
+
}
|
|
17
27
|
const ch = parseChapterComponent(raw);
|
|
28
|
+
// 重复 id 检测(P2):Map.set 静默覆盖且覆盖顺序依赖目录枚举,id 碰撞会静默改变整条流水线。
|
|
29
|
+
if (seen.has(ch.id)) {
|
|
30
|
+
throw new Error(`Duplicate chapter id "${ch.id}" (in ${file}); chapter ids must be unique across templates/document/chapters/`);
|
|
31
|
+
}
|
|
32
|
+
seen.add(ch.id);
|
|
18
33
|
lib.set(ch.id, ch);
|
|
19
34
|
}
|
|
20
35
|
return lib;
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { OutlineChapter, ConstraintItem, Entities } from './schemas.js';
|
|
12
12
|
import type { DocumentLLM } from './llm.js';
|
|
13
|
-
export declare const DIGEST_BUDGET_TOKENS = 1000;
|
|
14
13
|
export declare function extractConstraintList(chapter: OutlineChapter, entities: Entities): ConstraintItem[];
|
|
15
14
|
export interface DigestInput {
|
|
16
15
|
chapterId: string;
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
* items present in digest).
|
|
9
9
|
* - recordDigestSources / affectedDownstream: invalidation propagation (decision 9).
|
|
10
10
|
*/
|
|
11
|
-
export const DIGEST_BUDGET_TOKENS = 1000;
|
|
12
11
|
// ============= Constraint extraction (T7.1) =============
|
|
13
12
|
export function extractConstraintList(chapter, entities) {
|
|
14
13
|
const items = [];
|
|
@@ -19,11 +18,13 @@ export function extractConstraintList(chapter, entities) {
|
|
|
19
18
|
}
|
|
20
19
|
}
|
|
21
20
|
for (const d of entities.decisions) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
// A decision is preserved in this chapter's digest when the chapter references it
|
|
22
|
+
// (deterministic signal) or its text is mentioned in a point (heuristic fallback).
|
|
23
|
+
// (Previously wrapped in an always-true `|| true` outer condition — dead code removed.)
|
|
24
|
+
const referenced = chapter.points.some((p) => (p.references ?? []).includes(d.id));
|
|
25
|
+
const mentioned = chapter.points.some((p) => p.text.includes(d.text.slice(0, 20)));
|
|
26
|
+
if (referenced || mentioned) {
|
|
27
|
+
items.push({ kind: 'decision', id: d.id, label: d.text.slice(0, 40) });
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
30
|
// dedupe by kind+id
|
|
@@ -75,8 +76,11 @@ export function deterministicDigest(entities, ids) {
|
|
|
75
76
|
export function lintDigest(digest, constraints, knownEntityIds) {
|
|
76
77
|
const diags = [];
|
|
77
78
|
// Reverse: every constraint item must appear in digest (decision 8).
|
|
79
|
+
// 用整词匹配(P2):短 id 子串匹配会把 T1 误判为出现(T10/ST1/GET /api/T1),
|
|
80
|
+
// 或把 t1/D-1 误判为缺失 → 触发确定性回退丢弃约束。
|
|
78
81
|
for (const c of constraints) {
|
|
79
|
-
|
|
82
|
+
const re = new RegExp(`(^|[^A-Za-z0-9_])${escapeRegExp(c.id)}($|[^A-Za-z0-9_])`);
|
|
83
|
+
if (!re.test(digest)) {
|
|
80
84
|
diags.push({
|
|
81
85
|
code: 'constraint_missing',
|
|
82
86
|
severity: 'error',
|
|
@@ -85,14 +89,14 @@ export function lintDigest(digest, constraints, knownEntityIds) {
|
|
|
85
89
|
}
|
|
86
90
|
}
|
|
87
91
|
// Forward: any entity id mentioned in digest that we can detect must exist in known set.
|
|
88
|
-
//
|
|
92
|
+
// (P2 修复:此前 I\d+/T\d+ 被豁免导致前向检查恒不触发;现加入 D\d+ 且不再豁免。)
|
|
89
93
|
const mentioned = new Set();
|
|
90
94
|
for (const m of digest.matchAll(/`([A-Za-z0-9_.]+)`/g))
|
|
91
95
|
mentioned.add(m[1]);
|
|
92
|
-
for (const m of digest.matchAll(/\b(I\d+|T\d+)\b/g))
|
|
96
|
+
for (const m of digest.matchAll(/\b(I\d+|T\d+|D\d+)\b/g))
|
|
93
97
|
mentioned.add(m[1]);
|
|
94
98
|
for (const id of mentioned) {
|
|
95
|
-
if (knownEntityIds.size > 0 && !knownEntityIds.has(id)
|
|
99
|
+
if (knownEntityIds.size > 0 && !knownEntityIds.has(id)) {
|
|
96
100
|
diags.push({
|
|
97
101
|
code: 'digest_hallucinated_entity',
|
|
98
102
|
severity: 'warning',
|
|
@@ -102,6 +106,10 @@ export function lintDigest(digest, constraints, knownEntityIds) {
|
|
|
102
106
|
}
|
|
103
107
|
return diags;
|
|
104
108
|
}
|
|
109
|
+
/** 转义正则特殊字符(供整词匹配使用)。 */
|
|
110
|
+
function escapeRegExp(s) {
|
|
111
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
112
|
+
}
|
|
105
113
|
export function recordDigestSources(chapter, sourceEntities) {
|
|
106
114
|
const entityIds = new Set();
|
|
107
115
|
for (const p of chapter.points) {
|
|
@@ -9,6 +9,7 @@ import type { DocumentLLM } from './llm.js';
|
|
|
9
9
|
import { SegmentableInput } from './input-digest.js';
|
|
10
10
|
import { expandProfile } from './profiles.js';
|
|
11
11
|
import { GateDiagnostic } from './gates.js';
|
|
12
|
+
import { ExtractedContracts } from './extract.js';
|
|
12
13
|
export interface RunDocumentOptions {
|
|
13
14
|
llm: DocumentLLM;
|
|
14
15
|
workRoot: string;
|
|
@@ -34,6 +35,12 @@ export interface RunDocumentOptions {
|
|
|
34
35
|
renderHtmlOutput?: boolean;
|
|
35
36
|
outlinePrompt?: string;
|
|
36
37
|
reviewPrompt?: string;
|
|
38
|
+
/** Project root for deterministic contract extraction (OpenAPI/proto/DDL). Optional —
|
|
39
|
+
* when set, engine extracts real contract entities and injects them into outline
|
|
40
|
+
* generation as frozen candidates (grounding P0). */
|
|
41
|
+
projectRoot?: string;
|
|
42
|
+
/** Pre-extracted contracts (skip file scan). Useful for tests/hosts that already know them. */
|
|
43
|
+
extractedContracts?: ExtractedContracts;
|
|
37
44
|
/** Persist per-chapter files/entities to workRoot (default true). */
|
|
38
45
|
persist?: boolean;
|
|
39
46
|
}
|
|
@@ -47,6 +54,8 @@ export interface RunDocumentResult {
|
|
|
47
54
|
tokenEstimate?: number;
|
|
48
55
|
}
|
|
49
56
|
export declare function runDocument(options: RunDocumentOptions): Promise<RunDocumentResult>;
|
|
57
|
+
/** 附录覆盖校验(README §7.3):profile.appendices 含 anti-ai 但自检条目为空/不足 → warning。 */
|
|
58
|
+
export declare function appendixDiagnostics(profile: ScenarioProfile, antiAiContent: string): GateDiagnostic[];
|
|
50
59
|
export interface AgentStep {
|
|
51
60
|
/** stable id: 'input-summary' | 'outline' | 'chapter:<id>' | 'review' */
|
|
52
61
|
id: string;
|
|
@@ -12,14 +12,15 @@ import { generateOutline, lintOutline, outlineHasErrors, freezeOutline, readOutl
|
|
|
12
12
|
import { expandProfile } from './profiles.js';
|
|
13
13
|
import { generateDigest, extractConstraintList, lintDigest, deterministicDigest, recordDigestSources, affectedDownstream } from './digests.js';
|
|
14
14
|
import { fillChapter, fillOnce, shouldFillOnce } from './map.js';
|
|
15
|
-
import { buildReviewPacket, reviewWithAgent, checkReviewResult, writeReviewResult, computePartHashes, applyFindings } from './review.js';
|
|
15
|
+
import { buildReviewPacket, reviewWithAgent, checkReviewResult, writeReviewResult, computePartHashes, applyFindings, sha256 } from './review.js';
|
|
16
16
|
import { renderDocument, renderHtml } from './render.js';
|
|
17
17
|
import { runGates } from './gates.js';
|
|
18
|
-
import { resolvePaths } from './paths.js';
|
|
18
|
+
import { resolvePaths, reposPath, chapterPath } from './paths.js';
|
|
19
19
|
import { parseEntities } from './schemas.js';
|
|
20
|
-
import { lintNarrative } from './lint.js';
|
|
20
|
+
import { lintNarrative, checkRepoSectionCoverage } from './lint.js';
|
|
21
21
|
import { checkStructuredCoverage } from './coverage.js';
|
|
22
22
|
import { resolveProjectConventionPaths } from '../project-conventions.js';
|
|
23
|
+
import { extractContracts, EMPTY_CONTRACTS } from './extract.js';
|
|
23
24
|
const DEFAULT_MAX_ROUNDS = 3;
|
|
24
25
|
const OUTLINE_MAX_CONFIRM_ROUNDS = 2;
|
|
25
26
|
export async function runDocument(options) {
|
|
@@ -29,6 +30,12 @@ export async function runDocument(options) {
|
|
|
29
30
|
const paths = resolvePaths(workRoot);
|
|
30
31
|
// --- Pass 0.5: input compression (T4) ---
|
|
31
32
|
const inputDigest = await compressInputs(workRoot, inputs, llm);
|
|
33
|
+
// --- Pass 0.5b: deterministic contract extraction (grounding P0, engine-level) ---
|
|
34
|
+
// 显式注入的提取结果优先(测试/宿主);否则从 projectRoot + 输入文件扫描 OpenAPI/proto/DDL。
|
|
35
|
+
const extractedContracts = options.extractedContracts
|
|
36
|
+
?? (options.projectRoot
|
|
37
|
+
? await extractContracts(inputs, options.projectRoot)
|
|
38
|
+
: EMPTY_CONTRACTS);
|
|
32
39
|
// --- Pass 1: outline (T5) with user confirmation gate (T5.4) ---
|
|
33
40
|
const expanded = expandProfile(profile, library, inputFeatures);
|
|
34
41
|
if (expanded.chapters.length === 0) {
|
|
@@ -38,7 +45,7 @@ export async function runDocument(options) {
|
|
|
38
45
|
let confirmed = false;
|
|
39
46
|
const confirmer = options.outlineConfirmer;
|
|
40
47
|
for (let round = 0; round < OUTLINE_MAX_CONFIRM_ROUNDS; round++) {
|
|
41
|
-
outline = await generateOutline(llm, { profile, inputDigest, prompt: options.outlinePrompt });
|
|
48
|
+
outline = await generateOutline(llm, { profile, inputDigest, prompt: options.outlinePrompt, extractedContracts });
|
|
42
49
|
const outlineDiags = lintOutline(outline, profile);
|
|
43
50
|
if (outlineHasErrors(outlineDiags)) {
|
|
44
51
|
return { ok: false, stage: 'outline-lint', diagnostics: outlineDiags.map((d) => ({ gate: 'outline', code: d.code, severity: d.severity, message: d.message, chapter: d.chapter })) };
|
|
@@ -66,7 +73,7 @@ export async function runDocument(options) {
|
|
|
66
73
|
}
|
|
67
74
|
// --- Chapter-by-chapter fill (Pass 2, T6) with real dependency digests (T7) ---
|
|
68
75
|
const chapterById = library;
|
|
69
|
-
const ctx = { outline, digests: new Map(), upstreamEntities: { interfaces: [], tables: [], decisions: [] }, workRoot, chapterById };
|
|
76
|
+
const ctx = { outline, digests: new Map(), upstreamEntities: { interfaces: [], tables: [], decisions: [] }, workRoot, chapterById, contextCache: new Map() };
|
|
70
77
|
// Process in DAG order: outline.chapters is already topological from expandProfile.
|
|
71
78
|
const narrativeMap = new Map();
|
|
72
79
|
const entityMap = new Map();
|
|
@@ -94,7 +101,7 @@ export async function runDocument(options) {
|
|
|
94
101
|
allEntities.decisions.push(...f.entities.decisions);
|
|
95
102
|
if (persist) {
|
|
96
103
|
await fs.mkdir(paths.chaptersDir, { recursive: true });
|
|
97
|
-
await fs.writeFile(
|
|
104
|
+
await fs.writeFile(chapterPath(paths.workRoot, f.chapterId), f.narrative, 'utf-8');
|
|
98
105
|
}
|
|
99
106
|
}
|
|
100
107
|
}
|
|
@@ -111,9 +118,26 @@ export async function runDocument(options) {
|
|
|
111
118
|
let review;
|
|
112
119
|
let reviewDiags = [];
|
|
113
120
|
if (!options.skipReview) {
|
|
121
|
+
let prevFindings = [];
|
|
122
|
+
// 跨轮去重:同一 finding 只应用一次(open → 应用后标记;fixed/waived 不再应用)。
|
|
123
|
+
const appliedFindingIds = new Set();
|
|
124
|
+
// 跨轮累积修复章节:post_review_repairs 必须记录所有轮次的 engine 改动,
|
|
125
|
+
// 否则后一轮(无新修复)会用空集合覆盖前一轮的 tamper 豁免记录。
|
|
126
|
+
const touchedChapters = new Set();
|
|
114
127
|
for (let round = 0; round < maxRounds; round++) {
|
|
115
|
-
const packet = await buildReviewPacket(workRoot, change, outline);
|
|
128
|
+
const packet = await buildReviewPacket(workRoot, change, outline, prevFindings, narrativeMap);
|
|
116
129
|
const candidate = await reviewWithAgent(llm, packet, options.reviewPrompt);
|
|
130
|
+
// Authoritative snapshot of what the reviewer actually saw: the LLM cannot compute sha256
|
|
131
|
+
// reliably, so the engine replaces the reviewer-reported hashes with its own computation
|
|
132
|
+
// taken at review time. This snapshot becomes the tamper baseline — it is NOT refreshed
|
|
133
|
+
// after repairs (that made the check vacuous).
|
|
134
|
+
// persist=false 时磁盘无章节文件 → 基于内存 narrativeMap 计算(P2:否则篡改校验形同虚设)。
|
|
135
|
+
if (persist) {
|
|
136
|
+
candidate.part_hashes = await computePartHashes(workRoot, outline.chapters.map((c) => c.id));
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
candidate.part_hashes = [...narrativeMap.entries()].map(([id, content]) => ({ id, sha256: sha256(content) }));
|
|
140
|
+
}
|
|
117
141
|
// Apply findings by target:
|
|
118
142
|
// narrative → append fix note to chapters/<id>.md
|
|
119
143
|
// entity → regenerate the affected chapter (real entity repair via re-fill)
|
|
@@ -127,59 +151,84 @@ export async function runDocument(options) {
|
|
|
127
151
|
if (target === 'narrative' || target === 'mixed') {
|
|
128
152
|
const chId = finding.chapter;
|
|
129
153
|
if (chId) {
|
|
154
|
+
touchedChapters.add(chId);
|
|
130
155
|
const existing = narrativeMap.get(chId) ?? '';
|
|
131
|
-
|
|
156
|
+
// Note format: message INSIDE the HTML comment so `stripReviewNotes` can remove the
|
|
157
|
+
// whole note from the final render; the audit trail stays in chapters/<id>.md and
|
|
158
|
+
// review-result.json. message/id 可能含 `-->`,先净化避免注释提前闭合注入 HTML。
|
|
159
|
+
const safeId = String(finding.id).replace(/--/g, '—');
|
|
160
|
+
const safeMsg = String(finding.message).replace(/--/g, '—').replace(/<!--/g, '〈!--');
|
|
161
|
+
narrativeMap.set(chId, `${existing}\n\n<!-- review-fix ${safeId}: ${safeMsg} -->`);
|
|
132
162
|
if (persist) {
|
|
133
163
|
await fs.mkdir(paths.chaptersDir, { recursive: true });
|
|
134
|
-
await fs.writeFile(
|
|
164
|
+
await fs.writeFile(chapterPath(paths.workRoot, chId), narrativeMap.get(chId), 'utf-8');
|
|
135
165
|
}
|
|
136
166
|
}
|
|
137
167
|
}
|
|
138
|
-
});
|
|
168
|
+
}, appliedFindingIds);
|
|
139
169
|
// Entity targets: re-fill the affected chapters so contract entities are regenerated (real fix).
|
|
140
170
|
for (const chId of new Set(entityRefills)) {
|
|
141
171
|
const ch = outline.chapters.find((c) => c.id === chId);
|
|
142
172
|
if (!ch)
|
|
143
173
|
continue;
|
|
174
|
+
touchedChapters.add(ch.id);
|
|
144
175
|
ctx.upstreamEntities = allEntities;
|
|
145
176
|
ctx.digests = await buildDigestsFor(ch, ctx.chapterById, outline, narrativeMap, allEntities, llm);
|
|
146
177
|
const result = await fillChapter(llm, ch, ctx, ctx.chapterById.get(ch.id)?.output_budget_tokens);
|
|
147
178
|
if (result.ok && result.fill) {
|
|
148
179
|
narrativeMap.set(ch.id, result.fill.narrative);
|
|
149
|
-
entityMap.set(ch.id, result.fill.entities);
|
|
150
180
|
tokenMap.set(ch.id, result.fill.tokens);
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
allEntities
|
|
156
|
-
allEntities
|
|
157
|
-
|
|
181
|
+
// 按「章节所有权」整体替换该章节的实体(P2):先移除该章旧实体(含 id 漂移的陈旧实体),
|
|
182
|
+
// 再合并新实体,避免全量按 id 过滤时误删他章副本/残留已改名实体。
|
|
183
|
+
// 注意顺序:先读旧实体(entityMap 当前值)用于移除,再写新实体。
|
|
184
|
+
const oldEntities = entityMap.get(ch.id);
|
|
185
|
+
removeChapterEntities(allEntities, oldEntities);
|
|
186
|
+
mergeEntities(allEntities, result.fill.entities);
|
|
187
|
+
entityMap.set(ch.id, result.fill.entities);
|
|
158
188
|
if (persist) {
|
|
159
189
|
await fs.mkdir(paths.chaptersDir, { recursive: true });
|
|
160
|
-
await fs.writeFile(
|
|
190
|
+
await fs.writeFile(chapterPath(paths.workRoot, ch.id), result.fill.narrative, 'utf-8');
|
|
161
191
|
await fs.writeFile(paths.entities, JSON.stringify(allEntities, null, 2), 'utf-8');
|
|
162
192
|
}
|
|
163
193
|
}
|
|
194
|
+
else {
|
|
195
|
+
// refill 失败(P2):记录诊断而非静默吞掉,避免问题只在模糊的 review_finding_open 中暴露。
|
|
196
|
+
reviewDiags.push({
|
|
197
|
+
gate: 'review',
|
|
198
|
+
code: 'entity_refill_failed',
|
|
199
|
+
severity: 'warning',
|
|
200
|
+
message: `Review entity refill for chapter "${ch.id}" failed: ${result.error ?? 'unknown error'}`,
|
|
201
|
+
chapter: ch.id,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
164
204
|
}
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
205
|
+
// Record which chapters the ENGINE changed after the review snapshot (repairs are
|
|
206
|
+
// engine-sanctioned, not external tampering) so the tamper check can distinguish the two.
|
|
207
|
+
candidate.post_review_repairs = [...touchedChapters].map((chapter) => ({
|
|
208
|
+
chapter,
|
|
209
|
+
at: new Date().toISOString(),
|
|
210
|
+
}));
|
|
211
|
+
// 当前哈希:persist 时读磁盘,否则用内存 narrativeMap(P2:与 review 时快照同源)。
|
|
212
|
+
const currentHashes = persist
|
|
213
|
+
? await computePartHashes(workRoot, outline.chapters.map((c) => c.id))
|
|
214
|
+
: [...narrativeMap.entries()].map(([id, content]) => ({ id, sha256: sha256(content) }));
|
|
215
|
+
const check = await checkReviewResult(workRoot, candidate, outline, currentHashes);
|
|
171
216
|
review = candidate;
|
|
172
217
|
reviewDiags = check.diagnostics.map((d) => ({ gate: 'review', code: d.code, severity: d.severity, message: d.message }));
|
|
173
218
|
if (check.ok) {
|
|
174
219
|
break;
|
|
175
220
|
}
|
|
221
|
+
// Feed this round's findings to the next round so the reviewer verifies the applied
|
|
222
|
+
// fix notes instead of re-reporting the same issues (closed loop, decision 9).
|
|
223
|
+
prevFindings = candidate.findings;
|
|
176
224
|
}
|
|
177
225
|
}
|
|
178
226
|
if (review && persist) {
|
|
179
227
|
await writeReviewResult(workRoot, review);
|
|
180
228
|
}
|
|
181
229
|
// --- Pass 4: render (T9) ---
|
|
182
|
-
const
|
|
230
|
+
const appendixAntiAI = await readAppendix(workRoot, 'anti-ai');
|
|
231
|
+
const documentMd = renderDocument({ outline, entities: allEntities, narratives: narrativeMap, profile, appendixAntiAI });
|
|
183
232
|
if (persist)
|
|
184
233
|
await fs.writeFile(paths.documentMd, documentMd, 'utf-8');
|
|
185
234
|
let documentHtml;
|
|
@@ -189,6 +238,16 @@ export async function runDocument(options) {
|
|
|
189
238
|
await fs.writeFile(paths.documentHtml, documentHtml, 'utf-8');
|
|
190
239
|
}
|
|
191
240
|
// --- Gates (T10) ---
|
|
241
|
+
// Multi-repo synthesize marker: repos.json (written by document synthesize) → runGates enforces
|
|
242
|
+
// the per-repo section gate in the library pipeline too (not only Agent-mode validateWork).
|
|
243
|
+
let synthRepos = [];
|
|
244
|
+
try {
|
|
245
|
+
const raw = JSON.parse(await fs.readFile(reposPath(workRoot), 'utf-8'));
|
|
246
|
+
synthRepos = Array.isArray(raw) ? raw.filter((r) => typeof r === 'string') : [];
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// no repos.json → not a synthesize workRoot → gate skipped (bundle/run unaffected)
|
|
250
|
+
}
|
|
192
251
|
const gates = await runGates({
|
|
193
252
|
outline,
|
|
194
253
|
chapterNarratives: narrativeMap,
|
|
@@ -198,7 +257,17 @@ export async function runDocument(options) {
|
|
|
198
257
|
entities: allEntities,
|
|
199
258
|
review,
|
|
200
259
|
workRoot,
|
|
260
|
+
library,
|
|
261
|
+
repos: synthRepos,
|
|
262
|
+
// 审查篡改校验的当前哈希(P2):persist=false 时磁盘无章节,传最终内存快照。
|
|
263
|
+
currentHashes: review && persist
|
|
264
|
+
? await computePartHashes(workRoot, outline.chapters.map((c) => c.id))
|
|
265
|
+
: review
|
|
266
|
+
? [...narrativeMap.entries()].map(([id, content]) => ({ id, sha256: sha256(content) }))
|
|
267
|
+
: undefined,
|
|
201
268
|
});
|
|
269
|
+
// 附录覆盖校验(§7.3):anti-ai 自检条目为空/不足 → warning。
|
|
270
|
+
const appendixDiags = appendixDiagnostics(profile, appendixAntiAI);
|
|
202
271
|
let totalTokens = 0;
|
|
203
272
|
for (const t of tokenMap.values())
|
|
204
273
|
totalTokens += t;
|
|
@@ -208,10 +277,36 @@ export async function runDocument(options) {
|
|
|
208
277
|
outline,
|
|
209
278
|
documentMd,
|
|
210
279
|
documentHtml,
|
|
211
|
-
diagnostics: [...reviewDiags, ...gates.diagnostics],
|
|
280
|
+
diagnostics: [...reviewDiags, ...appendixDiags, ...gates.diagnostics],
|
|
212
281
|
tokenEstimate: totalTokens,
|
|
213
282
|
};
|
|
214
283
|
}
|
|
284
|
+
/** Read an LLM-written appendix file (appendices/<name>.md); missing → empty string. */
|
|
285
|
+
async function readAppendix(workRoot, name) {
|
|
286
|
+
try {
|
|
287
|
+
return await fs.readFile(join(workRoot, 'appendices', `${name}.md`), 'utf-8');
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return '';
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/** 附录覆盖校验(README §7.3):profile.appendices 含 anti-ai 但自检条目为空/不足 → warning。 */
|
|
294
|
+
export function appendixDiagnostics(profile, antiAiContent) {
|
|
295
|
+
if (!profile.appendices.includes('anti-ai'))
|
|
296
|
+
return [];
|
|
297
|
+
const items = antiAiContent
|
|
298
|
+
.trim()
|
|
299
|
+
.split('\n')
|
|
300
|
+
.filter((l) => l.trim().length > 0 && !l.trim().startsWith('#'))
|
|
301
|
+
.length;
|
|
302
|
+
if (items === 0) {
|
|
303
|
+
return [{ gate: 'appendix', code: 'anti_ai_empty', severity: 'warning', message: '附录 B 去AI味自检条目为空(profile.appendices 含 anti-ai,需 ≥3 条)' }];
|
|
304
|
+
}
|
|
305
|
+
if (items < 3) {
|
|
306
|
+
return [{ gate: 'appendix', code: 'anti_ai_too_few', severity: 'warning', message: `附录 B 去AI味自检仅 ${items} 条(需 ≥3 条)` }];
|
|
307
|
+
}
|
|
308
|
+
return [];
|
|
309
|
+
}
|
|
215
310
|
/**
|
|
216
311
|
* Build dependency digests for a chapter's direct deps (decisions 2 + 8).
|
|
217
312
|
* For each dep chapter, derive its constraint list (points/entities/decisions it must preserve),
|
|
@@ -261,6 +356,68 @@ async function buildDigestsFor(chapter, library, outline, narrativeMap, allEntit
|
|
|
261
356
|
}
|
|
262
357
|
return digests;
|
|
263
358
|
}
|
|
359
|
+
/** 按 id 去重合并契约实体(P2):同一 id 保留后写者(最新版本),避免共享实体重复渲染。 */
|
|
360
|
+
function mergeEntities(target, incoming) {
|
|
361
|
+
const interfaceIds = new Set(target.interfaces.map((i) => i.id));
|
|
362
|
+
for (const it of incoming.interfaces) {
|
|
363
|
+
if (interfaceIds.has(it.id)) {
|
|
364
|
+
target.interfaces = target.interfaces.map((x) => (x.id === it.id ? it : x));
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
interfaceIds.add(it.id);
|
|
368
|
+
target.interfaces.push(it);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const tableIds = new Set(target.tables.map((t) => t.id));
|
|
372
|
+
for (const tb of incoming.tables) {
|
|
373
|
+
if (tableIds.has(tb.id)) {
|
|
374
|
+
target.tables = target.tables.map((x) => (x.id === tb.id ? tb : x));
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
tableIds.add(tb.id);
|
|
378
|
+
target.tables.push(tb);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const decisionIds = new Set(target.decisions.map((d) => d.id));
|
|
382
|
+
for (const d of incoming.decisions) {
|
|
383
|
+
if (decisionIds.has(d.id)) {
|
|
384
|
+
target.decisions = target.decisions.map((x) => (x.id === d.id ? d : x));
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
decisionIds.add(d.id);
|
|
388
|
+
target.decisions.push(d);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/** 从目标集合中移除某章节贡献的全部实体(按章节所有权,P2)。 */
|
|
393
|
+
function removeChapterEntities(target, chapterEntities) {
|
|
394
|
+
if (!chapterEntities)
|
|
395
|
+
return;
|
|
396
|
+
const ids = new Set([
|
|
397
|
+
...chapterEntities.interfaces.map((i) => i.id),
|
|
398
|
+
...chapterEntities.tables.map((t) => t.id),
|
|
399
|
+
...chapterEntities.decisions.map((d) => d.id),
|
|
400
|
+
]);
|
|
401
|
+
target.interfaces = target.interfaces.filter((i) => !ids.has(i.id));
|
|
402
|
+
target.tables = target.tables.filter((t) => !ids.has(t.id));
|
|
403
|
+
target.decisions = target.decisions.filter((d) => !ids.has(d.id));
|
|
404
|
+
}
|
|
405
|
+
/** 从章节叙述提取其内嵌的契约实体(```json 块,与 map.ts 语义一致)。 */
|
|
406
|
+
function extractChapterEntities(content) {
|
|
407
|
+
const out = { interfaces: [], tables: [], decisions: [] };
|
|
408
|
+
const jsonBlockRe = /```json\s*([\s\S]*?)```/g;
|
|
409
|
+
let m;
|
|
410
|
+
while ((m = jsonBlockRe.exec(content)) !== null) {
|
|
411
|
+
try {
|
|
412
|
+
const parsed = parseEntities(JSON.parse(m[1]));
|
|
413
|
+
mergeEntities(out, parsed);
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
// 非实体块(叙述示例)→ 忽略
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return out;
|
|
420
|
+
}
|
|
264
421
|
/**
|
|
265
422
|
* Fill all chapters in DAG order (real dependency digests + per-chapter budget).
|
|
266
423
|
* Returns [] on success, or a non-empty error diagnostic array on failure.
|
|
@@ -272,7 +429,19 @@ async function fillAllChapters(outline, ctx, narrativeMap, entityMap, tokenMap,
|
|
|
272
429
|
const changedChapterId = incremental.changedChapterId;
|
|
273
430
|
const changedEntityIds = incremental.changedEntityIds ?? [];
|
|
274
431
|
if (changedChapterId || changedEntityIds.length > 0) {
|
|
275
|
-
|
|
432
|
+
// 失效集必须基于「已知实体集合」计算,而不是入口处的空 allEntities(P2):
|
|
433
|
+
// 用 outline 冻结的实体注册表作为 known ids,否则 affectedDownstream 恒返回空、
|
|
434
|
+
// 增量失效静默 no-op。实体注册表在 Pass 1 已冻结,是增量场景下唯一可靠的已知集。
|
|
435
|
+
const registry = {
|
|
436
|
+
interfaces: outline.entities
|
|
437
|
+
.filter((e) => /^I\d+$/.test(e.id))
|
|
438
|
+
.map((e) => ({ id: e.id, name: e.frozen, method: 'GET', path: '/', errors: [], failure_examples: [] })),
|
|
439
|
+
tables: outline.entities
|
|
440
|
+
.filter((e) => /^T\d+$/.test(e.id))
|
|
441
|
+
.map((e) => ({ id: e.id, name: e.frozen, ddl: '' })),
|
|
442
|
+
decisions: [],
|
|
443
|
+
};
|
|
444
|
+
const sources = outline.chapters.map((ch) => recordDigestSources(ch, registry));
|
|
276
445
|
const affected = affectedDownstream(changedChapterId ?? '', changedEntityIds, sources);
|
|
277
446
|
refillSet = new Set(affected);
|
|
278
447
|
}
|
|
@@ -285,11 +454,16 @@ async function fillAllChapters(outline, ctx, narrativeMap, entityMap, tokenMap,
|
|
|
285
454
|
if (refillSet && !refillSet.has(ch.id) && !(incremental?.changedChapterId === ch.id) && !producesEntities) {
|
|
286
455
|
if (persist) {
|
|
287
456
|
try {
|
|
288
|
-
const cached = await fs.readFile(
|
|
457
|
+
const cached = await fs.readFile(chapterPath(paths.workRoot, ch.id), 'utf-8');
|
|
289
458
|
if (cached.trim().length > 0) {
|
|
290
459
|
narrativeMap.set(ch.id, cached);
|
|
291
460
|
tokenMap.set(ch.id, estimateTokens(cached));
|
|
292
461
|
budgetMap.set(ch.id, ctx.chapterById.get(ch.id)?.output_budget_tokens);
|
|
462
|
+
// 缓存复用的章节:把其上一轮产出的契约实体合并回 allEntities(P2),
|
|
463
|
+
// 否则第 175 行用内存中的精简集合覆写 entities.json 会丢掉缓存章节的实体。
|
|
464
|
+
const cachedEntities = extractChapterEntities(cached);
|
|
465
|
+
mergeEntities(allEntities, cachedEntities);
|
|
466
|
+
entityMap.set(ch.id, cachedEntities);
|
|
293
467
|
continue;
|
|
294
468
|
}
|
|
295
469
|
}
|
|
@@ -313,13 +487,11 @@ async function fillAllChapters(outline, ctx, narrativeMap, entityMap, tokenMap,
|
|
|
313
487
|
entityMap.set(ch.id, fill.entities);
|
|
314
488
|
tokenMap.set(ch.id, fill.tokens);
|
|
315
489
|
budgetMap.set(ch.id, budget);
|
|
316
|
-
// Merge entities for downstream digest/render
|
|
317
|
-
allEntities
|
|
318
|
-
allEntities.tables.push(...fill.entities.tables);
|
|
319
|
-
allEntities.decisions.push(...fill.entities.decisions);
|
|
490
|
+
// Merge entities for downstream digest/render(按 id 去重,P2:同一 id 保留新版本)。
|
|
491
|
+
mergeEntities(allEntities, fill.entities);
|
|
320
492
|
if (persist) {
|
|
321
493
|
await fs.mkdir(paths.chaptersDir, { recursive: true });
|
|
322
|
-
await fs.writeFile(
|
|
494
|
+
await fs.writeFile(chapterPath(paths.workRoot, ch.id), fill.narrative, 'utf-8');
|
|
323
495
|
await fs.writeFile(paths.entities, JSON.stringify(allEntities, null, 2), 'utf-8');
|
|
324
496
|
}
|
|
325
497
|
}
|
|
@@ -330,10 +502,16 @@ export function sceneTopics(profileId) {
|
|
|
330
502
|
switch (profileId) {
|
|
331
503
|
case '0to1':
|
|
332
504
|
return ['architecture', 'database', 'api', 'frontend'];
|
|
505
|
+
case 'frontend-0to1':
|
|
506
|
+
return ['frontend', 'api'];
|
|
333
507
|
case 'bugfix':
|
|
334
|
-
return ['database', 'api', 'architecture'];
|
|
508
|
+
return ['database', 'api', 'frontend', 'architecture'];
|
|
335
509
|
case 'feature':
|
|
336
510
|
return ['api', 'database', 'frontend', 'architecture'];
|
|
511
|
+
case 'migration':
|
|
512
|
+
return ['frontend', 'api', 'database', 'architecture'];
|
|
513
|
+
case 'poc':
|
|
514
|
+
return ['frontend', 'api', 'database'];
|
|
337
515
|
default:
|
|
338
516
|
return [];
|
|
339
517
|
}
|
|
@@ -416,6 +594,19 @@ export function planSteps(options) {
|
|
|
416
594
|
outputFile: 'review-result.json',
|
|
417
595
|
required: false,
|
|
418
596
|
});
|
|
597
|
+
// 附录(profile.appendices,§7.3):anti-ai 需要 LLM 条目;handoff 为确定性模板,无需步骤。
|
|
598
|
+
if (profile.appendices.includes('anti-ai')) {
|
|
599
|
+
steps.push({
|
|
600
|
+
id: 'appendix:anti-ai',
|
|
601
|
+
phase: 'fill',
|
|
602
|
+
title: '填写去AI味自检条目(附录 B)',
|
|
603
|
+
description: '按五维自检表(不做决策/数字具体/竞品立场/此刻合理性/优先级倾斜)写至少 3 条自检条目,写入 appendices/anti-ai.md。缺失将触发 validate warning。',
|
|
604
|
+
prompt: '.claude/specflow/prompts/document/map/anti-ai.md',
|
|
605
|
+
inputFiles: expanded.chapters.map((c) => `chapters/${c}.md`),
|
|
606
|
+
outputFile: 'appendices/anti-ai.md',
|
|
607
|
+
required: true,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
419
610
|
return { steps, chapters: expanded.chapters };
|
|
420
611
|
}
|
|
421
612
|
/** Read Agent-written artifacts from workRoot, validate (lint/coverage/zod) and render. No LLM. */
|
|
@@ -441,9 +632,26 @@ export async function validateWork(options) {
|
|
|
441
632
|
const tokenMap = new Map();
|
|
442
633
|
for (const ch of outline.chapters) {
|
|
443
634
|
try {
|
|
444
|
-
const content = await fs.readFile(
|
|
635
|
+
const content = await fs.readFile(chapterPath(paths.workRoot, ch.id), 'utf-8');
|
|
445
636
|
narrativeMap.set(ch.id, content);
|
|
446
637
|
tokenMap.set(ch.id, estimateTokens(content));
|
|
638
|
+
// 章节级实体归属:从章节内嵌 ```json 块提取该章贡献的契约实体,供结构化覆盖校验
|
|
639
|
+
// (与 map.ts 的提取语义一致;无法解析的块不影响叙述 lint)。
|
|
640
|
+
const chapterEntities = { interfaces: [], tables: [], decisions: [] };
|
|
641
|
+
const jsonBlockRe = /```json\s*([\s\S]*?)```/g;
|
|
642
|
+
let jm;
|
|
643
|
+
while ((jm = jsonBlockRe.exec(content)) !== null) {
|
|
644
|
+
try {
|
|
645
|
+
const parsed = parseEntities(JSON.parse(jm[1]));
|
|
646
|
+
chapterEntities.interfaces.push(...parsed.interfaces);
|
|
647
|
+
chapterEntities.tables.push(...parsed.tables);
|
|
648
|
+
chapterEntities.decisions.push(...parsed.decisions);
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
// 非实体块(叙述示例 JSON)→ 忽略
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
entityMap.set(ch.id, chapterEntities);
|
|
447
655
|
}
|
|
448
656
|
catch (e) {
|
|
449
657
|
diags.push({ gate: 'lint', code: 'chapter_missing', severity: 'error', message: `Missing chapter file ${ch.id}.md`, chapter: ch.id });
|
|
@@ -466,16 +674,43 @@ export async function validateWork(options) {
|
|
|
466
674
|
diags.push({ gate: 'lint', code: d.code, severity: d.severity, message: d.message, chapter: chId });
|
|
467
675
|
}
|
|
468
676
|
}
|
|
677
|
+
// Multi-repo synthesize quality gate: if repos.json exists (document synthesize wrote it),
|
|
678
|
+
// every required chapter must have a per-repo section (`### <repo>`) for ALL repos.
|
|
679
|
+
// This prevents "伪统一" — cross-repo content diluting each other into vague prose.
|
|
680
|
+
let synthRepos = [];
|
|
681
|
+
try {
|
|
682
|
+
const raw = JSON.parse(await fs.readFile(reposPath(workRoot), 'utf-8'));
|
|
683
|
+
synthRepos = Array.isArray(raw) ? raw.filter((r) => typeof r === 'string') : [];
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
// no repos.json → not a synthesize workRoot → gate skipped (bundle/run unaffected)
|
|
687
|
+
}
|
|
688
|
+
if (synthRepos.length >= 2) {
|
|
689
|
+
const requiredIds = new Set(profile.required);
|
|
690
|
+
for (const ch of outline.chapters) {
|
|
691
|
+
if (!requiredIds.has(ch.id))
|
|
692
|
+
continue;
|
|
693
|
+
const narrative = narrativeMap.get(ch.id);
|
|
694
|
+
if (narrative === undefined)
|
|
695
|
+
continue; // chapter_missing already reported
|
|
696
|
+
for (const d of checkRepoSectionCoverage(ch.id, narrative, synthRepos)) {
|
|
697
|
+
diags.push({ gate: 'synthesize', code: d.code, severity: d.severity, message: d.message, chapter: ch.id });
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
469
701
|
// Structured coverage (entity points must be filled).
|
|
470
702
|
for (const d of checkStructuredCoverage(outline, entityMap)) {
|
|
471
703
|
diags.push({ gate: 'coverage', code: d.code, severity: d.severity, message: d.message, chapter: d.chapter });
|
|
472
704
|
}
|
|
705
|
+
// 附录覆盖校验(§7.3):anti-ai 自检条目为空/不足 → warning。
|
|
706
|
+
const appendixAntiAI = await readAppendix(workRoot, 'anti-ai');
|
|
707
|
+
diags.push(...appendixDiagnostics(profile, appendixAntiAI));
|
|
473
708
|
const hasError = diags.some((d) => d.severity === 'error');
|
|
474
709
|
if (hasError) {
|
|
475
710
|
return { ok: false, diagnostics: diags };
|
|
476
711
|
}
|
|
477
|
-
// Render.
|
|
478
|
-
const documentMd = renderDocument({ outline, entities: allEntities, narratives: narrativeMap });
|
|
712
|
+
// Render (appendices driven by profile.appendices).
|
|
713
|
+
const documentMd = renderDocument({ outline, entities: allEntities, narratives: narrativeMap, profile, appendixAntiAI });
|
|
479
714
|
await fs.writeFile(paths.documentMd, documentMd, 'utf-8');
|
|
480
715
|
let documentHtml;
|
|
481
716
|
if (options.renderHtmlOutput !== false) {
|