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.
- 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 +11 -1
- package/README.md +531 -213
- package/RELEASE_LOG.md +424 -0
- package/cli/commands/config.ts +18 -0
- package/cli/commands/create.ts +1248 -0
- package/cli/commands/dashboard.ts +62 -0
- package/cli/commands/init.ts +45 -8
- package/cli/commands/mock.ts +175 -0
- package/cli/commands/scan.ts +99 -0
- package/cli/commands/types.ts +69 -0
- package/cli/commands/vcr.ts +70 -0
- package/cli/index.ts +34 -2517
- package/cli/utils.ts +4 -0
- package/core/code-generator.ts +6 -4
- package/core/combined-generator.ts +13 -3
- package/core/dashboard-generator.ts +340 -0
- package/core/design-dialogue.ts +124 -0
- package/core/dsl-extractor.ts +9 -1
- package/core/dsl-feedback.ts +41 -5
- package/core/dsl-validator.ts +32 -0
- package/core/error-feedback.ts +46 -2
- package/core/key-store.ts +5 -4
- package/core/project-index.ts +301 -0
- package/core/provider-utils.ts +39 -4
- package/core/reviewer.ts +84 -6
- package/core/run-logger.ts +109 -3
- package/core/run-trend.ts +24 -4
- package/core/self-evaluator.ts +39 -11
- 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 +7407 -5643
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +7401 -5637
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +34 -5
- package/dist/index.d.ts +34 -5
- package/dist/index.js +497 -232
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +495 -233
- 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 +1 -1
- 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/code-generator.test.ts +253 -0
- package/tests/context-loader.test.ts +207 -0
- package/tests/dsl-validator.test.ts +105 -0
- package/tests/openapi-exporter.test.ts +310 -0
- package/tests/reviewer.test.ts +214 -0
- package/tests/spec-generator.test.ts +228 -0
- package/tests/spec-versioning.test.ts +205 -0
package/core/dsl-validator.ts
CHANGED
|
@@ -74,6 +74,22 @@ export function validateDsl(raw: unknown): DslValidationResult {
|
|
|
74
74
|
for (let i = 0; i < Math.min(eps.length, MAX_ENDPOINTS); i++) {
|
|
75
75
|
validateEndpoint(eps[i], `endpoints[${i}]`, errors);
|
|
76
76
|
}
|
|
77
|
+
// ── Endpoint ID uniqueness ──────────────────────────────────────────────
|
|
78
|
+
const seenEpIds = new Set<string>();
|
|
79
|
+
for (let i = 0; i < Math.min(eps.length, MAX_ENDPOINTS); i++) {
|
|
80
|
+
const ep = eps[i] as Record<string, unknown> | null;
|
|
81
|
+
if (ep && typeof ep === "object" && typeof ep["id"] === "string") {
|
|
82
|
+
const id = ep["id"] as string;
|
|
83
|
+
if (seenEpIds.has(id)) {
|
|
84
|
+
errors.push({
|
|
85
|
+
path: `endpoints[${i}].id`,
|
|
86
|
+
message: `Duplicate endpoint id "${id}" — each endpoint must have a unique id`,
|
|
87
|
+
});
|
|
88
|
+
} else {
|
|
89
|
+
seenEpIds.add(id);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
77
93
|
}
|
|
78
94
|
|
|
79
95
|
// ── behaviors (optional, but must be array if present) ────────────────────
|
|
@@ -149,6 +165,22 @@ function validateModel(
|
|
|
149
165
|
for (let j = 0; j < Math.min(fields.length, MAX_FIELDS_PER_MODEL); j++) {
|
|
150
166
|
validateModelField(fields[j], `${path}.fields[${j}]`, errors);
|
|
151
167
|
}
|
|
168
|
+
// ── Field name uniqueness within model ──────────────────────────────────
|
|
169
|
+
const seenFieldNames = new Set<string>();
|
|
170
|
+
for (let j = 0; j < Math.min(fields.length, MAX_FIELDS_PER_MODEL); j++) {
|
|
171
|
+
const f = fields[j] as Record<string, unknown> | null;
|
|
172
|
+
if (f && typeof f === "object" && typeof f["name"] === "string") {
|
|
173
|
+
const name = f["name"] as string;
|
|
174
|
+
if (seenFieldNames.has(name)) {
|
|
175
|
+
errors.push({
|
|
176
|
+
path: `${path}.fields[${j}].name`,
|
|
177
|
+
message: `Duplicate field name "${name}" — each field within a model must have a unique name`,
|
|
178
|
+
});
|
|
179
|
+
} else {
|
|
180
|
+
seenFieldNames.add(name);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
152
184
|
}
|
|
153
185
|
|
|
154
186
|
// relations: optional array of strings
|
package/core/error-feedback.ts
CHANGED
|
@@ -22,6 +22,22 @@ interface FixResult {
|
|
|
22
22
|
explanation: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// ─── Budgets ────────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Maximum characters captured from a single command's output before parsing.
|
|
29
|
+
* ~10K tokens — enough for any realistic error listing; prevents a pathological
|
|
30
|
+
* build output (e.g. 10MB of warnings) from ballooning the AI context.
|
|
31
|
+
*/
|
|
32
|
+
const MAX_COMMAND_OUTPUT_CHARS = 50_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Maximum characters of an existing file sent to the AI for auto-fix.
|
|
36
|
+
* ~12K tokens — covers large files; content beyond this is truncated with a
|
|
37
|
+
* notice so the AI knows it may be seeing an incomplete file.
|
|
38
|
+
*/
|
|
39
|
+
const MAX_FIX_FILE_CHARS = 60_000;
|
|
40
|
+
|
|
25
41
|
// ─── Error Detection ────────────────────────────────────────────────────────────
|
|
26
42
|
|
|
27
43
|
function runCommand(cmd: string, cwd: string): { success: boolean; output: string } {
|
|
@@ -30,7 +46,13 @@ function runCommand(cmd: string, cwd: string): { success: boolean; output: strin
|
|
|
30
46
|
return { success: true, output };
|
|
31
47
|
} catch (err) {
|
|
32
48
|
const e = err as { stdout?: string; stderr?: string; message?: string };
|
|
33
|
-
|
|
49
|
+
const raw = e.stdout || e.stderr || e.message || "";
|
|
50
|
+
// Apply output budget: cap before parsing to prevent huge outputs from
|
|
51
|
+
// filling up the AI context on subsequent fix cycles.
|
|
52
|
+
const output = raw.length > MAX_COMMAND_OUTPUT_CHARS
|
|
53
|
+
? raw.slice(0, MAX_COMMAND_OUTPUT_CHARS) + `\n... [output truncated at ${MAX_COMMAND_OUTPUT_CHARS} chars]`
|
|
54
|
+
: raw;
|
|
55
|
+
return { success: false, output };
|
|
34
56
|
}
|
|
35
57
|
}
|
|
36
58
|
|
|
@@ -328,6 +350,13 @@ async function attemptFix(
|
|
|
328
350
|
const dslSection = dsl ? `\n${buildDslContextSection(dsl)}\n` : "";
|
|
329
351
|
const errorSummary = fileErrors.map((e) => `[${e.source}] ${e.message}`).join("\n");
|
|
330
352
|
|
|
353
|
+
// Apply file content budget — very large files are truncated with a notice.
|
|
354
|
+
// The AI still has enough context to fix the errors (which reference specific lines).
|
|
355
|
+
const fileContent = existingContent.length > MAX_FIX_FILE_CHARS
|
|
356
|
+
? existingContent.slice(0, MAX_FIX_FILE_CHARS) +
|
|
357
|
+
`\n\n// ... [file truncated at ${MAX_FIX_FILE_CHARS} chars — fix only the error lines above]`
|
|
358
|
+
: existingContent;
|
|
359
|
+
|
|
331
360
|
const prompt = `Fix the following errors in the file.
|
|
332
361
|
|
|
333
362
|
File: ${file}
|
|
@@ -336,7 +365,7 @@ ${dslSection}
|
|
|
336
365
|
${errorSummary}
|
|
337
366
|
|
|
338
367
|
=== Current File Content ===
|
|
339
|
-
${
|
|
368
|
+
${fileContent}
|
|
340
369
|
|
|
341
370
|
Output ONLY the complete fixed file content. No markdown fences, no explanations.`;
|
|
342
371
|
|
|
@@ -394,6 +423,8 @@ export async function runErrorFeedback(
|
|
|
394
423
|
|
|
395
424
|
if (buildCmd) console.log(chalk.gray(` Type-check: ${buildCmd}`));
|
|
396
425
|
|
|
426
|
+
let prevErrorCount = Infinity; // circuit-breaker: tracks error count from previous cycle
|
|
427
|
+
|
|
397
428
|
for (let cycle = 1; cycle <= maxCycles; cycle++) {
|
|
398
429
|
const allErrors: ErrorEntry[] = [];
|
|
399
430
|
|
|
@@ -462,6 +493,19 @@ export async function runErrorFeedback(
|
|
|
462
493
|
return true;
|
|
463
494
|
}
|
|
464
495
|
|
|
496
|
+
// Circuit breaker: if the fix cycle made no progress (error count did not
|
|
497
|
+
// decrease), stop immediately rather than spending another AI cycle.
|
|
498
|
+
if (allErrors.length >= prevErrorCount) {
|
|
499
|
+
console.log(
|
|
500
|
+
chalk.yellow(
|
|
501
|
+
`\n ⚠ Auto-fix made no progress (${allErrors.length} error(s) before and after). Stopping early.`
|
|
502
|
+
)
|
|
503
|
+
);
|
|
504
|
+
console.log(chalk.gray(" Manual intervention needed."));
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
prevErrorCount = allErrors.length;
|
|
508
|
+
|
|
465
509
|
if (cycle < maxCycles) {
|
|
466
510
|
console.log(chalk.cyan(`\n Attempting auto-fix (${allErrors.length} error(s))...`));
|
|
467
511
|
await attemptFix(provider, allErrors, workingDir, dsl);
|
package/core/key-store.ts
CHANGED
|
@@ -11,16 +11,17 @@ async function readStore(): Promise<KeyStore> {
|
|
|
11
11
|
if (await fs.pathExists(KEY_STORE_FILE)) {
|
|
12
12
|
return await fs.readJson(KEY_STORE_FILE);
|
|
13
13
|
}
|
|
14
|
-
} catch {
|
|
15
|
-
|
|
14
|
+
} catch (err) {
|
|
15
|
+
console.warn(`Warning: Could not read key store at ${KEY_STORE_FILE}: ${(err as Error).message}. Using empty store.`);
|
|
16
16
|
}
|
|
17
17
|
return {};
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
async function writeStore(store: KeyStore): Promise<void> {
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
// Ensure file exists with restricted permissions BEFORE writing sensitive data
|
|
22
|
+
await fs.ensureFile(KEY_STORE_FILE);
|
|
23
23
|
await fs.chmod(KEY_STORE_FILE, 0o600);
|
|
24
|
+
await fs.writeJson(KEY_STORE_FILE, store, { spaces: 2 });
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
export async function getSavedKey(provider: string): Promise<string | undefined> {
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* project-index.ts — Persistent project discovery & index.
|
|
3
|
+
*
|
|
4
|
+
* Scans a root directory for sub-projects (any dir with a recognisable
|
|
5
|
+
* project manifest), and maintains an incremental JSON index file at
|
|
6
|
+
* .ai-spec-index.json in the scan root.
|
|
7
|
+
*
|
|
8
|
+
* Incremental rules:
|
|
9
|
+
* - New project found → added with firstSeen = now
|
|
10
|
+
* - Existing project → techStack / type / role / hasConstitution refreshed, lastSeen = now
|
|
11
|
+
* - Previously indexed but directory gone → marked missing:true, NOT deleted
|
|
12
|
+
*
|
|
13
|
+
* The index is intentionally lightweight — no AI calls, pure filesystem scan.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as fs from "fs-extra";
|
|
17
|
+
import * as path from "path";
|
|
18
|
+
import { detectRepoType, RepoType, RepoRole, WORKSPACE_CONFIG_FILE } from "./workspace-loader";
|
|
19
|
+
import { CONSTITUTION_FILE } from "./constitution-generator";
|
|
20
|
+
|
|
21
|
+
export const INDEX_FILE = ".ai-spec-index.json";
|
|
22
|
+
|
|
23
|
+
// ─── Key dependency lists for tech-stack extraction ──────────────────────────
|
|
24
|
+
|
|
25
|
+
const KEY_DEPS: string[] = [
|
|
26
|
+
// Frameworks
|
|
27
|
+
"express", "fastify", "koa", "@nestjs/core", "hapi",
|
|
28
|
+
"next", "react", "vue", "nuxt", "svelte",
|
|
29
|
+
"react-native", "expo",
|
|
30
|
+
// DB / ORM
|
|
31
|
+
"prisma", "@prisma/client", "mongoose", "typeorm", "sequelize", "drizzle-orm",
|
|
32
|
+
// Auth
|
|
33
|
+
"jsonwebtoken", "passport", "next-auth", "@clerk/nextjs",
|
|
34
|
+
// Build / Lang
|
|
35
|
+
"typescript", "vite", "webpack", "esbuild", "turbo",
|
|
36
|
+
// Testing
|
|
37
|
+
"jest", "vitest", "mocha", "cypress", "playwright",
|
|
38
|
+
// Infra
|
|
39
|
+
"redis", "bull", "socket.io", "graphql", "@trpc/server",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
export interface ProjectEntry {
|
|
45
|
+
/** Directory name */
|
|
46
|
+
name: string;
|
|
47
|
+
/** Path relative to scanRoot */
|
|
48
|
+
path: string;
|
|
49
|
+
type: RepoType;
|
|
50
|
+
role: RepoRole;
|
|
51
|
+
/** Key dependencies detected (subset of package.json deps or language markers) */
|
|
52
|
+
techStack: string[];
|
|
53
|
+
/** Whether .ai-spec-constitution.md exists */
|
|
54
|
+
hasConstitution: boolean;
|
|
55
|
+
/** Whether .ai-spec-workspace.json exists (this repo is a workspace root) */
|
|
56
|
+
hasWorkspace: boolean;
|
|
57
|
+
/** ISO timestamp of first discovery */
|
|
58
|
+
firstSeen: string;
|
|
59
|
+
/** ISO timestamp of last successful scan */
|
|
60
|
+
lastSeen: string;
|
|
61
|
+
/** true when the directory no longer exists on disk */
|
|
62
|
+
missing?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ProjectIndex {
|
|
66
|
+
/** Absolute path of the directory that was scanned */
|
|
67
|
+
scanRoot: string;
|
|
68
|
+
/** ISO timestamp of last scan */
|
|
69
|
+
lastScanned: string;
|
|
70
|
+
projects: ProjectEntry[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/** Directories to always skip during scan */
|
|
76
|
+
const SKIP_DIRS = new Set([
|
|
77
|
+
"node_modules", ".git", ".svn", "dist", "build", "out", ".next",
|
|
78
|
+
".nuxt", "coverage", ".turbo", ".cache", "__pycache__", "vendor",
|
|
79
|
+
".ai-spec-vcr", ".ai-spec-logs", "specs",
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
/** Manifest files that identify a directory as a project root */
|
|
83
|
+
const MANIFEST_FILES = [
|
|
84
|
+
"package.json",
|
|
85
|
+
"go.mod",
|
|
86
|
+
"Cargo.toml",
|
|
87
|
+
"pom.xml",
|
|
88
|
+
"build.gradle",
|
|
89
|
+
"build.gradle.kts",
|
|
90
|
+
"requirements.txt",
|
|
91
|
+
"pyproject.toml",
|
|
92
|
+
"setup.py",
|
|
93
|
+
"composer.json",
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
async function isProjectRoot(absPath: string): Promise<boolean> {
|
|
97
|
+
for (const manifest of MANIFEST_FILES) {
|
|
98
|
+
if (await fs.pathExists(path.join(absPath, manifest))) return true;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function extractTechStack(absPath: string, type: RepoType): Promise<string[]> {
|
|
104
|
+
const stack: string[] = [];
|
|
105
|
+
|
|
106
|
+
// Language marker for non-Node projects
|
|
107
|
+
if (type === "go") stack.push("go");
|
|
108
|
+
if (type === "rust") stack.push("rust");
|
|
109
|
+
if (type === "java") stack.push("java");
|
|
110
|
+
if (type === "python") stack.push("python");
|
|
111
|
+
if (type === "php") stack.push("php");
|
|
112
|
+
|
|
113
|
+
const pkgPath = path.join(absPath, "package.json");
|
|
114
|
+
if (!(await fs.pathExists(pkgPath))) return stack;
|
|
115
|
+
|
|
116
|
+
let pkg: Record<string, unknown> = {};
|
|
117
|
+
try { pkg = await fs.readJson(pkgPath); } catch { return stack; }
|
|
118
|
+
|
|
119
|
+
const allDeps = {
|
|
120
|
+
...((pkg.dependencies as Record<string, string>) ?? {}),
|
|
121
|
+
...((pkg.devDependencies as Record<string, string>) ?? {}),
|
|
122
|
+
};
|
|
123
|
+
const depKeys = new Set(Object.keys(allDeps));
|
|
124
|
+
|
|
125
|
+
for (const dep of KEY_DEPS) {
|
|
126
|
+
if (depKeys.has(dep)) stack.push(dep);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return stack;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Scan ─────────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Discover all project roots under `rootDir` up to `maxDepth` levels deep.
|
|
136
|
+
* Returns paths relative to rootDir.
|
|
137
|
+
*/
|
|
138
|
+
async function discoverProjects(
|
|
139
|
+
rootDir: string,
|
|
140
|
+
maxDepth: number
|
|
141
|
+
): Promise<string[]> {
|
|
142
|
+
const found: string[] = [];
|
|
143
|
+
|
|
144
|
+
async function walk(absDir: string, depth: number): Promise<void> {
|
|
145
|
+
if (depth > maxDepth) return;
|
|
146
|
+
|
|
147
|
+
let entries: fs.Dirent[];
|
|
148
|
+
try {
|
|
149
|
+
entries = await fs.readdir(absDir, { withFileTypes: true });
|
|
150
|
+
} catch {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
if (!entry.isDirectory()) continue;
|
|
156
|
+
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
157
|
+
|
|
158
|
+
const childAbs = path.join(absDir, entry.name);
|
|
159
|
+
|
|
160
|
+
// Skip git worktrees — they have a .git *file* (not directory)
|
|
161
|
+
const gitPath = path.join(childAbs, ".git");
|
|
162
|
+
if (await fs.pathExists(gitPath)) {
|
|
163
|
+
const gitStat = await fs.stat(gitPath);
|
|
164
|
+
if (gitStat.isFile()) continue; // git worktree — skip
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (await isProjectRoot(childAbs)) {
|
|
168
|
+
found.push(path.relative(rootDir, childAbs));
|
|
169
|
+
// Don't recurse into a project root — avoids picking up nested node_modules etc.
|
|
170
|
+
} else {
|
|
171
|
+
await walk(childAbs, depth + 1);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
await walk(rootDir, 0);
|
|
177
|
+
return found;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── Index load / save ────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
export async function loadIndex(scanRoot: string): Promise<ProjectIndex | null> {
|
|
183
|
+
const filePath = path.join(scanRoot, INDEX_FILE);
|
|
184
|
+
try {
|
|
185
|
+
return await fs.readJson(filePath);
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function saveIndex(scanRoot: string, index: ProjectIndex): Promise<string> {
|
|
192
|
+
const filePath = path.join(scanRoot, INDEX_FILE);
|
|
193
|
+
await fs.writeJson(filePath, index, { spaces: 2 });
|
|
194
|
+
return filePath;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── Incremental merge ────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
export interface ScanResult {
|
|
200
|
+
index: ProjectIndex;
|
|
201
|
+
added: ProjectEntry[];
|
|
202
|
+
updated: ProjectEntry[];
|
|
203
|
+
unchanged: ProjectEntry[];
|
|
204
|
+
nowMissing: ProjectEntry[];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Run an incremental scan of `scanRoot`, merge with the existing index, and
|
|
209
|
+
* return the updated index along with a change summary.
|
|
210
|
+
*/
|
|
211
|
+
export async function runScan(
|
|
212
|
+
scanRoot: string,
|
|
213
|
+
maxDepth = 2
|
|
214
|
+
): Promise<ScanResult> {
|
|
215
|
+
const now = new Date().toISOString();
|
|
216
|
+
const existing = await loadIndex(scanRoot);
|
|
217
|
+
const existingMap = new Map<string, ProjectEntry>(
|
|
218
|
+
(existing?.projects ?? []).map((p) => [p.path, p])
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const discoveredPaths = await discoverProjects(scanRoot, maxDepth);
|
|
222
|
+
|
|
223
|
+
const added: ProjectEntry[] = [];
|
|
224
|
+
const updated: ProjectEntry[] = [];
|
|
225
|
+
const unchanged: ProjectEntry[] = [];
|
|
226
|
+
const seenPaths = new Set<string>();
|
|
227
|
+
|
|
228
|
+
for (const relPath of discoveredPaths) {
|
|
229
|
+
const absPath = path.join(scanRoot, relPath);
|
|
230
|
+
seenPaths.add(relPath);
|
|
231
|
+
|
|
232
|
+
const { type, role } = await detectRepoType(absPath);
|
|
233
|
+
const techStack = await extractTechStack(absPath, type);
|
|
234
|
+
const hasConstitution = await fs.pathExists(path.join(absPath, CONSTITUTION_FILE));
|
|
235
|
+
const hasWorkspace = await fs.pathExists(path.join(absPath, WORKSPACE_CONFIG_FILE));
|
|
236
|
+
const name = path.basename(relPath);
|
|
237
|
+
|
|
238
|
+
const prev = existingMap.get(relPath);
|
|
239
|
+
if (!prev) {
|
|
240
|
+
const entry: ProjectEntry = {
|
|
241
|
+
name,
|
|
242
|
+
path: relPath,
|
|
243
|
+
type,
|
|
244
|
+
role,
|
|
245
|
+
techStack,
|
|
246
|
+
hasConstitution,
|
|
247
|
+
hasWorkspace,
|
|
248
|
+
firstSeen: now,
|
|
249
|
+
lastSeen: now,
|
|
250
|
+
};
|
|
251
|
+
added.push(entry);
|
|
252
|
+
existingMap.set(relPath, entry);
|
|
253
|
+
} else {
|
|
254
|
+
// Check if anything changed
|
|
255
|
+
const changed =
|
|
256
|
+
prev.type !== type ||
|
|
257
|
+
prev.role !== role ||
|
|
258
|
+
prev.hasConstitution !== hasConstitution ||
|
|
259
|
+
prev.hasWorkspace !== hasWorkspace ||
|
|
260
|
+
JSON.stringify(prev.techStack.sort()) !== JSON.stringify(techStack.sort());
|
|
261
|
+
|
|
262
|
+
const entry: ProjectEntry = {
|
|
263
|
+
...prev,
|
|
264
|
+
type,
|
|
265
|
+
role,
|
|
266
|
+
techStack,
|
|
267
|
+
hasConstitution,
|
|
268
|
+
hasWorkspace,
|
|
269
|
+
lastSeen: now,
|
|
270
|
+
missing: undefined, // clear missing flag if it came back
|
|
271
|
+
};
|
|
272
|
+
existingMap.set(relPath, entry);
|
|
273
|
+
|
|
274
|
+
if (changed) {
|
|
275
|
+
updated.push(entry);
|
|
276
|
+
} else {
|
|
277
|
+
unchanged.push(entry);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Mark previously known projects as missing if their directory is gone
|
|
283
|
+
const nowMissing: ProjectEntry[] = [];
|
|
284
|
+
for (const [relPath, entry] of existingMap) {
|
|
285
|
+
if (!seenPaths.has(relPath) && !entry.missing) {
|
|
286
|
+
const gone: ProjectEntry = { ...entry, missing: true };
|
|
287
|
+
existingMap.set(relPath, gone);
|
|
288
|
+
nowMissing.push(gone);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const projects = [...existingMap.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
293
|
+
|
|
294
|
+
const index: ProjectIndex = {
|
|
295
|
+
scanRoot,
|
|
296
|
+
lastScanned: now,
|
|
297
|
+
projects,
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
return { index, added, updated, unchanged, nowMissing };
|
|
301
|
+
}
|
package/core/provider-utils.ts
CHANGED
|
@@ -22,14 +22,49 @@ function classifyError(err: unknown, label: string): ProviderError {
|
|
|
22
22
|
const status = e.status ?? e.response?.status;
|
|
23
23
|
|
|
24
24
|
if (status === 401 || status === 403)
|
|
25
|
-
return new ProviderError(
|
|
25
|
+
return new ProviderError(
|
|
26
|
+
`Auth error (${label}): API key is invalid or expired.\n` +
|
|
27
|
+
` → Check that the correct API key is set in your environment or ~/.ai-spec-keys.json\n` +
|
|
28
|
+
` → Run "ai-spec model" to reconfigure your provider and key`,
|
|
29
|
+
"auth", err
|
|
30
|
+
);
|
|
26
31
|
if (status === 429)
|
|
27
|
-
return new ProviderError(
|
|
32
|
+
return new ProviderError(
|
|
33
|
+
`Rate limit hit (${label}): too many requests.\n` +
|
|
34
|
+
` → Wait a few minutes and retry, or switch to a different provider/model\n` +
|
|
35
|
+
` → Check your provider's billing dashboard for quota status`,
|
|
36
|
+
"rate_limit", err
|
|
37
|
+
);
|
|
28
38
|
if ((e as Error & { _timeout?: boolean })._timeout || e.message?.toLowerCase().includes("timed out"))
|
|
29
39
|
return new ProviderError(`Request timed out (${label})`, "timeout", err);
|
|
30
40
|
if (e.code === "ECONNRESET" || e.code === "ENOTFOUND" || e.code === "ECONNREFUSED")
|
|
31
|
-
return new ProviderError(
|
|
32
|
-
|
|
41
|
+
return new ProviderError(
|
|
42
|
+
`Network error (${label}): ${e.message}\n` +
|
|
43
|
+
` → Check your internet connection and proxy settings (HTTPS_PROXY)\n` +
|
|
44
|
+
` → If behind a firewall, ensure the provider's API endpoint is reachable`,
|
|
45
|
+
"network", err
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Check for common model-not-found errors
|
|
49
|
+
const msg = e.message ?? "";
|
|
50
|
+
if (status === 404 || msg.includes("model") && (msg.includes("not found") || msg.includes("does not exist")))
|
|
51
|
+
return new ProviderError(
|
|
52
|
+
`Model not found (${label}): ${msg}\n` +
|
|
53
|
+
` → Run "ai-spec model" to see available models for your provider\n` +
|
|
54
|
+
` → The model name may have changed — check your provider's documentation`,
|
|
55
|
+
"provider", err
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// Check for insufficient balance / quota exhaustion
|
|
59
|
+
if (msg.includes("insufficient") || msg.includes("quota") || msg.includes("balance"))
|
|
60
|
+
return new ProviderError(
|
|
61
|
+
`Quota/balance error (${label}): ${msg}\n` +
|
|
62
|
+
` → Check your provider's billing dashboard\n` +
|
|
63
|
+
` → Consider switching to a different provider with "ai-spec model"`,
|
|
64
|
+
"provider", err
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return new ProviderError(`Provider error (${label}): ${msg}`, "provider", err);
|
|
33
68
|
}
|
|
34
69
|
|
|
35
70
|
function isRetryable(err: unknown): boolean {
|
package/core/reviewer.ts
CHANGED
|
@@ -4,10 +4,37 @@ import * as path from "path";
|
|
|
4
4
|
import * as fs from "fs-extra";
|
|
5
5
|
import { AIProvider } from "./spec-generator";
|
|
6
6
|
import {
|
|
7
|
+
specComplianceSystemPrompt,
|
|
7
8
|
reviewArchitectureSystemPrompt,
|
|
8
9
|
reviewImplementationSystemPrompt,
|
|
9
10
|
reviewImpactComplexitySystemPrompt,
|
|
10
11
|
} from "../prompts/codegen.prompt";
|
|
12
|
+
import { CONSTITUTION_FILE } from "./constitution-generator";
|
|
13
|
+
|
|
14
|
+
// ─── Constitution Lessons Helper ──────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extract the §9 accumulated lessons section from a constitution file.
|
|
18
|
+
* Returns null if the section is absent or the file cannot be read.
|
|
19
|
+
*/
|
|
20
|
+
async function loadAccumulatedLessons(projectRoot: string): Promise<string | null> {
|
|
21
|
+
const constitutionPath = path.join(projectRoot, CONSTITUTION_FILE);
|
|
22
|
+
let content: string;
|
|
23
|
+
try {
|
|
24
|
+
content = await fs.readFile(constitutionPath, "utf-8");
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const marker = "## 9. 积累教训";
|
|
29
|
+
const idx = content.indexOf(marker);
|
|
30
|
+
if (idx === -1) return null;
|
|
31
|
+
// Extract from §9 header to end of file (or next top-level section)
|
|
32
|
+
const section = content.slice(idx);
|
|
33
|
+
const nextSection = section.slice(marker.length).match(/\n## \d/);
|
|
34
|
+
return nextSection
|
|
35
|
+
? section.slice(0, marker.length + nextSection.index!)
|
|
36
|
+
: section;
|
|
37
|
+
}
|
|
11
38
|
|
|
12
39
|
// ─── Review History ────────────────────────────────────────────────────────────
|
|
13
40
|
|
|
@@ -15,6 +42,7 @@ interface ReviewHistoryEntry {
|
|
|
15
42
|
date: string;
|
|
16
43
|
specFile: string;
|
|
17
44
|
score: number;
|
|
45
|
+
complianceScore?: number;
|
|
18
46
|
topIssues: string[];
|
|
19
47
|
impactLevel?: "低" | "中" | "高";
|
|
20
48
|
complexityLevel?: "低" | "中" | "高";
|
|
@@ -55,6 +83,18 @@ function extractScore(reviewText: string): number {
|
|
|
55
83
|
return match ? parseFloat(match[1]) : 0;
|
|
56
84
|
}
|
|
57
85
|
|
|
86
|
+
/** Extract compliance score from Pass 0 output (looks for "ComplianceScore: X/10") */
|
|
87
|
+
export function extractComplianceScore(complianceText: string): number {
|
|
88
|
+
const match = complianceText.match(/ComplianceScore:\s*(\d+(?:\.\d+)?)\s*\/\s*10/i);
|
|
89
|
+
return match ? parseFloat(match[1]) : 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Count missing requirements from Pass 0 output */
|
|
93
|
+
export function extractMissingCount(complianceText: string): number {
|
|
94
|
+
const summaryMatch = complianceText.match(/Missing:\s*(\d+)/i);
|
|
95
|
+
return summaryMatch ? parseInt(summaryMatch[1], 10) : 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
58
98
|
/** Extract impact level from Pass 3 review ("影响等级:低/中/高") */
|
|
59
99
|
function extractImpactLevel(reviewText: string): "低" | "中" | "高" | undefined {
|
|
60
100
|
const match = reviewText.match(/影响等级[::]\s*(低|中|高)/);
|
|
@@ -126,8 +166,9 @@ export class CodeReviewer {
|
|
|
126
166
|
}
|
|
127
167
|
|
|
128
168
|
/**
|
|
129
|
-
*
|
|
130
|
-
* Pass
|
|
169
|
+
* Four-pass review:
|
|
170
|
+
* Pass 0 — spec compliance (exhaustive requirement coverage audit)
|
|
171
|
+
* Pass 1 — architecture (layer separation, contract design, auth posture)
|
|
131
172
|
* Pass 2 — implementation details (validation, error handling, edge cases)
|
|
132
173
|
* + historical issue recurrence check
|
|
133
174
|
* Pass 3 — impact assessment + code complexity
|
|
@@ -137,11 +178,43 @@ export class CodeReviewer {
|
|
|
137
178
|
codeContext: string,
|
|
138
179
|
specFile?: string
|
|
139
180
|
): Promise<string> {
|
|
140
|
-
|
|
181
|
+
// ── Pass 0: Spec Compliance (skip if no spec provided) ───────────────────
|
|
182
|
+
let complianceReview = "";
|
|
183
|
+
if (specContent && specContent.trim() && specContent !== "(No spec — review for general code quality)") {
|
|
184
|
+
console.log(chalk.gray(" Pass 0/3: Spec compliance check..."));
|
|
185
|
+
const compliancePrompt = `Check whether the implementation covers every requirement in the spec.
|
|
141
186
|
|
|
142
|
-
|
|
143
|
-
|
|
187
|
+
=== Feature Spec ===
|
|
188
|
+
${specContent}
|
|
189
|
+
|
|
190
|
+
=== Code ===
|
|
191
|
+
${codeContext}`;
|
|
192
|
+
complianceReview = await this.provider.generate(compliancePrompt, specComplianceSystemPrompt);
|
|
193
|
+
|
|
194
|
+
// Surface compliance score immediately
|
|
195
|
+
const complianceScore = extractComplianceScore(complianceReview);
|
|
196
|
+
const missingCount = extractMissingCount(complianceReview);
|
|
197
|
+
if (complianceScore > 0) {
|
|
198
|
+
const scoreColor = complianceScore >= 8 ? chalk.green : complianceScore >= 6 ? chalk.yellow : chalk.red;
|
|
199
|
+
console.log(
|
|
200
|
+
chalk.gray(" Pass 0 result: ") +
|
|
201
|
+
scoreColor(`ComplianceScore ${complianceScore}/10`) +
|
|
202
|
+
(missingCount > 0 ? chalk.red(` · ${missingCount} missing requirement(s)`) : chalk.green(" · all requirements covered"))
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
144
206
|
|
|
207
|
+
console.log(chalk.gray(` Pass 1/3: Architecture review...`));
|
|
208
|
+
|
|
209
|
+
// ── Pass 1: Architecture (+ §9 lessons cross-check) ──────────────────────
|
|
210
|
+
const accumulatedLessons = await loadAccumulatedLessons(this.projectRoot);
|
|
211
|
+
const archPrompt = `Review the architecture of this change.
|
|
212
|
+
${complianceReview
|
|
213
|
+
? `\n=== Spec Compliance Report (Pass 0 — already audited, do NOT re-audit missing requirements) ===\n${complianceReview}\n`
|
|
214
|
+
: ""}
|
|
215
|
+
${accumulatedLessons
|
|
216
|
+
? `\n=== §9 历史积累教训 (Accumulated Lessons — check if any are repeated in this code) ===\n${accumulatedLessons}\n`
|
|
217
|
+
: ""}
|
|
145
218
|
=== Feature Spec ===
|
|
146
219
|
${specContent || "(No spec — review for general code quality)"}
|
|
147
220
|
|
|
@@ -189,10 +262,14 @@ ${implReview}`;
|
|
|
189
262
|
|
|
190
263
|
// ── Combine ───────────────────────────────────────────────────────────────
|
|
191
264
|
const sep = "─".repeat(52);
|
|
192
|
-
const
|
|
265
|
+
const parts = complianceReview
|
|
266
|
+
? [complianceReview, archReview, implReview, impactReview]
|
|
267
|
+
: [archReview, implReview, impactReview];
|
|
268
|
+
const combined = parts.join(`\n\n${sep}\n\n`);
|
|
193
269
|
|
|
194
270
|
// ── Persist history ───────────────────────────────────────────────────────
|
|
195
271
|
const score = extractScore(implReview) || extractScore(archReview);
|
|
272
|
+
const complianceScore = extractComplianceScore(complianceReview);
|
|
196
273
|
const topIssues = extractTopIssues(implReview);
|
|
197
274
|
const impactLevel = extractImpactLevel(impactReview);
|
|
198
275
|
const complexityLevel = extractComplexityLevel(impactReview);
|
|
@@ -201,6 +278,7 @@ ${implReview}`;
|
|
|
201
278
|
date: new Date().toISOString().slice(0, 10),
|
|
202
279
|
specFile: path.relative(this.projectRoot, specFile),
|
|
203
280
|
score,
|
|
281
|
+
...(complianceScore > 0 ? { complianceScore } : {}),
|
|
204
282
|
topIssues,
|
|
205
283
|
...(impactLevel ? { impactLevel } : {}),
|
|
206
284
|
...(complexityLevel ? { complexityLevel } : {}),
|