ai-spec-dev 0.55.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.
@@ -662,6 +662,15 @@ export async function runMultiRepoPipeline(
662
662
  { scopedFiles: fe.generatedFiles }
663
663
  );
664
664
  printCrossStackReport(fe.repoName, report);
665
+ if (report.hasViolations) {
666
+ console.log(
667
+ chalk.yellow(
668
+ ` ⚠ [W5] ${fe.repoName} has cross-stack violations` +
669
+ ` (${report.phantom.length} phantom, ${report.methodMismatch.length} method mismatch).` +
670
+ ` Review the report above and fix generated frontend code.`
671
+ )
672
+ );
673
+ }
665
674
  } catch (err) {
666
675
  console.log(chalk.yellow(` ⚠ Verification failed for ${fe.repoName}: ${(err as Error).message}`));
667
676
  }
@@ -11,6 +11,9 @@ export interface FrontendApiCall {
11
11
  file: string; // relative path from frontend root
12
12
  line: number; // 1-indexed line number
13
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;
14
17
  }
15
18
 
16
19
  export interface CrossStackReport {
@@ -24,7 +27,12 @@ export interface CrossStackReport {
24
27
  methodMismatch: Array<{ call: FrontendApiCall; expectedMethod: string }>;
25
28
  /** Calls whose method+path both match the DSL */
26
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[];
27
33
  totalScannedFiles: number;
34
+ /** True when there are phantom calls or method mismatches — use to fail CI / pipeline steps. */
35
+ hasViolations: boolean;
28
36
  }
29
37
 
30
38
  // ─── File scanning ────────────────────────────────────────────────────────────
@@ -92,8 +100,9 @@ export function extractApiCallsFromSource(
92
100
 
93
101
  // Pattern 1: .get('/path') / .post('/path') / .delete('/path') / .put('/path') / .patch('/path')
94
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).
95
104
  const methodCallRegex =
96
- /\.(get|post|put|patch|delete)\s*\(\s*(['"`])([^'"`]+)\2/gi;
105
+ /\.(get|post|put|patch|delete)\s*\(\s*(['"`])([^'"`]+)\2(?!\s*\+)/gi;
97
106
 
98
107
  // Pattern 2: fetch('/path', { method: 'POST' })
99
108
  // We detect fetch( + URL + optional method in the next ~100 chars
@@ -107,6 +116,15 @@ export function extractApiCallsFromSource(
107
116
  const genericRequestRegex =
108
117
  /\brequest\s*\(\s*(['"`])([^'"`]+)\1\s*(?:,\s*(['"`])(GET|POST|PUT|PATCH|DELETE)\3)?/gi;
109
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
+
110
128
  function getLineNumber(offset: number): number {
111
129
  // Count newlines up to offset
112
130
  let ln = 1;
@@ -131,6 +149,15 @@ export function extractApiCallsFromSource(
131
149
  return true;
132
150
  }
133
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
+
134
161
  let match: RegExpExecArray | null;
135
162
 
136
163
  while ((match = methodCallRegex.exec(source)) !== null) {
@@ -189,6 +216,39 @@ export function extractApiCallsFromSource(
189
216
  });
190
217
  }
191
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
+
192
252
  return calls;
193
253
  }
194
254
 
@@ -211,6 +271,7 @@ export function normalizePathSegments(p: string): string[] {
211
271
  const withoutQs = p.split("?")[0];
212
272
  const segments = withoutQs.split("/").filter(Boolean);
213
273
  return segments.map((seg) => {
274
+ if (seg === "*") return "*"; // explicit wildcard (concat paths)
214
275
  if (seg.startsWith(":")) return "*";
215
276
  if (seg.includes("${") || seg.includes("{{")) return "*";
216
277
  if (/^\d+$/.test(seg)) return "*";
@@ -286,9 +347,13 @@ export async function verifyCrossStackContract(
286
347
  const phantom: FrontendApiCall[] = [];
287
348
  const methodMismatch: Array<{ call: FrontendApiCall; expectedMethod: string }> = [];
288
349
  const matched: Array<{ call: FrontendApiCall; endpointId: string }> = [];
350
+ const unknownMethodCalls: FrontendApiCall[] = [];
289
351
  const usedEndpointIds = new Set<string>();
290
352
 
291
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
+
292
357
  // Find all DSL endpoints whose path matches this call's path.
293
358
  const pathMatches = backendEndpoints.filter((ep) => pathsMatch(ep.path, call.path));
294
359
  if (pathMatches.length === 0) {
@@ -296,6 +361,7 @@ export async function verifyCrossStackContract(
296
361
  continue;
297
362
  }
298
363
  // Check if any path-match also matches the method.
364
+ // UNKNOWN is treated permissively — matched against the first path hit.
299
365
  const methodMatch = pathMatches.find(
300
366
  (ep) => call.method === "UNKNOWN" || ep.method === call.method
301
367
  );
@@ -319,7 +385,9 @@ export async function verifyCrossStackContract(
319
385
  unused,
320
386
  methodMismatch,
321
387
  matched,
388
+ unknownMethodCalls,
322
389
  totalScannedFiles: files.length,
390
+ hasViolations: phantom.length > 0 || methodMismatch.length > 0,
323
391
  };
324
392
  }
325
393
 
@@ -332,10 +400,12 @@ export function printCrossStackReport(repoName: string, report: CrossStackReport
332
400
  const mismatchCount = report.methodMismatch.length;
333
401
  const unusedCount = report.unused.length;
334
402
 
403
+ const concatCount = report.frontendCalls.filter((c) => c.isConcatPath).length;
404
+ const concatNote = concatCount > 0 ? ` (${concatCount} via string concat — approximate)` : "";
335
405
  console.log(chalk.cyan(`\n─── Cross-Stack Contract Verification [${repoName}] ─────────────`));
336
406
  console.log(
337
407
  chalk.gray(
338
- ` Scanned ${report.totalScannedFiles} file(s), found ${report.frontendCalls.length} HTTP call(s)`
408
+ ` Scanned ${report.totalScannedFiles} file(s), found ${report.frontendCalls.length} HTTP call(s)${concatNote}`
339
409
  )
340
410
  );
341
411
  console.log(chalk.gray(` Backend DSL endpoints: ${totalEp}`));
@@ -387,8 +457,25 @@ export function printCrossStackReport(repoName: string, report: CrossStackReport
387
457
  }
388
458
  }
389
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
+
390
477
  // ── Summary ─────────────────────────────────────────────────────────────────
391
- if (phantomCount === 0 && mismatchCount === 0 && unusedCount === 0 && matchedCount === totalEp && totalEp > 0) {
478
+ if (!report.hasViolations && unusedCount === 0 && matchedCount === totalEp && totalEp > 0) {
392
479
  console.log(chalk.green(`\n ✔ Contract fully aligned — all ${totalEp} endpoints consumed correctly.`));
393
480
  }
394
481
  console.log(chalk.cyan("─".repeat(65)));
package/dist/cli/index.js CHANGED
@@ -715,7 +715,7 @@ var require_package = __commonJS({
715
715
  "package.json"(exports2, module2) {
716
716
  module2.exports = {
717
717
  name: "ai-spec-dev",
718
- version: "0.55.0",
718
+ version: "0.56.0",
719
719
  description: "AI-driven Development Orchestrator SDK & CLI",
720
720
  main: "dist/index.js",
721
721
  types: "dist/index.d.ts",
@@ -10321,10 +10321,12 @@ async function walkSource(root) {
10321
10321
  function extractApiCallsFromSource(source, relFile) {
10322
10322
  const calls = [];
10323
10323
  const lines = source.split("\n");
10324
- const methodCallRegex = /\.(get|post|put|patch|delete)\s*\(\s*(['"`])([^'"`]+)\2/gi;
10324
+ const methodCallRegex = /\.(get|post|put|patch|delete)\s*\(\s*(['"`])([^'"`]+)\2(?!\s*\+)/gi;
10325
10325
  const fetchRegex = /\bfetch\s*\(\s*(['"`])([^'"`]+)\1([^)]*)\)/g;
10326
10326
  const useRequestRegex = /\buseRequest\s*\(\s*(['"`])([^'"`]+)\1([^)]*)\)/g;
10327
10327
  const genericRequestRegex = /\brequest\s*\(\s*(['"`])([^'"`]+)\1\s*(?:,\s*(['"`])(GET|POST|PUT|PATCH|DELETE)\3)?/gi;
10328
+ const concatMethodRegex = /\.(get|post|put|patch|delete)\s*\(\s*(['"`])([^'"`]+)\2\s*\+/gi;
10329
+ const concatFetchRegex = /\bfetch\s*\(\s*(['"`])([^'"`]+)\1\s*\+([^)]*)\)/g;
10328
10330
  function getLineNumber(offset) {
10329
10331
  let ln = 1;
10330
10332
  for (let i = 0; i < offset && i < source.length; i++) {
@@ -10341,6 +10343,10 @@ function extractApiCallsFromSource(source, relFile) {
10341
10343
  if (/\.(css|svg|png|jpe?g|gif|ico|woff2?|ttf|eot)$/i.test(p)) return false;
10342
10344
  return true;
10343
10345
  }
10346
+ function concatPath(prefix) {
10347
+ const stripped = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
10348
+ return stripped + "/*";
10349
+ }
10344
10350
  let match;
10345
10351
  while ((match = methodCallRegex.exec(source)) !== null) {
10346
10352
  const rawPath = match[3];
@@ -10394,12 +10400,41 @@ function extractApiCallsFromSource(source, relFile) {
10394
10400
  snippet: getSnippet(line)
10395
10401
  });
10396
10402
  }
10403
+ while ((match = concatMethodRegex.exec(source)) !== null) {
10404
+ const rawPrefix = match[3];
10405
+ if (!isApiLike(rawPrefix)) continue;
10406
+ const line = getLineNumber(match.index);
10407
+ calls.push({
10408
+ method: match[1].toUpperCase(),
10409
+ path: concatPath(rawPrefix),
10410
+ file: relFile,
10411
+ line,
10412
+ snippet: getSnippet(line),
10413
+ isConcatPath: true
10414
+ });
10415
+ }
10416
+ while ((match = concatFetchRegex.exec(source)) !== null) {
10417
+ const rawPrefix = match[2];
10418
+ if (!isApiLike(rawPrefix)) continue;
10419
+ const tail = match[3] ?? "";
10420
+ const methodMatch = tail.match(/method\s*:\s*['"`](GET|POST|PUT|PATCH|DELETE)['"`]/i);
10421
+ const line = getLineNumber(match.index);
10422
+ calls.push({
10423
+ method: methodMatch ? methodMatch[1].toUpperCase() : "GET",
10424
+ path: concatPath(rawPrefix),
10425
+ file: relFile,
10426
+ line,
10427
+ snippet: getSnippet(line),
10428
+ isConcatPath: true
10429
+ });
10430
+ }
10397
10431
  return calls;
10398
10432
  }
10399
10433
  function normalizePathSegments(p) {
10400
10434
  const withoutQs = p.split("?")[0];
10401
10435
  const segments = withoutQs.split("/").filter(Boolean);
10402
10436
  return segments.map((seg) => {
10437
+ if (seg === "*") return "*";
10403
10438
  if (seg.startsWith(":")) return "*";
10404
10439
  if (seg.includes("${") || seg.includes("{{")) return "*";
10405
10440
  if (/^\d+$/.test(seg)) return "*";
@@ -10451,8 +10486,10 @@ async function verifyCrossStackContract(backendDsl, frontendRoot, opts = {}) {
10451
10486
  const phantom = [];
10452
10487
  const methodMismatch = [];
10453
10488
  const matched = [];
10489
+ const unknownMethodCalls = [];
10454
10490
  const usedEndpointIds = /* @__PURE__ */ new Set();
10455
10491
  for (const call of allCalls) {
10492
+ if (call.method === "UNKNOWN") unknownMethodCalls.push(call);
10456
10493
  const pathMatches = backendEndpoints.filter((ep) => pathsMatch(ep.path, call.path));
10457
10494
  if (pathMatches.length === 0) {
10458
10495
  phantom.push(call);
@@ -10477,7 +10514,9 @@ async function verifyCrossStackContract(backendDsl, frontendRoot, opts = {}) {
10477
10514
  unused,
10478
10515
  methodMismatch,
10479
10516
  matched,
10480
- totalScannedFiles: files.length
10517
+ unknownMethodCalls,
10518
+ totalScannedFiles: files.length,
10519
+ hasViolations: phantom.length > 0 || methodMismatch.length > 0
10481
10520
  };
10482
10521
  }
10483
10522
  function printCrossStackReport(repoName, report) {
@@ -10486,11 +10525,13 @@ function printCrossStackReport(repoName, report) {
10486
10525
  const phantomCount = report.phantom.length;
10487
10526
  const mismatchCount = report.methodMismatch.length;
10488
10527
  const unusedCount = report.unused.length;
10528
+ const concatCount = report.frontendCalls.filter((c) => c.isConcatPath).length;
10529
+ const concatNote = concatCount > 0 ? ` (${concatCount} via string concat \u2014 approximate)` : "";
10489
10530
  console.log(import_chalk19.default.cyan(`
10490
10531
  \u2500\u2500\u2500 Cross-Stack Contract Verification [${repoName}] \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`));
10491
10532
  console.log(
10492
10533
  import_chalk19.default.gray(
10493
- ` Scanned ${report.totalScannedFiles} file(s), found ${report.frontendCalls.length} HTTP call(s)`
10534
+ ` Scanned ${report.totalScannedFiles} file(s), found ${report.frontendCalls.length} HTTP call(s)${concatNote}`
10494
10535
  )
10495
10536
  );
10496
10537
  console.log(import_chalk19.default.gray(` Backend DSL endpoints: ${totalEp}`));
@@ -10532,7 +10573,22 @@ function printCrossStackReport(repoName, report) {
10532
10573
  console.log(import_chalk19.default.gray(` ... and ${unusedCount - 8} more`));
10533
10574
  }
10534
10575
  }
10535
- if (phantomCount === 0 && mismatchCount === 0 && unusedCount === 0 && matchedCount === totalEp && totalEp > 0) {
10576
+ if (report.unknownMethodCalls.length > 0) {
10577
+ console.log(
10578
+ import_chalk19.default.gray(
10579
+ `
10580
+ \xB7 Unknown method (${report.unknownMethodCalls.length}): HTTP method could not be determined \u2014 matched permissively`
10581
+ )
10582
+ );
10583
+ for (const call of report.unknownMethodCalls.slice(0, 5)) {
10584
+ console.log(import_chalk19.default.gray(` UNKNWN ${call.path}`));
10585
+ console.log(import_chalk19.default.gray(` ${call.file}:${call.line}`));
10586
+ }
10587
+ if (report.unknownMethodCalls.length > 5) {
10588
+ console.log(import_chalk19.default.gray(` ... and ${report.unknownMethodCalls.length - 5} more`));
10589
+ }
10590
+ }
10591
+ if (!report.hasViolations && unusedCount === 0 && matchedCount === totalEp && totalEp > 0) {
10536
10592
  console.log(import_chalk19.default.green(`
10537
10593
  \u2714 Contract fully aligned \u2014 all ${totalEp} endpoints consumed correctly.`));
10538
10594
  }
@@ -12012,6 +12068,13 @@ async function runMultiRepoPipeline(idea, workspace, opts, currentDir, config2)
12012
12068
  { scopedFiles: fe2.generatedFiles }
12013
12069
  );
12014
12070
  printCrossStackReport(fe2.repoName, report);
12071
+ if (report.hasViolations) {
12072
+ console.log(
12073
+ import_chalk22.default.yellow(
12074
+ ` \u26A0 [W5] ${fe2.repoName} has cross-stack violations (${report.phantom.length} phantom, ${report.methodMismatch.length} method mismatch). Review the report above and fix generated frontend code.`
12075
+ )
12076
+ );
12077
+ }
12015
12078
  } catch (err) {
12016
12079
  console.log(import_chalk22.default.yellow(` \u26A0 Verification failed for ${fe2.repoName}: ${err.message}`));
12017
12080
  }