ai-spec-dev 0.42.0 → 0.55.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 +86 -40
- package/cli/commands/config.ts +129 -1
- package/cli/commands/create.ts +246 -11
- package/cli/commands/fix-history.ts +176 -0
- package/cli/commands/init.ts +344 -106
- package/cli/index.ts +3 -7
- package/cli/pipeline/helpers.ts +6 -0
- package/cli/pipeline/multi-repo.ts +291 -26
- package/cli/pipeline/single-repo.ts +103 -2
- package/cli/utils.ts +95 -4
- package/core/code-generator.ts +63 -14
- package/core/config-defaults.ts +44 -0
- package/core/constitution-generator.ts +2 -1
- package/core/cross-stack-verifier.ts +395 -0
- package/core/dsl-extractor.ts +2 -1
- package/core/error-feedback.ts +3 -2
- package/core/fix-history.ts +333 -0
- package/core/import-fixer.ts +827 -0
- package/core/import-verifier.ts +569 -0
- package/core/knowledge-memory.ts +55 -6
- package/core/openapi-exporter.ts +3 -2
- package/core/repo-store.ts +95 -0
- package/core/reviewer.ts +14 -13
- package/core/run-logger.ts +3 -4
- package/core/run-snapshot.ts +2 -3
- package/core/run-trend.ts +3 -4
- package/core/self-evaluator.ts +44 -7
- package/core/spec-generator.ts +30 -45
- package/core/token-budget.ts +3 -8
- package/core/types-generator.ts +2 -2
- package/core/vcr.ts +3 -1
- package/dist/cli/index.js +3889 -1937
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +3888 -1936
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +17 -2
- package/dist/index.d.ts +17 -2
- package/dist/index.js +292 -181
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +292 -181
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/tests/cross-stack-verifier.test.ts +301 -0
- package/tests/fix-history.test.ts +335 -0
- package/tests/import-fixer.test.ts +944 -0
- package/tests/import-verifier.test.ts +420 -0
- package/tests/knowledge-memory.test.ts +40 -0
- package/tests/self-evaluator.test.ts +97 -0
- package/cli/commands/model.ts +0 -156
- package/cli/commands/scan.ts +0 -99
- package/cli/commands/workspace.ts +0 -219
- package/demo-backend/.ai-spec-constitution.md +0 -65
- package/demo-backend/package.json +0 -21
- package/demo-backend/prisma/schema.prisma +0 -22
- package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.dsl.json +0 -186
- package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.md +0 -211
- package/demo-backend/src/controllers/bookmark.controller.test.ts +0 -255
- package/demo-backend/src/controllers/bookmark.controller.ts +0 -187
- package/demo-backend/src/index.ts +0 -17
- package/demo-backend/src/routes/bookmark.routes.test.ts +0 -264
- package/demo-backend/src/routes/bookmark.routes.ts +0 -11
- package/demo-backend/src/routes/index.ts +0 -8
- package/demo-backend/src/services/bookmark.service.test.ts +0 -433
- package/demo-backend/src/services/bookmark.service.ts +0 -261
- package/demo-backend/tsconfig.json +0 -12
- package/demo-frontend/.ai-spec-constitution.md +0 -95
- package/demo-frontend/package.json +0 -23
- package/demo-frontend/src/App.tsx +0 -12
- package/demo-frontend/src/main.tsx +0 -9
- package/demo-frontend/tsconfig.json +0 -13
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import * as fs from "fs-extra";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { SpecDSL } from "./dsl-types";
|
|
5
|
+
|
|
6
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface FrontendApiCall {
|
|
9
|
+
method: string; // 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'UNKNOWN'
|
|
10
|
+
path: string; // raw URL string as found in source
|
|
11
|
+
file: string; // relative path from frontend root
|
|
12
|
+
line: number; // 1-indexed line number
|
|
13
|
+
snippet: string; // one-line source snippet
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CrossStackReport {
|
|
17
|
+
frontendCalls: FrontendApiCall[];
|
|
18
|
+
backendEndpoints: Array<{ method: string; path: string; id: string }>;
|
|
19
|
+
/** Frontend calls whose path does not match any backend DSL endpoint */
|
|
20
|
+
phantom: FrontendApiCall[];
|
|
21
|
+
/** Backend DSL endpoints that no frontend file ever calls */
|
|
22
|
+
unused: Array<{ method: string; path: string; id: string }>;
|
|
23
|
+
/** Frontend calls whose path matches a DSL endpoint but method differs */
|
|
24
|
+
methodMismatch: Array<{ call: FrontendApiCall; expectedMethod: string }>;
|
|
25
|
+
/** Calls whose method+path both match the DSL */
|
|
26
|
+
matched: Array<{ call: FrontendApiCall; endpointId: string }>;
|
|
27
|
+
totalScannedFiles: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── File scanning ────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const SCANNABLE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".vue", ".mjs"]);
|
|
33
|
+
const SKIP_DIRS = new Set([
|
|
34
|
+
"node_modules", "dist", "build", ".git", ".next", "out",
|
|
35
|
+
"coverage", ".turbo", ".cache", ".ai-spec-vcr", ".ai-spec-logs",
|
|
36
|
+
".ai-spec-backup", "__snapshots__",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
async function walkSource(root: string): Promise<string[]> {
|
|
40
|
+
const files: string[] = [];
|
|
41
|
+
async function walk(dir: string): Promise<void> {
|
|
42
|
+
let entries: fs.Dirent[];
|
|
43
|
+
try {
|
|
44
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
45
|
+
} catch {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
if (entry.name.startsWith(".") && !entry.name.startsWith(".ai-spec")) {
|
|
50
|
+
// skip hidden except the ones we explicitly allow
|
|
51
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
52
|
+
}
|
|
53
|
+
if (entry.isDirectory()) {
|
|
54
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
55
|
+
await walk(path.join(dir, entry.name));
|
|
56
|
+
} else if (entry.isFile()) {
|
|
57
|
+
const ext = path.extname(entry.name);
|
|
58
|
+
if (SCANNABLE_EXTENSIONS.has(ext)) {
|
|
59
|
+
files.push(path.join(dir, entry.name));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
await walk(root);
|
|
65
|
+
return files;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── API call extraction ──────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Detect HTTP calls in a single source file.
|
|
72
|
+
* Covers the most common frontend patterns:
|
|
73
|
+
*
|
|
74
|
+
* - fetch('/api/...', { method: 'POST' })
|
|
75
|
+
* - fetch(`/api/users/${id}`)
|
|
76
|
+
* - axios.get('/api/...') / axios.post(...) etc.
|
|
77
|
+
* - axios({ url: '/api/...', method: 'POST' })
|
|
78
|
+
* - useRequest('/api/...', { method: 'POST' })
|
|
79
|
+
* - request('/api/...', 'POST')
|
|
80
|
+
* - $http.get('/api/...')
|
|
81
|
+
* - api.get('/api/...')
|
|
82
|
+
*
|
|
83
|
+
* Does NOT currently handle: URLs constructed from config imports,
|
|
84
|
+
* URLs stored in constants (follow-up work). Those show up as misses.
|
|
85
|
+
*/
|
|
86
|
+
export function extractApiCallsFromSource(
|
|
87
|
+
source: string,
|
|
88
|
+
relFile: string
|
|
89
|
+
): FrontendApiCall[] {
|
|
90
|
+
const calls: FrontendApiCall[] = [];
|
|
91
|
+
const lines = source.split("\n");
|
|
92
|
+
|
|
93
|
+
// Pattern 1: .get('/path') / .post('/path') / .delete('/path') / .put('/path') / .patch('/path')
|
|
94
|
+
// Matches things like: axios.get('/api/users'), api.post(`/api/users/${id}`)
|
|
95
|
+
const methodCallRegex =
|
|
96
|
+
/\.(get|post|put|patch|delete)\s*\(\s*(['"`])([^'"`]+)\2/gi;
|
|
97
|
+
|
|
98
|
+
// Pattern 2: fetch('/path', { method: 'POST' })
|
|
99
|
+
// We detect fetch( + URL + optional method in the next ~100 chars
|
|
100
|
+
const fetchRegex = /\bfetch\s*\(\s*(['"`])([^'"`]+)\1([^)]*)\)/g;
|
|
101
|
+
|
|
102
|
+
// Pattern 3: useRequest('/path', { method: 'POST' }) — ahooks / swr style
|
|
103
|
+
const useRequestRegex =
|
|
104
|
+
/\buseRequest\s*\(\s*(['"`])([^'"`]+)\1([^)]*)\)/g;
|
|
105
|
+
|
|
106
|
+
// Pattern 4: request('/path', 'POST') — generic helper
|
|
107
|
+
const genericRequestRegex =
|
|
108
|
+
/\brequest\s*\(\s*(['"`])([^'"`]+)\1\s*(?:,\s*(['"`])(GET|POST|PUT|PATCH|DELETE)\3)?/gi;
|
|
109
|
+
|
|
110
|
+
function getLineNumber(offset: number): number {
|
|
111
|
+
// Count newlines up to offset
|
|
112
|
+
let ln = 1;
|
|
113
|
+
for (let i = 0; i < offset && i < source.length; i++) {
|
|
114
|
+
if (source[i] === "\n") ln++;
|
|
115
|
+
}
|
|
116
|
+
return ln;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function getSnippet(lineNum: number): string {
|
|
120
|
+
return (lines[lineNum - 1] ?? "").trim().slice(0, 140);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isApiLike(p: string): boolean {
|
|
124
|
+
// Heuristic: must contain at least one slash and look like an API path.
|
|
125
|
+
// We intentionally accept paths that don't start with /api/ because many
|
|
126
|
+
// codebases use /v1/, /rest/, or bare paths like /users/:id.
|
|
127
|
+
if (!p.startsWith("/")) return false;
|
|
128
|
+
if (p.length < 2) return false;
|
|
129
|
+
// Skip CSS/asset/static paths
|
|
130
|
+
if (/\.(css|svg|png|jpe?g|gif|ico|woff2?|ttf|eot)$/i.test(p)) return false;
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let match: RegExpExecArray | null;
|
|
135
|
+
|
|
136
|
+
while ((match = methodCallRegex.exec(source)) !== null) {
|
|
137
|
+
const rawPath = match[3];
|
|
138
|
+
if (!isApiLike(rawPath)) continue;
|
|
139
|
+
const line = getLineNumber(match.index);
|
|
140
|
+
calls.push({
|
|
141
|
+
method: match[1].toUpperCase(),
|
|
142
|
+
path: rawPath,
|
|
143
|
+
file: relFile,
|
|
144
|
+
line,
|
|
145
|
+
snippet: getSnippet(line),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
while ((match = fetchRegex.exec(source)) !== null) {
|
|
150
|
+
const rawPath = match[2];
|
|
151
|
+
if (!isApiLike(rawPath)) continue;
|
|
152
|
+
const tail = match[3] ?? "";
|
|
153
|
+
const methodMatch = tail.match(/method\s*:\s*['"`](GET|POST|PUT|PATCH|DELETE)['"`]/i);
|
|
154
|
+
const line = getLineNumber(match.index);
|
|
155
|
+
calls.push({
|
|
156
|
+
method: methodMatch ? methodMatch[1].toUpperCase() : "GET",
|
|
157
|
+
path: rawPath,
|
|
158
|
+
file: relFile,
|
|
159
|
+
line,
|
|
160
|
+
snippet: getSnippet(line),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
while ((match = useRequestRegex.exec(source)) !== null) {
|
|
165
|
+
const rawPath = match[2];
|
|
166
|
+
if (!isApiLike(rawPath)) continue;
|
|
167
|
+
const tail = match[3] ?? "";
|
|
168
|
+
const methodMatch = tail.match(/method\s*:\s*['"`](GET|POST|PUT|PATCH|DELETE)['"`]/i);
|
|
169
|
+
const line = getLineNumber(match.index);
|
|
170
|
+
calls.push({
|
|
171
|
+
method: methodMatch ? methodMatch[1].toUpperCase() : "GET",
|
|
172
|
+
path: rawPath,
|
|
173
|
+
file: relFile,
|
|
174
|
+
line,
|
|
175
|
+
snippet: getSnippet(line),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
while ((match = genericRequestRegex.exec(source)) !== null) {
|
|
180
|
+
const rawPath = match[2];
|
|
181
|
+
if (!isApiLike(rawPath)) continue;
|
|
182
|
+
const line = getLineNumber(match.index);
|
|
183
|
+
calls.push({
|
|
184
|
+
method: match[4] ? match[4].toUpperCase() : "UNKNOWN",
|
|
185
|
+
path: rawPath,
|
|
186
|
+
file: relFile,
|
|
187
|
+
line,
|
|
188
|
+
snippet: getSnippet(line),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return calls;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─── Path matching ────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Normalize a path for structural comparison.
|
|
199
|
+
*
|
|
200
|
+
* /api/users/:id → ["api","users","*"]
|
|
201
|
+
* /api/users/${userId} → ["api","users","*"]
|
|
202
|
+
* /api/users/123 → ["api","users","*"] (numeric id segment)
|
|
203
|
+
* /api/users → ["api","users"]
|
|
204
|
+
*
|
|
205
|
+
* Template-literal slots (${...}) and `:name` are treated as wildcards.
|
|
206
|
+
* Pure-numeric segments are also treated as wildcards so calls with literal
|
|
207
|
+
* IDs still match a `:id`-parameterized DSL path.
|
|
208
|
+
*/
|
|
209
|
+
export function normalizePathSegments(p: string): string[] {
|
|
210
|
+
// strip querystring
|
|
211
|
+
const withoutQs = p.split("?")[0];
|
|
212
|
+
const segments = withoutQs.split("/").filter(Boolean);
|
|
213
|
+
return segments.map((seg) => {
|
|
214
|
+
if (seg.startsWith(":")) return "*";
|
|
215
|
+
if (seg.includes("${") || seg.includes("{{")) return "*";
|
|
216
|
+
if (/^\d+$/.test(seg)) return "*";
|
|
217
|
+
return seg.toLowerCase();
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Two paths match if their normalized segment arrays are equal.
|
|
223
|
+
*/
|
|
224
|
+
export function pathsMatch(a: string, b: string): boolean {
|
|
225
|
+
const sa = normalizePathSegments(a);
|
|
226
|
+
const sb = normalizePathSegments(b);
|
|
227
|
+
if (sa.length !== sb.length) return false;
|
|
228
|
+
for (let i = 0; i < sa.length; i++) {
|
|
229
|
+
const x = sa[i];
|
|
230
|
+
const y = sb[i];
|
|
231
|
+
if (x === "*" || y === "*") continue;
|
|
232
|
+
if (x !== y) return false;
|
|
233
|
+
}
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─── Verification ─────────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
export async function verifyCrossStackContract(
|
|
240
|
+
backendDsl: SpecDSL,
|
|
241
|
+
frontendRoot: string,
|
|
242
|
+
opts: {
|
|
243
|
+
/**
|
|
244
|
+
* When provided, only these files are scanned for HTTP calls.
|
|
245
|
+
* Use this to scope verification to files generated in the current run,
|
|
246
|
+
* avoiding false-positive "phantom" reports from pre-existing code.
|
|
247
|
+
*
|
|
248
|
+
* Paths may be absolute or relative to `frontendRoot`.
|
|
249
|
+
*/
|
|
250
|
+
scopedFiles?: string[];
|
|
251
|
+
} = {}
|
|
252
|
+
): Promise<CrossStackReport> {
|
|
253
|
+
let files: string[];
|
|
254
|
+
if (opts.scopedFiles && opts.scopedFiles.length > 0) {
|
|
255
|
+
// Resolve relative paths, keep only files that actually exist + have a scannable extension
|
|
256
|
+
files = [];
|
|
257
|
+
for (const f of opts.scopedFiles) {
|
|
258
|
+
const abs = path.isAbsolute(f) ? f : path.join(frontendRoot, f);
|
|
259
|
+
const ext = path.extname(abs);
|
|
260
|
+
if (!SCANNABLE_EXTENSIONS.has(ext)) continue;
|
|
261
|
+
if (await fs.pathExists(abs)) files.push(abs);
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
files = await walkSource(frontendRoot);
|
|
265
|
+
}
|
|
266
|
+
const allCalls: FrontendApiCall[] = [];
|
|
267
|
+
|
|
268
|
+
for (const abs of files) {
|
|
269
|
+
let src: string;
|
|
270
|
+
try {
|
|
271
|
+
src = await fs.readFile(abs, "utf-8");
|
|
272
|
+
} catch {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const rel = path.relative(frontendRoot, abs);
|
|
276
|
+
const calls = extractApiCallsFromSource(src, rel);
|
|
277
|
+
allCalls.push(...calls);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const backendEndpoints = backendDsl.endpoints.map((ep) => ({
|
|
281
|
+
method: ep.method.toUpperCase(),
|
|
282
|
+
path: ep.path,
|
|
283
|
+
id: ep.id,
|
|
284
|
+
}));
|
|
285
|
+
|
|
286
|
+
const phantom: FrontendApiCall[] = [];
|
|
287
|
+
const methodMismatch: Array<{ call: FrontendApiCall; expectedMethod: string }> = [];
|
|
288
|
+
const matched: Array<{ call: FrontendApiCall; endpointId: string }> = [];
|
|
289
|
+
const usedEndpointIds = new Set<string>();
|
|
290
|
+
|
|
291
|
+
for (const call of allCalls) {
|
|
292
|
+
// Find all DSL endpoints whose path matches this call's path.
|
|
293
|
+
const pathMatches = backendEndpoints.filter((ep) => pathsMatch(ep.path, call.path));
|
|
294
|
+
if (pathMatches.length === 0) {
|
|
295
|
+
phantom.push(call);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
// Check if any path-match also matches the method.
|
|
299
|
+
const methodMatch = pathMatches.find(
|
|
300
|
+
(ep) => call.method === "UNKNOWN" || ep.method === call.method
|
|
301
|
+
);
|
|
302
|
+
if (methodMatch) {
|
|
303
|
+
matched.push({ call, endpointId: methodMatch.id });
|
|
304
|
+
usedEndpointIds.add(methodMatch.id);
|
|
305
|
+
} else {
|
|
306
|
+
// Path matches but method differs — report the first path-match's method
|
|
307
|
+
// as the expected one.
|
|
308
|
+
methodMismatch.push({ call, expectedMethod: pathMatches[0].method });
|
|
309
|
+
usedEndpointIds.add(pathMatches[0].id);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const unused = backendEndpoints.filter((ep) => !usedEndpointIds.has(ep.id));
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
frontendCalls: allCalls,
|
|
317
|
+
backendEndpoints,
|
|
318
|
+
phantom,
|
|
319
|
+
unused,
|
|
320
|
+
methodMismatch,
|
|
321
|
+
matched,
|
|
322
|
+
totalScannedFiles: files.length,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ─── Display ──────────────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
export function printCrossStackReport(repoName: string, report: CrossStackReport): void {
|
|
329
|
+
const totalEp = report.backendEndpoints.length;
|
|
330
|
+
const matchedCount = report.matched.length;
|
|
331
|
+
const phantomCount = report.phantom.length;
|
|
332
|
+
const mismatchCount = report.methodMismatch.length;
|
|
333
|
+
const unusedCount = report.unused.length;
|
|
334
|
+
|
|
335
|
+
console.log(chalk.cyan(`\n─── Cross-Stack Contract Verification [${repoName}] ─────────────`));
|
|
336
|
+
console.log(
|
|
337
|
+
chalk.gray(
|
|
338
|
+
` Scanned ${report.totalScannedFiles} file(s), found ${report.frontendCalls.length} HTTP call(s)`
|
|
339
|
+
)
|
|
340
|
+
);
|
|
341
|
+
console.log(chalk.gray(` Backend DSL endpoints: ${totalEp}`));
|
|
342
|
+
|
|
343
|
+
// ── Matched ─────────────────────────────────────────────────────────────────
|
|
344
|
+
const matchTag = matchedCount === totalEp && phantomCount === 0 && mismatchCount === 0
|
|
345
|
+
? chalk.green(`✔ ${matchedCount}/${totalEp} endpoints matched`)
|
|
346
|
+
: matchedCount > 0
|
|
347
|
+
? chalk.yellow(`~ ${matchedCount}/${totalEp} endpoints matched`)
|
|
348
|
+
: chalk.red(`✘ 0/${totalEp} endpoints matched`);
|
|
349
|
+
console.log(` ${matchTag}`);
|
|
350
|
+
|
|
351
|
+
// ── Phantom endpoints (frontend calls not in DSL) ───────────────────────────
|
|
352
|
+
if (phantomCount > 0) {
|
|
353
|
+
console.log(chalk.red(`\n ❌ Phantom endpoints (${phantomCount}): frontend calls not declared in backend DSL`));
|
|
354
|
+
for (const call of report.phantom.slice(0, 8)) {
|
|
355
|
+
console.log(chalk.gray(` ${call.method.padEnd(6)} ${call.path}`));
|
|
356
|
+
console.log(chalk.gray(` ${call.file}:${call.line}`));
|
|
357
|
+
}
|
|
358
|
+
if (phantomCount > 8) {
|
|
359
|
+
console.log(chalk.gray(` ... and ${phantomCount - 8} more`));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── Method mismatches ───────────────────────────────────────────────────────
|
|
364
|
+
if (mismatchCount > 0) {
|
|
365
|
+
console.log(chalk.yellow(`\n ⚠ Method mismatches (${mismatchCount}): path matches but HTTP method differs`));
|
|
366
|
+
for (const m of report.methodMismatch.slice(0, 8)) {
|
|
367
|
+
console.log(
|
|
368
|
+
chalk.gray(
|
|
369
|
+
` ${m.call.method} ${m.call.path} ${chalk.yellow("→")} expected ${m.expectedMethod}`
|
|
370
|
+
)
|
|
371
|
+
);
|
|
372
|
+
console.log(chalk.gray(` ${m.call.file}:${m.call.line}`));
|
|
373
|
+
}
|
|
374
|
+
if (mismatchCount > 8) {
|
|
375
|
+
console.log(chalk.gray(` ... and ${mismatchCount - 8} more`));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ── Unused endpoints ────────────────────────────────────────────────────────
|
|
380
|
+
if (unusedCount > 0) {
|
|
381
|
+
console.log(chalk.gray(`\n · Unused DSL endpoints (${unusedCount}): declared but never called by frontend`));
|
|
382
|
+
for (const ep of report.unused.slice(0, 8)) {
|
|
383
|
+
console.log(chalk.gray(` ${ep.method.padEnd(6)} ${ep.path} (${ep.id})`));
|
|
384
|
+
}
|
|
385
|
+
if (unusedCount > 8) {
|
|
386
|
+
console.log(chalk.gray(` ... and ${unusedCount - 8} more`));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ── Summary ─────────────────────────────────────────────────────────────────
|
|
391
|
+
if (phantomCount === 0 && mismatchCount === 0 && unusedCount === 0 && matchedCount === totalEp && totalEp > 0) {
|
|
392
|
+
console.log(chalk.green(`\n ✔ Contract fully aligned — all ${totalEp} endpoints consumed correctly.`));
|
|
393
|
+
}
|
|
394
|
+
console.log(chalk.cyan("─".repeat(65)));
|
|
395
|
+
}
|
package/core/dsl-extractor.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "../prompts/dsl.prompt";
|
|
14
14
|
import { estimateTokens, getDefaultBudget } from "./token-budget";
|
|
15
15
|
import { parseJsonFromAiOutput } from "./safe-json";
|
|
16
|
+
import { DEFAULT_DSL_MAX_RETRIES } from "./config-defaults";
|
|
16
17
|
|
|
17
18
|
// ─── DSL Sanitizer ───────────────────────────────────────────────────────────
|
|
18
19
|
|
|
@@ -50,7 +51,7 @@ function sanitizeDsl(raw: unknown): unknown {
|
|
|
50
51
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
51
52
|
|
|
52
53
|
/** Maximum AI attempts (1 initial + up to this many retries). */
|
|
53
|
-
const MAX_RETRIES =
|
|
54
|
+
const MAX_RETRIES = DEFAULT_DSL_MAX_RETRIES;
|
|
54
55
|
|
|
55
56
|
/** Default maximum spec length passed to AI. Overridden by token budget when provider is known. */
|
|
56
57
|
const DEFAULT_MAX_SPEC_CHARS = 12_000;
|
package/core/error-feedback.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { SpecDSL } from "./dsl-types";
|
|
|
8
8
|
import { buildDslContextSection } from "./dsl-extractor";
|
|
9
9
|
import { getActiveSnapshot } from "./run-snapshot";
|
|
10
10
|
import { startSpinner } from "./cli-ui";
|
|
11
|
+
import { DEFAULT_MAX_COMMAND_OUTPUT_CHARS, DEFAULT_MAX_FIX_FILE_CHARS } from "./config-defaults";
|
|
11
12
|
|
|
12
13
|
// ─── Types ──────────────────────────────────────────────────────────────────────
|
|
13
14
|
|
|
@@ -30,14 +31,14 @@ interface FixResult {
|
|
|
30
31
|
* ~10K tokens — enough for any realistic error listing; prevents a pathological
|
|
31
32
|
* build output (e.g. 10MB of warnings) from ballooning the AI context.
|
|
32
33
|
*/
|
|
33
|
-
const MAX_COMMAND_OUTPUT_CHARS =
|
|
34
|
+
const MAX_COMMAND_OUTPUT_CHARS = DEFAULT_MAX_COMMAND_OUTPUT_CHARS;
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* Maximum characters of an existing file sent to the AI for auto-fix.
|
|
37
38
|
* ~12K tokens — covers large files; content beyond this is truncated with a
|
|
38
39
|
* notice so the AI knows it may be seeing an incomplete file.
|
|
39
40
|
*/
|
|
40
|
-
const MAX_FIX_FILE_CHARS =
|
|
41
|
+
const MAX_FIX_FILE_CHARS = DEFAULT_MAX_FIX_FILE_CHARS;
|
|
41
42
|
|
|
42
43
|
// ─── Error Detection ────────────────────────────────────────────────────────────
|
|
43
44
|
|