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,602 @@
|
|
|
1
|
+
import * as fs from "fs-extra";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { glob } from "glob";
|
|
4
|
+
|
|
5
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export type FrontendFramework = "react" | "next" | "vue" | "react-native" | "unknown";
|
|
8
|
+
|
|
9
|
+
export interface FrontendContext {
|
|
10
|
+
framework: FrontendFramework;
|
|
11
|
+
/** e.g. ['zustand', 'redux', 'jotai'] */
|
|
12
|
+
stateManagement: string[];
|
|
13
|
+
/** e.g. 'axios', 'fetch', 'swr', 'react-query' */
|
|
14
|
+
httpClient: string;
|
|
15
|
+
/** e.g. 'tailwind', 'antd', 'shadcn' */
|
|
16
|
+
uiLibrary: string;
|
|
17
|
+
/** e.g. 'react-router', 'next-app-router' */
|
|
18
|
+
routingPattern: string;
|
|
19
|
+
/** Whether tests use React Testing Library */
|
|
20
|
+
testFramework: "rtl" | "cypress" | "jest" | "vitest" | "unknown";
|
|
21
|
+
/** api/ or services/ file paths */
|
|
22
|
+
existingApiFiles: string[];
|
|
23
|
+
/** First 60 lines of up to 2 existing API wrapper files */
|
|
24
|
+
apiWrapperContent: string[];
|
|
25
|
+
/** Custom hook files (use*.ts/tsx) — relative paths */
|
|
26
|
+
hookFiles: string[];
|
|
27
|
+
/** First 30 lines of up to 2 hook files */
|
|
28
|
+
hookPatterns: string[];
|
|
29
|
+
/** State slice / store files — relative paths */
|
|
30
|
+
storeFiles: string[];
|
|
31
|
+
/** First 60 lines of up to 2 existing store files — shows the real store pattern */
|
|
32
|
+
storePatterns: string[];
|
|
33
|
+
/**
|
|
34
|
+
* The exact HTTP client import line found in an existing API file.
|
|
35
|
+
* e.g. "import request from '@/utils/http'"
|
|
36
|
+
* Extracted from real code — use this verbatim, never invent a different import path.
|
|
37
|
+
*/
|
|
38
|
+
httpClientImport?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Reusable components found in src/components/ — relative paths.
|
|
41
|
+
* AI must check this list before creating any new component.
|
|
42
|
+
*/
|
|
43
|
+
reusableComponents: string[];
|
|
44
|
+
/**
|
|
45
|
+
* First 60 lines of up to 2 existing page/view files.
|
|
46
|
+
* Shows how UI library components and shared components are actually imported and used.
|
|
47
|
+
*/
|
|
48
|
+
pageExamples: string[];
|
|
49
|
+
/** Sample component structures (first 40 lines of up to 3 components) */
|
|
50
|
+
componentPatterns: string[];
|
|
51
|
+
/**
|
|
52
|
+
* The exact layout component import line found in an existing route module.
|
|
53
|
+
* e.g. "const Layout = () => import('@/layout/index.vue')"
|
|
54
|
+
* Extracted from real code — must be copied verbatim when creating new route modules.
|
|
55
|
+
*/
|
|
56
|
+
layoutImport?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Full content of one existing route module file — use as a copy-paste template.
|
|
59
|
+
* Relative path + content.
|
|
60
|
+
*/
|
|
61
|
+
routeModuleExample?: { path: string; content: string };
|
|
62
|
+
/**
|
|
63
|
+
* A real paginated API function extracted from the existing codebase.
|
|
64
|
+
* Shows the exact pagination parameter names (e.g. pageIndex/pageSize vs page/size)
|
|
65
|
+
* and how they are passed (POST body vs GET query params).
|
|
66
|
+
* COPY THIS PATTERN exactly for all new paginated list APIs.
|
|
67
|
+
*/
|
|
68
|
+
paginationExample?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Detection Maps ────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
const STATE_MANAGEMENT_LIBS = [
|
|
74
|
+
"zustand",
|
|
75
|
+
"redux",
|
|
76
|
+
"@reduxjs/toolkit",
|
|
77
|
+
"jotai",
|
|
78
|
+
"recoil",
|
|
79
|
+
"mobx",
|
|
80
|
+
"mobx-react",
|
|
81
|
+
"valtio",
|
|
82
|
+
"pinia",
|
|
83
|
+
"vuex",
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const HTTP_CLIENT_LIBS: Array<[string, string]> = [
|
|
87
|
+
["swr", "swr"],
|
|
88
|
+
["@tanstack/react-query", "react-query"],
|
|
89
|
+
["react-query", "react-query"],
|
|
90
|
+
["axios", "axios"],
|
|
91
|
+
["ky", "ky"],
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
const UI_LIBRARY_LIBS: Array<[string, string]> = [
|
|
95
|
+
["antd", "antd"],
|
|
96
|
+
["@ant-design/pro-components", "antd-pro"],
|
|
97
|
+
["@mui/material", "mui"],
|
|
98
|
+
["@chakra-ui/react", "chakra-ui"],
|
|
99
|
+
["shadcn-ui", "shadcn"],
|
|
100
|
+
["@radix-ui/react-primitive", "radix-ui"],
|
|
101
|
+
["element-plus", "element-plus"],
|
|
102
|
+
["vant", "vant"],
|
|
103
|
+
["tailwindcss", "tailwind"],
|
|
104
|
+
["@tailwindcss/vite", "tailwind"],
|
|
105
|
+
["react-native-paper", "react-native-paper"],
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
const ROUTING_LIBS: Array<[string, string]> = [
|
|
109
|
+
["react-router-dom", "react-router"],
|
|
110
|
+
["react-router", "react-router"],
|
|
111
|
+
["@tanstack/react-router", "tanstack-router"],
|
|
112
|
+
["react-navigation", "react-navigation"],
|
|
113
|
+
["expo-router", "expo-router"],
|
|
114
|
+
["vue-router", "vue-router"],
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
// ─── Main function ─────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Load frontend-specific project context (framework, state mgmt, HTTP client, etc.)
|
|
121
|
+
* Never throws — returns partial results on failure.
|
|
122
|
+
*/
|
|
123
|
+
export async function loadFrontendContext(
|
|
124
|
+
projectRoot: string
|
|
125
|
+
): Promise<FrontendContext> {
|
|
126
|
+
const ctx: FrontendContext = {
|
|
127
|
+
framework: "unknown",
|
|
128
|
+
stateManagement: [],
|
|
129
|
+
httpClient: "fetch",
|
|
130
|
+
uiLibrary: "unknown",
|
|
131
|
+
routingPattern: "unknown",
|
|
132
|
+
testFramework: "unknown",
|
|
133
|
+
existingApiFiles: [],
|
|
134
|
+
apiWrapperContent: [],
|
|
135
|
+
hookFiles: [],
|
|
136
|
+
hookPatterns: [],
|
|
137
|
+
storeFiles: [],
|
|
138
|
+
storePatterns: [],
|
|
139
|
+
reusableComponents: [],
|
|
140
|
+
pageExamples: [],
|
|
141
|
+
componentPatterns: [],
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const pkgPath = path.join(projectRoot, "package.json");
|
|
146
|
+
if (!(await fs.pathExists(pkgPath))) return ctx;
|
|
147
|
+
|
|
148
|
+
const pkg = await fs.readJson(pkgPath);
|
|
149
|
+
const allDeps: Record<string, string> = {
|
|
150
|
+
...(pkg.dependencies ?? {}),
|
|
151
|
+
...(pkg.devDependencies ?? {}),
|
|
152
|
+
};
|
|
153
|
+
const depKeys = Object.keys(allDeps);
|
|
154
|
+
const has = (name: string) => depKeys.includes(name);
|
|
155
|
+
|
|
156
|
+
// Framework
|
|
157
|
+
if (has("react-native") || has("expo")) {
|
|
158
|
+
ctx.framework = "react-native";
|
|
159
|
+
} else if (has("next")) {
|
|
160
|
+
ctx.framework = "next";
|
|
161
|
+
} else if (has("react")) {
|
|
162
|
+
ctx.framework = "react";
|
|
163
|
+
} else if (has("vue")) {
|
|
164
|
+
ctx.framework = "vue";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// State management (may have multiple)
|
|
168
|
+
ctx.stateManagement = STATE_MANAGEMENT_LIBS.filter((lib) => has(lib));
|
|
169
|
+
|
|
170
|
+
// HTTP client (first match wins)
|
|
171
|
+
for (const [lib, label] of HTTP_CLIENT_LIBS) {
|
|
172
|
+
if (has(lib)) {
|
|
173
|
+
ctx.httpClient = label;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// UI library (first match wins)
|
|
179
|
+
for (const [lib, label] of UI_LIBRARY_LIBS) {
|
|
180
|
+
if (has(lib)) {
|
|
181
|
+
ctx.uiLibrary = label;
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (ctx.uiLibrary === "unknown") {
|
|
186
|
+
ctx.uiLibrary = "none";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Routing
|
|
190
|
+
if (ctx.framework === "next") {
|
|
191
|
+
// Detect app router vs pages router
|
|
192
|
+
const hasAppDir = await fs.pathExists(path.join(projectRoot, "app"));
|
|
193
|
+
ctx.routingPattern = hasAppDir ? "next-app-router" : "next-pages-router";
|
|
194
|
+
} else {
|
|
195
|
+
for (const [lib, label] of ROUTING_LIBS) {
|
|
196
|
+
if (has(lib)) {
|
|
197
|
+
ctx.routingPattern = label;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Test framework detection
|
|
204
|
+
if (depKeys.includes("@testing-library/react") || depKeys.includes("@testing-library/vue")) {
|
|
205
|
+
ctx.testFramework = "rtl";
|
|
206
|
+
} else if (depKeys.includes("cypress")) {
|
|
207
|
+
ctx.testFramework = "cypress";
|
|
208
|
+
} else if (depKeys.includes("vitest")) {
|
|
209
|
+
ctx.testFramework = "vitest";
|
|
210
|
+
} else if (depKeys.includes("jest") || depKeys.includes("@jest/core")) {
|
|
211
|
+
ctx.testFramework = "jest";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Existing API files
|
|
215
|
+
const apiFilePatterns = [
|
|
216
|
+
"src/api/**/*.{ts,js}",
|
|
217
|
+
"src/apis/**/*.{ts,js}",
|
|
218
|
+
"src/services/**/*.{ts,js}",
|
|
219
|
+
"src/lib/api/**/*.{ts,js}",
|
|
220
|
+
"src/utils/api/**/*.{ts,js}",
|
|
221
|
+
"api/**/*.{ts,js}",
|
|
222
|
+
"services/**/*.{ts,js}",
|
|
223
|
+
];
|
|
224
|
+
for (const pattern of apiFilePatterns) {
|
|
225
|
+
const files = await glob(pattern, {
|
|
226
|
+
cwd: projectRoot,
|
|
227
|
+
ignore: ["**/*.test.*", "**/*.spec.*", "node_modules/**"],
|
|
228
|
+
});
|
|
229
|
+
ctx.existingApiFiles.push(...files);
|
|
230
|
+
}
|
|
231
|
+
ctx.existingApiFiles = [...new Set(ctx.existingApiFiles)].slice(0, 20);
|
|
232
|
+
|
|
233
|
+
// API wrapper content preview — first 60 lines of up to 2 files
|
|
234
|
+
for (const relPath of ctx.existingApiFiles.slice(0, 2)) {
|
|
235
|
+
try {
|
|
236
|
+
const content = await fs.readFile(path.join(projectRoot, relPath), "utf-8");
|
|
237
|
+
const preview = content.split("\n").slice(0, 60).join("\n");
|
|
238
|
+
ctx.apiWrapperContent.push(`// ${relPath}\n${preview}`);
|
|
239
|
+
} catch {
|
|
240
|
+
// skip
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Hook files — use*.ts/tsx
|
|
245
|
+
const hookPatterns = [
|
|
246
|
+
"src/hooks/use*.{ts,tsx}",
|
|
247
|
+
"src/**/hooks/use*.{ts,tsx}",
|
|
248
|
+
"hooks/use*.{ts,tsx}",
|
|
249
|
+
];
|
|
250
|
+
for (const pattern of hookPatterns) {
|
|
251
|
+
const files = await glob(pattern, {
|
|
252
|
+
cwd: projectRoot,
|
|
253
|
+
ignore: ["**/*.test.*", "**/*.spec.*", "node_modules/**"],
|
|
254
|
+
});
|
|
255
|
+
ctx.hookFiles.push(...files);
|
|
256
|
+
}
|
|
257
|
+
ctx.hookFiles = [...new Set(ctx.hookFiles)].slice(0, 15);
|
|
258
|
+
|
|
259
|
+
// Hook content preview — first 30 lines of up to 2 hook files
|
|
260
|
+
for (const relPath of ctx.hookFiles.slice(0, 2)) {
|
|
261
|
+
try {
|
|
262
|
+
const content = await fs.readFile(path.join(projectRoot, relPath), "utf-8");
|
|
263
|
+
const preview = content.split("\n").slice(0, 30).join("\n");
|
|
264
|
+
ctx.hookPatterns.push(`// ${relPath}\n${preview}`);
|
|
265
|
+
} catch {
|
|
266
|
+
// skip
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Store / slice files (Redux slices, Zustand stores, Pinia stores)
|
|
271
|
+
const storeFilePatterns = [
|
|
272
|
+
"src/store/**/*.{ts,js}",
|
|
273
|
+
"src/stores/**/*.{ts,js}",
|
|
274
|
+
"src/**/slice*.{ts,js}",
|
|
275
|
+
"src/**/*slice.{ts,js}",
|
|
276
|
+
"src/**/*store.{ts,js}",
|
|
277
|
+
"src/**/*Store.{ts,js}",
|
|
278
|
+
"store/**/*.{ts,js}",
|
|
279
|
+
"stores/**/*.{ts,js}",
|
|
280
|
+
];
|
|
281
|
+
for (const pattern of storeFilePatterns) {
|
|
282
|
+
const files = await glob(pattern, {
|
|
283
|
+
cwd: projectRoot,
|
|
284
|
+
ignore: ["**/*.test.*", "**/*.spec.*", "node_modules/**"],
|
|
285
|
+
});
|
|
286
|
+
ctx.storeFiles.push(...files);
|
|
287
|
+
}
|
|
288
|
+
ctx.storeFiles = [...new Set(ctx.storeFiles)].slice(0, 10);
|
|
289
|
+
|
|
290
|
+
// Store content preview — first 60 lines of up to 2 store files
|
|
291
|
+
// (shows the AI what stores actually do: state + actions that call API layer, NOT HTTP directly)
|
|
292
|
+
for (const relPath of ctx.storeFiles.slice(0, 2)) {
|
|
293
|
+
try {
|
|
294
|
+
const content = await fs.readFile(path.join(projectRoot, relPath), "utf-8");
|
|
295
|
+
const preview = content.split("\n").slice(0, 60).join("\n");
|
|
296
|
+
ctx.storePatterns.push(`// ${relPath}\n${preview}`);
|
|
297
|
+
} catch {
|
|
298
|
+
// skip
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Extract the exact HTTP client import line from an existing API file
|
|
303
|
+
// e.g. "import request from '@/utils/http'" or "import axios from 'axios'"
|
|
304
|
+
// This is ground truth — prevents the AI from inventing a different import path.
|
|
305
|
+
const httpImportRegex = /^import\s+(?:\w+|\{[^}]+\})\s+from\s+['"](@\/[^'"]+|axios|ky)['"]/m;
|
|
306
|
+
for (const relPath of ctx.existingApiFiles.slice(0, 5)) {
|
|
307
|
+
try {
|
|
308
|
+
const content = await fs.readFile(path.join(projectRoot, relPath), "utf-8");
|
|
309
|
+
const match = content.match(httpImportRegex);
|
|
310
|
+
if (match) {
|
|
311
|
+
ctx.httpClientImport = match[0].trim();
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
} catch {
|
|
315
|
+
// skip
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Pagination pattern extraction — find a real paginated list function from the existing codebase.
|
|
320
|
+
// Scans API files for interfaces/params that contain pagination field names,
|
|
321
|
+
// then extracts the matching function as ground truth for the AI to copy.
|
|
322
|
+
const paginationFieldNames = ["pageIndex", "pageSize", "pageNum", "current", "page", "size", "offset", "limit"];
|
|
323
|
+
const paginationInterfaceRegex = new RegExp(
|
|
324
|
+
`(?:interface|type)\\s+(\\w*(?:Params|Query|Request|Filter|Page)\\w*)\\s*\\{[^}]*\\b(?:${paginationFieldNames.join("|")})\\b[^}]*\\}`,
|
|
325
|
+
"s"
|
|
326
|
+
);
|
|
327
|
+
const apiExportFnRegex = /export\s+(?:async\s+)?function\s+\w+\s*\([^)]*\)[^{]*\{[\s\S]*?\n\}/g;
|
|
328
|
+
for (const relPath of ctx.existingApiFiles) {
|
|
329
|
+
// Skip type-only and index files
|
|
330
|
+
if (/types?\.ts$|index\.ts$/.test(relPath)) continue;
|
|
331
|
+
try {
|
|
332
|
+
const content = await fs.readFile(path.join(projectRoot, relPath), "utf-8");
|
|
333
|
+
// Only process files that have pagination field names at all
|
|
334
|
+
if (!paginationFieldNames.some((f) => content.includes(f))) continue;
|
|
335
|
+
// Check this file has an interface with pagination fields
|
|
336
|
+
const interfaceMatch = content.match(paginationInterfaceRegex);
|
|
337
|
+
if (!interfaceMatch) continue;
|
|
338
|
+
// Find the first exported function that uses this interface
|
|
339
|
+
const interfaceName = interfaceMatch[1];
|
|
340
|
+
const fnRegex = new RegExp(
|
|
341
|
+
`export\\s+(?:async\\s+)?function\\s+\\w+\\s*\\(\\s*\\w+\\s*:\\s*${interfaceName}[^)]*\\)[\\s\\S]*?\\n\\}`,
|
|
342
|
+
""
|
|
343
|
+
);
|
|
344
|
+
const fnMatch = content.match(fnRegex);
|
|
345
|
+
if (fnMatch) {
|
|
346
|
+
ctx.paginationExample = `// From ${relPath}\n${interfaceMatch[0]}\n\n${fnMatch[0]}`;
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
} catch {
|
|
350
|
+
// skip
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Reusable components — scan src/components/ (the shared component library)
|
|
355
|
+
const sharedComponentDirs = ["src/components", "components"];
|
|
356
|
+
for (const dir of sharedComponentDirs) {
|
|
357
|
+
const absDir = path.join(projectRoot, dir);
|
|
358
|
+
if (!(await fs.pathExists(absDir))) continue;
|
|
359
|
+
const files = await glob("**/*.{vue,tsx,jsx}", {
|
|
360
|
+
cwd: absDir,
|
|
361
|
+
ignore: ["**/*.test.*", "**/*.spec.*", "node_modules/**"],
|
|
362
|
+
maxDepth: 4,
|
|
363
|
+
});
|
|
364
|
+
ctx.reusableComponents.push(...files.map((f) => path.join(dir, f)));
|
|
365
|
+
}
|
|
366
|
+
ctx.reusableComponents = [...new Set(ctx.reusableComponents)].slice(0, 40);
|
|
367
|
+
|
|
368
|
+
// Page examples — read 2 existing view/page files to show component usage patterns
|
|
369
|
+
const viewDirs = ["src/views", "src/pages", "views", "pages"];
|
|
370
|
+
const viewFiles: string[] = [];
|
|
371
|
+
for (const dir of viewDirs) {
|
|
372
|
+
const absDir = path.join(projectRoot, dir);
|
|
373
|
+
if (!(await fs.pathExists(absDir))) continue;
|
|
374
|
+
const files = await glob("**/*.{vue,tsx,jsx}", {
|
|
375
|
+
cwd: absDir,
|
|
376
|
+
ignore: ["**/*.test.*", "**/*.spec.*", "node_modules/**"],
|
|
377
|
+
maxDepth: 3,
|
|
378
|
+
});
|
|
379
|
+
viewFiles.push(...files.map((f) => path.join(dir, f)));
|
|
380
|
+
if (viewFiles.length >= 6) break;
|
|
381
|
+
}
|
|
382
|
+
for (const relPath of viewFiles.slice(0, 2)) {
|
|
383
|
+
try {
|
|
384
|
+
const content = await fs.readFile(path.join(projectRoot, relPath), "utf-8");
|
|
385
|
+
// Read up to 80 lines — enough to see the template section with component usage
|
|
386
|
+
const preview = content.split("\n").slice(0, 80).join("\n");
|
|
387
|
+
ctx.pageExamples.push(`// ${relPath}\n${preview}`);
|
|
388
|
+
} catch {
|
|
389
|
+
// skip
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Component patterns — sample a few component files (generic structure reference)
|
|
394
|
+
const componentFiles: string[] = [];
|
|
395
|
+
for (const dir of sharedComponentDirs) {
|
|
396
|
+
const absDir = path.join(projectRoot, dir);
|
|
397
|
+
if (!(await fs.pathExists(absDir))) continue;
|
|
398
|
+
const files = await glob("**/*.{tsx,vue,jsx}", {
|
|
399
|
+
cwd: absDir,
|
|
400
|
+
ignore: ["**/*.test.*", "**/*.spec.*", "node_modules/**"],
|
|
401
|
+
maxDepth: 2,
|
|
402
|
+
});
|
|
403
|
+
componentFiles.push(...files.map((f) => path.join(dir, f)));
|
|
404
|
+
if (componentFiles.length >= 5) break;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Read first 40 lines of up to 3 component files
|
|
408
|
+
for (const relPath of componentFiles.slice(0, 3)) {
|
|
409
|
+
try {
|
|
410
|
+
const content = await fs.readFile(path.join(projectRoot, relPath), "utf-8");
|
|
411
|
+
const preview = content.split("\n").slice(0, 40).join("\n");
|
|
412
|
+
ctx.componentPatterns.push(`// ${relPath}\n${preview}`);
|
|
413
|
+
} catch {
|
|
414
|
+
// skip unreadable files
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// Route module example + layout import extraction
|
|
418
|
+
await extractRouteModuleContext(projectRoot, ctx);
|
|
419
|
+
|
|
420
|
+
} catch {
|
|
421
|
+
// Graceful degradation — return whatever we've collected so far
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return ctx;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Scan existing router module files to extract:
|
|
429
|
+
* 1. The exact layout component import line (ground truth, not guessed)
|
|
430
|
+
* 2. A full route module file as a copy-paste template
|
|
431
|
+
*/
|
|
432
|
+
async function extractRouteModuleContext(
|
|
433
|
+
projectRoot: string,
|
|
434
|
+
ctx: FrontendContext
|
|
435
|
+
): Promise<void> {
|
|
436
|
+
const modulePatterns = [
|
|
437
|
+
"src/router/modules/**/*.{ts,js}",
|
|
438
|
+
"src/routes/modules/**/*.{ts,js}",
|
|
439
|
+
"src/router/**/*.{ts,js}",
|
|
440
|
+
];
|
|
441
|
+
|
|
442
|
+
const moduleFiles: string[] = [];
|
|
443
|
+
for (const pattern of modulePatterns) {
|
|
444
|
+
const files = await glob(pattern, {
|
|
445
|
+
cwd: projectRoot,
|
|
446
|
+
ignore: ["**/index.{ts,js}", "node_modules/**", "**/*.test.*"],
|
|
447
|
+
});
|
|
448
|
+
moduleFiles.push(...files);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (moduleFiles.length === 0) return;
|
|
452
|
+
|
|
453
|
+
// Layout import regex — matches common patterns:
|
|
454
|
+
// const Layout = () => import('@/layout/index.vue')
|
|
455
|
+
// import Layout from '@/layouts/default/index.vue'
|
|
456
|
+
// const Layout = defineAsyncComponent(() => import('@/layout/index.vue'))
|
|
457
|
+
const layoutImportRegex =
|
|
458
|
+
/^(?:const\s+Layout\s*=.*import\(['"][^'"]+['"]\)|import\s+Layout\s+from\s+['"][^'"]+['"])/m;
|
|
459
|
+
|
|
460
|
+
for (const relPath of moduleFiles) {
|
|
461
|
+
try {
|
|
462
|
+
const content = await fs.readFile(path.join(projectRoot, relPath), "utf-8");
|
|
463
|
+
const match = content.match(layoutImportRegex);
|
|
464
|
+
if (match) {
|
|
465
|
+
ctx.layoutImport = match[0].trim();
|
|
466
|
+
// Use this file as the route module template (cap at 100 lines)
|
|
467
|
+
const preview = content.split("\n").slice(0, 100).join("\n");
|
|
468
|
+
ctx.routeModuleExample = { path: relPath, content: preview };
|
|
469
|
+
break; // first match is enough
|
|
470
|
+
}
|
|
471
|
+
} catch {
|
|
472
|
+
// skip
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Build a concise context section from FrontendContext for prompt injection.
|
|
479
|
+
*/
|
|
480
|
+
export function buildFrontendContextSection(ctx: FrontendContext): string {
|
|
481
|
+
const lines: string[] = [
|
|
482
|
+
"=== Frontend Project Context ===",
|
|
483
|
+
`Framework : ${ctx.framework}`,
|
|
484
|
+
`State Management : ${ctx.stateManagement.join(", ") || "none detected"}`,
|
|
485
|
+
`HTTP Client : ${ctx.httpClient}`,
|
|
486
|
+
`UI Library : ${ctx.uiLibrary}`,
|
|
487
|
+
`Routing : ${ctx.routingPattern}`,
|
|
488
|
+
`Test Framework : ${ctx.testFramework}`,
|
|
489
|
+
];
|
|
490
|
+
|
|
491
|
+
// Layout import — most critical for correct route module generation
|
|
492
|
+
if (ctx.layoutImport) {
|
|
493
|
+
lines.push(
|
|
494
|
+
`\nLayout component import (COPY THIS EXACTLY in every new route module — do NOT invent a different path):`,
|
|
495
|
+
` ${ctx.layoutImport}`
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Route module template — shows exact file structure to replicate
|
|
500
|
+
if (ctx.routeModuleExample) {
|
|
501
|
+
lines.push(
|
|
502
|
+
`\nExisting route module template (${ctx.routeModuleExample.path}) — use this as the structural template for new route modules:`,
|
|
503
|
+
"```",
|
|
504
|
+
ctx.routeModuleExample.content,
|
|
505
|
+
"```"
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (ctx.existingApiFiles.length > 0) {
|
|
510
|
+
lines.push(`\nExisting API/service files (${ctx.existingApiFiles.length}):`);
|
|
511
|
+
ctx.existingApiFiles.slice(0, 10).forEach((f) => lines.push(` - ${f}`));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// HTTP client import — must be copied verbatim
|
|
515
|
+
if (ctx.httpClientImport) {
|
|
516
|
+
lines.push(
|
|
517
|
+
`\nHTTP client import (COPY THIS EXACTLY in every new API file — do NOT import from any other path):`,
|
|
518
|
+
` ${ctx.httpClientImport}`
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Pagination example — the most critical ground truth for list APIs
|
|
523
|
+
if (ctx.paginationExample) {
|
|
524
|
+
lines.push(
|
|
525
|
+
`\nPagination pattern (COPY THIS EXACTLY for all paginated list APIs — use IDENTICAL parameter names, HTTP method, and call style):`,
|
|
526
|
+
"```typescript",
|
|
527
|
+
ctx.paginationExample,
|
|
528
|
+
"```"
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (ctx.apiWrapperContent.length > 0) {
|
|
533
|
+
lines.push(`\nAPI file patterns (new API functions must follow this exact structure):`);
|
|
534
|
+
ctx.apiWrapperContent.forEach((p) => {
|
|
535
|
+
lines.push("```");
|
|
536
|
+
lines.push(p);
|
|
537
|
+
lines.push("```");
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (ctx.hookFiles.length > 0) {
|
|
542
|
+
lines.push(`\nExisting custom hooks (${ctx.hookFiles.length}):`);
|
|
543
|
+
ctx.hookFiles.slice(0, 8).forEach((f) => lines.push(` - ${f}`));
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (ctx.hookPatterns.length > 0) {
|
|
547
|
+
lines.push(`\nHook patterns (follow same structure):`);
|
|
548
|
+
ctx.hookPatterns.forEach((p) => {
|
|
549
|
+
lines.push("```");
|
|
550
|
+
lines.push(p);
|
|
551
|
+
lines.push("```");
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (ctx.storeFiles.length > 0) {
|
|
556
|
+
lines.push(`\nState store files (${ctx.storeFiles.length}):`);
|
|
557
|
+
ctx.storeFiles.slice(0, 8).forEach((f) => lines.push(` - ${f}`));
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (ctx.storePatterns.length > 0) {
|
|
561
|
+
lines.push(
|
|
562
|
+
`\nExisting store patterns (CRITICAL — stores in this project call API layer functions, they do NOT make HTTP requests directly):`,
|
|
563
|
+
`Follow this exact structure for new stores:`
|
|
564
|
+
);
|
|
565
|
+
ctx.storePatterns.forEach((p) => {
|
|
566
|
+
lines.push("```");
|
|
567
|
+
lines.push(p);
|
|
568
|
+
lines.push("```");
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (ctx.reusableComponents.length > 0) {
|
|
573
|
+
lines.push(
|
|
574
|
+
`\nExisting reusable components in src/components/ (${ctx.reusableComponents.length} files):`,
|
|
575
|
+
`ALWAYS check this list before creating a new component. Import and reuse existing ones instead of reinventing.`
|
|
576
|
+
);
|
|
577
|
+
ctx.reusableComponents.forEach((f) => lines.push(` - ${f}`));
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (ctx.pageExamples.length > 0) {
|
|
581
|
+
lines.push(
|
|
582
|
+
`\nExisting page examples (shows which UI library components and shared components are used — follow the same import and usage patterns):`
|
|
583
|
+
);
|
|
584
|
+
ctx.pageExamples.forEach((p) => {
|
|
585
|
+
lines.push("```");
|
|
586
|
+
lines.push(p);
|
|
587
|
+
lines.push("```");
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (ctx.componentPatterns.length > 0) {
|
|
592
|
+
lines.push(`\nShared component structure patterns:`);
|
|
593
|
+
ctx.componentPatterns.forEach((p) => {
|
|
594
|
+
lines.push("```");
|
|
595
|
+
lines.push(p.slice(0, 500));
|
|
596
|
+
lines.push("```");
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
lines.push("=== End of Frontend Context ===");
|
|
601
|
+
return lines.join("\n");
|
|
602
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import * as fs from "fs-extra";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
|
|
5
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export const GLOBAL_CONSTITUTION_FILE = ".ai-spec-global-constitution.md";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Search order for global constitution:
|
|
11
|
+
* 1. Workspace root (for monorepo-level shared rules)
|
|
12
|
+
* 2. User home directory (for personal cross-project rules)
|
|
13
|
+
*/
|
|
14
|
+
const SEARCH_ROOTS = [
|
|
15
|
+
// Workspace root is injected at runtime — see loadGlobalConstitution()
|
|
16
|
+
os.homedir(),
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
// ─── Load ─────────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Search for a global constitution file.
|
|
23
|
+
* @param extraRoots Additional directories to check first (e.g. workspace root).
|
|
24
|
+
* Returns the content string, or null if not found anywhere.
|
|
25
|
+
*/
|
|
26
|
+
export async function loadGlobalConstitution(
|
|
27
|
+
extraRoots: string[] = []
|
|
28
|
+
): Promise<{ content: string; source: string } | null> {
|
|
29
|
+
const roots = [...extraRoots, ...SEARCH_ROOTS];
|
|
30
|
+
|
|
31
|
+
for (const root of roots) {
|
|
32
|
+
const filePath = path.join(root, GLOBAL_CONSTITUTION_FILE);
|
|
33
|
+
if (await fs.pathExists(filePath)) {
|
|
34
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
35
|
+
return { content, source: filePath };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── Merge ────────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Merge global and project constitutions.
|
|
46
|
+
*
|
|
47
|
+
* Injection order (from lowest to highest priority):
|
|
48
|
+
* 1. Global constitution — team/personal baseline rules
|
|
49
|
+
* 2. Project constitution — project-specific overrides (wins on conflict)
|
|
50
|
+
*
|
|
51
|
+
* The merged text is what gets injected into Spec/codegen prompts.
|
|
52
|
+
*/
|
|
53
|
+
export function mergeConstitutions(
|
|
54
|
+
globalContent: string,
|
|
55
|
+
projectContent: string | undefined
|
|
56
|
+
): string {
|
|
57
|
+
const parts: string[] = [
|
|
58
|
+
"<!-- BEGIN GLOBAL CONSTITUTION (team baseline — lower priority) -->",
|
|
59
|
+
globalContent.trim(),
|
|
60
|
+
"<!-- END GLOBAL CONSTITUTION -->",
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
if (projectContent && projectContent.trim()) {
|
|
64
|
+
parts.push(
|
|
65
|
+
"",
|
|
66
|
+
"<!-- BEGIN PROJECT CONSTITUTION (project-specific — HIGHER priority, overrides global) -->",
|
|
67
|
+
projectContent.trim(),
|
|
68
|
+
"<!-- END PROJECT CONSTITUTION -->"
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return parts.join("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Save ─────────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Write a global constitution to disk.
|
|
79
|
+
* @param targetDir Directory to write to. Defaults to user home directory.
|
|
80
|
+
*/
|
|
81
|
+
export async function saveGlobalConstitution(
|
|
82
|
+
content: string,
|
|
83
|
+
targetDir: string = os.homedir()
|
|
84
|
+
): Promise<string> {
|
|
85
|
+
const filePath = path.join(targetDir, GLOBAL_CONSTITUTION_FILE);
|
|
86
|
+
await fs.writeFile(filePath, content, "utf-8");
|
|
87
|
+
return filePath;
|
|
88
|
+
}
|