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.
- package/README.md +381 -1796
- package/RELEASE_LOG.md +231 -0
- package/cli/commands/create.ts +9 -1176
- package/cli/commands/dashboard.ts +1 -1
- package/cli/pipeline/helpers.ts +34 -0
- package/cli/pipeline/multi-repo.ts +483 -0
- package/cli/pipeline/single-repo.ts +755 -0
- package/cli/utils.ts +2 -0
- package/core/code-generator.ts +52 -341
- package/core/codegen/helpers.ts +219 -0
- package/core/codegen/topo-sort.ts +98 -0
- package/core/constitution-consolidator.ts +2 -2
- package/core/dsl-coverage-checker.ts +298 -0
- package/core/dsl-extractor.ts +19 -46
- package/core/dsl-feedback.ts +1 -1
- package/core/dsl-validator.ts +74 -0
- package/core/error-feedback.ts +95 -11
- package/core/frontend-context-loader.ts +27 -5
- package/core/knowledge-memory.ts +52 -0
- package/core/mock/fixtures.ts +89 -0
- package/core/mock/proxy.ts +380 -0
- package/core/mock-server-generator.ts +12 -460
- package/core/requirement-decomposer.ts +4 -28
- package/core/reviewer.ts +1 -1
- package/core/safe-json.ts +76 -0
- package/core/spec-updater.ts +5 -21
- package/core/token-budget.ts +124 -0
- package/core/vcr.ts +20 -1
- package/dist/cli/index.js +4110 -3534
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +4237 -3661
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +18 -16
- package/dist/index.d.ts +18 -16
- package/dist/index.js +310 -182
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +308 -180
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/purpose.md +173 -33
- package/tests/auto-consolidation.test.ts +109 -0
- package/tests/combined-generator.test.ts +81 -0
- package/tests/constitution-consolidator.test.ts +161 -0
- package/tests/constitution-generator.test.ts +94 -0
- package/tests/contract-bridge.test.ts +201 -0
- package/tests/design-dialogue.test.ts +108 -0
- package/tests/dsl-coverage-checker.test.ts +230 -0
- package/tests/dsl-feedback.test.ts +45 -0
- package/tests/dsl-validator-xref.test.ts +99 -0
- package/tests/error-feedback-repair.test.ts +319 -0
- package/tests/error-feedback-validation.test.ts +91 -0
- package/tests/frontend-context-loader.test.ts +609 -0
- package/tests/global-constitution.test.ts +110 -0
- package/tests/key-store.test.ts +73 -0
- package/tests/knowledge-memory.test.ts +327 -0
- package/tests/project-index.test.ts +206 -0
- package/tests/prompt-hasher.test.ts +19 -0
- package/tests/requirement-decomposer.test.ts +171 -0
- package/tests/reviewer.test.ts +4 -1
- package/tests/run-logger.test.ts +289 -0
- package/tests/run-snapshot.test.ts +113 -0
- package/tests/safe-json.test.ts +63 -0
- package/tests/spec-updater.test.ts +161 -0
- package/tests/test-generator.test.ts +146 -0
- package/tests/token-budget.test.ts +124 -0
- package/tests/vcr-hash.test.ts +101 -0
- 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
|
|
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
|
+
}
|
package/core/dsl-extractor.ts
CHANGED
|
@@ -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
|
-
/**
|
|
54
|
-
const
|
|
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
|
-
|
|
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 >
|
|
103
|
+
specContent.length > maxSpecChars
|
|
131
104
|
? (() => {
|
|
132
|
-
console.log(chalk.yellow(` ⚠ Spec is ${specContent.length} chars — truncating to ${
|
|
133
|
-
return specContent.slice(0,
|
|
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 >
|
|
174
|
-
console.log(chalk.gray(` Note: spec was truncated to ${
|
|
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
|
|
package/core/dsl-feedback.ts
CHANGED
|
@@ -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 */
|
package/core/dsl-validator.ts
CHANGED
|
@@ -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(
|