ai-spec-dev 0.33.0 → 0.36.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 (64) 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 +11 -1
  8. package/README.md +531 -213
  9. package/RELEASE_LOG.md +424 -0
  10. package/cli/commands/config.ts +18 -0
  11. package/cli/commands/create.ts +1248 -0
  12. package/cli/commands/dashboard.ts +62 -0
  13. package/cli/commands/init.ts +45 -8
  14. package/cli/commands/mock.ts +175 -0
  15. package/cli/commands/scan.ts +99 -0
  16. package/cli/commands/types.ts +69 -0
  17. package/cli/commands/vcr.ts +70 -0
  18. package/cli/index.ts +34 -2517
  19. package/cli/utils.ts +4 -0
  20. package/core/code-generator.ts +6 -4
  21. package/core/combined-generator.ts +13 -3
  22. package/core/dashboard-generator.ts +340 -0
  23. package/core/design-dialogue.ts +124 -0
  24. package/core/dsl-extractor.ts +9 -1
  25. package/core/dsl-feedback.ts +41 -5
  26. package/core/dsl-validator.ts +32 -0
  27. package/core/error-feedback.ts +46 -2
  28. package/core/key-store.ts +5 -4
  29. package/core/project-index.ts +301 -0
  30. package/core/provider-utils.ts +39 -4
  31. package/core/reviewer.ts +84 -6
  32. package/core/run-logger.ts +109 -3
  33. package/core/run-trend.ts +24 -4
  34. package/core/self-evaluator.ts +39 -11
  35. package/core/spec-generator.ts +14 -8
  36. package/core/task-generator.ts +17 -0
  37. package/core/types-generator.ts +219 -0
  38. package/core/vcr.ts +210 -0
  39. package/dist/cli/index.js +7407 -5643
  40. package/dist/cli/index.js.map +1 -1
  41. package/dist/cli/index.mjs +7401 -5637
  42. package/dist/cli/index.mjs.map +1 -1
  43. package/dist/index.d.mts +34 -5
  44. package/dist/index.d.ts +34 -5
  45. package/dist/index.js +497 -232
  46. package/dist/index.js.map +1 -1
  47. package/dist/index.mjs +495 -233
  48. package/dist/index.mjs.map +1 -1
  49. package/docs-assets/purpose/architecture-overview.svg +64 -0
  50. package/docs-assets/purpose/create-pipeline.svg +113 -0
  51. package/docs-assets/purpose/task-layering.svg +74 -0
  52. package/package.json +1 -1
  53. package/prompts/codegen.prompt.ts +97 -9
  54. package/prompts/design.prompt.ts +59 -0
  55. package/prompts/spec.prompt.ts +8 -1
  56. package/prompts/tasks.prompt.ts +27 -2
  57. package/purpose.md +600 -174
  58. package/tests/code-generator.test.ts +253 -0
  59. package/tests/context-loader.test.ts +207 -0
  60. package/tests/dsl-validator.test.ts +105 -0
  61. package/tests/openapi-exporter.test.ts +310 -0
  62. package/tests/reviewer.test.ts +214 -0
  63. package/tests/spec-generator.test.ts +228 -0
  64. package/tests/spec-versioning.test.ts +205 -0
@@ -4,6 +4,86 @@ import chalk from "chalk";
4
4
 
5
5
  const LOG_DIR = ".ai-spec-logs";
6
6
 
