ai-spec-dev 0.46.0 → 0.56.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 (41) hide show
  1. package/README.md +60 -30
  2. package/cli/commands/config.ts +129 -1
  3. package/cli/commands/create.ts +14 -0
  4. package/cli/commands/fix-history.ts +176 -0
  5. package/cli/commands/init.ts +36 -1
  6. package/cli/index.ts +2 -6
  7. package/cli/pipeline/helpers.ts +6 -0
  8. package/cli/pipeline/multi-repo.ts +300 -26
  9. package/cli/pipeline/single-repo.ts +103 -2
  10. package/cli/utils.ts +23 -0
  11. package/core/code-generator.ts +63 -14
  12. package/core/cross-stack-verifier.ts +482 -0
  13. package/core/fix-history.ts +333 -0
  14. package/core/import-fixer.ts +827 -0
  15. package/core/import-verifier.ts +569 -0
  16. package/core/knowledge-memory.ts +55 -6
  17. package/core/self-evaluator.ts +44 -7
  18. package/core/spec-generator.ts +3 -3
  19. package/core/types-generator.ts +2 -2
  20. package/dist/cli/index.js +3968 -2353
  21. package/dist/cli/index.js.map +1 -1
  22. package/dist/cli/index.mjs +3810 -2195
  23. package/dist/cli/index.mjs.map +1 -1
  24. package/dist/index.d.mts +14 -0
  25. package/dist/index.d.ts +14 -0
  26. package/dist/index.js +249 -128
  27. package/dist/index.js.map +1 -1
  28. package/dist/index.mjs +249 -128
  29. package/dist/index.mjs.map +1 -1
  30. package/package.json +2 -2
  31. package/tests/cross-stack-verifier.test.ts +402 -0
  32. package/tests/fix-history.test.ts +335 -0
  33. package/tests/import-fixer.test.ts +944 -0
  34. package/tests/import-verifier.test.ts +420 -0
  35. package/tests/knowledge-memory.test.ts +40 -0
  36. package/tests/self-evaluator.test.ts +97 -0
  37. package/.ai-spec-workspace.json +0 -17
  38. package/.ai-spec.json +0 -7
  39. package/cli/commands/model.ts +0 -152
  40. package/cli/commands/scan.ts +0 -99
  41. package/cli/commands/workspace.ts +0 -219
