ai-spec-dev 0.37.0 → 0.41.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 (67) hide show
  1. package/README.md +381 -1796
  2. package/RELEASE_LOG.md +231 -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 +755 -0
  8. package/cli/utils.ts +2 -0
  9. package/core/code-generator.ts +52 -341
  10. package/core/codegen/helpers.ts +219 -0
  11. package/core/codegen/topo-sort.ts +98 -0
  12. package/core/constitution-consolidator.ts +2 -2
  13. package/core/dsl-coverage-checker.ts +298 -0
  14. package/core/dsl-extractor.ts +19 -46
  15. package/core/dsl-feedback.ts +1 -1
  16. package/core/dsl-validator.ts +74 -0
  17. package/core/error-feedback.ts +95 -11
  18. package/core/frontend-context-loader.ts +27 -5
  19. package/core/knowledge-memory.ts +52 -0
  20. package/core/mock/fixtures.ts +89 -0
  21. package/core/mock/proxy.ts +380 -0
  22. package/core/mock-server-generator.ts +12 -460
  23. package/core/requirement-decomposer.ts +4 -28
  24. package/core/reviewer.ts +1 -1
  25. package/core/safe-json.ts +76 -0
  26. package/core/spec-updater.ts +5 -21
  27. package/core/token-budget.ts +124 -0
  28. package/core/vcr.ts +20 -1
  29. package/dist/cli/index.js +4110 -3534
  30. package/dist/cli/index.js.map +1 -1
  31. package/dist/cli/index.mjs +4237 -3661
  32. package/dist/cli/index.mjs.map +1 -1
  33. package/dist/index.d.mts +18 -16
  34. package/dist/index.d.ts +18 -16
  35. package/dist/index.js +310 -182
  36. package/dist/index.js.map +1 -1
  37. package/dist/index.mjs +308 -180
  38. package/dist/index.mjs.map +1 -1
  39. package/package.json +2 -2
  40. package/purpose.md +173 -33
  41. package/tests/auto-consolidation.test.ts +109 -0
  42. package/tests/combined-generator.test.ts +81 -0
  43. package/tests/constitution-consolidator.test.ts +161 -0
  44. package/tests/constitution-generator.test.ts +94 -0
  45. package/tests/contract-bridge.test.ts +201 -0
  46. package/tests/design-dialogue.test.ts +108 -0
  47. package/tests/dsl-coverage-checker.test.ts +230 -0
  48. package/tests/dsl-feedback.test.ts +45 -0
  49. package/tests/dsl-validator-xref.test.ts +99 -0
  50. package/tests/error-feedback-repair.test.ts +319 -0
  51. package/tests/error-feedback-validation.test.ts +91 -0
  52. package/tests/frontend-context-loader.test.ts +609 -0
  53. package/tests/global-constitution.test.ts +110 -0
  54. package/tests/key-store.test.ts +73 -0
  55. package/tests/knowledge-memory.test.ts +327 -0
  56. package/tests/project-index.test.ts +206 -0
  57. package/tests/prompt-hasher.test.ts +19 -0
  58. package/tests/requirement-decomposer.test.ts +171 -0
  59. package/tests/reviewer.test.ts +4 -1
  60. package/tests/run-logger.test.ts +289 -0
  61. package/tests/run-snapshot.test.ts +113 -0
  62. package/tests/safe-json.test.ts +63 -0
  63. package/tests/spec-updater.test.ts +161 -0
  64. package/tests/test-generator.test.ts +146 -0
  65. package/tests/token-budget.test.ts +124 -0
  66. package/tests/vcr-hash.test.ts +101 -0
  67. package/tests/workspace-loader.test.ts +277 -0
@@ -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
+ }
@@ -11,6 +11,8 @@ import {
11
11
  buildDslExtractionPrompt,
12
12
  buildDslRetryPrompt,
13
13
  } from "../prompts/dsl.prompt";
14
+ import { estimateTokens, getDefaultBudget } from "./token-budget";
15
+ import { parseJsonFromAiOutput } from "./safe-json";
14
16
 
15
17
  // ─── DSL Sanitizer ───────────────────────────────────────────────────────────
16
18
 
