ai-spec-dev 0.31.0 → 0.35.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 (70) hide show
  1. package/.claude/commands/add-lesson.md +34 -0
  2. package/.claude/commands/check-layers.md +65 -0
  3. package/.claude/commands/installed-deps.md +35 -0
  4. package/.claude/commands/recall-lessons.md +40 -0
  5. package/.claude/commands/scan-singletons.md +45 -0
  6. package/.claude/commands/verify-imports.md +48 -0
  7. package/.claude/settings.local.json +15 -1
  8. package/README.md +531 -213
  9. package/RELEASE_LOG.md +460 -0
  10. package/cli/commands/config.ts +93 -0
  11. package/cli/commands/create.ts +1233 -0
  12. package/cli/commands/dashboard.ts +62 -0
  13. package/cli/commands/export.ts +66 -0
  14. package/cli/commands/init.ts +190 -0
  15. package/cli/commands/learn.ts +30 -0
  16. package/cli/commands/logs.ts +106 -0
  17. package/cli/commands/mock.ts +175 -0
  18. package/cli/commands/model.ts +156 -0
  19. package/cli/commands/restore.ts +22 -0
  20. package/cli/commands/review.ts +63 -0
  21. package/cli/commands/scan.ts +99 -0
  22. package/cli/commands/trend.ts +36 -0
  23. package/cli/commands/types.ts +69 -0
  24. package/cli/commands/update.ts +178 -0
  25. package/cli/commands/vcr.ts +70 -0
  26. package/cli/commands/workspace.ts +219 -0
  27. package/cli/index.ts +34 -2240
  28. package/cli/utils.ts +83 -0
  29. package/core/combined-generator.ts +13 -3
  30. package/core/dashboard-generator.ts +340 -0
  31. package/core/design-dialogue.ts +124 -0
  32. package/core/dsl-feedback.ts +285 -0
  33. package/core/error-feedback.ts +46 -2
  34. package/core/project-index.ts +301 -0
  35. package/core/reviewer.ts +84 -6
  36. package/core/run-logger.ts +109 -3
  37. package/core/run-trend.ts +261 -0
  38. package/core/self-evaluator.ts +139 -7
  39. package/core/spec-generator.ts +14 -8
  40. package/core/task-generator.ts +17 -0
  41. package/core/types-generator.ts +219 -0
  42. package/core/vcr.ts +210 -0
  43. package/dist/cli/index.js +6692 -4512
  44. package/dist/cli/index.js.map +1 -1
  45. package/dist/cli/index.mjs +6692 -4512
  46. package/dist/cli/index.mjs.map +1 -1
  47. package/dist/index.d.mts +19 -5
  48. package/dist/index.d.ts +19 -5
  49. package/dist/index.js +420 -224
  50. package/dist/index.js.map +1 -1
  51. package/dist/index.mjs +418 -224
  52. package/dist/index.mjs.map +1 -1
  53. package/docs-assets/purpose/architecture-overview.svg +64 -0
  54. package/docs-assets/purpose/create-pipeline.svg +113 -0
  55. package/docs-assets/purpose/task-layering.svg +74 -0
  56. package/package.json +6 -3
  57. package/prompts/codegen.prompt.ts +97 -9
  58. package/prompts/design.prompt.ts +59 -0
  59. package/prompts/spec.prompt.ts +8 -1
  60. package/prompts/tasks.prompt.ts +27 -2
  61. package/purpose.md +600 -174
  62. package/tests/dsl-extractor.test.ts +264 -0
  63. package/tests/dsl-feedback.test.ts +266 -0
  64. package/tests/dsl-validator.test.ts +283 -0
  65. package/tests/error-feedback.test.ts +292 -0
  66. package/tests/provider-utils.test.ts +173 -0
  67. package/tests/run-trend.test.ts +186 -0
  68. package/tests/self-evaluator.test.ts +339 -0
  69. package/tests/spec-assessor.test.ts +142 -0
  70. package/tests/task-generator.test.ts +230 -0
@@ -1,6 +1,7 @@
1
1
  import chalk from "chalk";
2
2
  import { SpecDSL } from "./dsl-types";
3
3
  import { RunLogger } from "./run-logger";
4
+ import { extractComplianceScore } from "./reviewer";
4
5
 
5
6
  // ─── Types ────────────────────────────────────────────────────────────────────