@@ -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
- for (let i = 0; i < settled.length; i++) {
536
- const outcome = settled[i];
537
- if (outcome.status === "fulfilled") {
538
- batchResults.push(outcome.value);
539
- } else {
540
- const task = batch[i];
541
- console.log(chalk.yellow(` ⚠ ${task.id} threw unexpectedly: ${outcome.reason?.message ?? outcome.reason}`));
542
- batchResults.push({ task, files: [], createdFiles: [], success: 0, total: 0, impliesRegistration: false });
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,482 @@
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
+ /** True when path was extracted from a string concatenation (e.g. '/api/' + id).
15
+ * The path ends with /* to represent the unknown suffix — matching is approximate. */
16
+ isConcatPath?: boolean;
17
+ }
18
+
19
+ export interface CrossStackReport {
20
+ frontendCalls: FrontendApiCall[];
21
+ backendEndpoints: Array<{ method: string; path: string; id: string }>;
22
+ /** Frontend calls whose path does not match any backend DSL endpoint */
23
+ phantom: FrontendApiCall[];
24
+ /** Backend DSL endpoints that no frontend file ever calls */
25
+ unused: Array<{ method: string; path: string; id: string }>;
26
+ /** Frontend calls whose path matches a DSL endpoint but method differs */
27
+ methodMismatch: Array<{ call: FrontendApiCall; expectedMethod: string }>;
28
+ /** Calls whose method+path both match the DSL */
29
+ matched: Array<{ call: FrontendApiCall; endpointId: string }>;
30
+ /** Calls with UNKNOWN method (generic `request('/path')` helpers without a method arg).
31
+ * These are counted as matched (permissive) but surfaced for visibility. */
32
+ unknownMethodCalls: FrontendApiCall[];
33
+ totalScannedFiles: number;
34
+ /** True when there are phantom calls or method mismatches — use to fail CI / pipeline steps. */
35
+ hasViolations: boolean;
36
+ }
37
+
38
+ // ─── File scanning ────────────────────────────────────────────────────────────
39
+
40
+ const SCANNABLE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".vue", ".mjs"]);
41
+ const SKIP_DIRS = new Set([
42
+ "node_modules", "dist", "build", ".git", ".next", "out",
43
+ "coverage", ".turbo", ".cache", ".ai-spec-vcr", ".ai-spec-logs",
44
+ ".ai-spec-backup", "__snapshots__",
45
+ ]);
46
+
47
+ async function walkSource(root: string): Promise<string[]> {
48
+ const files: string[] = [];
49
+ async function walk(dir: string): Promise<void> {
50
+ let entries: fs.Dirent[];
51
+ try {
52
+ entries = await fs.readdir(dir, { withFileTypes: true });
53
+ } catch {
54
+ return;
55
+ }
56
+ for (const entry of entries) {
57
+ if (entry.name.startsWith(".") && !entry.name.startsWith(".ai-spec")) {
58
+ // skip hidden except the ones we explicitly allow
59
+ if (SKIP_DIRS.has(entry.name)) continue;
60
+ }
61
+ if (entry.isDirectory()) {
62
+ if (SKIP_DIRS.has(entry.name)) continue;
63
+ await walk(path.join(dir, entry.name));
64
+ } else if (entry.isFile()) {
65
+ const ext = path.extname(entry.name);
66
+ if (SCANNABLE_EXTENSIONS.has(ext)) {
67
+ files.push(path.join(dir, entry.name));
68
+ }
69
+ }
70
+ }
71
+ }
72
+ await walk(root);
73
+ return files;
74
+ }
75
+
76
+ // ─── API call extraction ──────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Detect HTTP calls in a single source file.
80
+ * Covers the most common frontend patterns:
81
+ *
82
+ * - fetch('/api/...', { method: 'POST' })
83
+ * - fetch(`/api/users/${id}`)
84
+ * - axios.get('/api/...') / axios.post(...) etc.
85
+ * - axios({ url: '/api/...', method: 'POST' })
86
+ * - useRequest('/api/...', { method: 'POST' })
87
+ * - request('/api/...', 'POST')
88
+ * - $http.get('/api/...')
89
+ * - api.get('/api/...')
90
+ *
91
+ * Does NOT currently handle: URLs constructed from config imports,
92
+ * URLs stored in constants (follow-up work). Those show up as misses.
93
+ */
94
+ export function extractApiCallsFromSource(
95
+ source: string,
96
+ relFile: string
97
+ ): FrontendApiCall[] {
98
+ const calls: FrontendApiCall[] = [];
99
+ const lines = source.split("\n");
100
+
101
+ // Pattern 1: .get('/path') / .post('/path') / .delete('/path') / .put('/path') / .patch('/path')
102
+ // Matches things like: axios.get('/api/users'), api.post(`/api/users/${id}`)
103
+ // Negative lookahead (?!\s*\+) ensures we don't match string concatenation (handled by Pattern 5).
104
+ const methodCallRegex =
105
+ /\.(get|post|put|patch|delete)\s*\(\s*(['"`])([^'"`]+)\2(?!\s*\+)/gi;
106
+
107
+ // Pattern 2: fetch('/path', { method: 'POST' })
108
+ // We detect fetch( + URL + optional method in the next ~100 chars
109
+ const fetchRegex = /\bfetch\s*\(\s*(['"`])([^'"`]+)\1([^)]*)\)/g;
110
+
111
+ // Pattern 3: useRequest('/path', { method: 'POST' }) — ahooks / swr style
112
+ const useRequestRegex =
113
+ /\buseRequest\s*\(\s*(['"`])([^'"`]+)\1([^)]*)\)/g;
114
+
115
+ // Pattern 4: request('/path', 'POST') — generic helper
116
+ const genericRequestRegex =
117
+ /\brequest\s*\(\s*(['"`])([^'"`]+)\1\s*(?:,\s*(['"`])(GET|POST|PUT|PATCH|DELETE)\3)?/gi;
118
+
119
+ // Pattern 5: axios.get('/api/prefix/' + variable) — string concatenation with static prefix.
120
+ // We capture the static prefix and treat the unknown suffix as a wildcard segment.
121
+ // Only the method-call variant is handled here; fetch+concat is covered separately below.
122
+ const concatMethodRegex =
123
+ /\.(get|post|put|patch|delete)\s*\(\s*(['"`])([^'"`]+)\2\s*\+/gi;
124
+
125
+ // Pattern 6: fetch('/api/prefix/' + variable, ...) — concat inside fetch
126
+ const concatFetchRegex = /\bfetch\s*\(\s*(['"`])([^'"`]+)\1\s*\+([^)]*)\)/g;
127
+
128
+ function getLineNumber(offset: number): number {
129
+ // Count newlines up to offset
130
+ let ln = 1;
131
+ for (let i = 0; i < offset && i < source.length; i++) {
132
+ if (source[i] === "\n") ln++;
133
+ }
134
+ return ln;
135
+ }
136
+
137
+ function getSnippet(lineNum: number): string {
138
+ return (lines[lineNum - 1] ?? "").trim().slice(0, 140);
139
+ }
140
+
141
+ function isApiLike(p: string): boolean {
142
+ // Heuristic: must contain at least one slash and look like an API path.
143
+ // We intentionally accept paths that don't start with /api/ because many
144
+ // codebases use /v1/, /rest/, or bare paths like /users/:id.
145
+ if (!p.startsWith("/")) return false;
146
+ if (p.length < 2) return false;
147
+ // Skip CSS/asset/static paths
148
+ if (/\.(css|svg|png|jpe?g|gif|ico|woff2?|ttf|eot)$/i.test(p)) return false;
149
+ return true;
150
+ }
151
+
152
+ /** Build a wildcard-terminated path from a static concat prefix.
153
+ * '/api/users/' → '/api/users/*'
154
+ * '/api/users' → '/api/users/*'
155
+ */
156
+ function concatPath(prefix: string): string {
157
+ const stripped = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
158
+ return stripped + "/*";
159
+ }
160
+
161
+ let match: RegExpExecArray | null;
162
+
163
+ while ((match = methodCallRegex.exec(source)) !== null) {
164
+ const rawPath = match[3];
165
+ if (!isApiLike(rawPath)) continue;
166
+ const line = getLineNumber(match.index);
167
+ calls.push({
168
+ method: match[1].toUpperCase(),
169
+ path: rawPath,
170
+ file: relFile,
171
+ line,
172
+ snippet: getSnippet(line),
173
+ });
174
+ }
175
+
176
+ while ((match = fetchRegex.exec(source)) !== null) {
177
+ const rawPath = match[2];
178
+ if (!isApiLike(rawPath)) continue;
179
+ const tail = match[3] ?? "";
180
+ const methodMatch = tail.match(/method\s*:\s*['"`](GET|POST|PUT|PATCH|DELETE)['"`]/i);
181
+ const line = getLineNumber(match.index);
182
+ calls.push({
183
+ method: methodMatch ? methodMatch[1].toUpperCase() : "GET",
184
+ path: rawPath,
185
+ file: relFile,
186
+ line,
187
+ snippet: getSnippet(line),
188
+ });
189
+ }
190
+
191
+ while ((match = useRequestRegex.exec(source)) !== null) {
192
+ const rawPath = match[2];
193
+ if (!isApiLike(rawPath)) continue;
194
+ const tail = match[3] ?? "";
195
+ const methodMatch = tail.match(/method\s*:\s*['"`](GET|POST|PUT|PATCH|DELETE)['"`]/i);
196
+ const line = getLineNumber(match.index);
197
+ calls.push({
198
+ method: methodMatch ? methodMatch[1].toUpperCase() : "GET",
199
+ path: rawPath,
200
+ file: relFile,
201
+ line,
202
+ snippet: getSnippet(line),
203
+ });
204
+ }
205
+
206
+ while ((match = genericRequestRegex.exec(source)) !== null) {
207
+ const rawPath = match[2];
208
+ if (!isApiLike(rawPath)) continue;
209
+ const line = getLineNumber(match.index);
210
+ calls.push({
211
+ method: match[4] ? match[4].toUpperCase() : "UNKNOWN",
212
+ path: rawPath,
213
+ file: relFile,
214
+ line,
215
+ snippet: getSnippet(line),
216
+ });
217
+ }
218
+
219
+ // Pattern 5: axios.get('/api/prefix/' + variable)
220
+ // Pattern 1's negative lookahead excludes these cases, so no dedup needed.
221
+ while ((match = concatMethodRegex.exec(source)) !== null) {
222
+ const rawPrefix = match[3];
223
+ if (!isApiLike(rawPrefix)) continue;
224
+ const line = getLineNumber(match.index);
225
+ calls.push({
226
+ method: match[1].toUpperCase(),
227
+ path: concatPath(rawPrefix),
228
+ file: relFile,
229
+ line,
230
+ snippet: getSnippet(line),
231
+ isConcatPath: true,
232
+ });
233
+ }
234
+
235
+ // Pattern 6: fetch('/api/prefix/' + variable, ...)
236
+ while ((match = concatFetchRegex.exec(source)) !== null) {
237
+ const rawPrefix = match[2];
238
+ if (!isApiLike(rawPrefix)) continue;
239
+ const tail = match[3] ?? "";
240
+ const methodMatch = tail.match(/method\s*:\s*['"`](GET|POST|PUT|PATCH|DELETE)['"`]/i);
241
+ const line = getLineNumber(match.index);
242
+ calls.push({
243
+ method: methodMatch ? methodMatch[1].toUpperCase() : "GET",
244
+ path: concatPath(rawPrefix),
245
+ file: relFile,
246
+ line,
247
+ snippet: getSnippet(line),
248
+ isConcatPath: true,
249
+ });
250
+ }
251
+
252
+ return calls;
253
+ }
254
+
255
+ // ─── Path matching ────────────────────────────────────────────────────────────
256
+
257
+ /**
258
+ * Normalize a path for structural comparison.
259
+ *
260
+ * /api/users/:id → ["api","users","*"]
261
+ * /api/users/${userId} → ["api","users","*"]
262
+ * /api/users/123 → ["api","users","*"] (numeric id segment)
263
+ * /api/users → ["api","users"]
264
+ *
265
+ * Template-literal slots (${...}) and `:name` are treated as wildcards.
266
+ * Pure-numeric segments are also treated as wildcards so calls with literal
267
+ * IDs still match a `:id`-parameterized DSL path.
268
+ */
269
+ export function normalizePathSegments(p: string): string[] {
270
+ // strip querystring
271
+ const withoutQs = p.split("?")[0];
272
+ const segments = withoutQs.split("/").filter(Boolean);
273
+ return segments.map((seg) => {
274
+ if (seg === "*") return "*"; // explicit wildcard (concat paths)
275
+ if (seg.startsWith(":")) return "*";
276
+ if (seg.includes("${") || seg.includes("{{")) return "*";
277
+ if (/^\d+$/.test(seg)) return "*";
278
+ return seg.toLowerCase();
279
+ });
280
+ }
281
+
282
+ /**
283
+ * Two paths match if their normalized segment arrays are equal.
284
+ */
285
+ export function pathsMatch(a: string, b: string): boolean {
286
+ const sa = normalizePathSegments(a);
287
+ const sb = normalizePathSegments(b);
288
+ if (sa.length !== sb.length) return false;
289
+ for (let i = 0; i < sa.length; i++) {
290
+ const x = sa[i];
291
+ const y = sb[i];
292
+ if (x === "*" || y === "*") continue;
293
+ if (x !== y) return false;
294
+ }
295
+ return true;
296
+ }
297
+
298
+ // ─── Verification ─────────────────────────────────────────────────────────────
299
+
300
+ export async function verifyCrossStackContract(
301
+ backendDsl: SpecDSL,
302
+ frontendRoot: string,
303
+ opts: {
304
+ /**
305
+ * When provided, only these files are scanned for HTTP calls.
306
+ * Use this to scope verification to files generated in the current run,
307
+ * avoiding false-positive "phantom" reports from pre-existing code.
308
+ *
309
+ * Paths may be absolute or relative to `frontendRoot`.
310
+ */
311
+ scopedFiles?: string[];
312
+ } = {}
313
+ ): Promise<CrossStackReport> {
314
+ let files: string[];
315
+ if (opts.scopedFiles && opts.scopedFiles.length > 0) {
316
+ // Resolve relative paths, keep only files that actually exist + have a scannable extension
317
+ files = [];
318
+ for (const f of opts.scopedFiles) {
319
+ const abs = path.isAbsolute(f) ? f : path.join(frontendRoot, f);
320
+ const ext = path.extname(abs);
321
+ if (!SCANNABLE_EXTENSIONS.has(ext)) continue;
322
+ if (await fs.pathExists(abs)) files.push(abs);
323
+ }
324
+ } else {
325
+ files = await walkSource(frontendRoot);
326
+ }
327
+ const allCalls: FrontendApiCall[] = [];
328
+
329
+ for (const abs of files) {
330
+ let src: string;
331
+ try {
332
+ src = await fs.readFile(abs, "utf-8");
333
+ } catch {
334
+ continue;
335
+ }
336
+ const rel = path.relative(frontendRoot, abs);
337
+ const calls = extractApiCallsFromSource(src, rel);
338
+ allCalls.push(...calls);
339
+ }
340
+
341
+ const backendEndpoints = backendDsl.endpoints.map((ep) => ({
342
+ method: ep.method.toUpperCase(),
343
+ path: ep.path,
344
+ id: ep.id,
345
+ }));
346
+
347
+ const phantom: FrontendApiCall[] = [];
348
+ const methodMismatch: Array<{ call: FrontendApiCall; expectedMethod: string }> = [];
349
+ const matched: Array<{ call: FrontendApiCall; endpointId: string }> = [];
350
+ const unknownMethodCalls: FrontendApiCall[] = [];
351
+ const usedEndpointIds = new Set<string>();
352
+
353
+ for (const call of allCalls) {
354
+ // Track UNKNOWN-method calls for visibility regardless of matching outcome.
355
+ if (call.method === "UNKNOWN") unknownMethodCalls.push(call);
356
+
357
+ // Find all DSL endpoints whose path matches this call's path.
358
+ const pathMatches = backendEndpoints.filter((ep) => pathsMatch(ep.path, call.path));
359
+ if (pathMatches.length === 0) {
360
+ phantom.push(call);
361
+ continue;
362
+ }
363
+ // Check if any path-match also matches the method.
364
+ // UNKNOWN is treated permissively — matched against the first path hit.
365
+ const methodMatch = pathMatches.find(
366
+ (ep) => call.method === "UNKNOWN" || ep.method === call.method
367
+ );
368
+ if (methodMatch) {
369
+ matched.push({ call, endpointId: methodMatch.id });
370
+ usedEndpointIds.add(methodMatch.id);
371
+ } else {
372
+ // Path matches but method differs — report the first path-match's method
373
+ // as the expected one.
374
+ methodMismatch.push({ call, expectedMethod: pathMatches[0].method });
375
+ usedEndpointIds.add(pathMatches[0].id);
376
+ }
377
+ }
378
+
379
+ const unused = backendEndpoints.filter((ep) => !usedEndpointIds.has(ep.id));
380
+
381
+ return {
382
+ frontendCalls: allCalls,
383
+ backendEndpoints,
384
+ phantom,
385
+ unused,
386
+ methodMismatch,
387
+ matched,
388
+ unknownMethodCalls,
389
+ totalScannedFiles: files.length,
390
+ hasViolations: phantom.length > 0 || methodMismatch.length > 0,
391
+ };
392
+ }
393
+
394
+ // ─── Display ──────────────────────────────────────────────────────────────────
395
+
396
+ export function printCrossStackReport(repoName: string, report: CrossStackReport): void {
397
+ const totalEp = report.backendEndpoints.length;
398
+ const matchedCount = report.matched.length;
399
+ const phantomCount = report.phantom.length;
400
+ const mismatchCount = report.methodMismatch.length;
401
+ const unusedCount = report.unused.length;
402
+
403
+ const concatCount = report.frontendCalls.filter((c) => c.isConcatPath).length;
404
+ const concatNote = concatCount > 0 ? ` (${concatCount} via string concat — approximate)` : "";
405
+ console.log(chalk.cyan(`\n─── Cross-Stack Contract Verification [${repoName}] ─────────────`));
406
+ console.log(
407
+ chalk.gray(
408
+ ` Scanned ${report.totalScannedFiles} file(s), found ${report.frontendCalls.length} HTTP call(s)${concatNote}`
409
+ )
410
+ );
411
+ console.log(chalk.gray(` Backend DSL endpoints: ${totalEp}`));
412
+
413
+ // ── Matched ─────────────────────────────────────────────────────────────────
414
+ const matchTag = matchedCount === totalEp && phantomCount === 0 && mismatchCount === 0
415
+ ? chalk.green(`✔ ${matchedCount}/${totalEp} endpoints matched`)
416
+ : matchedCount > 0
417
+ ? chalk.yellow(`~ ${matchedCount}/${totalEp} endpoints matched`)
418
+ : chalk.red(`✘ 0/${totalEp} endpoints matched`);
419
+ console.log(` ${matchTag}`);
420
+
421
+ // ── Phantom endpoints (frontend calls not in DSL) ───────────────────────────
422
+ if (phantomCount > 0) {
423
+ console.log(chalk.red(`\n ❌ Phantom endpoints (${phantomCount}): frontend calls not declared in backend DSL`));
424
+ for (const call of report.phantom.slice(0, 8)) {
425
+ console.log(chalk.gray(` ${call.method.padEnd(6)} ${call.path}`));
426
+ console.log(chalk.gray(` ${call.file}:${call.line}`));
427
+ }
428
+ if (phantomCount > 8) {
429
+ console.log(chalk.gray(` ... and ${phantomCount - 8} more`));
430
+ }
431
+ }
432
+
433
+ // ── Method mismatches ───────────────────────────────────────────────────────
434
+ if (mismatchCount > 0) {
435
+ console.log(chalk.yellow(`\n ⚠ Method mismatches (${mismatchCount}): path matches but HTTP method differs`));
436
+ for (const m of report.methodMismatch.slice(0, 8)) {
437
+ console.log(
438
+ chalk.gray(
439
+ ` ${m.call.method} ${m.call.path} ${chalk.yellow("→")} expected ${m.expectedMethod}`
440
+ )
441
+ );
442
+ console.log(chalk.gray(` ${m.call.file}:${m.call.line}`));
443
+ }
444
+ if (mismatchCount > 8) {
445
+ console.log(chalk.gray(` ... and ${mismatchCount - 8} more`));
446
+ }
447
+ }
448
+
449
+ // ── Unused endpoints ────────────────────────────────────────────────────────
450
+ if (unusedCount > 0) {
451
+ console.log(chalk.gray(`\n · Unused DSL endpoints (${unusedCount}): declared but never called by frontend`));
452
+ for (const ep of report.unused.slice(0, 8)) {
453
+ console.log(chalk.gray(` ${ep.method.padEnd(6)} ${ep.path} (${ep.id})`));
454
+ }
455
+ if (unusedCount > 8) {
456
+ console.log(chalk.gray(` ... and ${unusedCount - 8} more`));
457
+ }
458
+ }
459
+
460
+ // ── UNKNOWN method calls ─────────────────────────────────────────────────────
461
+ // Surface for visibility; they were matched permissively and may hide real mismatches.
462
+ if (report.unknownMethodCalls.length > 0) {
463
+ console.log(
464
+ chalk.gray(
465
+ `\n · Unknown method (${report.unknownMethodCalls.length}): HTTP method could not be determined — matched permissively`
466
+ )
467
+ );
468
+ for (const call of report.unknownMethodCalls.slice(0, 5)) {
469
+ console.log(chalk.gray(` UNKNWN ${call.path}`));
470
+ console.log(chalk.gray(` ${call.file}:${call.line}`));
471
+ }
472
+ if (report.unknownMethodCalls.length > 5) {
473
+ console.log(chalk.gray(` ... and ${report.unknownMethodCalls.length - 5} more`));
474
+ }
475
+ }
476
+
477
+ // ── Summary ─────────────────────────────────────────────────────────────────
478
+ if (!report.hasViolations && unusedCount === 0 && matchedCount === totalEp && totalEp > 0) {
479
+ console.log(chalk.green(`\n ✔ Contract fully aligned — all ${totalEp} endpoints consumed correctly.`));
480
+ }
481
+ console.log(chalk.cyan("─".repeat(65)));
482
+ }