@@ -50,8 +52,8 @@ function sanitizeDsl(raw: unknown): unknown {
50
52
  /** Maximum AI attempts (1 initial + up to this many retries). */
51
53
  const MAX_RETRIES = 2;
52
54
 
53
- /** Maximum spec length passed to AI to avoid token/context blow-up. */
54
- const MAX_SPEC_CHARS = 12_000;
55
+ /** Default maximum spec length passed to AI. Overridden by token budget when provider is known. */
56
+ const DEFAULT_MAX_SPEC_CHARS = 12_000;
55
57
 
56
58
  // ─── DSL file naming ──────────────────────────────────────────────────────────
57
59
 
@@ -63,45 +65,8 @@ export function dslFilePath(specFilePath: string): string {
63
65
 
64
66
  // ─── Parser ───────────────────────────────────────────────────────────────────
65
67
 
66
- /**
67
- * Parse JSON from raw AI output.
68
- * Handles two cases:
69
- * 1. Bare JSON object starting with `{`
70
- * 2. JSON wrapped in a ```json ... ``` fence (model sometimes ignores instructions)
71
- *
72
- * Does NOT use eval or Function() — only JSON.parse().
73
- */
74
- function parseJsonFromOutput(raw: string): unknown {
75
- const trimmed = raw.trim();
76
-
77
- // Case 1: bare JSON
78
- if (trimmed.startsWith("{")) {
79
- return JSON.parse(trimmed);
80
- }
81
-
82
- // Case 2: fenced JSON — extract content between first ``` and last ```
83
- const fenceStart = trimmed.indexOf("```");
84
- if (fenceStart !== -1) {
85
- const afterFence = trimmed.slice(fenceStart + 3);
86
- // Skip optional language tag (e.g. "json\n")
87
- const newlinePos = afterFence.indexOf("\n");
88
- const jsonStart = newlinePos !== -1 ? newlinePos + 1 : 0;
89
- const fenceEnd = afterFence.lastIndexOf("```");
90
- if (fenceEnd > jsonStart) {
91
- const jsonStr = afterFence.slice(jsonStart, fenceEnd).trim();
92
- return JSON.parse(jsonStr);
93
- }
94
- }
95
-
96
- // Case 3: try to find the first `{` and last `}` pair
97
- const objStart = trimmed.indexOf("{");
98
- const objEnd = trimmed.lastIndexOf("}");
99
- if (objStart !== -1 && objEnd > objStart) {
100
- return JSON.parse(trimmed.slice(objStart, objEnd + 1));
101
- }
102
-
103
- throw new SyntaxError("No JSON object found in AI output");
104
- }
68
+ // Uses shared parseJsonFromAiOutput from safe-json.ts
69
+ const parseJsonFromOutput = parseJsonFromAiOutput;
105
70
 
106
71
  // ─── DslExtractor ────────────────────────────────────────────────────────────
107
72
 
@@ -125,12 +90,20 @@ export class DslExtractor {
125
90
  specContent: string,
126
91
  opts: { auto?: boolean; isFrontend?: boolean } = {}
127
92
  ): Promise<SpecDSL | null> {
93
+ // Compute dynamic spec char limit based on provider's token budget.
94
+ // Reserve ~30% of budget for DSL extraction prompt + response; use 70% for spec content.
95
+ const providerBudget = getDefaultBudget(this.provider.providerName);
96
+ const maxSpecChars = Math.max(
97
+ DEFAULT_MAX_SPEC_CHARS,
98
+ Math.floor(providerBudget * 0.7 * 3) // ~3 chars per token, 70% of budget
99
+ );
100
+
128
101
  // Truncate very long specs to avoid token issues
129
102
  const specForAI =
130
- specContent.length > MAX_SPEC_CHARS
103
+ specContent.length > maxSpecChars
131
104
  ? (() => {
132
- console.log(chalk.yellow(` ⚠ Spec is ${specContent.length} chars — truncating to ${MAX_SPEC_CHARS} for DSL extraction. Details at the end may be lost.`));
133
- return specContent.slice(0, MAX_SPEC_CHARS) + "\n... (truncated for DSL extraction)";
105
+ console.log(chalk.yellow(` ⚠ Spec is ${specContent.length} chars — truncating to ${maxSpecChars} for DSL extraction (${this.provider.providerName} budget: ${Math.round(providerBudget / 1000)}K tokens).`));
106
+ return specContent.slice(0, maxSpecChars) + "\n... (truncated for DSL extraction)";
134
107
  })()
135
108
  : specContent;
136
109
 
@@ -170,8 +143,8 @@ export class DslExtractor {
170
143
  console.log(chalk.red(` ✘ Failed to parse JSON from AI output: ${(parseErr as Error).message}`));
171
144
  const preview = rawOutput.slice(0, 500).replace(/\n/g, "\\n");
172
145
  console.log(chalk.gray(` AI output preview (first 500 chars): ${preview}`));
173
- if (rawOutput.length > MAX_SPEC_CHARS) {
174
- console.log(chalk.gray(` Note: spec was truncated to ${MAX_SPEC_CHARS} chars — long specs may lose context`));
146
+ if (rawOutput.length > maxSpecChars) {
147
+ console.log(chalk.gray(` Note: spec was truncated to ${maxSpecChars} chars — long specs may lose context`));
175
148
  }
176
149
  lastErrors = [{ path: "root", message: "Output is not valid JSON — see raw output above" }];
177
150
 
@@ -21,7 +21,7 @@ import { SpecDSL } from "./dsl-types";
21
21
 
22
22
  export interface DslGap {
23
23
  /** Short machine key for RunLog serialisation */
24
- code: "sparse_model" | "missing_errors" | "generic_endpoint_desc" | "no_models_no_endpoints";
24
+ code: "sparse_model" | "missing_errors" | "generic_endpoint_desc" | "no_models_no_endpoints" | "uncovered_requirement";
25
25
  /** Human-readable message shown to the user */
26
26
  message: string;
27
27
  /** Concrete suggestion injected into the refinement prompt */
@@ -119,6 +119,9 @@ export function validateDsl(raw: unknown): DslValidationResult {
119
119
  }
120
120
  }
121
121
 
122
+ // ── Cross-reference checks ──────────────────────────────────────────────
123
+ crossReferenceChecks(obj, errors);
124
+
122
125
  if (errors.length > 0) {
123
126
  return { valid: false, errors };
124
127
  }
@@ -428,6 +431,77 @@ function validateComponent(
428
431
  }
429
432
  }
430
433
 
434
+ // ─── Cross-reference checks ──────────────────────────────────────────────────
435
+
436
+ function crossReferenceChecks(
437
+ obj: Record<string, unknown>,
438
+ errors: DslValidationError[]
439
+ ): void {
440
+ const models = Array.isArray(obj["models"]) ? (obj["models"] as Record<string, unknown>[]) : [];
441
+ const endpoints = Array.isArray(obj["endpoints"]) ? (obj["endpoints"] as Record<string, unknown>[]) : [];
442
+ const components = Array.isArray(obj["components"]) ? (obj["components"] as Record<string, unknown>[]) : [];
443
+
444
+ // 1. Duplicate path+method detection
445
+ const seenRoutes = new Map<string, number>();
446
+ for (let i = 0; i < endpoints.length; i++) {
447
+ const ep = endpoints[i];
448
+ if (typeof ep?.["method"] === "string" && typeof ep?.["path"] === "string") {
449
+ const route = `${(ep["method"] as string).toUpperCase()} ${ep["path"]}`;
450
+ if (seenRoutes.has(route)) {
451
+ errors.push({
452
+ path: `endpoints[${i}]`,
453
+ message: `Duplicate route "${route}" — also defined at endpoints[${seenRoutes.get(route)}]`,
454
+ });
455
+ } else {
456
+ seenRoutes.set(route, i);
457
+ }
458
+ }
459
+ }
460
+
461
+ // 2. Model relations reference existing model names
462
+ const modelNames = new Set(
463
+ models.filter((m) => typeof m?.["name"] === "string").map((m) => m["name"] as string)
464
+ );
465
+ for (let i = 0; i < models.length; i++) {
466
+ const m = models[i];
467
+ if (!Array.isArray(m?.["relations"])) continue;
468
+ for (const rel of m["relations"] as string[]) {
469
+ if (typeof rel !== "string") continue;
470
+ // Extract referenced model name: "User hasMany Post" → "Post", "belongsTo Category" → "Category"
471
+ const refMatch = rel.match(/(?:hasMany|hasOne|belongsTo|manyToMany)\s+(\w+)/i);
472
+ if (refMatch) {
473
+ const refName = refMatch[1];
474
+ if (!modelNames.has(refName)) {
475
+ errors.push({
476
+ path: `models[${i}].relations`,
477
+ message: `Relation references model "${refName}" which is not defined in models[]`,
478
+ });
479
+ }
480
+ }
481
+ }
482
+ }
483
+
484
+ // 3. Component apiCalls reference existing endpoint IDs
485
+ const endpointIds = new Set(
486
+ endpoints.filter((e) => typeof e?.["id"] === "string").map((e) => e["id"] as string)
487
+ );
488
+ if (endpointIds.size > 0) {
489
+ for (let i = 0; i < components.length; i++) {
490
+ const c = components[i];
491
+ if (!Array.isArray(c?.["apiCalls"])) continue;
492
+ for (const call of c["apiCalls"] as string[]) {
493
+ if (typeof call !== "string") continue;
494
+ if (!endpointIds.has(call)) {
495
+ errors.push({
496
+ path: `components[${i}].apiCalls`,
497
+ message: `References endpoint "${call}" which is not defined in endpoints[]`,
498
+ });
499
+ }
500
+ }
501
+ }
502
+ }
503
+ }
504
+
431
505
  // ─── Helpers ──────────────────────────────────────────────────────────────────
432
506
 
433
507
  function requireNonEmptyString(