ai-spec-dev 0.38.0 → 0.42.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 (89) hide show
  1. package/.ai-spec-workspace.json +17 -0
  2. package/.ai-spec.json +7 -0
  3. package/cli/commands/create.ts +9 -1176
  4. package/cli/commands/dashboard.ts +1 -1
  5. package/cli/pipeline/helpers.ts +34 -0
  6. package/cli/pipeline/multi-repo.ts +483 -0
  7. package/cli/pipeline/single-repo.ts +764 -0
  8. package/cli/utils.ts +2 -0
  9. package/core/cli-ui.ts +136 -0
  10. package/core/code-generator.ts +56 -343
  11. package/core/codegen/helpers.ts +219 -0
  12. package/core/codegen/topo-sort.ts +98 -0
  13. package/core/constitution-consolidator.ts +2 -2
  14. package/core/dsl-coverage-checker.ts +298 -0
  15. package/core/dsl-extractor.ts +19 -46
  16. package/core/dsl-feedback.ts +1 -1
  17. package/core/dsl-validator.ts +74 -0
  18. package/core/error-feedback.ts +99 -13
  19. package/core/frontend-context-loader.ts +27 -5
  20. package/core/knowledge-memory.ts +52 -0
  21. package/core/mock/fixtures.ts +89 -0
  22. package/core/mock/proxy.ts +380 -0
  23. package/core/mock-server-generator.ts +12 -460
  24. package/core/provider-utils.ts +8 -7
  25. package/core/requirement-decomposer.ts +4 -28
  26. package/core/reviewer.ts +1 -1
  27. package/core/safe-json.ts +76 -0
  28. package/core/spec-updater.ts +5 -21
  29. package/core/token-budget.ts +124 -0
  30. package/core/vcr.ts +20 -1
  31. package/demo-backend/.ai-spec-constitution.md +65 -0
  32. package/demo-backend/package.json +21 -0
  33. package/demo-backend/prisma/schema.prisma +22 -0
  34. package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.dsl.json +186 -0
  35. package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.md +211 -0
  36. package/demo-backend/src/controllers/bookmark.controller.test.ts +255 -0
  37. package/demo-backend/src/controllers/bookmark.controller.ts +187 -0
  38. package/demo-backend/src/index.ts +17 -0
  39. package/demo-backend/src/routes/bookmark.routes.test.ts +264 -0
  40. package/demo-backend/src/routes/bookmark.routes.ts +11 -0
  41. package/demo-backend/src/routes/index.ts +8 -0
  42. package/demo-backend/src/services/bookmark.service.test.ts +433 -0
  43. package/demo-backend/src/services/bookmark.service.ts +261 -0
  44. package/demo-backend/tsconfig.json +12 -0
  45. package/demo-frontend/.ai-spec-constitution.md +95 -0
  46. package/demo-frontend/package.json +23 -0
  47. package/demo-frontend/src/App.tsx +12 -0
  48. package/demo-frontend/src/main.tsx +9 -0
  49. package/demo-frontend/tsconfig.json +13 -0
  50. package/dist/cli/index.js +4351 -3666
  51. package/dist/cli/index.js.map +1 -1
  52. package/dist/cli/index.mjs +3997 -3312
  53. package/dist/cli/index.mjs.map +1 -1
  54. package/dist/index.d.mts +18 -16
  55. package/dist/index.d.ts +18 -16
  56. package/dist/index.js +388 -188
  57. package/dist/index.js.map +1 -1
  58. package/dist/index.mjs +386 -186
  59. package/dist/index.mjs.map +1 -1
  60. package/package.json +2 -2
  61. package/tests/auto-consolidation.test.ts +109 -0
  62. package/tests/combined-generator.test.ts +81 -0
  63. package/tests/constitution-consolidator.test.ts +161 -0
  64. package/tests/constitution-generator.test.ts +94 -0
  65. package/tests/contract-bridge.test.ts +201 -0
  66. package/tests/design-dialogue.test.ts +108 -0
  67. package/tests/dsl-coverage-checker.test.ts +230 -0
  68. package/tests/dsl-feedback.test.ts +45 -0
  69. package/tests/dsl-validator-xref.test.ts +99 -0
  70. package/tests/error-feedback-repair.test.ts +319 -0
  71. package/tests/error-feedback-validation.test.ts +91 -0
  72. package/tests/frontend-context-loader.test.ts +609 -0
  73. package/tests/global-constitution.test.ts +110 -0
  74. package/tests/key-store.test.ts +73 -0
  75. package/tests/knowledge-memory.test.ts +327 -0
  76. package/tests/project-index.test.ts +206 -0
  77. package/tests/prompt-hasher.test.ts +19 -0
  78. package/tests/requirement-decomposer.test.ts +171 -0
  79. package/tests/reviewer.test.ts +4 -1
  80. package/tests/run-logger.test.ts +289 -0
  81. package/tests/run-snapshot.test.ts +113 -0
  82. package/tests/safe-json.test.ts +63 -0
  83. package/tests/spec-updater.test.ts +161 -0
  84. package/tests/test-generator.test.ts +146 -0
  85. package/tests/token-budget.test.ts +124 -0
  86. package/tests/vcr-hash.test.ts +101 -0
  87. package/tests/workspace-loader.test.ts +277 -0
  88. package/RELEASE_LOG.md +0 -2731
  89. package/purpose.md +0 -1294
