ai-spec-dev 0.38.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/.ai-spec-workspace.json +17 -0
  2. package/.ai-spec.json +7 -0
  3. package/cli/commands/create.ts +9 -1176
  4. package/cli/commands/dashboard.ts +1 -1
  5. package/cli/pipeline/helpers.ts +34 -0
  6. package/cli/pipeline/multi-repo.ts +483 -0
  7. package/cli/pipeline/single-repo.ts +764 -0
  8. package/cli/utils.ts +2 -0
  9. package/core/cli-ui.ts +136 -0
  10. package/core/code-generator.ts +56 -343
  11. package/core/codegen/helpers.ts +219 -0
  12. package/core/codegen/topo-sort.ts +98 -0
  13. package/core/constitution-consolidator.ts +2 -2
  14. package/core/dsl-coverage-checker.ts +298 -0
  15. package/core/dsl-extractor.ts +19 -46
  16. package/core/dsl-feedback.ts +1 -1
  17. package/core/dsl-validator.ts +74 -0
  18. package/core/error-feedback.ts +99 -13
  19. package/core/frontend-context-loader.ts +27 -5
  20. package/core/knowledge-memory.ts +52 -0
  21. package/core/mock/fixtures.ts +89 -0
  22. package/core/mock/proxy.ts +380 -0
  23. package/core/mock-server-generator.ts +12 -460
  24. package/core/provider-utils.ts +8 -7
  25. package/core/requirement-decomposer.ts +4 -28
  26. package/core/reviewer.ts +1 -1
  27. package/core/safe-json.ts +76 -0
  28. package/core/spec-updater.ts +5 -21
  29. package/core/token-budget.ts +124 -0
  30. package/core/vcr.ts +20 -1
  31. package/demo-backend/.ai-spec-constitution.md +65 -0
  32. package/demo-backend/package.json +21 -0
  33. package/demo-backend/prisma/schema.prisma +22 -0
  34. package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.dsl.json +186 -0
  35. package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.md +211 -0
  36. package/demo-backend/src/controllers/bookmark.controller.test.ts +255 -0
  37. package/demo-backend/src/controllers/bookmark.controller.ts +187 -0
  38. package/demo-backend/src/index.ts +17 -0
  39. package/demo-backend/src/routes/bookmark.routes.test.ts +264 -0
  40. package/demo-backend/src/routes/bookmark.routes.ts +11 -0
  41. package/demo-backend/src/routes/index.ts +8 -0
  42. package/demo-backend/src/services/bookmark.service.test.ts +433 -0
  43. package/demo-backend/src/services/bookmark.service.ts +261 -0
  44. package/demo-backend/tsconfig.json +12 -0
  45. package/demo-frontend/.ai-spec-constitution.md +95 -0
  46. package/demo-frontend/package.json +23 -0
  47. package/demo-frontend/src/App.tsx +12 -0
  48. package/demo-frontend/src/main.tsx +9 -0
  49. package/demo-frontend/tsconfig.json +13 -0
  50. package/dist/cli/index.js +4351 -3666
  51. package/dist/cli/index.js.map +1 -1
  52. package/dist/cli/index.mjs +3997 -3312
  53. package/dist/cli/index.mjs.map +1 -1
  54. package/dist/index.d.mts +18 -16
  55. package/dist/index.d.ts +18 -16
  56. package/dist/index.js +388 -188
  57. package/dist/index.js.map +1 -1
  58. package/dist/index.mjs +386 -186
  59. package/dist/index.mjs.map +1 -1
  60. package/package.json +2 -2
  61. package/tests/auto-consolidation.test.ts +109 -0
  62. package/tests/combined-generator.test.ts +81 -0
  63. package/tests/constitution-consolidator.test.ts +161 -0
  64. package/tests/constitution-generator.test.ts +94 -0
  65. package/tests/contract-bridge.test.ts +201 -0
  66. package/tests/design-dialogue.test.ts +108 -0
  67. package/tests/dsl-coverage-checker.test.ts +230 -0
  68. package/tests/dsl-feedback.test.ts +45 -0
  69. package/tests/dsl-validator-xref.test.ts +99 -0
  70. package/tests/error-feedback-repair.test.ts +319 -0
  71. package/tests/error-feedback-validation.test.ts +91 -0
  72. package/tests/frontend-context-loader.test.ts +609 -0
  73. package/tests/global-constitution.test.ts +110 -0
  74. package/tests/key-store.test.ts +73 -0
  75. package/tests/knowledge-memory.test.ts +327 -0
  76. package/tests/project-index.test.ts +206 -0
  77. package/tests/prompt-hasher.test.ts +19 -0
  78. package/tests/requirement-decomposer.test.ts +171 -0
  79. package/tests/reviewer.test.ts +4 -1
  80. package/tests/run-logger.test.ts +289 -0
  81. package/tests/run-snapshot.test.ts +113 -0
  82. package/tests/safe-json.test.ts +63 -0
  83. package/tests/spec-updater.test.ts +161 -0
  84. package/tests/test-generator.test.ts +146 -0
  85. package/tests/token-budget.test.ts +124 -0
  86. package/tests/vcr-hash.test.ts +101 -0
  87. package/tests/workspace-loader.test.ts +277 -0
  88. package/RELEASE_LOG.md +0 -2731
  89. package/purpose.md +0 -1294