7
+ // ─── JSONL helpers ────────────────────────────────────────────────────────────
8
+ // Each event is synchronously appended as one JSON line to a `.jsonl` shadow
9
+ // file alongside the full `.json`. If the process crashes mid-run the `.json`
10
+ // may be empty or stale, but every line written to the `.jsonl` is durable.
11
+ // `loadRunLogs` (run-trend.ts) can reconstruct a RunLog from orphan `.jsonl`
12
+ // files for crash recovery.
13
+
14
+ function appendJsonlLine(filePath: string, record: Record<string, unknown>): void {
15
+ try {
16
+ fs.appendFileSync(filePath, JSON.stringify(record) + "\n");
17
+ } catch {
18
+ // JSONL write must never crash the pipeline
19
+ }
20
+ }
21
+
22
+ /** Reconstruct a RunLog from a `.jsonl` file (crash recovery path). */
23
+ export function reconstructRunLogFromJsonl(jsonlPath: string): RunLog | null {
24
+ let raw: string;
25
+ try {
26
+ raw = fs.readFileSync(jsonlPath, "utf-8");
27
+ } catch {
28
+ return null;
29
+ }
30
+
31
+ const log: Partial<RunLog> & { entries: LogEntry[]; filesWritten: string[]; errors: string[] } = {
32
+ entries: [],
33
+ filesWritten: [],
34
+ errors: [],
35
+ runId: "",
36
+ startedAt: "",
37
+ workingDir: "",
38
+ };
39
+
40
+ for (const line of raw.split("\n")) {
41
+ const trimmed = line.trim();
42
+ if (!trimmed) continue;
43
+ try {
44
+ const rec = JSON.parse(trimmed) as Record<string, unknown>;
45
+ switch (rec["type"]) {
46
+ case "header":
47
+ log.runId = rec["runId"] as string;
48
+ log.startedAt = rec["startedAt"] as string;
49
+ log.workingDir = rec["workingDir"] as string;
50
+ if (rec["provider"]) log.provider = rec["provider"] as string;
51
+ if (rec["model"]) log.model = rec["model"] as string;
52
+ if (rec["specPath"]) log.specPath = rec["specPath"] as string;
53
+ break;
54
+ case "meta":
55
+ if (rec["key"] === "promptHash") log.promptHash = rec["value"] as string;
56
+ if (rec["key"] === "harnessScore") log.harnessScore = rec["value"] as number;
57
+ break;
58
+ case "entry":
59
+ log.entries.push({
60
+ ts: rec["ts"] as string,
61
+ event: rec["event"] as string,
62
+ ...(rec["durationMs"] !== undefined ? { durationMs: rec["durationMs"] as number } : {}),
63
+ ...(rec["data"] ? { data: rec["data"] as Record<string, unknown> } : {}),
64
+ });
65
+ break;
66
+ case "file":
67
+ if (rec["path"]) log.filesWritten.push(rec["path"] as string);
68
+ break;
69
+ case "error":
70
+ if (rec["message"]) log.errors.push(rec["message"] as string);
71
+ break;
72
+ case "footer":
73
+ if (rec["endedAt"]) log.endedAt = rec["endedAt"] as string;
74
+ if (rec["totalDurationMs"]) log.totalDurationMs = rec["totalDurationMs"] as number;
75
+ if (rec["harnessScore"]) log.harnessScore = rec["harnessScore"] as number;
76
+ break;
77
+ }
78
+ } catch {
79
+ // corrupt line — skip
80
+ }
81
+ }
82
+
83
+ if (!log.runId || !log.startedAt) return null;
84
+ return log as RunLog;
85
+ }
86
+
7
87
  // ─── Types ────────────────────────────────────────────────────────────────────
8
88
 
