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.
- package/.claude/commands/add-lesson.md +34 -0
- package/.claude/commands/check-layers.md +65 -0
- package/.claude/commands/installed-deps.md +35 -0
- package/.claude/commands/recall-lessons.md +40 -0
- package/.claude/commands/scan-singletons.md +45 -0
- package/.claude/commands/verify-imports.md +48 -0
- package/.claude/settings.local.json +15 -1
- package/README.md +531 -213
- package/RELEASE_LOG.md +460 -0
- package/cli/commands/config.ts +93 -0
- package/cli/commands/create.ts +1233 -0
- package/cli/commands/dashboard.ts +62 -0
- package/cli/commands/export.ts +66 -0
- package/cli/commands/init.ts +190 -0
- package/cli/commands/learn.ts +30 -0
- package/cli/commands/logs.ts +106 -0
- package/cli/commands/mock.ts +175 -0
- package/cli/commands/model.ts +156 -0
- package/cli/commands/restore.ts +22 -0
- package/cli/commands/review.ts +63 -0
- package/cli/commands/scan.ts +99 -0
- package/cli/commands/trend.ts +36 -0
- package/cli/commands/types.ts +69 -0
- package/cli/commands/update.ts +178 -0
- package/cli/commands/vcr.ts +70 -0
- package/cli/commands/workspace.ts +219 -0
- package/cli/index.ts +34 -2240
- package/cli/utils.ts +83 -0
- package/core/combined-generator.ts +13 -3
- package/core/dashboard-generator.ts +340 -0
- package/core/design-dialogue.ts +124 -0
- package/core/dsl-feedback.ts +285 -0
- package/core/error-feedback.ts +46 -2
- package/core/project-index.ts +301 -0
- package/core/reviewer.ts +84 -6
- package/core/run-logger.ts +109 -3
- package/core/run-trend.ts +261 -0
- package/core/self-evaluator.ts +139 -7
- package/core/spec-generator.ts +14 -8
- package/core/task-generator.ts +17 -0
- package/core/types-generator.ts +219 -0
- package/core/vcr.ts +210 -0
- package/dist/cli/index.js +6692 -4512
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +6692 -4512
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +19 -5
- package/dist/index.d.ts +19 -5
- package/dist/index.js +420 -224
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +418 -224
- package/dist/index.mjs.map +1 -1
- package/docs-assets/purpose/architecture-overview.svg +64 -0
- package/docs-assets/purpose/create-pipeline.svg +113 -0
- package/docs-assets/purpose/task-layering.svg +74 -0
- package/package.json +6 -3
- package/prompts/codegen.prompt.ts +97 -9
- package/prompts/design.prompt.ts +59 -0
- package/prompts/spec.prompt.ts +8 -1
- package/prompts/tasks.prompt.ts +27 -2
- package/purpose.md +600 -174
- package/tests/dsl-extractor.test.ts +264 -0
- package/tests/dsl-feedback.test.ts +266 -0
- package/tests/dsl-validator.test.ts +283 -0
- package/tests/error-feedback.test.ts +292 -0
- package/tests/provider-utils.test.ts +173 -0
- package/tests/run-trend.test.ts +186 -0
- package/tests/self-evaluator.test.ts +339 -0
- package/tests/spec-assessor.test.ts +142 -0
- package/tests/task-generator.test.ts +230 -0
package/core/self-evaluator.ts
CHANGED
|
@@ -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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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
|
-
`
|
|
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.
|
|
303
|
+
console.log(chalk.cyan("─".repeat(49)));
|
|
172
304
|
}
|
package/core/spec-generator.ts
CHANGED
|
@@ -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
|
|
148
|
+
description: "智谱 AI — GLM-5.1 / GLM-5 / GLM-4 series",
|
|
149
149
|
models: [
|
|
150
|
-
"glm-5",
|
|
151
|
-
"glm-5
|
|
152
|
-
"glm-
|
|
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
|
package/core/task-generator.ts
CHANGED
|
@@ -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
|
+
}
|