6
7
 
@@ -11,6 +12,8 @@ export interface SelfEvalResult {
11
12
  compileScore: number;
12
13
  /** 0-10 extracted from 3-pass review text, or null when review was skipped */
13
14
  reviewScore: number | null;
15
+ /** 0-10 from Pass 0 spec compliance check, or null when skipped/unavailable */
16
+ complianceScore: number | null;
14
17
  /** 0-10 weighted overall — the "Harness Score" recorded in RunLog */
15
18
  harnessScore: number;
16
19
  /** Prompt hash at the time this run executed */
@@ -18,8 +21,14 @@ export interface SelfEvalResult {
18
21
  detail: {
19
22
  endpointsTotal: number;
20
23
  endpointLayerCovered: boolean;
24
+ /** Number of endpoint-layer files generated */
25
+ endpointLayerFiles: number;
21
26
  modelsTotal: number;
22
27
  modelLayerCovered: boolean;
28
+ /** 0-1: fraction of DSL model names found in generated file paths */
29
+ modelNameCoverage: number;
30
+ /** Number of DSL model names actually matched in file paths */
31
+ modelNameMatched: number;
23
32
  filesWritten: number;
24
33
  };
25
34
  }
@@ -57,6 +66,32 @@ function extractReviewScore(reviewText: string): number | null {
57
66
 
58
67
  // ─── Main ─────────────────────────────────────────────────────────────────────
59
68
 
69
+ /**
70
+ * Normalize a PascalCase or camelCase model name to a set of search tokens
71
+ * that would appear in file paths.
72
+ *
73
+ * "OrderItem" → ["orderitem", "order-item", "order_item"]
74
+ * "User" → ["user"]
75
+ */
76
+ export function modelNameTokens(name: string): string[] {
77
+ const lower = name.toLowerCase();
78
+ // split on uppercase boundaries: "OrderItem" → ["order", "item"]
79
+ const parts = name
80
+ .replace(/([A-Z])/g, "-$1")
81
+ .toLowerCase()
82
+ .replace(/^-/, "")
83
+ .split("-")
84
+ .filter(Boolean);
85
+
86
+ const tokens = new Set<string>();
87
+ tokens.add(lower);
88
+ if (parts.length > 1) {
89
+ tokens.add(parts.join("-"));
90
+ tokens.add(parts.join("_"));
91
+ }
92
+ return [...tokens];
93
+ }
94
+
60
95
  /**
61
96
  * Run a lightweight self-evaluation at the end of `ai-spec create`.
62
97
  *
@@ -71,6 +106,18 @@ function extractReviewScore(reviewText: string): number | null {
71
106
  * | DSL Coverage | 40 % | 55 % |
72
107
  * | Compile/Error | 30 % | 45 % |
73
108
  * | Review Score | 30 % | — |
109
+ *
110
+ * DSL Coverage Score breakdown (0-10):
111
+ * Tier 1 — Layer existence (same as before):
112
+ * - No files generated → 0 (early exit)
113
+ * - Endpoints declared but no endpoint layer → -4
114
+ * - Models declared but no model layer → -3
115
+ * Tier 2 — Model name coverage (new):
116
+ * - coverage < 50 % → -2
117
+ * - coverage 50–79 % → -1
118
+ * - coverage ≥ 80 % → 0
119
+ * Tier 3 — Endpoint file adequacy (new):
120
+ * - ≥5 endpoints declared but only 1 endpoint-layer file → -1
74
121
  */
75
122
  export function runSelfEval(opts: {
76
123
  dsl: SpecDSL | null;
@@ -91,18 +138,55 @@ export function runSelfEval(opts: {
91
138
  const endpointLayerCovered = generatedFiles.some((f) =>
92
139
  ENDPOINT_LAYER_PATTERNS.some((p) => p.test(f))
93
140
  );
141
+ const endpointLayerFiles = generatedFiles.filter((f) =>
142
+ ENDPOINT_LAYER_PATTERNS.some((p) => p.test(f))
143
+ ).length;
94
144
  const modelLayerCovered = generatedFiles.some((f) =>
95
145
  MODEL_LAYER_PATTERNS.some((p) => p.test(f))
96
146
  );
97
147
 
148
+ // ── Tier 2: Model name coverage ───────────────────────────────────────────
149
+ // For each DSL model, check if its name (lowercased/tokenized) appears
150
+ // in any generated file path. This catches "User model was declared but
151
+ // no user.ts / user.model.ts was generated".
152
+ let modelNameMatched = 0;
153
+ if (modelsTotal > 0 && dsl?.models) {
154
+ for (const model of dsl.models) {
155
+ const tokens = modelNameTokens(model.name);
156
+ const found = generatedFiles.some((f) => {
157
+ const lf = f.toLowerCase();
158
+ return tokens.some((t) => lf.includes(t));
159
+ });
160
+ if (found) modelNameMatched++;
161
+ }
162
+ }
163
+ const modelNameCoverage = modelsTotal > 0 ? modelNameMatched / modelsTotal : 1;
164
+
165
+ // ── Compute DSL Coverage Score ────────────────────────────────────────────
98
166
  let dslCoverageScore = 10;
167
+
99
168
  if (generatedFiles.length === 0) {
100
169
  dslCoverageScore = 0;
101
170
  } else {
171
+ // Tier 1: layer existence
102
172
  if (endpointsTotal > 0 && !endpointLayerCovered) dslCoverageScore -= 4;
103
173
  if (modelsTotal > 0 && !modelLayerCovered) dslCoverageScore -= 3;
174
+
175
+ // Tier 2: model name coverage (only meaningful when model layer exists)
176
+ if (modelsTotal > 0 && modelLayerCovered) {
177
+ if (modelNameCoverage < 0.5) dslCoverageScore -= 2;
178
+ else if (modelNameCoverage < 0.8) dslCoverageScore -= 1;
179
+ }
180
+
181
+ // Tier 3: endpoint file adequacy (many endpoints, very few files)
182
+ if (endpointsTotal >= 5 && endpointLayerCovered && endpointLayerFiles < 2) {
183
+ dslCoverageScore -= 1;
184
+ }
104
185
  }
105
186
 
187
+ // clamp to [0, 10]
188
+ dslCoverageScore = Math.max(0, Math.min(10, dslCoverageScore));
189
+
106
190
  // ── Compile Score ─────────────────────────────────────────────────────────
107
191
  // 10 = clean pass, 5 = error feedback ran but didn't fully clear / was skipped
108
192
  const compileScore = compilePassed ? 10 : 5;
@@ -110,22 +194,45 @@ export function runSelfEval(opts: {
110
194
  // ── Review Score ──────────────────────────────────────────────────────────
111
195
  const reviewScore = reviewText ? extractReviewScore(reviewText) : null;
112
196
 
197
+ // ── Compliance Score (Pass 0) ──────────────────────────────────────────────
198
+ const rawCompliance = reviewText ? extractComplianceScore(reviewText) : 0;
199
+ const complianceScore: number | null = rawCompliance > 0 ? rawCompliance : null;
200
+
113
201
  // ── Harness Score (weighted average) ──────────────────────────────────────
114
- const harnessScore = reviewScore !== null
115
- ? Math.round((dslCoverageScore * 0.4 + compileScore * 0.3 + reviewScore * 0.3) * 10) / 10
116
- : Math.round((dslCoverageScore * 0.55 + compileScore * 0.45) * 10) / 10;
202
+ // Weights reflect importance: compliance (did we build the right thing?) > dsl > review > compile
203
+ //
204
+ // compliance + review available → 0.30 compliance + 0.25 dsl + 0.20 compile + 0.25 review
205
+ // review only → 0.40 dsl + 0.30 compile + 0.30 review (unchanged)
206
+ // compliance only → 0.35 compliance + 0.35 dsl + 0.30 compile
207
+ // neither → 0.55 dsl + 0.45 compile (unchanged)
208
+ let harnessScore: number;
209
+ if (complianceScore !== null && reviewScore !== null) {
210
+ harnessScore = Math.round(
211
+ (complianceScore * 0.30 + dslCoverageScore * 0.25 + compileScore * 0.20 + reviewScore * 0.25) * 10
212
+ ) / 10;
213
+ } else if (reviewScore !== null) {
214
+ harnessScore = Math.round((dslCoverageScore * 0.4 + compileScore * 0.3 + reviewScore * 0.3) * 10) / 10;
215
+ } else if (complianceScore !== null) {
216
+ harnessScore = Math.round((complianceScore * 0.35 + dslCoverageScore * 0.35 + compileScore * 0.30) * 10) / 10;
217
+ } else {
218
+ harnessScore = Math.round((dslCoverageScore * 0.55 + compileScore * 0.45) * 10) / 10;
219
+ }
117
220
 
118
221
  const result: SelfEvalResult = {
119
222
  dslCoverageScore,
120
223
  compileScore,
121
224
  reviewScore,
225
+ complianceScore,
122
226
  harnessScore,
123
227
  promptHash,
124
228
  detail: {
125
229
  endpointsTotal,
126
230
  endpointLayerCovered,
231
+ endpointLayerFiles,
127
232
  modelsTotal,
128
233
  modelLayerCovered,
234
+ modelNameCoverage: Math.round(modelNameCoverage * 100) / 100,
235
+ modelNameMatched,
129
236
  filesWritten: generatedFiles.length,
130
237
  },
131
238
  };
@@ -137,7 +244,11 @@ export function runSelfEval(opts: {
137
244
  dslCoverageScore,
138
245
  compileScore,
139
246
  reviewScore: reviewScore ?? undefined,
247
+ complianceScore: complianceScore ?? undefined,
140
248
  promptHash,
249
+ modelNameCoverage: result.detail.modelNameCoverage,
250
+ modelNameMatched: result.detail.modelNameMatched,
251
+ endpointLayerFiles: result.detail.endpointLayerFiles,
141
252
  });
142
253
 
143
254
  return result;
@@ -157,16 +268,37 @@ export function printSelfEval(result: SelfEvalResult): void {
157
268
  const compileTag = result.compileScore === 10
158
269
  ? chalk.green("pass")
159
270
  : chalk.yellow("partial");
160
- const reviewTag = result.reviewScore !== null
271
+ const reviewTag = result.reviewScore !== null
161
272
  ? `Review: ${result.reviewScore}/10`
162
273
  : chalk.gray("Review: skipped");
274
+ const complianceTag = result.complianceScore !== null
275
+ ? (result.complianceScore >= 8
276
+ ? chalk.green(`Compliance: ${result.complianceScore}/10`)
277
+ : result.complianceScore >= 6
278
+ ? chalk.yellow(`Compliance: ${result.complianceScore}/10`)
279
+ : chalk.red(`Compliance: ${result.complianceScore}/10 ⚠`))
280
+ : chalk.gray("Compliance: skipped");
281
+
282
+ // Model coverage tag (only shown when there are declared models)
283
+ let modelCoverageTag = "";
284
+ if (result.detail.modelsTotal > 0) {
285
+ const pct = Math.round(result.detail.modelNameCoverage * 100);
286
+ const tag = `Models: ${result.detail.modelNameMatched}/${result.detail.modelsTotal} (${pct}%)`;
287
+ modelCoverageTag = pct >= 80
288
+ ? chalk.green(tag)
289
+ : pct >= 50
290
+ ? chalk.yellow(tag)
291
+ : chalk.red(tag);
292
+ }
163
293
 
164
294
  console.log(chalk.cyan("\n─── Harness Self-Eval ───────────────────────────"));
165
295
  console.log(` Score : ${scoreColor(`[${bar}] ${result.harnessScore}/10`)}`);
296
+ console.log(` ${complianceTag} Compile: ${compileTag} ${reviewTag}`);
166
297
  console.log(
167
- ` DSL : ${scoreColor(result.dslCoverageScore + "/10")} ` +
168
- `Compile: ${compileTag} ${reviewTag}`
298
+ ` DSL : ${scoreColor(String(result.dslCoverageScore) + "/10")}` +
299
+ (modelCoverageTag ? ` ${modelCoverageTag}` : "") +
300
+ chalk.gray(` Endpoints: ${result.detail.endpointsTotal} Files: ${result.detail.filesWritten}`)
169
301
  );
170
302
  console.log(chalk.gray(` Prompt : ${result.promptHash}`));
171
- console.log(chalk.gray("─".repeat(49)));
303
+ console.log(chalk.cyan("─".repeat(49)));
172
304
  }
@@ -145,15 +145,16 @@ export const PROVIDER_CATALOG: Record<string, ProviderMeta> = {
145
145
  },
146
146
  glm: {
147
147
  displayName: "智谱 GLM (Zhipu AI)",
148
- description: "智谱 AI — GLM-5 / GLM-4 series + Z1 reasoning",
148
+ description: "智谱 AI — GLM-5.1 / GLM-5 / GLM-4 series",
149
149
  models: [
150
- "glm-5", // GLM-5 flagship (如不可用请确认最新 model ID)
151
- "glm-5-flash",
152
- "glm-z1", // GLM-Z1 reasoning model
150
+ "glm-5.1", // GLM-5.1 — latest flagship (2026)
151
+ "glm-5", // GLM-5 — premium (Max/Pro plans)
152
+ "glm-5-turbo", // GLM-5-Turbo fast & cost-efficient
153
+ "glm-4.7", // GLM-4.7
154
+ "glm-4.6", // GLM-4.6
155
+ "glm-4.5-air", // GLM-4.5-Air — lightweight
156
+ "glm-z1", // GLM-Z1 — reasoning model
153
157
  "glm-z1-flash",
154
- "glm-4-plus",
155
- "glm-4-flash",
156
- "glm-4-long",
157
158
  ],
158
159
  envKey: "ZHIPU_API_KEY",
159
160
  baseURL: "https://open.bigmodel.cn/api/paas/v4/",
@@ -405,8 +406,13 @@ export function createProvider(
405
406
  export class SpecGenerator {
406
407
  constructor(private provider: AIProvider) {}
407
408
 
408
- async generateSpec(idea: string, context?: ProjectContext): Promise<string> {
409
+ async generateSpec(idea: string, context?: ProjectContext, architectureDecision?: string): Promise<string> {
409
410
  const parts: string[] = [idea];
411
+ if (architectureDecision) {
412
+ parts.push(
413
+ `\n=== Architecture Decision (MUST follow this approach in the spec) ===\n${architectureDecision}`
414
+ );
415
+ }
410
416
 
411
417
  if (context) {
412
418
  // Constitution is highest priority — put it first so the AI respects it
@@ -76,6 +76,15 @@ export interface SpecTask {
76
76
  layer: TaskLayer;
77
77
  filesToTouch: string[];
78
78
  acceptanceCriteria: string[];
79
+ /**
80
+ * Concrete, runnable verification steps — each entry is a specific command
81
+ * or action with an expected observable outcome.
82
+ * Examples:
83
+ * "POST /api/orders with body {...} → HTTP 201, body contains {id, status:'pending'}"
84
+ * "npm run build exits 0 with no TypeScript errors"
85
+ * "GET /api/orders/:id returns 404 when id does not exist"
86
+ */
87
+ verificationSteps: string[];
79
88
  dependencies: string[];
80
89
  priority: TaskPriority;
81
90
  /** Runtime checkpoint — set by code generator, persisted to tasks file */
@@ -148,6 +157,14 @@ export function printTasks(tasks: SpecTask[]): void {
148
157
  const badge = color(`[${task.layer}]`);
149
158
  const prio = task.priority === "high" ? chalk.red("●") : task.priority === "medium" ? chalk.yellow("●") : chalk.gray("●");
150
159
  console.log(` ${prio} ${chalk.bold(task.id)} ${badge} ${task.title}`);
160
+ if (task.verificationSteps?.length) {
161
+ for (const step of task.verificationSteps.slice(0, 2)) {
162
+ console.log(chalk.gray(` ✓ ${step}`));
163
+ }
164
+ if (task.verificationSteps.length > 2) {
165
+ console.log(chalk.gray(` + ${task.verificationSteps.length - 2} more verification step(s)`));
166
+ }
167
+ }
151
168
  }
152
169
  }
153
170
 
@@ -0,0 +1,219 @@
1
+ import * as path from "path";
2
+ import * as fs from "fs-extra";
3
+ import { SpecDSL, ModelField, ApiEndpoint } from "./dsl-types";
4
+
5
+ // ─── Type Mapping ─────────────────────────────────────────────────────────────
6
+
7
+ const PRIMITIVE_MAP: Record<string, string> = {
8
+ String: "string",
9
+ string: "string",
10
+ Int: "number",
11
+ int: "number",
12
+ Float: "number",
13
+ float: "number",
14
+ Number: "number",
15
+ number: "number",
16
+ Boolean: "boolean",
17
+ boolean: "boolean",
18
+ DateTime: "string",
19
+ Date: "string",
20
+ Json: "Record<string, unknown>",
21
+ JSON: "Record<string, unknown>",
22
+ Any: "unknown",
23
+ any: "unknown",
24
+ };
25
+
26
+ function mapFieldType(raw: string): string {
27
+ const trimmed = raw.trim();
28
+ // Array types: "String[]" or "User[]"
29
+ if (trimmed.endsWith("[]")) {
30
+ return `${mapFieldType(trimmed.slice(0, -2))}[]`;
31
+ }
32
+ // Nullable / optional markers
33
+ const base = trimmed.replace(/[?!]$/, "");
34
+ if (PRIMITIVE_MAP[base]) return PRIMITIVE_MAP[base];
35
+ // PascalCase → treat as model reference (stays as-is)
36
+ if (/^[A-Z]/.test(base)) return base;
37
+ return "string";
38
+ }
39
+
40
+ // ─── Model → Interface ────────────────────────────────────────────────────────
41
+
42
+ function renderModelInterface(
43
+ name: string,
44
+ fields: ModelField[],
45
+ description?: string
46
+ ): string {
47
+ const lines: string[] = [];
48
+ if (description) lines.push(`/** ${description} */`);
49
+ lines.push(`export interface ${name} {`);
50
+ for (const f of fields) {
51
+ const optional = f.required ? "" : "?";
52
+ const tsType = mapFieldType(f.type);
53
+ if (f.description) lines.push(` /** ${f.description} */`);
54
+ lines.push(` ${f.name}${optional}: ${tsType};`);
55
+ }
56
+ lines.push("}");
57
+ return lines.join("\n");
58
+ }
59
+
60
+ // ─── Endpoint → Request/Response types ───────────────────────────────────────
61
+
62
+ function sanitizeName(str: string): string {
63
+ // "/users/:id" → "UsersById", "POST /auth/login" → "PostAuthLogin"
64
+ return str
65
+ .replace(/^\//, "")
66
+ .replace(/:([a-zA-Z]+)/g, "By$1")
67
+ .split(/[\/\-_]/)
68
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
69
+ .join("");
70
+ }
71
+
72
+ function endpointTypeName(ep: ApiEndpoint): string {
73
+ return ep.method.charAt(0) + ep.method.slice(1).toLowerCase() + sanitizeName(ep.path);
74
+ }
75
+
76
+ function renderEndpointTypes(ep: ApiEndpoint): string | null {
77
+ const baseName = endpointTypeName(ep);
78
+ const parts: string[] = [];
79
+
80
+ parts.push(`// ${ep.method} ${ep.path}${ep.description ? ` — ${ep.description}` : ""}`);
81
+
82
+ let hasRequest = false;
83
+
84
+ // Request body
85
+ if (ep.request?.body && Object.keys(ep.request.body).length > 0) {
86
+ hasRequest = true;
87
+ parts.push(`export interface ${baseName}Request {`);
88
+ for (const [key, typeDesc] of Object.entries(ep.request.body)) {
89
+ const tsType = mapFieldType(typeDesc);
90
+ parts.push(` ${key}: ${tsType};`);
91
+ }
92
+ parts.push("}");
93
+ }
94
+
95
+ // Query params
96
+ if (ep.request?.query && Object.keys(ep.request.query).length > 0) {
97
+ parts.push(`export interface ${baseName}Query {`);
98
+ for (const [key, typeDesc] of Object.entries(ep.request.query)) {
99
+ const tsType = mapFieldType(typeDesc);
100
+ parts.push(` ${key}?: ${tsType};`);
101
+ }
102
+ parts.push("}");
103
+ }
104
+
105
+ // Path params
106
+ if (ep.request?.params && Object.keys(ep.request.params).length > 0) {
107
+ parts.push(`export interface ${baseName}Params {`);
108
+ for (const [key, typeDesc] of Object.entries(ep.request.params)) {
109
+ const tsType = mapFieldType(typeDesc);
110
+ parts.push(` ${key}: ${tsType};`);
111
+ }
112
+ parts.push("}");
113
+ }
114
+
115
+ if (parts.length === 1) return null; // only comment, no types to emit
116
+ return parts.join("\n");
117
+ }
118
+
119
+ // ─── Endpoint map constant ───────────────────────────────────────────────────
120
+
121
+ function renderEndpointMap(endpoints: ApiEndpoint[]): string {
122
+ const lines: string[] = [];
123
+ lines.push("export const API_ENDPOINTS = {");
124
+ for (const ep of endpoints) {
125
+ const key = endpointTypeName(ep);
126
+ const keyLower = key.charAt(0).toLowerCase() + key.slice(1);
127
+ lines.push(` ${keyLower}: { method: '${ep.method}', path: '${ep.path}', auth: ${ep.auth} },`);
128
+ }
129
+ lines.push("} as const;");
130
+ lines.push("");
131
+ lines.push("export type ApiEndpointKey = keyof typeof API_ENDPOINTS;");
132
+ return lines.join("\n");
133
+ }
134
+
135
+ // ─── Main generator ───────────────────────────────────────────────────────────
136
+
137
+ export interface TypesGeneratorOptions {
138
+ /** Include endpoint request/response types (default: true) */
139
+ includeEndpointTypes?: boolean;
140
+ /** Include API_ENDPOINTS constant map (default: true) */
141
+ includeEndpointMap?: boolean;
142
+ /** Header comment to inject */
143
+ header?: string;
144
+ }
145
+
146
+ export function generateTypescriptTypes(
147
+ dsl: SpecDSL,
148
+ opts: TypesGeneratorOptions = {}
149
+ ): string {
150
+ const {
151
+ includeEndpointTypes = true,
152
+ includeEndpointMap = true,
153
+ } = opts;
154
+
155
+ const sections: string[] = [];
156
+
157
+ // Header
158
+ const header = opts.header ?? `// Generated by ai-spec — DO NOT EDIT\n// Feature: ${dsl.feature.title}\n// Generated at: ${new Date().toISOString()}`;
159
+ sections.push(header);
160
+
161
+ // Data Models
162
+ if (dsl.models.length > 0) {
163
+ sections.push("// ─── Data Models " + "─".repeat(57));
164
+ for (const model of dsl.models) {
165
+ sections.push(renderModelInterface(model.name, model.fields, model.description));
166
+ }
167
+ }
168
+
169
+ // Frontend Components (props only)
170
+ if (dsl.components && dsl.components.length > 0) {
171
+ sections.push("// ─── Component Props " + "─".repeat(53));
172
+ for (const comp of dsl.components) {
173
+ const lines: string[] = [];
174
+ if (comp.description) lines.push(`/** ${comp.description} */`);
175
+ lines.push(`export interface ${comp.name}Props {`);
176
+ for (const prop of comp.props) {
177
+ const optional = prop.required ? "" : "?";
178
+ const tsType = mapFieldType(prop.type);
179
+ if (prop.description) lines.push(` /** ${prop.description} */`);
180
+ lines.push(` ${prop.name}${optional}: ${tsType};`);
181
+ }
182
+ lines.push("}");
183
+ sections.push(lines.join("\n"));
184
+ }
185
+ }
186
+
187
+ // Endpoint request/response types
188
+ if (includeEndpointTypes && dsl.endpoints.length > 0) {
189
+ sections.push("// ─── API Request Types " + "─".repeat(51));
190
+ for (const ep of dsl.endpoints) {
191
+ const rendered = renderEndpointTypes(ep);
192
+ if (rendered) sections.push(rendered);
193
+ }
194
+ }
195
+
196
+ // Endpoint map
197
+ if (includeEndpointMap && dsl.endpoints.length > 0) {
198
+ sections.push("// ─── Endpoint Map " + "─".repeat(55));
199
+ sections.push(renderEndpointMap(dsl.endpoints));
200
+ }
201
+
202
+ return sections.join("\n\n") + "\n";
203
+ }
204
+
205
+ // ─── File save ────────────────────────────────────────────────────────────────
206
+
207
+ export async function saveTypescriptTypes(
208
+ dsl: SpecDSL,
209
+ projectDir: string,
210
+ opts: TypesGeneratorOptions & { outputPath?: string } = {}
211
+ ): Promise<string> {
212
+ const outputPath =
213
+ opts.outputPath ?? path.join(projectDir, ".ai-spec", `${dsl.feature.title.replace(/\s+/g, "-").toLowerCase()}.types.ts`);
214
+
215
+ await fs.ensureDir(path.dirname(outputPath));
216
+ const content = generateTypescriptTypes(dsl, opts);
217
+ await fs.writeFile(outputPath, content, "utf-8");
218
+ return outputPath;
219
+ }