9
89
  export interface LogEntry {
@@ -42,6 +122,7 @@ export class RunLogger {
42
122
  private log: RunLog;
43
123
  private readonly startMs: number;
44
124
  private readonly logPath: string;
125
+ private readonly jsonlPath: string;
45
126
  private readonly stageStartMs = new Map<string, number>();
46
127
 
47
128
  constructor(
@@ -50,7 +131,8 @@ export class RunLogger {
50
131
  meta?: { provider?: string; model?: string; specPath?: string }
51
132
  ) {
52
133
  this.startMs = Date.now();
53
- this.logPath = path.join(workingDir, LOG_DIR, `${runId}.json`);
134
+ this.logPath = path.join(workingDir, LOG_DIR, `${runId}.json`);
135
+ this.jsonlPath = path.join(workingDir, LOG_DIR, `${runId}.jsonl`);
54
136
  this.log = {
55
137
  runId,
56
138
  startedAt: new Date().toISOString(),
@@ -60,6 +142,16 @@ export class RunLogger {
60
142
  filesWritten: [],
61
143
  errors: [],
62
144
  };
145
+ // Write JSONL header immediately — ensures the file exists even on early crash
146
+ fs.ensureDir(path.dirname(this.jsonlPath)).then(() => {
147
+ appendJsonlLine(this.jsonlPath, {
148
+ type: "header",
149
+ runId,
150
+ startedAt: this.log.startedAt,
151
+ workingDir,
152
+ ...meta,
153
+ });
154
+ }).catch(() => {});
63
155
  this.flush();
64
156
  }
65
157
 
@@ -78,25 +170,30 @@ export class RunLogger {
78
170
  const start = this.stageStartMs.get(event);
79
171
  const durationMs = start !== undefined ? Date.now() - start : undefined;
80
172
  this.push(`${event}:failed`, { ...data, error, durationMs });
81
- this.log.errors.push(`[${event}] ${error}`);
173
+ const errorMsg = `[${event}] ${error}`;
174
+ this.log.errors.push(errorMsg);
175
+ appendJsonlLine(this.jsonlPath, { type: "error", message: errorMsg });
82
176
  this.flush();
83
177
  }
84
178
 
85
179
  /** Record the prompt hash for this run (call once at run start). */
86
180
  setPromptHash(hash: string): void {
87
181
  this.log.promptHash = hash;
182
+ appendJsonlLine(this.jsonlPath, { type: "meta", key: "promptHash", value: hash });
88
183
  this.flush();
89
184
  }
90
185
 
91
186
  /** Record the harness self-eval score (call once at run end). */
92
187
  setHarnessScore(score: number): void {
93
188
  this.log.harnessScore = score;
189
+ appendJsonlLine(this.jsonlPath, { type: "meta", key: "harnessScore", value: score });
94
190
  this.flush();
95
191
  }
96
192
 
97
193
  fileWritten(filePath: string): void {
98
194
  if (!this.log.filesWritten.includes(filePath)) {
99
195
  this.log.filesWritten.push(filePath);
196
+ appendJsonlLine(this.jsonlPath, { type: "file", path: filePath });
100
197
  this.flush();
101
198
  }
102
199
  }
@@ -104,6 +201,12 @@ export class RunLogger {
104
201
  finish(): void {
105
202
  this.log.endedAt = new Date().toISOString();
106
203
  this.log.totalDurationMs = Date.now() - this.startMs;
204
+ appendJsonlLine(this.jsonlPath, {
205
+ type: "footer",
206
+ endedAt: this.log.endedAt,
207
+ totalDurationMs: this.log.totalDurationMs,
208
+ harnessScore: this.log.harnessScore,
209
+ });
107
210
  this.flush();
108
211
  }
109
212
 
@@ -123,7 +226,10 @@ export class RunLogger {
123
226
  }
124
227
 
125
228
  private push(event: string, data?: Record<string, unknown>): void {
126
- this.log.entries.push({ ts: new Date().toISOString(), event, ...( data ? { data } : {}) });
229
+ const entry: LogEntry = { ts: new Date().toISOString(), event, ...(data ? { data } : {}) };
230
+ this.log.entries.push(entry);
231
+ // Append to JSONL synchronously — durable even on crash
232
+ appendJsonlLine(this.jsonlPath, { type: "entry", ...entry });
127
233
  this.flush();
128
234
  }
129
235
 
package/core/run-trend.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as fs from "fs-extra";
2
2
  import * as path from "path";
3
3
  import chalk from "chalk";
4
- import { RunLog } from "./run-logger";
4
+ import { RunLog, reconstructRunLogFromJsonl } from "./run-logger";
5
5
 
6
6
  const LOG_DIR = ".ai-spec-logs";
7
7
 
@@ -49,20 +49,40 @@ export async function loadRunLogs(workingDir: string): Promise<RunLog[]> {
49
49
  if (!(await fs.pathExists(logDir))) return [];
50
50
 
51
51
  const files = await fs.readdir(logDir);
52
- const jsonFiles = files.filter((f) => f.endsWith(".json")).sort().reverse();
52
+ const jsonFiles = new Set(files.filter((f) => f.endsWith(".json")));
53
+ const jsonlFiles = files.filter((f) => f.endsWith(".jsonl")).sort().reverse();
53
54
 
54
55
  const logs: RunLog[] = [];
55
- for (const file of jsonFiles) {
56
+ const seenRunIds = new Set<string>();
57
+
58
+ // Primary path: read complete .json files (newest-first)
59
+ for (const file of [...jsonFiles].sort().reverse()) {
56
60
  try {
57
61
  const log: RunLog = await fs.readJson(path.join(logDir, file));
58
- // only include runs that have a startedAt (minimal validity check)
59
62
  if (log.runId && log.startedAt) {
60
63
  logs.push(log);
64
+ seenRunIds.add(log.runId);
61
65
  }
62
66
  } catch {
63
67
  // corrupt file — skip silently
64
68
  }
65
69
  }
70
+
71
+ // Crash-recovery path: reconstruct from orphan .jsonl files (no matching .json)
72
+ for (const file of jsonlFiles) {
73
+ const runId = file.replace(/\.jsonl$/, "");
74
+ if (seenRunIds.has(runId)) continue; // already loaded via .json
75
+ const correspondingJson = `${runId}.json`;
76
+ if (jsonFiles.has(correspondingJson)) continue; // .json exists, prefer it
77
+ const log = reconstructRunLogFromJsonl(path.join(logDir, file));
78
+ if (log) {
79
+ logs.push(log);
80
+ seenRunIds.add(log.runId);
81
+ }
82
+ }
83
+
84
+ // Sort newest-first by startedAt
85
+ logs.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
66
86
  return logs;
67
87
  }
68
88
 
@@ -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 */
@@ -191,15 +194,35 @@ export function runSelfEval(opts: {
191
194
  // ── Review Score ──────────────────────────────────────────────────────────
192
195
  const reviewScore = reviewText ? extractReviewScore(reviewText) : null;
193
196
 
197
+ // ── Compliance Score (Pass 0) ──────────────────────────────────────────────
198
+ const rawCompliance = reviewText ? extractComplianceScore(reviewText) : 0;
199
+ const complianceScore: number | null = rawCompliance > 0 ? rawCompliance : null;
200
+
194
201
  // ── Harness Score (weighted average) ──────────────────────────────────────
195
- const harnessScore = reviewScore !== null
196
- ? Math.round((dslCoverageScore * 0.4 + compileScore * 0.3 + reviewScore * 0.3) * 10) / 10
197
- : 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
+ }
198
220
 
199
221
  const result: SelfEvalResult = {
200
222
  dslCoverageScore,
201
223
  compileScore,
202
224
  reviewScore,
225
+ complianceScore,
203
226
  harnessScore,
204
227
  promptHash,
205
228
  detail: {
@@ -221,6 +244,7 @@ export function runSelfEval(opts: {
221
244
  dslCoverageScore,
222
245
  compileScore,
223
246
  reviewScore: reviewScore ?? undefined,
247
+ complianceScore: complianceScore ?? undefined,
224
248
  promptHash,
225
249
  modelNameCoverage: result.detail.modelNameCoverage,
226
250
  modelNameMatched: result.detail.modelNameMatched,
@@ -244,9 +268,16 @@ export function printSelfEval(result: SelfEvalResult): void {
244
268
  const compileTag = result.compileScore === 10
245
269
  ? chalk.green("pass")
246
270
  : chalk.yellow("partial");
247
- const reviewTag = result.reviewScore !== null
271
+ const reviewTag = result.reviewScore !== null
248
272
  ? `Review: ${result.reviewScore}/10`
249
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");
250
281
 
251
282
  // Model coverage tag (only shown when there are declared models)
252
283
  let modelCoverageTag = "";
@@ -262,15 +293,12 @@ export function printSelfEval(result: SelfEvalResult): void {
262
293
 
263
294
  console.log(chalk.cyan("\n─── Harness Self-Eval ───────────────────────────"));
264
295
  console.log(` Score : ${scoreColor(`[${bar}] ${result.harnessScore}/10`)}`);
296
+ console.log(` ${complianceTag} Compile: ${compileTag} ${reviewTag}`);
265
297
  console.log(
266
- ` DSL : ${scoreColor(String(result.dslCoverageScore) + "/10")} ` +
267
- `Compile: ${compileTag} ${reviewTag}`
298
+ ` DSL : ${scoreColor(String(result.dslCoverageScore) + "/10")}` +
299
+ (modelCoverageTag ? ` ${modelCoverageTag}` : "") +
300
+ chalk.gray(` Endpoints: ${result.detail.endpointsTotal} Files: ${result.detail.filesWritten}`)
268
301
  );
269
- if (modelCoverageTag) {
270
- console.log(` Detail : ${modelCoverageTag} ` +
271
- chalk.gray(`Endpoints: ${result.detail.endpointsTotal} Files: ${result.detail.filesWritten}`)
272
- );
273
- }
274
302
  console.log(chalk.gray(` Prompt : ${result.promptHash}`));
275
303
  console.log(chalk.cyan("─".repeat(49)));
276
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
+ }