ai-spec-dev 0.1.0 → 0.14.1

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 (60) hide show
  1. package/.claude/settings.local.json +18 -0
  2. package/README.md +1211 -146
  3. package/RELEASE_LOG.md +1444 -0
  4. package/cli/index.ts +1961 -0
  5. package/cli/welcome.ts +151 -0
  6. package/core/code-generator.ts +740 -0
  7. package/core/combined-generator.ts +63 -0
  8. package/core/constitution-consolidator.ts +141 -0
  9. package/core/constitution-generator.ts +89 -0
  10. package/core/context-loader.ts +453 -0
  11. package/core/contract-bridge.ts +217 -0
  12. package/core/dsl-extractor.ts +337 -0
  13. package/core/dsl-types.ts +166 -0
  14. package/core/dsl-validator.ts +450 -0
  15. package/core/error-feedback.ts +354 -0
  16. package/core/frontend-context-loader.ts +602 -0
  17. package/core/global-constitution.ts +88 -0
  18. package/core/key-store.ts +49 -0
  19. package/core/knowledge-memory.ts +171 -0
  20. package/core/mock-server-generator.ts +571 -0
  21. package/core/openapi-exporter.ts +361 -0
  22. package/core/requirement-decomposer.ts +198 -0
  23. package/core/reviewer.ts +259 -0
  24. package/core/spec-assessor.ts +99 -0
  25. package/core/spec-generator.ts +428 -0
  26. package/core/spec-refiner.ts +89 -0
  27. package/core/spec-updater.ts +227 -0
  28. package/core/spec-versioning.ts +213 -0
  29. package/core/task-generator.ts +174 -0
  30. package/core/test-generator.ts +273 -0
  31. package/core/workspace-loader.ts +256 -0
  32. package/dist/cli/index.js +6717 -672
  33. package/dist/cli/index.js.map +1 -1
  34. package/dist/cli/index.mjs +6717 -670
  35. package/dist/cli/index.mjs.map +1 -1
  36. package/dist/index.d.mts +147 -27
  37. package/dist/index.d.ts +147 -27
  38. package/dist/index.js +2337 -286
  39. package/dist/index.js.map +1 -1
  40. package/dist/index.mjs +2329 -285
  41. package/dist/index.mjs.map +1 -1
  42. package/git/worktree.ts +109 -0
  43. package/index.ts +9 -0
  44. package/package.json +4 -28
  45. package/prompts/codegen.prompt.ts +259 -0
  46. package/prompts/consolidate.prompt.ts +73 -0
  47. package/prompts/constitution.prompt.ts +63 -0
  48. package/prompts/decompose.prompt.ts +168 -0
  49. package/prompts/dsl.prompt.ts +203 -0
  50. package/prompts/frontend-spec.prompt.ts +191 -0
  51. package/prompts/global-constitution.prompt.ts +61 -0
  52. package/prompts/spec-assess.prompt.ts +53 -0
  53. package/prompts/spec.prompt.ts +102 -0
  54. package/prompts/tasks.prompt.ts +35 -0
  55. package/prompts/testgen.prompt.ts +84 -0
  56. package/prompts/update.prompt.ts +131 -0
  57. package/purpose.docx +0 -0
  58. package/purpose.md +444 -0
  59. package/tsconfig.json +14 -0
  60. package/tsup.config.ts +10 -0
