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.
- package/.claude/settings.local.json +18 -0
- package/README.md +1211 -146
- package/RELEASE_LOG.md +1444 -0
- package/cli/index.ts +1961 -0
- package/cli/welcome.ts +151 -0
- package/core/code-generator.ts +740 -0
- package/core/combined-generator.ts +63 -0
- package/core/constitution-consolidator.ts +141 -0
- package/core/constitution-generator.ts +89 -0
- package/core/context-loader.ts +453 -0
- package/core/contract-bridge.ts +217 -0
- package/core/dsl-extractor.ts +337 -0
- package/core/dsl-types.ts +166 -0
- package/core/dsl-validator.ts +450 -0
- package/core/error-feedback.ts +354 -0
- package/core/frontend-context-loader.ts +602 -0
- package/core/global-constitution.ts +88 -0
- package/core/key-store.ts +49 -0
- package/core/knowledge-memory.ts +171 -0
- package/core/mock-server-generator.ts +571 -0
- package/core/openapi-exporter.ts +361 -0
- package/core/requirement-decomposer.ts +198 -0
- package/core/reviewer.ts +259 -0
- package/core/spec-assessor.ts +99 -0
- package/core/spec-generator.ts +428 -0
- package/core/spec-refiner.ts +89 -0
- package/core/spec-updater.ts +227 -0
- package/core/spec-versioning.ts +213 -0
- package/core/task-generator.ts +174 -0
- package/core/test-generator.ts +273 -0
- package/core/workspace-loader.ts +256 -0
- package/dist/cli/index.js +6717 -672
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +6717 -670
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +147 -27
- package/dist/index.d.ts +147 -27
- package/dist/index.js +2337 -286
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2329 -285
- package/dist/index.mjs.map +1 -1
- package/git/worktree.ts +109 -0
- package/index.ts +9 -0
- package/package.json +4 -28
- package/prompts/codegen.prompt.ts +259 -0
- package/prompts/consolidate.prompt.ts +73 -0
- package/prompts/constitution.prompt.ts +63 -0
- package/prompts/decompose.prompt.ts +168 -0
- package/prompts/dsl.prompt.ts +203 -0
- package/prompts/frontend-spec.prompt.ts +191 -0
- package/prompts/global-constitution.prompt.ts +61 -0
- package/prompts/spec-assess.prompt.ts +53 -0
- package/prompts/spec.prompt.ts +102 -0
- package/prompts/tasks.prompt.ts +35 -0
- package/prompts/testgen.prompt.ts +84 -0
- package/prompts/update.prompt.ts +131 -0
- package/purpose.docx +0 -0
- package/purpose.md +444 -0
- package/tsconfig.json +14 -0
- 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
|
+
}
|