@@ -0,0 +1,219 @@
1
+ import chalk from "chalk";
2
+ import { execSync } from "child_process";
3
+ import { ProjectContext } from "../context-loader";
4
+
5
+ // ─── Types ───────────────────────────────────────────────────────────────────
6
+
7
+ export interface FileAction {
8
+ file: string;
9
+ action: "create" | "modify";
10
+ description: string;
11
+ }
12
+
13
+ // ─── Shared Config Helper ───────────────────────────────────────────────────
14
+
15
+ export function buildSharedConfigSection(context?: ProjectContext): string {
16
+ if (!context?.sharedConfigFiles || context.sharedConfigFiles.length === 0) return "";
17
+
18
+ const lines: string[] = [
19
+ "\n=== Existing Shared Config Files (study these to learn project conventions) ===",
20
+ "These are real files from the project. Use them as ground truth for naming, structure, and registration patterns.",
21
+ "Modify them in-place when adding new entries. Do NOT create parallel files for the same purpose.\n",
22
+ ];
23
+
24
+ for (const f of context.sharedConfigFiles) {
25
+ lines.push(`--- File: ${f.path} [${f.category}] ---`);
26
+ lines.push(f.preview);
27
+ lines.push("");
28
+ }
29
+ return lines.join("\n") + "\n";
30
+ }
31
+
32
+ export function buildInstalledPackagesSection(context?: ProjectContext): string {
33
+ if (!context?.dependencies || context.dependencies.length === 0) return "";
34
+ return `\n=== Installed Packages (ONLY use packages from this list — NEVER import anything not listed here) ===\n${context.dependencies.join(", ")}\n`;
35
+ }
36
+
37
+ // ─── Behavioral Contract Extractor ──────────────────────────────────────────
38
+
39
+ /**
40
+ * Extract a behavioral contract summary from a generated file.
41
+ *
42
+ * Captures:
43
+ * - export interface / type / enum — full multi-line blocks (the actual TS contracts)
44
+ * - export function / const / class — opening signature line
45
+ * - Throw statements — error codes & validation constraints
46
+ *
47
+ * Multi-line blocks (interface, type alias with {}) are captured in full so
48
+ * downstream tasks see complete method signatures and field shapes, not just
49
+ * a single-line "export interface Foo {" that conveys nothing.
50
+ *
51
+ * Falls back to first 3000 chars for CommonJS files with no explicit exports.
52
+ */
53
+ export function extractBehavioralContract(content: string): string {
54
+ const lines = content.split("\n");
55
+ const contractLines: string[] = [];
56
+ const throwLines: string[] = [];
57
+ let i = 0;
58
+
59
+ while (i < lines.length) {
60
+ const line = lines[i];
61
+ const trimmed = line.trim();
62
+
63
+ // ── Multi-line block exports: interface / type X = { / class / enum ──────
64
+ if (/^export\s+(interface|type|class|abstract\s+class|enum)\s/.test(trimmed)) {
65
+ contractLines.push(line.trimEnd());
66
+ if (trimmed.includes("{")) {
67
+ let depth =
68
+ (trimmed.match(/\{/g) ?? []).length -
69
+ (trimmed.match(/\}/g) ?? []).length;
70
+ i++;
71
+ while (i < lines.length && depth > 0) {
72
+ const inner = lines[i];
73
+ contractLines.push(inner.trimEnd());
74
+ depth += (inner.match(/\{/g) ?? []).length;
75
+ depth -= (inner.match(/\}/g) ?? []).length;
76
+ i++;
77
+ }
78
+ } else {
79
+ i++;
80
+ }
81
+ continue;
82
+ }
83
+
84
+ // ── export const X = defineStore(...) — capture full block ───────────────
85
+ if (/^export\s+const\s+\w+\s*=\s*(defineStore|createStore|createSlice)\s*\(/.test(trimmed)) {
86
+ contractLines.push(line.trimEnd());
87
+ let depth = (trimmed.match(/\(/g) ?? []).length - (trimmed.match(/\)/g) ?? []).length;
88
+ i++;
89
+ while (i < lines.length && depth > 0) {
90
+ const inner = lines[i];
91
+ contractLines.push(inner.trimEnd());
92
+ depth += (inner.match(/\(/g) ?? []).length;
93
+ depth -= (inner.match(/\)/g) ?? []).length;
94
+ i++;
95
+ }
96
+ continue;
97
+ }
98
+
99
+ // ── return { ... } — composable/store public API surface ─────────────────
100
+ if (/^return\s*\{/.test(trimmed)) {
101
+ contractLines.push("// public API (return object):");
102
+ contractLines.push(line.trimEnd());
103
+ let depth = (trimmed.match(/\{/g) ?? []).length - (trimmed.match(/\}/g) ?? []).length;
104
+ i++;
105
+ while (i < lines.length && depth > 0) {
106
+ const inner = lines[i];
107
+ contractLines.push(inner.trimEnd());
108
+ depth += (inner.match(/\{/g) ?? []).length;
109
+ depth -= (inner.match(/\}/g) ?? []).length;
110
+ i++;
111
+ }
112
+ continue;
113
+ }
114
+
115
+ // ── export default function/class — capture full block ───────────────────
116
+ if (/^export\s+default\s+(async\s+)?(function|class)\b/.test(trimmed)) {
117
+ contractLines.push(line.trimEnd());
118
+ if (trimmed.includes("{")) {
119
+ let depth =
120
+ (trimmed.match(/\{/g) ?? []).length -
121
+ (trimmed.match(/\}/g) ?? []).length;
122
+ i++;
123
+ while (i < lines.length && depth > 0) {
124
+ const inner = lines[i];
125
+ contractLines.push(inner.trimEnd());
126
+ depth += (inner.match(/\{/g) ?? []).length;
127
+ depth -= (inner.match(/\}/g) ?? []).length;
128
+ i++;
129
+ }
130
+ } else {
131
+ i++;
132
+ }
133
+ continue;
134
+ }
135
+
136
+ // ── Single-line export declarations (functions, consts, re-exports) ───────
137
+ if (/^export\s/.test(trimmed)) {
138
+ contractLines.push(line.trimEnd());
139
+ }
140
+
141
+ // ── Throw patterns — validation constraints and named error codes ─────────
142
+ if (
143
+ /throw\s+(new\s+)?\w*[Ee]rror\b|throw\s+create[A-Z]\w*|@throws/.test(line) &&
144
+ throwLines.length < 20
145
+ ) {
146
+ throwLines.push(" // " + trimmed);
147
+ }
148
+
149
+ i++;
150
+ }
151
+
152
+ if (contractLines.length === 0 && throwLines.length === 0) {
153
+ return content.slice(0, 3000);
154
+ }
155
+
156
+ const parts: string[] = [...contractLines];
157
+ if (throwLines.length > 0) {
158
+ parts.push("", "// Error contracts (throws / validation):", ...throwLines);
159
+ }
160
+ return parts.join("\n");
161
+ }
162
+
163
+ /**
164
+ * Build a context section from files already written in this generation run.
165
+ */
166
+ export function buildGeneratedFilesSection(cache: Map<string, string>): string {
167
+ if (cache.size === 0) return "";
168
+ const lines = [
169
+ "\n=== Files Already Generated in This Run — USE EXACT EXPORTS (do not rename or invent alternatives) ===",
170
+ "// CRITICAL: function/action names and file paths below are ground truth. Copy them EXACTLY.",
171
+ "// Do NOT add suffixes (List, Data, All, Info) or change casing.",
172
+ "// For '// exists:' entries: use the EXACT filename shown — do NOT substitute index.vue or other defaults.",
173
+ ];
174
+ for (const [filePath, content] of cache) {
175
+ const isViewFile = /src[\\/](views?|pages?)[\\/]/i.test(filePath);
176
+ if (isViewFile) {
177
+ lines.push(`\n// exists: ${filePath}`);
178
+ continue;
179
+ }
180
+ lines.push(`\n--- ${filePath} ---`);
181
+ const isStoreOrComposable = /src[\\/](stores?|composables?)[\\/]/i.test(filePath);
182
+ lines.push(isStoreOrComposable ? content : extractBehavioralContract(content));
183
+ }
184
+ return lines.join("\n") + "\n";
185
+ }
186
+
187
+ // ─── RTK Helper ────────────────────────────────────────────────────────────────
188
+
189
+ export function isRtkAvailable(): boolean {
190
+ try {
191
+ execSync("rtk --version", { stdio: "ignore", timeout: 10_000 });
192
+ return true;
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
197
+
198
+ // ─── Parser Helpers ──────────────────────────────────────────────────────────
199
+
200
+ export function stripCodeFences(output: string): string {
201
+ const fenced = output.match(/^```(?:\w+)?\n([\s\S]*?)```\s*$/m);
202
+ if (fenced) return fenced[1].trim();
203
+ const lines = output.split("\n");
204
+ if (lines[0].startsWith("```")) lines.shift();
205
+ if (lines[lines.length - 1].trim() === "```") lines.pop();
206
+ return lines.join("\n").trim();
207
+ }
208
+
209
+ export function parseJsonArray(text: string): FileAction[] {
210
+ const fenced = text.match(/```(?:json)?\n(\[[\s\S]*?\])\n```/);
211
+ const raw = fenced ? fenced[1] : text.match(/\[[\s\S]*?\]/)?.[0] ?? "";
212
+ try {
213
+ const parsed = JSON.parse(raw);
214
+ if (Array.isArray(parsed)) return parsed as FileAction[];
215
+ } catch {
216
+ // fall through
217
+ }
218
+ return [];
219
+ }
@@ -0,0 +1,98 @@
1
+ import chalk from "chalk";
2
+ import { SpecTask } from "../task-generator";
3
+
4
+ // ─── Topological Batch Sort ────────────────────────────────────────────────────
5
+
6
+ /**
7
+ * Partition tasks within a layer into ordered batches that respect the
8
+ * `dependencies` field. Tasks in the same batch have no intra-layer
9
+ * dependencies on each other and can run in parallel. Tasks in later batches
10
+ * wait for earlier batches to complete.
11
+ *
12
+ * Only intra-layer dependencies (i.e. deps whose IDs also appear in `tasks`)
13
+ * are considered — cross-layer ordering is already handled by LAYER_ORDER.
14
+ *
15
+ * Returns at least one batch. On circular-dependency detection the remaining
16
+ * tasks are dumped into a final batch so execution always completes.
17
+ */
18
+ export function topoSortLayerTasks(tasks: SpecTask[]): SpecTask[][] {
19
+ if (tasks.length <= 1) return [tasks];
20
+
21
+ const idSet = new Set(tasks.map((t) => t.id));
22
+ const taskById = new Map(tasks.map((t) => [t.id, t]));
23
+ const inDegree = new Map<string, number>();
24
+ const dependents = new Map<string, string[]>(); // dep → tasks that depend on it
25
+
26
+ for (const task of tasks) {
27
+ inDegree.set(task.id, 0);
28
+ dependents.set(task.id, []);
29
+ }
30
+
31
+ for (const task of tasks) {
32
+ const intraDeps = task.dependencies.filter((dep) => idSet.has(dep));
33
+ inDegree.set(task.id, intraDeps.length);
34
+ for (const dep of intraDeps) {
35
+ dependents.get(dep)!.push(task.id);
36
+ }
37
+ }
38
+
39
+ const batches: SpecTask[][] = [];
40
+ const remaining = new Set(tasks.map((t) => t.id));
41
+
42
+ while (remaining.size > 0) {
43
+ const batch = [...remaining]
44
+ .filter((id) => inDegree.get(id) === 0)
45
+ .map((id) => taskById.get(id)!);
46
+
47
+ if (batch.length === 0) {
48
+ // Circular dependency — run all remaining tasks in parallel to avoid deadlock
49
+ batches.push([...remaining].map((id) => taskById.get(id)!));
50
+ break;
51
+ }
52
+
53
+ batches.push(batch);
54
+ for (const task of batch) {
55
+ remaining.delete(task.id);
56
+ for (const dependent of dependents.get(task.id)!) {
57
+ inDegree.set(dependent, inDegree.get(dependent)! - 1);
58
+ }
59
+ }
60
+ }
61
+
62
+ return batches;
63
+ }
64
+
65
+ // ─── Progress Bar Helper ───────────────────────────────────────────────────────
66
+
67
+ export const LAYER_ICONS: Record<string, string> = {
68
+ data: "💾",
69
+ infra: "⚙️ ",
70
+ service: "🔧",
71
+ api: "🌐",
72
+ view: "🖥️ ",
73
+ route: "🗺️ ",
74
+ test: "🧪",
75
+ };
76
+
77
+ export function printTaskProgress(
78
+ completed: number,
79
+ total: number,
80
+ task: SpecTask,
81
+ mode: "run" | "skip"
82
+ ): void {
83
+ const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
84
+ const barWidth = 20;
85
+ const filled = Math.round((pct / 100) * barWidth);
86
+ const bar = chalk.green("█".repeat(filled)) + chalk.gray("░".repeat(barWidth - filled));
87
+ const icon = LAYER_ICONS[task.layer] ?? " ";
88
+
89
+ if (mode === "skip") {
90
+ console.log(
91
+ chalk.gray(`\n [${bar}] ${pct}% ✓ ${task.id} ${icon} ${task.title} — already done`)
92
+ );
93
+ } else {
94
+ console.log(
95
+ chalk.bold(`\n [${bar}] ${pct}% → ${task.id} ${icon} ${task.title}`)
96
+ );
97
+ }
98
+ }
@@ -108,8 +108,8 @@ export class ConstitutionConsolidator {
108
108
  // ── Show diff ───────────────────────────────────────────────────────────
109
109
  const diff = computeDiff(original, consolidated);
110
110
  console.log(chalk.blue("\n Changes preview:"));
111
- printDiff(diff, 4);
112
- printDiffSummary(diff);
111
+ printDiff(diff);
112
+ printDiffSummary(diff, "consolidation");
113
113
 
114
114
  console.log(chalk.cyan("\n After consolidation:"));
115
115
  console.log(chalk.gray(` Size : ${after.totalLines} lines (was ${before.totalLines})`));
@@ -0,0 +1,298 @@
1
+ /**
2
+ * dsl-coverage-checker.ts — Verify that DSL covers all Spec requirements.
3
+ *
4
+ * Extracts User Stories and Functional Requirements from Spec markdown,
5
+ * then checks each against DSL endpoints/models/behaviors using keyword
6
+ * matching. Uncovered requirements are reported as DslGap entries.
7
+ */
8
+
9
+ import { SpecDSL } from "./dsl-types";
10
+
11
+ // ─── Types ──────────────────────────────────────────────────────────────────────
12
+
13
+ export interface SpecRequirement {
14
+ id: string;
15
+ text: string;
16
+ section: "user_story" | "functional_req" | "boundary_condition";
17
+ }
18
+
19
+ export interface CoverageResult {
20
+ covered: SpecRequirement[];
21
+ uncovered: SpecRequirement[];
22
+ coverageRatio: number;
23
+ }
24
+
25
+ // ─── Keyword Extraction ─────────────────────────────────────────────────────────
26
+
27
+ /** CJK character range regex. */
28
+ const CJK_RANGE = /[\u4e00-\u9fff\u3400-\u4dbf]/g;
29
+
30
+ /** Common stopwords to ignore (Chinese + English). */
31
+ const STOPWORDS = new Set([
32
+ // Chinese
33
+ "的", "了", "在", "是", "我", "有", "和", "就", "不", "人", "都", "一",
34
+ "一个", "上", "也", "到", "说", "要", "去", "你", "会", "着", "没有",
35
+ "看", "好", "自己", "这", "他", "她", "它", "我们", "可以", "能", "能够",
36
+ "需要", "应该", "作为", "希望", "以便", "通过", "使用", "进行", "支持",
37
+ "包括", "提供", "实现", "系统", "功能", "用户",
38
+ // English
39
+ "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
40
+ "have", "has", "had", "do", "does", "did", "will", "would", "could",
41
+ "should", "may", "might", "can", "shall", "to", "of", "in", "for",
42
+ "on", "with", "at", "by", "from", "as", "into", "through", "during",
43
+ "before", "after", "and", "or", "but", "not", "no", "if", "then",
44
+ "than", "so", "that", "this", "these", "those", "it", "its",
45
+ "i", "we", "you", "they", "he", "she", "my", "our", "your",
46
+ "able", "want", "need", "use", "make", "get", "set",
47
+ ]);
48
+
49
+ /**
50
+ * Extract meaningful keywords from text (handles mixed CJK + English).
51
+ * CJK: split into individual characters and bigrams.
52
+ * English: split by non-alpha, filter stopwords, lowercase.
53
+ */
54
+ export function extractKeywords(text: string): Set<string> {
55
+ const keywords = new Set<string>();
56
+
57
+ // Extract CJK characters and form bigrams
58
+ const cjkChars = text.match(CJK_RANGE) ?? [];
59
+ for (const ch of cjkChars) {
60
+ if (!STOPWORDS.has(ch)) keywords.add(ch);
61
+ }
62
+ for (let i = 0; i < cjkChars.length - 1; i++) {
63
+ const bigram = cjkChars[i] + cjkChars[i + 1];
64
+ if (!STOPWORDS.has(bigram)) keywords.add(bigram);
65
+ }
66
+
67
+ // Extract English words
68
+ const englishWords = text
69
+ .replace(CJK_RANGE, " ")
70
+ .toLowerCase()
71
+ .split(/[^a-z0-9]+/)
72
+ .filter((w) => w.length >= 2 && !STOPWORDS.has(w));
73
+ for (const w of englishWords) keywords.add(w);
74
+
75
+ return keywords;
76
+ }
77
+
78
+ // ─── Spec Requirement Extraction ────────────────────────────────────────────────
79
+
80
+ /**
81
+ * Parse User Stories from Spec markdown.
82
+ * Matches patterns like: "作为 **角色**,我希望 **动作**,以便 **目的**"
83
+ * and English "As a **role**, I want **action**, so that **purpose**"
84
+ */
85
+ function extractUserStories(spec: string): SpecRequirement[] {
86
+ const reqs: SpecRequirement[] = [];
87
+ const lines = spec.split("\n");
88
+ let storyIdx = 0;
89
+
90
+ for (const line of lines) {
91
+ const trimmed = line.trim();
92
+ // Chinese format: "- 作为 ..." or "1. 作为 ..." or "作为 ..."
93
+ if (/^[-*]\s+作为\s/.test(trimmed) || /^\d+[.)]\s*作为\s/.test(trimmed) || /^作为\s/.test(trimmed)) {
94
+ storyIdx++;
95
+ reqs.push({
96
+ id: `US-${storyIdx}`,
97
+ text: trimmed.replace(/^[-*]\s+/, "").replace(/^\d+[.)]\s*/, ""),
98
+ section: "user_story",
99
+ });
100
+ continue;
101
+ }
102
+ // English format: "- As a ..." or "1. As a ..." or "As a ..."
103
+ if (/^[-*]\s+As an?\s/i.test(trimmed) || /^\d+[.)]\s*As an?\s/i.test(trimmed) || /^As an?\s/i.test(trimmed)) {
104
+ storyIdx++;
105
+ reqs.push({
106
+ id: `US-${storyIdx}`,
107
+ text: trimmed.replace(/^[-*]\s+/, "").replace(/^\d+[.)]\s*/, ""),
108
+ section: "user_story",
109
+ });
110
+ }
111
+ }
112
+
113
+ return reqs;
114
+ }
115
+
116
+ /**
117
+ * Parse Functional Requirements from Spec markdown.
118
+ * Matches checklist items: "- [ ] requirement text" and numbered items under §4.
119
+ */
120
+ function extractFunctionalReqs(spec: string): SpecRequirement[] {
121
+ const reqs: SpecRequirement[] = [];
122
+ let inSection4 = false;
123
+ let reqIdx = 0;
124
+ const lines = spec.split("\n");
125
+
126
+ for (const line of lines) {
127
+ const trimmed = line.trim();
128
+
129
+ // Detect section 4 heading (functional requirements)
130
+ if (/^#{1,3}\s*(4\.|四|功能需求|Functional\s+Req)/i.test(trimmed)) {
131
+ inSection4 = true;
132
+ continue;
133
+ }
134
+ // Next section heading exits section 4
135
+ if (inSection4 && /^#{1,3}\s*(\d+\.|五|六|七|八|九|API|Data|Non-Func)/i.test(trimmed)) {
136
+ inSection4 = false;
137
+ continue;
138
+ }
139
+
140
+ if (!inSection4) continue;
141
+
142
+ // Checklist items: - [ ] or - [x]
143
+ const checklistMatch = trimmed.match(/^-\s*\[[ x]\]\s*(.+)/i);
144
+ if (checklistMatch) {
145
+ reqIdx++;
146
+ reqs.push({
147
+ id: `FR-${reqIdx}`,
148
+ text: checklistMatch[1],
149
+ section: "functional_req",
150
+ });
151
+ continue;
152
+ }
153
+
154
+ // Numbered sub-items: 4.1.1, 4.2.3, etc.
155
+ const numberedMatch = trimmed.match(/^(\d+\.)+\d*\s+(.+)/);
156
+ if (numberedMatch) {
157
+ reqIdx++;
158
+ reqs.push({
159
+ id: `FR-${reqIdx}`,
160
+ text: numberedMatch[2],
161
+ section: "functional_req",
162
+ });
163
+ }
164
+ }
165
+
166
+ return reqs;
167
+ }
168
+
169
+ /**
170
+ * Parse Boundary Conditions from Spec markdown (section 4.2 or edge cases).
171
+ */
172
+ function extractBoundaryConditions(spec: string): SpecRequirement[] {
173
+ const reqs: SpecRequirement[] = [];
174
+ let inBoundary = false;
175
+ let bcIdx = 0;
176
+ const lines = spec.split("\n");
177
+
178
+ for (const line of lines) {
179
+ const trimmed = line.trim();
180
+
181
+ if (/边界|boundary|edge\s+case|异常|错误处理/i.test(trimmed) && /^#{1,4}/.test(trimmed)) {
182
+ inBoundary = true;
183
+ continue;
184
+ }
185
+ if (inBoundary && /^#{1,3}\s/.test(trimmed)) {
186
+ inBoundary = false;
187
+ continue;
188
+ }
189
+
190
+ if (!inBoundary) continue;
191
+
192
+ const itemMatch = trimmed.match(/^[-*]\s+(.+)/) || trimmed.match(/^\d+[.)]\s*(.+)/);
193
+ if (itemMatch && itemMatch[1].length > 5) {
194
+ bcIdx++;
195
+ reqs.push({
196
+ id: `BC-${bcIdx}`,
197
+ text: itemMatch[1],
198
+ section: "boundary_condition",
199
+ });
200
+ }
201
+ }
202
+
203
+ return reqs;
204
+ }
205
+
206
+ // ─── Public API ──────────────────────────────────────────────────────────────────
207
+
208
+ /**
209
+ * Extract all requirements from a Spec markdown document.
210
+ */
211
+ export function extractSpecRequirements(spec: string): SpecRequirement[] {
212
+ return [
213
+ ...extractUserStories(spec),
214
+ ...extractFunctionalReqs(spec),
215
+ ...extractBoundaryConditions(spec),
216
+ ];
217
+ }
218
+
219
+ /**
220
+ * Build a keyword index from all DSL elements for fast matching.
221
+ */
222
+ function buildDslKeywordIndex(dsl: SpecDSL): Set<string> {
223
+ const allText: string[] = [];
224
+
225
+ // Feature
226
+ allText.push(dsl.feature.title, dsl.feature.description);
227
+
228
+ // Models
229
+ for (const m of dsl.models) {
230
+ allText.push(m.name, m.description ?? "");
231
+ for (const f of m.fields) allText.push(f.name, f.description ?? "");
232
+ for (const r of m.relations ?? []) allText.push(r);
233
+ }
234
+
235
+ // Endpoints
236
+ for (const ep of dsl.endpoints) {
237
+ allText.push(ep.description, ep.path);
238
+ if (ep.request?.body) allText.push(...Object.keys(ep.request.body));
239
+ if (ep.request?.query) allText.push(...Object.keys(ep.request.query));
240
+ for (const err of ep.errors ?? []) allText.push(err.code, err.description);
241
+ }
242
+
243
+ // Behaviors
244
+ for (const b of dsl.behaviors) {
245
+ allText.push(b.description, b.trigger ?? "");
246
+ for (const c of b.constraints ?? []) allText.push(c);
247
+ }
248
+
249
+ // Components
250
+ for (const c of dsl.components ?? []) {
251
+ allText.push(c.name, c.description);
252
+ for (const p of c.props) allText.push(p.name, p.description ?? "");
253
+ for (const e of c.events) allText.push(e.name, e.payload ?? "");
254
+ }
255
+
256
+ return extractKeywords(allText.join(" "));
257
+ }
258
+
259
+ /** Minimum keyword overlap to consider a requirement "covered". */
260
+ const MIN_KEYWORD_OVERLAP = 2;
261
+
262
+ /**
263
+ * Check how well the DSL covers the Spec requirements.
264
+ * Uses keyword overlap: a requirement is "covered" if it shares
265
+ * ≥ MIN_KEYWORD_OVERLAP significant keywords with any DSL element.
266
+ */
267
+ export function checkDslCoverage(
268
+ requirements: SpecRequirement[],
269
+ dsl: SpecDSL
270
+ ): CoverageResult {
271
+ if (requirements.length === 0) {
272
+ return { covered: [], uncovered: [], coverageRatio: 1.0 };
273
+ }
274
+
275
+ const dslKeywords = buildDslKeywordIndex(dsl);
276
+ const covered: SpecRequirement[] = [];
277
+ const uncovered: SpecRequirement[] = [];
278
+
279
+ for (const req of requirements) {
280
+ const reqKeywords = extractKeywords(req.text);
281
+ let overlap = 0;
282
+ for (const kw of reqKeywords) {
283
+ if (dslKeywords.has(kw)) overlap++;
284
+ }
285
+
286
+ if (overlap >= MIN_KEYWORD_OVERLAP) {
287
+ covered.push(req);
288
+ } else {
289
+ uncovered.push(req);
290
+ }
291
+ }
292
+
293
+ return {
294
+ covered,
295
+ uncovered,
296
+ coverageRatio: covered.length / requirements.length,
297
+ };
298
+ }