ai-spec-dev 0.37.0 → 0.41.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 (67) hide show
  1. package/README.md +381 -1796
  2. package/RELEASE_LOG.md +231 -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 +755 -0
  8. package/cli/utils.ts +2 -0
  9. package/core/code-generator.ts +52 -341
  10. package/core/codegen/helpers.ts +219 -0
  11. package/core/codegen/topo-sort.ts +98 -0
  12. package/core/constitution-consolidator.ts +2 -2
  13. package/core/dsl-coverage-checker.ts +298 -0
  14. package/core/dsl-extractor.ts +19 -46
  15. package/core/dsl-feedback.ts +1 -1
  16. package/core/dsl-validator.ts +74 -0
  17. package/core/error-feedback.ts +95 -11
  18. package/core/frontend-context-loader.ts +27 -5
  19. package/core/knowledge-memory.ts +52 -0
  20. package/core/mock/fixtures.ts +89 -0
  21. package/core/mock/proxy.ts +380 -0
  22. package/core/mock-server-generator.ts +12 -460
  23. package/core/requirement-decomposer.ts +4 -28
  24. package/core/reviewer.ts +1 -1
  25. package/core/safe-json.ts +76 -0
  26. package/core/spec-updater.ts +5 -21
  27. package/core/token-budget.ts +124 -0
  28. package/core/vcr.ts +20 -1
  29. package/dist/cli/index.js +4110 -3534
  30. package/dist/cli/index.js.map +1 -1
  31. package/dist/cli/index.mjs +4237 -3661
  32. package/dist/cli/index.mjs.map +1 -1
  33. package/dist/index.d.mts +18 -16
  34. package/dist/index.d.ts +18 -16
  35. package/dist/index.js +310 -182
  36. package/dist/index.js.map +1 -1
  37. package/dist/index.mjs +308 -180
  38. package/dist/index.mjs.map +1 -1
  39. package/package.json +2 -2
  40. package/purpose.md +173 -33
  41. package/tests/auto-consolidation.test.ts +109 -0
  42. package/tests/combined-generator.test.ts +81 -0
  43. package/tests/constitution-consolidator.test.ts +161 -0
  44. package/tests/constitution-generator.test.ts +94 -0
  45. package/tests/contract-bridge.test.ts +201 -0
  46. package/tests/design-dialogue.test.ts +108 -0
  47. package/tests/dsl-coverage-checker.test.ts +230 -0
  48. package/tests/dsl-feedback.test.ts +45 -0
  49. package/tests/dsl-validator-xref.test.ts +99 -0
  50. package/tests/error-feedback-repair.test.ts +319 -0
  51. package/tests/error-feedback-validation.test.ts +91 -0
  52. package/tests/frontend-context-loader.test.ts +609 -0
  53. package/tests/global-constitution.test.ts +110 -0
  54. package/tests/key-store.test.ts +73 -0
  55. package/tests/knowledge-memory.test.ts +327 -0
  56. package/tests/project-index.test.ts +206 -0
  57. package/tests/prompt-hasher.test.ts +19 -0
  58. package/tests/requirement-decomposer.test.ts +171 -0
  59. package/tests/reviewer.test.ts +4 -1
  60. package/tests/run-logger.test.ts +289 -0
  61. package/tests/run-snapshot.test.ts +113 -0
  62. package/tests/safe-json.test.ts +63 -0
  63. package/tests/spec-updater.test.ts +161 -0
  64. package/tests/test-generator.test.ts +146 -0
  65. package/tests/token-budget.test.ts +124 -0
  66. package/tests/vcr-hash.test.ts +101 -0
  67. package/tests/workspace-loader.test.ts +277 -0
@@ -10,7 +10,7 @@ import { getActiveSnapshot } from "./run-snapshot";
10
10
 
11
11
  // ─── Types ──────────────────────────────────────────────────────────────────────
12
12
 