@@ -11,6 +11,8 @@ import {
11
11
  buildDslExtractionPrompt,
12
12
  buildDslRetryPrompt,
13
13
  } from "../prompts/dsl.prompt";
14
+ import { estimateTokens, getDefaultBudget } from "./token-budget";
15
+ import { parseJsonFromAiOutput } from "./safe-json";
14
16
 
15
17
  // ─── DSL Sanitizer ───────────────────────────────────────────────────────────
16
18
 
@@ -50,8 +52,8 @@ function sanitizeDsl(raw: unknown): unknown {
50
52
  /** Maximum AI attempts (1 initial + up to this many retries). */
51
53
  const MAX_RETRIES = 2;
52
54
 
53
- /** Maximum spec length passed to AI to avoid token/context blow-up. */
54
- const MAX_SPEC_CHARS = 12_000;
55
+ /** Default maximum spec length passed to AI. Overridden by token budget when provider is known. */
56
+ const DEFAULT_MAX_SPEC_CHARS = 12_000;
55
57
 
56
58
  // ─── DSL file naming ──────────────────────────────────────────────────────────
57
59
 
@@ -63,45 +65,8 @@ export function dslFilePath(specFilePath: string): string {
63
65
 
64
66
  // ─── Parser ───────────────────────────────────────────────────────────────────
65
67
 
66
- /**
67
- * Parse JSON from raw AI output.
68
- * Handles two cases:
69
- * 1. Bare JSON object starting with `{`
70
- * 2. JSON wrapped in a ```json ... ``` fence (model sometimes ignores instructions)
71
- *
72
- * Does NOT use eval or Function() — only JSON.parse().
73
- */
74
- function parseJsonFromOutput(raw: string): unknown {
75
- const trimmed = raw.trim();
76
-
77
- // Case 1: bare JSON
78
- if (trimmed.startsWith("{")) {
79
- return JSON.parse(trimmed);
80
- }
81
-
82
- // Case 2: fenced JSON — extract content between first ``` and last ```
83
- const fenceStart = trimmed.indexOf("```");
84
- if (fenceStart !== -1) {
85
- const afterFence = trimmed.slice(fenceStart + 3);
86
- // Skip optional language tag (e.g. "json\n")
87
- const newlinePos = afterFence.indexOf("\n");
88
- const jsonStart = newlinePos !== -1 ? newlinePos + 1 : 0;
89
- const fenceEnd = afterFence.lastIndexOf("```");
90
- if (fenceEnd > jsonStart) {
91
- const jsonStr = afterFence.slice(jsonStart, fenceEnd).trim();
92
- return JSON.parse(jsonStr);
93
- }
94
- }
95
-
96
- // Case 3: try to find the first `{` and last `}` pair
97
- const objStart = trimmed.indexOf("{");
98
- const objEnd = trimmed.lastIndexOf("}");
99
- if (objStart !== -1 && objEnd > objStart) {
100
- return JSON.parse(trimmed.slice(objStart, objEnd + 1));
101
- }
102
-
103
- throw new SyntaxError("No JSON object found in AI output");
104
- }
68
+ // Uses shared parseJsonFromAiOutput from safe-json.ts
69
+ const parseJsonFromOutput = parseJsonFromAiOutput;
105
70
 
106
71
  // ─── DslExtractor ────────────────────────────────────────────────────────────
107
72
 
@@ -125,12 +90,20 @@ export class DslExtractor {
125
90
  specContent: string,
126
91
  opts: { auto?: boolean; isFrontend?: boolean } = {}
127
92
  ): Promise<SpecDSL | null> {
93
+ // Compute dynamic spec char limit based on provider's token budget.
94
+ // Reserve ~30% of budget for DSL extraction prompt + response; use 70% for spec content.
95
+ const providerBudget = getDefaultBudget(this.provider.providerName);
96
+ const maxSpecChars = Math.max(
97
+ DEFAULT_MAX_SPEC_CHARS,
98
+ Math.floor(providerBudget * 0.7 * 3) // ~3 chars per token, 70% of budget
99
+ );
100
+
128
101
  // Truncate very long specs to avoid token issues
129
102
  const specForAI =
130
- specContent.length > MAX_SPEC_CHARS
103
+ specContent.length > maxSpecChars
131
104
  ? (() => {
132
- console.log(chalk.yellow(` ⚠ Spec is ${specContent.length} chars — truncating to ${MAX_SPEC_CHARS} for DSL extraction. Details at the end may be lost.`));
133
- return specContent.slice(0, MAX_SPEC_CHARS) + "\n... (truncated for DSL extraction)";
105
+ console.log(chalk.yellow(` ⚠ Spec is ${specContent.length} chars — truncating to ${maxSpecChars} for DSL extraction (${this.provider.providerName} budget: ${Math.round(providerBudget / 1000)}K tokens).`));
106
+ return specContent.slice(0, maxSpecChars) + "\n... (truncated for DSL extraction)";
134
107
  })()
135
108
  : specContent;
136
109
 
@@ -170,8 +143,8 @@ export class DslExtractor {
170
143
  console.log(chalk.red(` ✘ Failed to parse JSON from AI output: ${(parseErr as Error).message}`));
171
144
  const preview = rawOutput.slice(0, 500).replace(/\n/g, "\\n");
172
145
  console.log(chalk.gray(` AI output preview (first 500 chars): ${preview}`));
173
- if (rawOutput.length > MAX_SPEC_CHARS) {
174
- console.log(chalk.gray(` Note: spec was truncated to ${MAX_SPEC_CHARS} chars — long specs may lose context`));
146
+ if (rawOutput.length > maxSpecChars) {
147
+ console.log(chalk.gray(` Note: spec was truncated to ${maxSpecChars} chars — long specs may lose context`));
175
148
  }
176
149
  lastErrors = [{ path: "root", message: "Output is not valid JSON — see raw output above" }];
177
150
 
@@ -21,7 +21,7 @@ import { SpecDSL } from "./dsl-types";
21
21
 
22
22
  export interface DslGap {
23
23
  /** Short machine key for RunLog serialisation */
24
- code: "sparse_model" | "missing_errors" | "generic_endpoint_desc" | "no_models_no_endpoints";
24
+ code: "sparse_model" | "missing_errors" | "generic_endpoint_desc" | "no_models_no_endpoints" | "uncovered_requirement";
25
25
  /** Human-readable message shown to the user */
26
26
  message: string;
27
27
  /** Concrete suggestion injected into the refinement prompt */
@@ -119,6 +119,9 @@ export function validateDsl(raw: unknown): DslValidationResult {
119
119
  }
120
120
  }
121
121
 
122
+ // ── Cross-reference checks ──────────────────────────────────────────────
123
+ crossReferenceChecks(obj, errors);
124
+
122
125
  if (errors.length > 0) {
123
126
  return { valid: false, errors };
124
127
  }
@@ -428,6 +431,77 @@ function validateComponent(
428
431
  }
429
432
  }
430
433
 
434
+ // ─── Cross-reference checks ──────────────────────────────────────────────────
435
+
436
+ function crossReferenceChecks(
437
+ obj: Record<string, unknown>,
438
+ errors: DslValidationError[]
439
+ ): void {
440
+ const models = Array.isArray(obj["models"]) ? (obj["models"] as Record<string, unknown>[]) : [];
441
+ const endpoints = Array.isArray(obj["endpoints"]) ? (obj["endpoints"] as Record<string, unknown>[]) : [];
442
+ const components = Array.isArray(obj["components"]) ? (obj["components"] as Record<string, unknown>[]) : [];
443
+
444
+ // 1. Duplicate path+method detection
445
+ const seenRoutes = new Map<string, number>();
446
+ for (let i = 0; i < endpoints.length; i++) {
447
+ const ep = endpoints[i];
448
+ if (typeof ep?.["method"] === "string" && typeof ep?.["path"] === "string") {
449
+ const route = `${(ep["method"] as string).toUpperCase()} ${ep["path"]}`;
450
+ if (seenRoutes.has(route)) {
451
+ errors.push({
452
+ path: `endpoints[${i}]`,
453
+ message: `Duplicate route "${route}" — also defined at endpoints[${seenRoutes.get(route)}]`,
454
+ });
455
+ } else {
456
+ seenRoutes.set(route, i);
457
+ }
458
+ }
459
+ }
460
+
461
+ // 2. Model relations reference existing model names
462
+ const modelNames = new Set(
463
+ models.filter((m) => typeof m?.["name"] === "string").map((m) => m["name"] as string)
464
+ );
465
+ for (let i = 0; i < models.length; i++) {
466
+ const m = models[i];
467
+ if (!Array.isArray(m?.["relations"])) continue;
468
+ for (const rel of m["relations"] as string[]) {
469
+ if (typeof rel !== "string") continue;
470
+ // Extract referenced model name: "User hasMany Post" → "Post", "belongsTo Category" → "Category"
471
+ const refMatch = rel.match(/(?:hasMany|hasOne|belongsTo|manyToMany)\s+(\w+)/i);
472
+ if (refMatch) {
473
+ const refName = refMatch[1];
474
+ if (!modelNames.has(refName)) {
475
+ errors.push({
476
+ path: `models[${i}].relations`,
477
+ message: `Relation references model "${refName}" which is not defined in models[]`,
478
+ });
479
+ }
480
+ }
481
+ }
482
+ }
483
+
484
+ // 3. Component apiCalls reference existing endpoint IDs
485
+ const endpointIds = new Set(
486
+ endpoints.filter((e) => typeof e?.["id"] === "string").map((e) => e["id"] as string)
487
+ );
488
+ if (endpointIds.size > 0) {
489
+ for (let i = 0; i < components.length; i++) {
490
+ const c = components[i];
491
+ if (!Array.isArray(c?.["apiCalls"])) continue;
492
+ for (const call of c["apiCalls"] as string[]) {
493
+ if (typeof call !== "string") continue;
494
+ if (!endpointIds.has(call)) {
495
+ errors.push({
496
+ path: `components[${i}].apiCalls`,
497
+ message: `References endpoint "${call}" which is not defined in endpoints[]`,
498
+ });
499
+ }
500
+ }
501
+ }
502
+ }
503
+ }
504
+
431
505
  // ─── Helpers ──────────────────────────────────────────────────────────────────
432
506
 
433
507
  function requireNonEmptyString(
@@ -7,10 +7,11 @@ import { getCodeGenSystemPrompt } from "../prompts/codegen.prompt";
7
7
  import { SpecDSL } from "./dsl-types";
8
8
  import { buildDslContextSection } from "./dsl-extractor";
9
9
  import { getActiveSnapshot } from "./run-snapshot";
10
+ import { startSpinner } from "./cli-ui";
10
11
 
11
12
  // ─── Types ──────────────────────────────────────────────────────────────────────
12
13
 
13
- interface ErrorEntry {
14
+ export interface ErrorEntry {
14
15
  source: "test" | "lint" | "build";
15
16
  message: string;
16
17
  file?: string;
@@ -60,7 +61,7 @@ function runCommand(cmd: string, cwd: string): { success: boolean; output: strin
60
61
  * Detect TypeScript type-check command for the given directory.
61
62
  * Returns null for non-TS projects or projects without tsconfig.
62
63
  */
63
- function detectBuildCommand(workingDir: string): string | null {
64
+ export function detectBuildCommand(workingDir: string): string | null {
64
65
  // Only applies to Node.js / frontend TypeScript projects
65
66
  if (!fs.existsSync(path.join(workingDir, "tsconfig.json"))) return null;
66
67
 
@@ -80,7 +81,7 @@ function detectBuildCommand(workingDir: string): string | null {
80
81
  return "npx tsc --noEmit";
81
82
  }
82
83
 
83
- function detectTestCommand(workingDir: string): string | null {
84
+ export function detectTestCommand(workingDir: string): string | null {
84
85
  // ── Go ──────────────────────────────────────────────────────────────────────
85
86
  if (fs.existsSync(path.join(workingDir, "go.mod"))) return "go test ./...";
86
87
 
@@ -129,7 +130,7 @@ function detectTestCommand(workingDir: string): string | null {
129
130
  return null;
130
131
  }
131
132
 
132
- function detectLintCommand(workingDir: string): string | null {
133
+ export function detectLintCommand(workingDir: string): string | null {
133
134
  // ── Go ──────────────────────────────────────────────────────────────────────
134
135
  if (fs.existsSync(path.join(workingDir, "go.mod"))) {
135
136
  // golangci-lint is optional; fall back to go vet
@@ -183,7 +184,7 @@ function detectLintCommand(workingDir: string): string | null {
183
184
  return null;
184
185
  }
185
186
 
186
- function parseErrors(output: string, source: ErrorEntry["source"]): ErrorEntry[] {
187
+ export function parseErrors(output: string, source: ErrorEntry["source"]): ErrorEntry[] {
187
188
  const errors: ErrorEntry[] = [];
188
189
  if (!output.trim()) return errors;
189
190
 
@@ -226,7 +227,7 @@ function parseErrors(output: string, source: ErrorEntry["source"]): ErrorEntry[]
226
227
  * Returns paths normalized to project-root-relative form (no extension).
227
228
  * Only considers relative imports (./foo, ../foo) — skips aliases and node_modules.
228
229
  */
229
- function parseRelativeImports(content: string, fromFileRel: string): string[] {
230
+ export function parseRelativeImports(content: string, fromFileRel: string): string[] {
230
231
  const relDir = path.dirname(fromFileRel);
231
232
  const results: string[] = [];
232
233
 
@@ -253,7 +254,7 @@ function parseRelativeImports(content: string, fromFileRel: string): string[] {
253
254
  * Example: if A exports a type used by B and C, fix A first so cycle 1 can cascade correctly.
254
255
  * Uses Kahn's topological sort; cycles are appended at the end (unavoidable, fix last).
255
256
  */
256
- async function buildRepairOrder(
257
+ export async function buildRepairOrder(
257
258
  errorsByFile: Map<string, ErrorEntry[]>,
258
259
  workingDir: string
259
260
  ): Promise<[string, ErrorEntry[]][]> {
@@ -369,22 +370,80 @@ ${fileContent}
369
370
 
370
371
  Output ONLY the complete fixed file content. No markdown fences, no explanations.`;
371
372
 
373
+ const fixSpinner = startSpinner(`Fixing ${chalk.bold(file)} (${fileErrors.length} error(s))...`);
372
374
  try {
373
375
  const raw = await provider.generate(prompt, getCodeGenSystemPrompt());
374
376
  const fixed = raw.replace(/^```\w*\n?/gm, "").replace(/\n?```$/gm, "").trim();
375
377
  await getActiveSnapshot()?.snapshotFile(fullPath);
376
378
  await fs.writeFile(fullPath, fixed, "utf-8");
377
379
  results.push({ fixed: true, file, explanation: `Fixed ${fileErrors.length} error(s)` });
378
- console.log(chalk.green(`Auto-fixed: ${file}`));
380
+ fixSpinner.succeed(`Auto-fixed: ${file}`);
379
381
  } catch (err) {
380
382
  results.push({ fixed: false, file, explanation: `AI fix failed: ${(err as Error).message}` });
381
- console.log(chalk.yellow(`Could not auto-fix: ${file}`));
383
+ fixSpinner.fail(`Could not auto-fix: ${file}`);
382
384
  }
383
385
  }
384
386
 
385
387
  return results;
386
388
  }
387
389
 
390
+ // ─── Test Existence Validation ──────────────────────────────────────────────────
391
+
392
+ /** Patterns that indicate a file contains actual test cases (language-agnostic). */
393
+ const TEST_PATTERNS = [
394
+ /\bdescribe\s*\(/, // JS/TS (vitest, jest, mocha)
395
+ /\bit\s*\(/, // JS/TS
396
+ /\btest\s*\(/, // JS/TS
397
+ /\bfunc\s+Test\w*\s*\(/, // Go
398
+ /\bdef\s+test_/, // Python
399
+ /@Test\b/, // Java (JUnit)
400
+ /#\[test\]/, // Rust
401
+ ];
402
+
403
+ export interface TestValidationResult {
404
+ valid: boolean;
405
+ fileCount: number;
406
+ reason?: string;
407
+ }
408
+
409
+ /**
410
+ * Check that generated test files exist on disk and contain actual test cases.
411
+ * Prevents false-positive "tests pass" when no tests were generated.
412
+ */
413
+ export function validateTestFilesExist(
414
+ workingDir: string,
415
+ generatedTestFiles: string[]
416
+ ): TestValidationResult {
417
+ if (generatedTestFiles.length === 0) {
418
+ return { valid: false, fileCount: 0, reason: "No test files were generated" };
419
+ }
420
+
421
+ let validCount = 0;
422
+ for (const relPath of generatedTestFiles) {
423
+ const absPath = path.join(workingDir, relPath);
424
+ if (!fs.existsSync(absPath)) continue;
425
+
426
+ try {
427
+ const content = fs.readFileSync(absPath, "utf-8");
428
+ if (TEST_PATTERNS.some((p) => p.test(content))) {
429
+ validCount++;
430
+ }
431
+ } catch {
432
+ // unreadable — skip
433
+ }
434
+ }
435
+
436
+ if (validCount === 0) {
437
+ return {
438
+ valid: false,
439
+ fileCount: generatedTestFiles.length,
440
+ reason: `${generatedTestFiles.length} test file(s) listed but none contain actual test cases`,
441
+ };
442
+ }
443
+
444
+ return { valid: true, fileCount: validCount };
445
+ }
446
+
388
447
  // ─── Public API ─────────────────────────────────────────────────────────────────
389
448
 
390
449
  export interface ErrorFeedbackOptions {
@@ -396,6 +455,8 @@ export interface ErrorFeedbackOptions {
396
455
  skipLint?: boolean;
397
456
  /** Whether to skip TypeScript type-check (tsc --noEmit / vue-tsc --noEmit) */
398
457
  skipBuild?: boolean;
458
+ /** List of generated test files — used to validate tests actually exist */
459
+ generatedTestFiles?: string[];
399
460
  }
400
461
 
401
462
  /**
@@ -421,6 +482,19 @@ export async function runErrorFeedback(
421
482
  return true;
422
483
  }
423
484
 
485
+ // ── Pre-check: validate generated test files actually exist ──────────────
486
+ let testsValidated = true;
487
+ if (testCmd && opts.generatedTestFiles) {
488
+ const testValidation = validateTestFilesExist(workingDir, opts.generatedTestFiles);
489
+ if (!testValidation.valid) {
490
+ console.log(chalk.yellow(` ⚠ Test validation: ${testValidation.reason}`));
491
+ console.log(chalk.yellow(" Test results will be treated as unvalidated (not as 'pass')."));
492
+ testsValidated = false;
493
+ } else {
494
+ console.log(chalk.gray(` Test validation: ${testValidation.fileCount} valid test file(s) found.`));
495
+ }
496
+ }
497
+
424
498
  if (buildCmd) console.log(chalk.gray(` Type-check: ${buildCmd}`));
425
499
 
426
500
  let prevErrorCount = Infinity; // circuit-breaker: tracks error count from previous cycle
@@ -489,16 +563,28 @@ export async function runErrorFeedback(
489
563
  }
490
564
 
491
565
  if (allErrors.length === 0) {
566
+ if (!testsValidated) {
567
+ console.log(chalk.yellow(`\n ⚠ Build/lint passed but tests were not validated (no test files with actual test cases).`));
568
+ return true; // don't block pipeline, but the warning is logged
569
+ }
492
570
  console.log(chalk.green(`\n ✔ All checks passed after ${cycle} cycle(s).`));
493
571
  return true;
494
572
  }
495
573
 
496
- // Circuit breaker: if the fix cycle made no progress (error count did not
497
- // decrease), stop immediately rather than spending another AI cycle.
498
- if (allErrors.length >= prevErrorCount) {
574
+ // Circuit breaker: stop if error count didn't decrease or is increasing.
575
+ if (allErrors.length > prevErrorCount) {
576
+ console.log(
577
+ chalk.red(
578
+ `\n ✘ Auto-fix caused regression (${prevErrorCount} → ${allErrors.length} errors). Stopping immediately.`
579
+ )
580
+ );
581
+ console.log(chalk.gray(" The fix attempt introduced new errors. Manual intervention needed."));
582
+ return false;
583
+ }
584
+ if (allErrors.length === prevErrorCount) {
499
585
  console.log(
500
586
  chalk.yellow(
501
- `\n ⚠ Auto-fix made no progress (${allErrors.length} error(s) before and after). Stopping early.`
587
+ `\n ⚠ Auto-fix made no progress (${allErrors.length} error(s) remain). Stopping early.`
502
588
  )
503
589
  );
504
590
  console.log(chalk.gray(" Manual intervention needed."));
@@ -4,7 +4,10 @@ import { glob } from "glob";
4
4
 
5
5
  // ─── Types ────────────────────────────────────────────────────────────────────
6
6
 
7
- export type FrontendFramework = "react" | "next" | "vue" | "react-native" | "unknown";
7
+ export type FrontendFramework =
8
+ | "react" | "next" | "vue" | "react-native"
9
+ | "svelte" | "sveltekit" | "solid" | "qwik" | "remix" | "astro"
10
+ | "unknown";
8
11
 
9
12
  export interface FrontendContext {
10
13
  framework: FrontendFramework;
@@ -88,7 +91,7 @@ interface ImportStatement {
88
91
  * - Returns structured objects so callers can inspect specifiers vs module path
89
92
  * without re-running a second regex
90
93
  */
91
- function parseImportStatements(content: string): ImportStatement[] {
94
+ export function parseImportStatements(content: string): ImportStatement[] {
92
95
  // 1. Strip block comments (/* ... */) to avoid matching imports inside comments
93
96
  const stripped = content.replace(/\/\*[\s\S]*?\*\//g, (m) => "\n".repeat(m.split("\n").length - 1));
94
97
 
@@ -137,7 +140,7 @@ const HTTP_MODULE_PATTERNS: RegExp[] = [
137
140
  * - Module-path matching is done on the resolved path string, not the full line,
138
141
  * so a long specifier list doesn't prevent a match
139
142
  */
140
- function findHttpClientImport(content: string): string | undefined {
143
+ export function findHttpClientImport(content: string): string | undefined {
141
144
  for (const stmt of parseImportStatements(content)) {
142
145
  if (HTTP_MODULE_PATTERNS.some((p) => p.test(stmt.modulePath))) {
143
146
  return stmt.line;
@@ -190,6 +193,8 @@ const ROUTING_LIBS: Array<[string, string]> = [
190
193
  ["react-navigation", "react-navigation"],
191
194
  ["expo-router", "expo-router"],
192
195
  ["vue-router", "vue-router"],
196
+ ["@solidjs/router", "solid-router"],
197
+ ["@builder.io/qwik-city", "qwik-city"],
193
198
  ];
194
199
 
195
200
  // ─── Main function ─────────────────────────────────────────────────────────────
@@ -231,9 +236,21 @@ export async function loadFrontendContext(
231
236
  const depKeys = Object.keys(allDeps);
232
237
  const has = (name: string) => depKeys.includes(name);
233
238
 
234
- // Framework
239
+ // Framework (order matters: more specific before generic)
235
240
  if (has("react-native") || has("expo")) {
236
241
  ctx.framework = "react-native";
242
+ } else if (has("@sveltejs/kit")) {
243
+ ctx.framework = "sveltekit";
244
+ } else if (has("svelte")) {
245
+ ctx.framework = "svelte";
246
+ } else if (has("@builder.io/qwik")) {
247
+ ctx.framework = "qwik";
248
+ } else if (has("@remix-run/react") || has("@remix-run/node")) {
249
+ ctx.framework = "remix";
250
+ } else if (has("astro")) {
251
+ ctx.framework = "astro";
252
+ } else if (has("solid-js")) {
253
+ ctx.framework = "solid";
237
254
  } else if (has("next")) {
238
255
  ctx.framework = "next";
239
256
  } else if (has("react")) {
@@ -266,9 +283,14 @@ export async function loadFrontendContext(
266
283
 
267
284
  // Routing
268
285
  if (ctx.framework === "next") {
269
- // Detect app router vs pages router
270
286
  const hasAppDir = await fs.pathExists(path.join(projectRoot, "app"));
271
287
  ctx.routingPattern = hasAppDir ? "next-app-router" : "next-pages-router";
288
+ } else if (ctx.framework === "sveltekit") {
289
+ ctx.routingPattern = "sveltekit-file-router";
290
+ } else if (ctx.framework === "remix") {
291
+ ctx.routingPattern = "remix-file-router";
292
+ } else if (ctx.framework === "astro") {
293
+ ctx.routingPattern = "astro-file-router";
272
294
  } else {
273
295
  for (const [lib, label] of ROUTING_LIBS) {
274
296
  if (has(lib)) {
@@ -4,6 +4,7 @@ import * as path from "path";
4
4
  import { AIProvider } from "./spec-generator";
5
5
  import { CONSTITUTION_FILE } from "./constitution-generator";
6
6
  import { parseConstitutionStats } from "../prompts/consolidate.prompt";
7
+ import { ConstitutionConsolidator } from "./constitution-consolidator";
7
8
 
8
9
  // ─── Types ──────────────────────────────────────────────────────────────────────
9
10
 
@@ -223,3 +224,54 @@ export async function accumulateReviewKnowledge(
223
224
 
224
225
  await appendLessonsToConstitution(projectRoot, issues);
225
226
  }
227
+
228
+ // ─── Auto-Consolidation ──────────────────────────────────────────────────────
229
+
230
+ const DEFAULT_AUTO_CONSOLIDATE_THRESHOLD = 12;
231
+
232
+ /**
233
+ * Check if §9 has grown past the threshold and auto-consolidate if so.
234
+ * Non-blocking, creates backups, uses the same ConstitutionConsolidator.
235
+ * Returns true if consolidation was performed.
236
+ */
237
+ export async function maybeAutoConsolidate(
238
+ provider: AIProvider,
239
+ projectRoot: string,
240
+ opts: { threshold?: number } = {}
241
+ ): Promise<boolean> {
242
+ const threshold = opts.threshold ?? DEFAULT_AUTO_CONSOLIDATE_THRESHOLD;
243
+ const constitutionPath = path.join(projectRoot, CONSTITUTION_FILE);
244
+
245
+ let content: string;
246
+ try {
247
+ content = await fs.readFile(constitutionPath, "utf-8");
248
+ } catch {
249
+ return false;
250
+ }
251
+
252
+ const stats = parseConstitutionStats(content);
253
+ if (stats.lessonCount < threshold) return false;
254
+
255
+ console.log(
256
+ chalk.cyan(
257
+ ` §9 has ${stats.lessonCount} lessons (threshold: ${threshold}) — auto-consolidating...`
258
+ )
259
+ );
260
+
261
+ try {
262
+ const consolidator = new ConstitutionConsolidator(provider);
263
+ const result = await consolidator.consolidate(projectRoot, { minLessons: threshold });
264
+ if (result.written) {
265
+ console.log(
266
+ chalk.green(
267
+ ` ✔ Auto-consolidated: ${result.before.lessonCount} → ${result.after!.lessonCount} lessons. Backup: ${result.backupPath}`
268
+ )
269
+ );
270
+ return true;
271
+ }
272
+ } catch (err) {
273
+ console.log(chalk.yellow(` ⚠ Auto-consolidation failed: ${(err as Error).message}`));
274
+ }
275
+
276
+ return false;
277
+ }
@@ -0,0 +1,89 @@
1
+ import { SpecDSL, ApiEndpoint, FieldMap } from "../dsl-types";
2
+
3
+ // ─── Fixture Generator ────────────────────────────────────────────────────────
4
+
5
+ /**
6
+ * Convert a type-description string to a fixture value (JavaScript literal).
7
+ */
8
+ function typeToFixture(fieldName: string, typeDesc: string): unknown {
9
+ const t = typeDesc.toLowerCase();
10
+
11
+ if (t.includes("boolean") || t === "bool") return true;
12
+ if (t.includes("int") || t.includes("number") || t.includes("float") || t.includes("decimal")) {
13
+ if (fieldName.toLowerCase().includes("id")) return 1;
14
+ if (fieldName.toLowerCase().includes("count") || fieldName.toLowerCase().includes("total")) return 42;
15
+ if (fieldName.toLowerCase().includes("price") || fieldName.toLowerCase().includes("amount")) return 9.99;
16
+ return 1;
17
+ }
18
+ if (t.includes("datetime") || t.includes("date") || t.includes("timestamp")) {
19
+ return "2024-01-15T10:30:00.000Z";
20
+ }
21
+ if (t.includes("[]") || t.includes("array") || t.includes("list")) return [];
22
+ if (t.includes("object") || t.includes("json") || t.includes("record")) return {};
23
+
24
+ // String heuristics by field name
25
+ const name = fieldName.toLowerCase();
26
+ if (name === "id" || name.endsWith("id")) return "abc123";
27
+ if (name.includes("email")) return "user@example.com";
28
+ if (name.includes("phone")) return "+1-555-0100";
29
+ if (name.includes("url") || name.includes("image") || name.includes("avatar")) return "https://example.com/sample.jpg";
30
+ if (name.includes("token") || name.includes("secret")) return "mock-token-xyz";
31
+ if (name.includes("name")) return "Example Name";
32
+ if (name.includes("title")) return "Example Title";
33
+ if (name.includes("description") || name.includes("content") || name.includes("body")) return "Example description text";
34
+ if (name.includes("status")) return "active";
35
+ if (name.includes("type") || name.includes("role")) return "default";
36
+ if (name.includes("code")) return "CODE001";
37
+
38
+ return `example_${fieldName}`;
39
+ }
40
+
41
+ function buildFixtureObject(fields: FieldMap): Record<string, unknown> {
42
+ const obj: Record<string, unknown> = {};
43
+ for (const [name, type] of Object.entries(fields)) {
44
+ obj[name] = typeToFixture(name, type);
45
+ }
46
+ return obj;
47
+ }
48
+
49
+ /**
50
+ * Build a fixture response object for an endpoint.
51
+ * For endpoints without explicit response schemas, generate minimal fixtures from model context.
52
+ */
53
+ export function buildEndpointFixture(endpoint: ApiEndpoint, dsl: SpecDSL): unknown {
54
+ const method = endpoint.method;
55
+ const status = endpoint.successStatus;
56
+
57
+ // DELETE with 204 → no body
58
+ if (status === 204) return null;
59
+
60
+ // Try to derive fixture from model names mentioned in endpoint description
61
+ const descLower = endpoint.description.toLowerCase();
62
+ const matchedModel = dsl.models.find((m) =>
63
+ descLower.includes(m.name.toLowerCase())
64
+ );
65
+
66
+ if (matchedModel) {
67
+ const fields: FieldMap = {};
68
+ for (const f of matchedModel.fields) {
69
+ fields[f.name] = f.type;
70
+ }
71
+ const item = buildFixtureObject(fields);
72
+
73
+ // List endpoints return arrays
74
+ if (method === "GET" && (descLower.includes("list") || descLower.includes("all") || descLower.includes("paginate"))) {
75
+ return {
76
+ data: [item, { ...item, id: "def456" }],
77
+ total: 2,
78
+ page: 1,
79
+ pageSize: 10,
80
+ };
81
+ }
82
+ return { data: item };
83
+ }
84
+
85
+ // Fallback based on method
86
+ if (method === "POST") return { data: { id: "abc123", createdAt: "2024-01-15T10:30:00.000Z" } };
87
+ if (method === "GET") return { data: { id: "abc123" } };
88
+ return { success: true };
89
+ }