ai-spec-dev 0.46.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 +60 -30
- package/cli/commands/config.ts +129 -1
- package/cli/commands/create.ts +14 -0
- package/cli/commands/fix-history.ts +176 -0
- package/cli/commands/init.ts +36 -1
- package/cli/index.ts +2 -6
- 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 +23 -0
- package/core/code-generator.ts +63 -14
- package/core/cross-stack-verifier.ts +395 -0
- 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/self-evaluator.ts +44 -7
- package/core/spec-generator.ts +3 -3
- package/core/types-generator.ts +2 -2
- package/dist/cli/index.js +3759 -2207
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +3747 -2195
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +14 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +249 -128
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +249 -128
- 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 -152
- package/cli/commands/scan.ts +0 -99
- package/cli/commands/workspace.ts +0 -219
package/core/code-generator.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { topoSortLayerTasks, printTaskProgress, LAYER_ICONS } from "./codegen/topo-sort";
|
|
24
24
|
import { estimateTokens, getDefaultBudget } from "./token-budget";
|
|
25
25
|
import { startSpinner } from "./cli-ui";
|
|
26
|
+
import { loadFixHistory, buildHallucinationAvoidanceSection } from "./fix-history";
|
|
26
27
|
|
|
27
28
|
// Re-export public symbols for backward compatibility
|
|
28
29
|
export { extractBehavioralContract } from "./codegen/helpers";
|
|
@@ -41,6 +42,20 @@ export interface CodeGenOptions {
|
|
|
41
42
|
dslFilePath?: string;
|
|
42
43
|
/** Repo language type — selects the appropriate codegen system prompt */
|
|
43
44
|
repoType?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Maximum number of tasks that can run concurrently within a single batch
|
|
47
|
+
* (api mode only). A batch larger than this value is split into sequential
|
|
48
|
+
* sub-chunks, each running maxConcurrency tasks in parallel. Default: 3.
|
|
49
|
+
*/
|
|
50
|
+
maxConcurrency?: number;
|
|
51
|
+
/**
|
|
52
|
+
* When true, prior hallucination patterns from `.ai-spec-fix-history.json`
|
|
53
|
+
* are injected into the codegen prompt as a "DO NOT REPEAT" section.
|
|
54
|
+
* Default: true when the ledger exists.
|
|
55
|
+
*/
|
|
56
|
+
injectFixHistory?: boolean;
|
|
57
|
+
/** Max number of past hallucination patterns to inject. Default: 10 */
|
|
58
|
+
fixHistoryInjectMax?: number;
|
|
44
59
|
}
|
|
45
60
|
|
|
46
61
|
export class CodeGenerator {
|
|
@@ -268,8 +283,27 @@ export class CodeGenerator {
|
|
|
268
283
|
console.log(chalk.gray(` Frontend context: ${fctx.framework} / ${fctx.httpClient} | hooks:${fctx.hookFiles.length} stores:${fctx.storeFiles.length}`));
|
|
269
284
|
}
|
|
270
285
|
|
|
286
|
+
// Inject past hallucination patterns so the AI learns from this project's fix history.
|
|
287
|
+
// Opt out via CodeGenOptions.injectFixHistory = false.
|
|
288
|
+
let fixHistorySection = "";
|
|
289
|
+
if (options.injectFixHistory !== false) {
|
|
290
|
+
try {
|
|
291
|
+
const history = await loadFixHistory(workingDir);
|
|
292
|
+
const section = buildHallucinationAvoidanceSection(history, {
|
|
293
|
+
maxItems: options.fixHistoryInjectMax ?? 10,
|
|
294
|
+
});
|
|
295
|
+
if (section) {
|
|
296
|
+
fixHistorySection = `\n${section}\n`;
|
|
297
|
+
const patternCount = (section.match(/❌ Do NOT/g) ?? []).length;
|
|
298
|
+
console.log(chalk.cyan(` ✓ Injected ${patternCount} prior hallucination pattern(s) from fix-history`));
|
|
299
|
+
}
|
|
300
|
+
} catch {
|
|
301
|
+
// Non-fatal: if the ledger is broken, just skip injection
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
271
305
|
// Token budget check — warn if context sections are large
|
|
272
|
-
const allContextText = spec + constitutionSection + dslSection + frontendSection + installedPackagesSection + sharedConfigSection;
|
|
306
|
+
const allContextText = spec + constitutionSection + dslSection + frontendSection + installedPackagesSection + sharedConfigSection + fixHistorySection;
|
|
273
307
|
const estimatedTokenCount = estimateTokens(allContextText);
|
|
274
308
|
const budget = getDefaultBudget(this.provider.providerName);
|
|
275
309
|
if (estimatedTokenCount > budget * 0.7) {
|
|
@@ -292,7 +326,7 @@ export class CodeGenerator {
|
|
|
292
326
|
// Use tasks if available for finer-grained generation with resume support
|
|
293
327
|
const tasks = await loadTasksForSpec(specFilePath);
|
|
294
328
|
if (tasks && tasks.length > 0) {
|
|
295
|
-
return this.runApiModeWithTasks(spec, tasks, specFilePath, workingDir, constitutionSection + dslSection + installedPackagesSection, frontendSection, sharedConfigSection, options, systemPrompt, context);
|
|
329
|
+
return this.runApiModeWithTasks(spec, tasks, specFilePath, workingDir, constitutionSection + dslSection + installedPackagesSection + fixHistorySection, frontendSection, sharedConfigSection, options, systemPrompt, context);
|
|
296
330
|
}
|
|
297
331
|
|
|
298
332
|
// Fallback: plan-then-generate
|
|
@@ -306,7 +340,7 @@ IMPORTANT: Check the "Frontend Project Context" section below. Extend existing h
|
|
|
306
340
|
|
|
307
341
|
=== Feature Spec ===
|
|
308
342
|
${spec}
|
|
309
|
-
${constitutionSection}${dslSection}${frontendSection}${installedPackagesSection}${sharedConfigSection}
|
|
343
|
+
${constitutionSection}${dslSection}${frontendSection}${installedPackagesSection}${sharedConfigSection}${fixHistorySection}
|
|
310
344
|
=== Project Context ===
|
|
311
345
|
${contextSummary}
|
|
312
346
|
|
|
@@ -336,7 +370,7 @@ Output ONLY a valid JSON array:
|
|
|
336
370
|
console.log(` ${icon} ${item.file}: ${chalk.gray(item.description)}`);
|
|
337
371
|
});
|
|
338
372
|
|
|
339
|
-
const { files } = await this.generateFiles(filePlan, spec, workingDir, constitutionSection + dslSection + frontendSection + installedPackagesSection, systemPrompt);
|
|
373
|
+
const { files } = await this.generateFiles(filePlan, spec, workingDir, constitutionSection + dslSection + frontendSection + installedPackagesSection + fixHistorySection, systemPrompt);
|
|
340
374
|
return files;
|
|
341
375
|
}
|
|
342
376
|
|
|
@@ -524,24 +558,39 @@ Output ONLY a valid JSON array:
|
|
|
524
558
|
|
|
525
559
|
// Partition tasks into topological batches (respects dependencies field).
|
|
526
560
|
// Each batch runs in parallel; batches run sequentially.
|
|
561
|
+
// Additionally, each batch is chunked by maxConcurrency to prevent
|
|
562
|
+
// rate-limit errors when a batch contains many independent tasks.
|
|
527
563
|
const taskBatches = topoSortLayerTasks(layerTasks);
|
|
528
564
|
const layerResults: TaskResult[] = [];
|
|
565
|
+
const maxConcurrency = Math.max(1, options.maxConcurrency ?? 3);
|
|
529
566
|
|
|
530
567
|
for (const batch of taskBatches) {
|
|
531
568
|
const batchIsParallel = batch.length > 1;
|
|
532
|
-
const batchResultPromises = batch.map((task) => executeTask(task, batchIsParallel));
|
|
533
|
-
const settled = await Promise.allSettled(batchResultPromises);
|
|
534
569
|
const batchResults: TaskResult[] = [];
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
570
|
+
|
|
571
|
+
// Split batch into chunks of at most `maxConcurrency` tasks.
|
|
572
|
+
// Each chunk runs in parallel; chunks run sequentially within the batch.
|
|
573
|
+
for (let chunkStart = 0; chunkStart < batch.length; chunkStart += maxConcurrency) {
|
|
574
|
+
const chunk = batch.slice(chunkStart, chunkStart + maxConcurrency);
|
|
575
|
+
if (batchIsParallel && batch.length > maxConcurrency) {
|
|
576
|
+
const chunkIdx = Math.floor(chunkStart / maxConcurrency) + 1;
|
|
577
|
+
const totalChunks = Math.ceil(batch.length / maxConcurrency);
|
|
578
|
+
console.log(chalk.gray(` ↳ chunk ${chunkIdx}/${totalChunks} (${chunk.length} tasks, concurrency cap: ${maxConcurrency})`));
|
|
579
|
+
}
|
|
580
|
+
const chunkResultPromises = chunk.map((task) => executeTask(task, batchIsParallel));
|
|
581
|
+
const settled = await Promise.allSettled(chunkResultPromises);
|
|
582
|
+
for (let i = 0; i < settled.length; i++) {
|
|
583
|
+
const outcome = settled[i];
|
|
584
|
+
if (outcome.status === "fulfilled") {
|
|
585
|
+
batchResults.push(outcome.value);
|
|
586
|
+
} else {
|
|
587
|
+
const task = chunk[i];
|
|
588
|
+
console.log(chalk.yellow(` ⚠ ${task.id} threw unexpectedly: ${outcome.reason?.message ?? outcome.reason}`));
|
|
589
|
+
batchResults.push({ task, files: [], createdFiles: [], success: 0, total: 0, impliesRegistration: false });
|
|
590
|
+
}
|
|
543
591
|
}
|
|
544
592
|
}
|
|
593
|
+
|
|
545
594
|
layerResults.push(...batchResults);
|
|
546
595
|
// Update cache after each batch so the next batch sees the exports.
|
|
547
596
|
await updateCacheFromBatch(batchResults);
|
|
@@ -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
|
+
}
|