13
- interface ErrorEntry {
13
+ export interface ErrorEntry {
14
14
  source: "test" | "lint" | "build";
15
15
  message: string;
16
16
  file?: string;
@@ -60,7 +60,7 @@ function runCommand(cmd: string, cwd: string): { success: boolean; output: strin
60
60
  * Detect TypeScript type-check command for the given directory.
61
61
  * Returns null for non-TS projects or projects without tsconfig.
62
62
  */
63
- function detectBuildCommand(workingDir: string): string | null {
63
+ export function detectBuildCommand(workingDir: string): string | null {
64
64
  // Only applies to Node.js / frontend TypeScript projects
65
65
  if (!fs.existsSync(path.join(workingDir, "tsconfig.json"))) return null;
66
66
 
@@ -80,7 +80,7 @@ function detectBuildCommand(workingDir: string): string | null {
80
80
  return "npx tsc --noEmit";
81
81
  }
82
82
 
83
- function detectTestCommand(workingDir: string): string | null {
83
+ export function detectTestCommand(workingDir: string): string | null {
84
84
  // ── Go ──────────────────────────────────────────────────────────────────────
85
85
  if (fs.existsSync(path.join(workingDir, "go.mod"))) return "go test ./...";
86
86
 
@@ -129,7 +129,7 @@ function detectTestCommand(workingDir: string): string | null {
129
129
  return null;
130
130
  }
131
131
 
132
- function detectLintCommand(workingDir: string): string | null {
132
+ export function detectLintCommand(workingDir: string): string | null {
133
133
  // ── Go ──────────────────────────────────────────────────────────────────────
134
134
  if (fs.existsSync(path.join(workingDir, "go.mod"))) {
135
135
  // golangci-lint is optional; fall back to go vet
@@ -183,7 +183,7 @@ function detectLintCommand(workingDir: string): string | null {
183
183
  return null;
184
184
  }
185
185
 
186
- function parseErrors(output: string, source: ErrorEntry["source"]): ErrorEntry[] {
186
+ export function parseErrors(output: string, source: ErrorEntry["source"]): ErrorEntry[] {
187
187
  const errors: ErrorEntry[] = [];
188
188
  if (!output.trim()) return errors;
189
189
 
@@ -226,7 +226,7 @@ function parseErrors(output: string, source: ErrorEntry["source"]): ErrorEntry[]
226
226
  * Returns paths normalized to project-root-relative form (no extension).
227
227
  * Only considers relative imports (./foo, ../foo) — skips aliases and node_modules.
228
228
  */
229
- function parseRelativeImports(content: string, fromFileRel: string): string[] {
229
+ export function parseRelativeImports(content: string, fromFileRel: string): string[] {
230
230
  const relDir = path.dirname(fromFileRel);
231
231
  const results: string[] = [];
232
232
 
@@ -253,7 +253,7 @@ function parseRelativeImports(content: string, fromFileRel: string): string[] {
253
253
  * Example: if A exports a type used by B and C, fix A first so cycle 1 can cascade correctly.
254
254
  * Uses Kahn's topological sort; cycles are appended at the end (unavoidable, fix last).
255
255
  */
256
- async function buildRepairOrder(
256
+ export async function buildRepairOrder(
257
257
  errorsByFile: Map<string, ErrorEntry[]>,
258
258
  workingDir: string
259
259
  ): Promise<[string, ErrorEntry[]][]> {
@@ -385,6 +385,63 @@ Output ONLY the complete fixed file content. No markdown fences, no explanations
385
385
  return results;
386
386
  }
387
387
 
388
+ // ─── Test Existence Validation ──────────────────────────────────────────────────
389
+
390
+ /** Patterns that indicate a file contains actual test cases (language-agnostic). */
391
+ const TEST_PATTERNS = [
392
+ /\bdescribe\s*\(/, // JS/TS (vitest, jest, mocha)
393
+ /\bit\s*\(/, // JS/TS
394
+ /\btest\s*\(/, // JS/TS
395
+ /\bfunc\s+Test\w*\s*\(/, // Go
396
+ /\bdef\s+test_/, // Python
397
+ /@Test\b/, // Java (JUnit)
398
+ /#\[test\]/, // Rust
399
+ ];
400
+
401
+ export interface TestValidationResult {
402
+ valid: boolean;
403
+ fileCount: number;
404
+ reason?: string;
405
+ }
406
+
407
+ /**
408
+ * Check that generated test files exist on disk and contain actual test cases.
409
+ * Prevents false-positive "tests pass" when no tests were generated.
410
+ */
411
+ export function validateTestFilesExist(
412
+ workingDir: string,
413
+ generatedTestFiles: string[]
414
+ ): TestValidationResult {
415
+ if (generatedTestFiles.length === 0) {
416
+ return { valid: false, fileCount: 0, reason: "No test files were generated" };
417
+ }
418
+
419
+ let validCount = 0;
420
+ for (const relPath of generatedTestFiles) {
421
+ const absPath = path.join(workingDir, relPath);
422
+ if (!fs.existsSync(absPath)) continue;
423
+
424
+ try {
425
+ const content = fs.readFileSync(absPath, "utf-8");
426
+ if (TEST_PATTERNS.some((p) => p.test(content))) {
427
+ validCount++;
428
+ }
429
+ } catch {
430
+ // unreadable — skip
431
+ }
432
+ }
433
+
434
+ if (validCount === 0) {
435
+ return {
436
+ valid: false,
437
+ fileCount: generatedTestFiles.length,
438
+ reason: `${generatedTestFiles.length} test file(s) listed but none contain actual test cases`,
439
+ };
440
+ }
441
+
442
+ return { valid: true, fileCount: validCount };
443
+ }
444
+
388
445
  // ─── Public API ─────────────────────────────────────────────────────────────────
389
446
 
390
447
  export interface ErrorFeedbackOptions {
@@ -396,6 +453,8 @@ export interface ErrorFeedbackOptions {
396
453
  skipLint?: boolean;
397
454
  /** Whether to skip TypeScript type-check (tsc --noEmit / vue-tsc --noEmit) */
398
455
  skipBuild?: boolean;
456
+ /** List of generated test files — used to validate tests actually exist */
457
+ generatedTestFiles?: string[];
399
458
  }
400
459
 
401
460
  /**
@@ -421,6 +480,19 @@ export async function runErrorFeedback(
421
480
  return true;
422
481
  }
423
482
 
483
+ // ── Pre-check: validate generated test files actually exist ──────────────
484
+ let testsValidated = true;
485
+ if (testCmd && opts.generatedTestFiles) {
486
+ const testValidation = validateTestFilesExist(workingDir, opts.generatedTestFiles);
487
+ if (!testValidation.valid) {
488
+ console.log(chalk.yellow(` ⚠ Test validation: ${testValidation.reason}`));
489
+ console.log(chalk.yellow(" Test results will be treated as unvalidated (not as 'pass')."));
490
+ testsValidated = false;
491
+ } else {
492
+ console.log(chalk.gray(` Test validation: ${testValidation.fileCount} valid test file(s) found.`));
493
+ }
494
+ }
495
+
424
496
  if (buildCmd) console.log(chalk.gray(` Type-check: ${buildCmd}`));
425
497
 
426
498
  let prevErrorCount = Infinity; // circuit-breaker: tracks error count from previous cycle
@@ -489,16 +561,28 @@ export async function runErrorFeedback(
489
561
  }
490
562
 
491
563
  if (allErrors.length === 0) {
564
+ if (!testsValidated) {
565
+ console.log(chalk.yellow(`\n ⚠ Build/lint passed but tests were not validated (no test files with actual test cases).`));
566
+ return true; // don't block pipeline, but the warning is logged
567
+ }
492
568
  console.log(chalk.green(`\n ✔ All checks passed after ${cycle} cycle(s).`));
493
569
  return true;
494
570
  }
495
571
 
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) {
572
+ // Circuit breaker: stop if error count didn't decrease or is increasing.
573
+ if (allErrors.length > prevErrorCount) {
574
+ console.log(
575
+ chalk.red(
576
+ `\n ✘ Auto-fix caused regression (${prevErrorCount} → ${allErrors.length} errors). Stopping immediately.`
577
+ )
578
+ );
579
+ console.log(chalk.gray(" The fix attempt introduced new errors. Manual intervention needed."));
580
+ return false;
581
+ }
582
+ if (allErrors.length === prevErrorCount) {
499
583
  console.log(
500
584
  chalk.yellow(
501
- `\n ⚠ Auto-fix made no progress (${allErrors.length} error(s) before and after). Stopping early.`
585
+ `\n ⚠ Auto-fix made no progress (${allErrors.length} error(s) remain). Stopping early.`
502
586
  )
503
587
  );
504
588
  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
+ }