@@ -0,0 +1,453 @@
1
+ import * as fs from "fs-extra";
2
+ import * as path from "path";
3
+ import { glob } from "glob";
4
+ import { loadGlobalConstitution, mergeConstitutions } from "./global-constitution";
5
+
6
+ export interface SharedConfigFile {
7
+ /** Relative path from project root */
8
+ path: string;
9
+ /** First 80 lines of the file */
10
+ preview: string;
11
+ /** Inferred category */
12
+ category: "i18n" | "constants" | "enums" | "config" | "route-index" | "store-index" | "other";
13
+ }
14
+
15
+ export interface ProjectContext {
16
+ techStack: string[];
17
+ fileStructure: string[];
18
+ dependencies: string[];
19
+ apiStructure: string[];
20
+ schema?: string;
21
+ routeSummary?: string;
22
+ /**
23
+ * Effective constitution injected into prompts.
24
+ * If a global constitution was found it is merged in here (global first, project overrides).
25
+ */
26
+ constitution?: string;
27
+ /** Extracted error handling patterns from source */
28
+ errorPatterns?: string;
29
+ /**
30
+ * Singleton/append-only config files that MUST be modified in-place,
31
+ * never recreated as a new parallel file (i18n, constants, enums, config indices, etc.)
32
+ */
33
+ sharedConfigFiles?: SharedConfigFile[];
34
+ }
35
+
36
+ /**
37
+ * Single source of truth for what counts as a "frontend" project.
38
+ * Import this constant (and isFrontendDeps) everywhere instead of
39
+ * repeating the inline array — one place to add Svelte, Solid, Qwik, etc.
40
+ */
41
+ export const FRONTEND_FRAMEWORKS = ["react", "vue", "next", "nuxt", "react-native", "expo", "svelte", "solid-js", "qwik"] as const;
42
+
43
+ /**
44
+ * Returns true if any of the given dependency keys is a recognised frontend framework.
45
+ * Works on the raw key list from package.json / context.dependencies.
46
+ */
47
+ export function isFrontendDeps(deps: string[]): boolean {
48
+ return deps.some((d) => (FRONTEND_FRAMEWORKS as readonly string[]).includes(d));
49
+ }
50
+
51
+ const STACK_MAP: Record<string, string> = {
52
+ react: "React",
53
+ vue: "Vue",
54
+ "next": "Next.js",
55
+ "nuxt": "Nuxt.js",
56
+ express: "Express",
57
+ koa: "Koa",
58
+ fastify: "Fastify",
59
+ "@nestjs/core": "NestJS",
60
+ prisma: "Prisma",
61
+ mongoose: "Mongoose",
62
+ typeorm: "TypeORM",
63
+ sequelize: "Sequelize",
64
+ tailwindcss: "Tailwind CSS",
65
+ typescript: "TypeScript",
66
+ "@supabase/supabase-js": "Supabase",
67
+ "socket.io": "Socket.IO",
68
+ redis: "Redis",
69
+ bull: "Bull (Queue)",
70
+ "@prisma/client": "Prisma",
71
+ };
72
+
73
+ export class ContextLoader {
74
+ constructor(private projectRoot: string) {}
75
+
76
+ async loadProjectContext(): Promise<ProjectContext> {
77
+ const context: ProjectContext = {
78
+ techStack: [],
79
+ fileStructure: [],
80
+ dependencies: [],
81
+ apiStructure: [],
82
+ };
83
+
84
+ try {
85
+ // PHP projects use composer.json instead of package.json
86
+ const isPhp = await fs.pathExists(path.join(this.projectRoot, "composer.json"));
87
+ const isJava =
88
+ (await fs.pathExists(path.join(this.projectRoot, "pom.xml"))) ||
89
+ (await fs.pathExists(path.join(this.projectRoot, "build.gradle"))) ||
90
+ (await fs.pathExists(path.join(this.projectRoot, "build.gradle.kts")));
91
+ if (isPhp) {
92
+ await this.loadComposerJson(context);
93
+ await this.loadPhpRoutes(context);
94
+ } else if (isJava) {
95
+ await this.loadMavenOrGradle(context);
96
+ await this.loadJavaApiStructure(context);
97
+ } else {
98
+ await this.loadPackageJson(context);
99
+ await this.loadPrismaSchema(context);
100
+ }
101
+ await this.loadFileStructure(context);
102
+ await this.loadApiStructure(context);
103
+ await this.loadConstitution(context);
104
+ await this.loadErrorPatterns(context);
105
+ await this.loadSharedConfigFiles(context);
106
+ } catch (e) {
107
+ console.warn("Warning: Could not load full project context.", e);
108
+ }
109
+
110
+ return context;
111
+ }
112
+
113
+ /** Load PHP project context from composer.json */
114
+ private async loadComposerJson(context: ProjectContext): Promise<void> {
115
+ const composerPath = path.join(this.projectRoot, "composer.json");
116
+ let composer: Record<string, unknown> = {};
117
+ try {
118
+ composer = await fs.readJson(composerPath);
119
+ } catch {
120
+ return;
121
+ }
122
+
123
+ const require = (composer.require as Record<string, string>) ?? {};
124
+ const requireDev = (composer["require-dev"] as Record<string, string>) ?? {};
125
+ context.dependencies = [...Object.keys(require), ...Object.keys(requireDev)];
126
+
127
+ const stack = new Set<string>();
128
+ stack.add("PHP");
129
+ if (require["laravel/lumen-framework"]) stack.add("Lumen");
130
+ if (require["laravel/framework"]) stack.add("Laravel");
131
+ if (require["symfony/framework-bundle"]) stack.add("Symfony");
132
+ if (require["slim/slim"]) stack.add("Slim");
133
+ if (require["illuminate/database"] || require["laravel/lumen-framework"]) stack.add("Eloquent ORM");
134
+ if (require["doctrine/orm"]) stack.add("Doctrine ORM");
135
+ if (require["tymon/jwt-auth"]) stack.add("JWT Auth");
136
+ if (require["league/fractal"] || require["spatie/laravel-fractal"]) stack.add("Fractal (Transformers)");
137
+
138
+ // PHP version
139
+ const phpVersion = require["php"];
140
+ if (phpVersion) stack.add(`PHP ${phpVersion}`);
141
+
142
+ context.techStack = Array.from(stack);
143
+ }
144
+
145
+ /**
146
+ * Load PHP route files (routes/api.php, routes/web.php) as routeSummary.
147
+ * Lumen uses these files to register API endpoints.
148
+ */
149
+ private async loadPhpRoutes(context: ProjectContext): Promise<void> {
150
+ const routeFiles = ["routes/api.php", "routes/web.php"];
151
+ const parts: string[] = [];
152
+
153
+ for (const rel of routeFiles) {
154
+ const fullPath = path.join(this.projectRoot, rel);
155
+ if (!(await fs.pathExists(fullPath))) continue;
156
+ try {
157
+ const content = await fs.readFile(fullPath, "utf-8");
158
+ parts.push(`// ${rel}\n${content.slice(0, 1500)}`);
159
+ } catch {
160
+ // skip
161
+ }
162
+ }
163
+
164
+ if (parts.length > 0) {
165
+ context.routeSummary = parts.join("\n\n");
166
+ }
167
+
168
+ // Also scan app/Http/Controllers for API structure
169
+ const controllerFiles = await glob("app/Http/Controllers/**/*.php", {
170
+ cwd: this.projectRoot,
171
+ ignore: ["vendor/**"],
172
+ });
173
+ context.apiStructure = controllerFiles.slice(0, 20);
174
+ }
175
+
176
+ /** Load Java project context from pom.xml or build.gradle */
177
+ private async loadMavenOrGradle(context: ProjectContext): Promise<void> {
178
+ const pomPath = path.join(this.projectRoot, "pom.xml");
179
+ const gradlePath = path.join(this.projectRoot, "build.gradle");
180
+ const gradleKtsPath = path.join(this.projectRoot, "build.gradle.kts");
181
+
182
+ const stack = new Set<string>(["Java"]);
183
+ const deps: string[] = [];
184
+
185
+ if (await fs.pathExists(pomPath)) {
186
+ try {
187
+ const xml = await fs.readFile(pomPath, "utf-8");
188
+ // Extract all <artifactId> values (skip the root artifact itself)
189
+ const artifactIds = [...xml.matchAll(/<artifactId>([^<]+)<\/artifactId>/g)]
190
+ .map((m) => m[1].trim())
191
+ .filter((id, i) => i > 0); // skip first = the project itself
192
+ deps.push(...artifactIds);
193
+
194
+ // Detect Java version
195
+ const javaVerMatch = xml.match(/<maven\.compiler\.source>(\d+)<\/maven\.compiler\.source>/);
196
+ if (javaVerMatch) stack.add(`Java ${javaVerMatch[1]}`);
197
+
198
+ // Detect common frameworks
199
+ if (deps.some((d) => d.includes("spring-boot"))) stack.add("Spring Boot");
200
+ if (deps.some((d) => d.includes("spring-web") || d.includes("spring-webmvc"))) stack.add("Spring MVC");
201
+ if (deps.some((d) => d.includes("mybatis"))) stack.add("MyBatis");
202
+ if (deps.some((d) => d.includes("hibernate") || d.includes("spring-data-jpa"))) stack.add("JPA/Hibernate");
203
+ if (deps.some((d) => d.includes("dubbo"))) stack.add("Dubbo");
204
+ if (deps.some((d) => d.includes("rocketmq"))) stack.add("RocketMQ");
205
+ if (deps.some((d) => d.includes("kafka"))) stack.add("Kafka");
206
+ if (deps.some((d) => d.includes("redis"))) stack.add("Redis");
207
+ if (deps.some((d) => d.includes("lombok"))) stack.add("Lombok");
208
+ if (deps.some((d) => d.includes("feign") || d.includes("openfeign"))) stack.add("OpenFeign");
209
+ if (deps.some((d) => d.includes("nacos"))) stack.add("Nacos");
210
+ if (deps.some((d) => d.includes("sentinel"))) stack.add("Sentinel");
211
+ } catch { /* ignore */ }
212
+ } else {
213
+ // Gradle — just mark as Gradle project; deep dep parsing is complex
214
+ const gradleFile = (await fs.pathExists(gradleKtsPath)) ? gradleKtsPath : gradlePath;
215
+ try {
216
+ const content = await fs.readFile(gradleFile, "utf-8");
217
+ // Extract simple dependency strings like: implementation 'group:artifact:version'
218
+ const depMatches = [...content.matchAll(/['"]([a-zA-Z0-9._-]+):([a-zA-Z0-9._-]+):[^'"]+['"]/g)];
219
+ deps.push(...depMatches.map((m) => m[2]));
220
+ if (deps.some((d) => d.includes("spring-boot"))) stack.add("Spring Boot");
221
+ if (deps.some((d) => d.includes("mybatis"))) stack.add("MyBatis");
222
+ } catch { /* ignore */ }
223
+ }
224
+
225
+ context.techStack = Array.from(stack);
226
+ context.dependencies = [...new Set(deps)];
227
+ }
228
+
229
+ /** Scan Java controller files for API structure */
230
+ private async loadJavaApiStructure(context: ProjectContext): Promise<void> {
231
+ const controllerFiles = await glob("**/src/main/java/**/*Controller.java", {
232
+ cwd: this.projectRoot,
233
+ ignore: ["**/target/**"],
234
+ });
235
+ context.apiStructure = controllerFiles.slice(0, 30);
236
+
237
+ // Also pick up routes from application.properties/yml if present
238
+ const propFiles = await glob("**/src/main/resources/application.{properties,yml,yaml}", {
239
+ cwd: this.projectRoot,
240
+ ignore: ["**/target/**"],
241
+ });
242
+ if (propFiles.length > 0 && !context.routeSummary) {
243
+ const parts: string[] = [];
244
+ for (const f of propFiles.slice(0, 2)) {
245
+ try {
246
+ const content = await fs.readFile(path.join(this.projectRoot, f), "utf-8");
247
+ parts.push(`// ${f}\n${content.slice(0, 800)}`);
248
+ } catch { /* skip */ }
249
+ }
250
+ if (parts.length > 0) context.routeSummary = parts.join("\n\n");
251
+ }
252
+ }
253
+
254
+ private async loadPackageJson(context: ProjectContext): Promise<void> {
255
+ const pkgPath = path.join(this.projectRoot, "package.json");
256
+ if (!(await fs.pathExists(pkgPath))) return;
257
+
258
+ const pkg = await fs.readJson(pkgPath);
259
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
260
+ context.dependencies = Object.keys(allDeps);
261
+
262
+ const detectedStack = new Set<string>();
263
+ for (const [key, name] of Object.entries(STACK_MAP)) {
264
+ if (context.dependencies.some((d) => d === key || d.startsWith(key + "/"))) {
265
+ detectedStack.add(name as string);
266
+ }
267
+ }
268
+ context.techStack = Array.from(detectedStack);
269
+ }
270
+
271
+ private async loadPrismaSchema(context: ProjectContext): Promise<void> {
272
+ const schemaPath = path.join(this.projectRoot, "prisma", "schema.prisma");
273
+ if (await fs.pathExists(schemaPath)) {
274
+ context.schema = await fs.readFile(schemaPath, "utf-8");
275
+ }
276
+ }
277
+
278
+ private async loadFileStructure(context: ProjectContext): Promise<void> {
279
+ const files = await glob("**/*", {
280
+ cwd: this.projectRoot,
281
+ ignore: [
282
+ "node_modules/**",
283
+ "vendor/**",
284
+ "dist/**",
285
+ "build/**",
286
+ ".git/**",
287
+ "coverage/**",
288
+ "*.lock",
289
+ ".DS_Store",
290
+ "**/*.min.js",
291
+ "**/*.map",
292
+ ],
293
+ nodir: false,
294
+ maxDepth: 5,
295
+ });
296
+ context.fileStructure = files.slice(0, 120);
297
+ }
298
+
299
+ private async loadConstitution(context: ProjectContext): Promise<void> {
300
+ // Project-level constitution
301
+ const projectFile = path.join(this.projectRoot, ".ai-spec-constitution.md");
302
+ const projectConstitution = (await fs.pathExists(projectFile))
303
+ ? await fs.readFile(projectFile, "utf-8")
304
+ : undefined;
305
+
306
+ // Global constitution — search workspace root (parent of projectRoot) then home dir
307
+ const workspaceRoot = path.dirname(this.projectRoot);
308
+ const globalResult = await loadGlobalConstitution([workspaceRoot]);
309
+
310
+ if (globalResult) {
311
+ // Merge: global baseline + project override
312
+ context.constitution = mergeConstitutions(globalResult.content, projectConstitution);
313
+ } else if (projectConstitution) {
314
+ context.constitution = projectConstitution;
315
+ }
316
+ }
317
+
318
+ private async loadErrorPatterns(context: ProjectContext): Promise<void> {
319
+ // Look for error handler middleware or error code files
320
+ const errorFiles = await glob(
321
+ "src/**/{error,errors,errorHandler,errorCodes,error-handler,error-codes}.{ts,js}",
322
+ { cwd: this.projectRoot }
323
+ );
324
+ const middlewareErrors = await glob("src/**/middleware/**/{error,notFound}.{ts,js}", {
325
+ cwd: this.projectRoot,
326
+ });
327
+ // PHP / Lumen exception handlers
328
+ const phpErrorFiles = await glob(
329
+ "app/Exceptions/{Handler,ErrorHandler}.php",
330
+ { cwd: this.projectRoot, ignore: ["vendor/**"] }
331
+ );
332
+ const allErrorFiles = [...new Set([...errorFiles, ...middlewareErrors, ...phpErrorFiles])].slice(0, 3);
333
+
334
+ if (allErrorFiles.length === 0) return;
335
+
336
+ const parts: string[] = [];
337
+ for (const f of allErrorFiles) {
338
+ try {
339
+ const content = await fs.readFile(path.join(this.projectRoot, f), "utf-8");
340
+ parts.push(`// ${f}\n${content.slice(0, 800)}`);
341
+ } catch {
342
+ // skip
343
+ }
344
+ }
345
+ if (parts.length > 0) {
346
+ context.errorPatterns = parts.join("\n\n");
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Scan for "singleton" config files that should never be duplicated.
352
+ * These are append-only files: i18n bundles, constants, enums, config indices.
353
+ */
354
+ private async loadSharedConfigFiles(context: ProjectContext): Promise<void> {
355
+ const patterns: Array<{ glob: string; category: SharedConfigFile["category"] }> = [
356
+ // i18n / locales
357
+ { glob: "src/locales/**/*.{json,ts,js}", category: "i18n" },
358
+ { glob: "src/i18n/**/*.{json,ts,js}", category: "i18n" },
359
+ { glob: "locales/**/*.{json,ts,js}", category: "i18n" },
360
+ { glob: "public/locales/**/*.{json,ts,js}", category: "i18n" },
361
+ // constants / enums
362
+ { glob: "src/constants/**/*.{ts,js}", category: "constants" },
363
+ { glob: "src/enums/**/*.{ts,js}", category: "enums" },
364
+ { glob: "src/**/constants.{ts,js}", category: "constants" },
365
+ { glob: "src/**/enums.{ts,js}", category: "enums" },
366
+ // config
367
+ { glob: "src/config/**/*.{ts,js}", category: "config" },
368
+ // ── Route registration files ────────────────────────────────────────────
369
+ // Node.js / Express
370
+ { glob: "src/routes/**/index.{ts,js}", category: "route-index" },
371
+ { glob: "src/routes/index.{ts,js}", category: "route-index" },
372
+ // Vue Router — root index and modules pattern
373
+ { glob: "src/router/index.{ts,js}", category: "route-index" },
374
+ { glob: "src/router/routes.{ts,js}", category: "route-index" },
375
+ { glob: "src/router/modules/**/*.{ts,js}", category: "route-index" },
376
+ // React Router — standalone routes file or App entry
377
+ { glob: "src/routes.{ts,tsx,js,jsx}", category: "route-index" },
378
+ { glob: "src/router.{ts,tsx,js,jsx}", category: "route-index" },
379
+ // PHP (Lumen / Laravel)
380
+ { glob: "routes/api.php", category: "route-index" },
381
+ { glob: "routes/web.php", category: "route-index" },
382
+ // ── Store registration files ────────────────────────────────────────────
383
+ // Pinia / Vuex index
384
+ { glob: "src/stores/index.{ts,js}", category: "store-index" },
385
+ { glob: "src/store/index.{ts,js}", category: "store-index" },
386
+ { glob: "src/store/modules/index.{ts,js}", category: "store-index" },
387
+ // Redux root reducer / store setup
388
+ { glob: "src/store/rootReducer.{ts,js}", category: "store-index" },
389
+ { glob: "src/store/store.{ts,js}", category: "store-index" },
390
+ { glob: "src/app/store.{ts,js}", category: "store-index" },
391
+ ];
392
+
393
+ const seen = new Set<string>();
394
+ const results: SharedConfigFile[] = [];
395
+
396
+ for (const { glob: pattern, category } of patterns) {
397
+ const files = await glob(pattern, {
398
+ cwd: this.projectRoot,
399
+ ignore: ["node_modules/**", "dist/**", "**/*.test.*", "**/*.spec.*"],
400
+ });
401
+ for (const filePath of files) {
402
+ if (seen.has(filePath)) continue;
403
+ seen.add(filePath);
404
+ try {
405
+ const content = await fs.readFile(path.join(this.projectRoot, filePath), "utf-8");
406
+ const preview = content.split("\n").slice(0, 120).join("\n");
407
+ results.push({ path: filePath, preview, category });
408
+ } catch {
409
+ // skip unreadable
410
+ }
411
+ }
412
+ }
413
+
414
+ if (results.length > 0) {
415
+ context.sharedConfigFiles = results;
416
+ }
417
+ }
418
+
419
+ private async loadApiStructure(context: ProjectContext): Promise<void> {
420
+ const apiFiles = await glob(
421
+ "src/**/{routes,controllers,api,router,middleware}/**/*.{ts,js}",
422
+ {
423
+ cwd: this.projectRoot,
424
+ ignore: ["**/*.test.*", "**/*.spec.*"],
425
+ }
426
+ );
427
+
428
+ // Also check common flat structures
429
+ const rootApiFiles = await glob("src/{routes,controllers,router}.{ts,js}", {
430
+ cwd: this.projectRoot,
431
+ });
432
+
433
+ context.apiStructure = [...new Set([...apiFiles, ...rootApiFiles])];
434
+
435
+ // Build route summary: read first 60 lines of each route file
436
+ if (context.apiStructure.length > 0) {
437
+ const summaryParts: string[] = [];
438
+ for (const filePath of context.apiStructure.slice(0, 8)) {
439
+ const fullPath = path.join(this.projectRoot, filePath);
440
+ try {
441
+ const content = await fs.readFile(fullPath, "utf-8");
442
+ const preview = content.split("\n").slice(0, 60).join("\n");
443
+ summaryParts.push(`\`\`\`\n// ${filePath}\n${preview}\n\`\`\``);
444
+ } catch {
445
+ // skip unreadable files
446
+ }
447
+ }
448
+ if (summaryParts.length > 0) {
449
+ context.routeSummary = summaryParts.join("\n\n");
450
+ }
451
+ }
452
+ }
453
+ }
@@ -0,0 +1,217 @@
1
+ import { SpecDSL, ApiEndpoint } from "./dsl-types";
2
+
3
+ // ─── Types ────────────────────────────────────────────────────────────────────
4
+
5
+ export interface FrontendApiContract {
6
+ endpoints: Array<{
7
+ method: string;
8
+ path: string;
9
+ auth: boolean;
10
+ requestShape: string; // TypeScript interface as string
11
+ responseShape: string; // TypeScript interface as string
12
+ errorCodes: string[];
13
+ }>;
14
+ /** Full TypeScript interfaces block */
15
+ typeDefinitions: string;
16
+ /** Human-readable summary for prompt injection */
17
+ summary: string;
18
+ }
19
+
20
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
21
+
22
+ /**
23
+ * Convert a DSL FieldMap (Record<string, string>) to a TypeScript interface body.
24
+ * Each entry becomes: fieldName: fieldType; // original description
25
+ */
26
+ function fieldMapToTsInterface(
27
+ fields: Record<string, string> | undefined,
28
+ interfaceName: string
29
+ ): string {
30
+ if (!fields || Object.keys(fields).length === 0) {
31
+ return `interface ${interfaceName} { /* empty */ }`;
32
+ }
33
+
34
+ const lines = Object.entries(fields).map(([name, typeDesc]) => {
35
+ // Try to extract a clean TS type from the description
36
+ const tsType = inferTsType(typeDesc);
37
+ return ` ${name}: ${tsType};`;
38
+ });
39
+
40
+ return `interface ${interfaceName} {\n${lines.join("\n")}\n}`;
41
+ }
42
+
43
+ /**
44
+ * Heuristically map DSL type-description strings to TS primitive types.
45
+ */
46
+ function inferTsType(desc: string): string {
47
+ const lower = desc.toLowerCase();
48
+ if (lower.includes("boolean") || lower.includes("bool")) return "boolean";
49
+ if (
50
+ lower.includes("number") ||
51
+ lower.includes("int") ||
52
+ lower.includes("float") ||
53
+ lower.includes("count") ||
54
+ lower.includes("age") ||
55
+ lower.includes("price") ||
56
+ lower.includes("amount")
57
+ )
58
+ return "number";
59
+ if (lower.includes("string[]") || lower.includes("array of string")) return "string[]";
60
+ if (lower.includes("number[]") || lower.includes("array of number")) return "number[]";
61
+ if (lower.includes("datetime") || lower.includes("date")) return "string /* ISO 8601 */";
62
+ if (lower.includes("json") || lower.includes("object")) return "Record<string, unknown>";
63
+ return "string";
64
+ }
65
+
66
+ /**
67
+ * Generate a PascalCase name from an endpoint ID + suffix.
68
+ */
69
+ function endpointTypeName(epId: string, suffix: "Request" | "Response"): string {
70
+ const normalized = epId.replace(/[^a-zA-Z0-9]/g, "");
71
+ return `${normalized}${suffix}`;
72
+ }
73
+
74
+ // ─── Core Functions ───────────────────────────────────────────────────────────
75
+
76
+ /**
77
+ * Convert a backend SpecDSL into a FrontendApiContract.
78
+ * This is the main bridge between backend output and frontend spec generation.
79
+ */
80
+ export function buildFrontendApiContract(dsl: SpecDSL): FrontendApiContract {
81
+ const typeBlocks: string[] = [];
82
+
83
+ const endpoints = dsl.endpoints.map((ep: ApiEndpoint) => {
84
+ const reqName = endpointTypeName(ep.id, "Request");
85
+ const resName = endpointTypeName(ep.id, "Response");
86
+
87
+ // Build request shape from body (primary) or params + query
88
+ const reqFields: Record<string, string> = {
89
+ ...(ep.request?.body ?? {}),
90
+ ...(ep.request?.params ?? {}),
91
+ ...(ep.request?.query ?? {}),
92
+ };
93
+ const requestShape = fieldMapToTsInterface(reqFields, reqName);
94
+ typeBlocks.push(requestShape);
95
+
96
+ // Build response shape — derive from success description + model info
97
+ // Since DSL doesn't have a structured response schema, we generate from model fields
98
+ const responseShape = buildResponseInterface(dsl, ep, resName);
99
+ typeBlocks.push(responseShape);
100
+
101
+ const errorCodes = (ep.errors ?? []).map((e) => e.code);
102
+
103
+ return {
104
+ method: ep.method,
105
+ path: ep.path,
106
+ auth: ep.auth,
107
+ requestShape,
108
+ responseShape,
109
+ errorCodes,
110
+ };
111
+ });
112
+
113
+ const typeDefinitions = typeBlocks.join("\n\n");
114
+
115
+ const summary = buildContractSummary(dsl, endpoints);
116
+
117
+ return { endpoints, typeDefinitions, summary };
118
+ }
119
+
120
+ /**
121
+ * Build a response TypeScript interface by inferring from the DSL.
122
+ * Uses model fields when the endpoint clearly returns a model, otherwise generates from description.
123
+ */
124
+ function buildResponseInterface(
125
+ dsl: SpecDSL,
126
+ ep: ApiEndpoint,
127
+ name: string
128
+ ): string {
129
+ // Try to match the endpoint to a data model by name heuristic
130
+ const pathSegments = ep.path.split("/").filter(Boolean);
131
+ const modelName = dsl.models.find((m) =>
132
+ pathSegments.some(
133
+ (seg) =>
134
+ seg.toLowerCase() === m.name.toLowerCase() ||
135
+ seg.toLowerCase() === m.name.toLowerCase() + "s"
136
+ )
137
+ );
138
+
139
+ if (modelName && (ep.method === "GET" || ep.method === "POST" || ep.method === "PUT" || ep.method === "PATCH")) {
140
+ const fields = modelName.fields.map((f) => {
141
+ const tsType = inferTsType(f.type);
142
+ const optional = f.required ? "" : "?";
143
+ return ` ${f.name}${optional}: ${tsType};`;
144
+ });
145
+ return `interface ${name} {\n${fields.join("\n")}\n}`;
146
+ }
147
+
148
+ // Generic success response
149
+ if (ep.successStatus === 204) {
150
+ return `interface ${name} { /* 204 No Content */ }`;
151
+ }
152
+
153
+ // Derive from success description keywords
154
+ const desc = ep.successDescription.toLowerCase();
155
+ const lines: string[] = [];
156
+
157
+ if (desc.includes("list") || desc.includes("array") || desc.includes("多")) {
158
+ lines.push(` items: unknown[];`);
159
+ lines.push(` total?: number;`);
160
+ } else if (desc.includes("token") || desc.includes("jwt")) {
161
+ lines.push(` token: string;`);
162
+ lines.push(` expiresIn?: number;`);
163
+ } else if (desc.includes("id")) {
164
+ lines.push(` id: number | string;`);
165
+ } else {
166
+ lines.push(` /* ${ep.successDescription} */`);
167
+ lines.push(` success: boolean;`);
168
+ }
169
+
170
+ return `interface ${name} {\n${lines.join("\n")}\n}`;
171
+ }
172
+
173
+ /**
174
+ * Build a human-readable summary of the API contract.
175
+ */
176
+ function buildContractSummary(
177
+ dsl: SpecDSL,
178
+ endpoints: FrontendApiContract["endpoints"]
179
+ ): string {
180
+ const lines: string[] = [
181
+ `Backend feature: ${dsl.feature.title}`,
182
+ `Description: ${dsl.feature.description}`,
183
+ "",
184
+ `Exposed endpoints (${endpoints.length}):`,
185
+ ];
186
+
187
+ for (const ep of endpoints) {
188
+ const authLabel = ep.auth ? "[auth required]" : "[public]";
189
+ const errorLabel =
190
+ ep.errorCodes.length > 0 ? ` | errors: ${ep.errorCodes.join(", ")}` : "";
191
+ lines.push(` ${ep.method} ${ep.path} ${authLabel}${errorLabel}`);
192
+ }
193
+
194
+ if (dsl.models.length > 0) {
195
+ lines.push("");
196
+ lines.push(`Data models: ${dsl.models.map((m) => m.name).join(", ")}`);
197
+ }
198
+
199
+ return lines.join("\n");
200
+ }
201
+
202
+ /**
203
+ * Build the contract context section to inject into frontend spec generation prompts.
204
+ */
205
+ export function buildContractContextSection(contract: FrontendApiContract): string {
206
+ const lines: string[] = [
207
+ "=== Backend API Contract (use these exact endpoints — do NOT change paths, methods, or types) ===",
208
+ "",
209
+ contract.summary,
210
+ "",
211
+ "-- TypeScript Interface Definitions --",
212
+ contract.typeDefinitions,
213
+ "",
214
+ "=== End of Backend API Contract ===",
215
+ ];
216
+ return lines.join("\n");
217
+ }