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.
- package/README.md +381 -1796
- package/RELEASE_LOG.md +231 -0
- package/cli/commands/create.ts +9 -1176
- package/cli/commands/dashboard.ts +1 -1
- package/cli/pipeline/helpers.ts +34 -0
- package/cli/pipeline/multi-repo.ts +483 -0
- package/cli/pipeline/single-repo.ts +755 -0
- package/cli/utils.ts +2 -0
- package/core/code-generator.ts +52 -341
- package/core/codegen/helpers.ts +219 -0
- package/core/codegen/topo-sort.ts +98 -0
- package/core/constitution-consolidator.ts +2 -2
- package/core/dsl-coverage-checker.ts +298 -0
- package/core/dsl-extractor.ts +19 -46
- package/core/dsl-feedback.ts +1 -1
- package/core/dsl-validator.ts +74 -0
- package/core/error-feedback.ts +95 -11
- package/core/frontend-context-loader.ts +27 -5
- package/core/knowledge-memory.ts +52 -0
- package/core/mock/fixtures.ts +89 -0
- package/core/mock/proxy.ts +380 -0
- package/core/mock-server-generator.ts +12 -460
- package/core/requirement-decomposer.ts +4 -28
- package/core/reviewer.ts +1 -1
- package/core/safe-json.ts +76 -0
- package/core/spec-updater.ts +5 -21
- package/core/token-budget.ts +124 -0
- package/core/vcr.ts +20 -1
- package/dist/cli/index.js +4110 -3534
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +4237 -3661
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +18 -16
- package/dist/index.d.ts +18 -16
- package/dist/index.js +310 -182
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +308 -180
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/purpose.md +173 -33
- package/tests/auto-consolidation.test.ts +109 -0
- package/tests/combined-generator.test.ts +81 -0
- package/tests/constitution-consolidator.test.ts +161 -0
- package/tests/constitution-generator.test.ts +94 -0
- package/tests/contract-bridge.test.ts +201 -0
- package/tests/design-dialogue.test.ts +108 -0
- package/tests/dsl-coverage-checker.test.ts +230 -0
- package/tests/dsl-feedback.test.ts +45 -0
- package/tests/dsl-validator-xref.test.ts +99 -0
- package/tests/error-feedback-repair.test.ts +319 -0
- package/tests/error-feedback-validation.test.ts +91 -0
- package/tests/frontend-context-loader.test.ts +609 -0
- package/tests/global-constitution.test.ts +110 -0
- package/tests/key-store.test.ts +73 -0
- package/tests/knowledge-memory.test.ts +327 -0
- package/tests/project-index.test.ts +206 -0
- package/tests/prompt-hasher.test.ts +19 -0
- package/tests/requirement-decomposer.test.ts +171 -0
- package/tests/reviewer.test.ts +4 -1
- package/tests/run-logger.test.ts +289 -0
- package/tests/run-snapshot.test.ts +113 -0
- package/tests/safe-json.test.ts +63 -0
- package/tests/spec-updater.test.ts +161 -0
- package/tests/test-generator.test.ts +146 -0
- package/tests/token-budget.test.ts +124 -0
- package/tests/vcr-hash.test.ts +101 -0
- package/tests/workspace-loader.test.ts +277 -0
package/core/error-feedback.ts
CHANGED
|
@@ -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
|
|
497
|
-
|
|
498
|
-
|
|
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)
|
|
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 =
|
|
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)) {
|
package/core/knowledge-memory.ts
CHANGED
|
@@ -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
|
+
}
|