ai-spec-dev 0.46.0 → 0.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -30
- package/cli/commands/config.ts +129 -1
- package/cli/commands/create.ts +14 -0
- package/cli/commands/fix-history.ts +176 -0
- package/cli/commands/init.ts +36 -1
- package/cli/index.ts +2 -6
- package/cli/pipeline/helpers.ts +6 -0
- package/cli/pipeline/multi-repo.ts +291 -26
- package/cli/pipeline/single-repo.ts +103 -2
- package/cli/utils.ts +23 -0
- package/core/code-generator.ts +63 -14
- package/core/cross-stack-verifier.ts +395 -0
- package/core/fix-history.ts +333 -0
- package/core/import-fixer.ts +827 -0
- package/core/import-verifier.ts +569 -0
- package/core/knowledge-memory.ts +55 -6
- package/core/self-evaluator.ts +44 -7
- package/core/spec-generator.ts +3 -3
- package/core/types-generator.ts +2 -2
- package/dist/cli/index.js +3759 -2207
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +3747 -2195
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +14 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +249 -128
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +249 -128
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/tests/cross-stack-verifier.test.ts +301 -0
- package/tests/fix-history.test.ts +335 -0
- package/tests/import-fixer.test.ts +944 -0
- package/tests/import-verifier.test.ts +420 -0
- package/tests/knowledge-memory.test.ts +40 -0
- package/tests/self-evaluator.test.ts +97 -0
- package/cli/commands/model.ts +0 -152
- package/cli/commands/scan.ts +0 -99
- package/cli/commands/workspace.ts +0 -219
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
import * as fs from "fs-extra";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
|
|
5
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export interface ImportRef {
|
|
8
|
+
/** Raw module specifier as written in source: '@/apis/foo' */
|
|
9
|
+
source: string;
|
|
10
|
+
/** Absolute path to the resolved file (when resolution succeeded) */
|
|
11
|
+
resolvedPath?: string;
|
|
12
|
+
/** Names imported from this module (empty for side-effect or default-only) */
|
|
13
|
+
importedNames: string[];
|
|
14
|
+
/** True for `import type { ... }` */
|
|
15
|
+
isTypeOnly: boolean;
|
|
16
|
+
/** True when default import is present: `import X from '...'` */
|
|
17
|
+
hasDefault: boolean;
|
|
18
|
+
/** Default import local name (when hasDefault is true) */
|
|
19
|
+
defaultName?: string;
|
|
20
|
+
/** File where this import is declared (relative to repo root) */
|
|
21
|
+
file: string;
|
|
22
|
+
/** 1-indexed line number */
|
|
23
|
+
line: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface BrokenImport {
|
|
27
|
+
ref: ImportRef;
|
|
28
|
+
reason: "file_not_found" | "missing_export";
|
|
29
|
+
/** When reason === "missing_export": which named imports are missing */
|
|
30
|
+
missingExports?: string[];
|
|
31
|
+
/**
|
|
32
|
+
* When reason === "missing_export": the full list of names the target file
|
|
33
|
+
* DOES export. Used by import-fixer to detect rename-style fixes (e.g.
|
|
34
|
+
* import `{ Task }` but target exports `{ TaskItem }`).
|
|
35
|
+
*/
|
|
36
|
+
availableExports?: string[];
|
|
37
|
+
/** Suggestion for what file/path the AI may have intended */
|
|
38
|
+
suggestion?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ImportVerificationReport {
|
|
42
|
+
totalFiles: number;
|
|
43
|
+
totalImports: number;
|
|
44
|
+
/** Imports skipped because the source is an external package */
|
|
45
|
+
externalImports: number;
|
|
46
|
+
/** Imports successfully resolved + (when applicable) named exports validated */
|
|
47
|
+
matchedImports: number;
|
|
48
|
+
brokenImports: BrokenImport[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── tsconfig path alias resolution ───────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
interface PathAliases {
|
|
54
|
+
baseUrl: string;
|
|
55
|
+
/** Map: alias prefix (with trailing /*) → target prefix */
|
|
56
|
+
paths: Array<{ alias: string; target: string }>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Strip JSON-with-comments to plain JSON. Handles // line comments,
|
|
61
|
+
* /* block comments, and trailing commas.
|
|
62
|
+
*/
|
|
63
|
+
function stripJsonComments(src: string): string {
|
|
64
|
+
// remove /* ... */ comments
|
|
65
|
+
let out = src.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
66
|
+
// remove // ... line comments
|
|
67
|
+
out = out.replace(/(^|[^:])\/\/[^\n]*/g, "$1");
|
|
68
|
+
// remove trailing commas before } or ]
|
|
69
|
+
out = out.replace(/,(\s*[}\]])/g, "$1");
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Load path aliases from tsconfig.json / tsconfig.app.json / jsconfig.json.
|
|
75
|
+
* Falls back to a sensible default mapping `@/*` → `src/*` if no config found.
|
|
76
|
+
*/
|
|
77
|
+
export async function loadPathAliases(repoRoot: string): Promise<PathAliases> {
|
|
78
|
+
const candidates = ["tsconfig.json", "tsconfig.app.json", "jsconfig.json"];
|
|
79
|
+
for (const name of candidates) {
|
|
80
|
+
const p = path.join(repoRoot, name);
|
|
81
|
+
if (!(await fs.pathExists(p))) continue;
|
|
82
|
+
try {
|
|
83
|
+
const raw = await fs.readFile(p, "utf-8");
|
|
84
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
85
|
+
const cfg: any = JSON.parse(stripJsonComments(raw));
|
|
86
|
+
const baseUrl = cfg?.compilerOptions?.baseUrl ?? ".";
|
|
87
|
+
const paths = cfg?.compilerOptions?.paths ?? {};
|
|
88
|
+
const entries: Array<{ alias: string; target: string }> = [];
|
|
89
|
+
for (const [aliasKey, targets] of Object.entries(paths)) {
|
|
90
|
+
if (!Array.isArray(targets) || targets.length === 0) continue;
|
|
91
|
+
const target = String((targets as string[])[0]);
|
|
92
|
+
entries.push({ alias: aliasKey, target });
|
|
93
|
+
}
|
|
94
|
+
if (entries.length > 0) {
|
|
95
|
+
return { baseUrl, paths: entries };
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
// ignore parse errors, try next file
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Default fallback: most Vue/React projects use `@/*` → `src/*`
|
|
103
|
+
return {
|
|
104
|
+
baseUrl: ".",
|
|
105
|
+
paths: [{ alias: "@/*", target: "src/*" }],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve an import specifier to an absolute candidate path (without extension).
|
|
111
|
+
* Returns null if the specifier is an external package.
|
|
112
|
+
*/
|
|
113
|
+
export function resolveSpecifier(
|
|
114
|
+
specifier: string,
|
|
115
|
+
fromFileAbs: string,
|
|
116
|
+
repoRoot: string,
|
|
117
|
+
aliases: PathAliases
|
|
118
|
+
): string | null {
|
|
119
|
+
// Relative import
|
|
120
|
+
if (specifier.startsWith("./") || specifier.startsWith("../")) {
|
|
121
|
+
return path.resolve(path.dirname(fromFileAbs), specifier);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Absolute path (rare in source code, but handle it)
|
|
125
|
+
if (specifier.startsWith("/")) {
|
|
126
|
+
return specifier;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Alias-based import
|
|
130
|
+
for (const { alias, target } of aliases.paths) {
|
|
131
|
+
const aliasPrefix = alias.replace(/\*$/, "");
|
|
132
|
+
if (specifier.startsWith(aliasPrefix)) {
|
|
133
|
+
const remainder = specifier.slice(aliasPrefix.length);
|
|
134
|
+
const targetPrefix = target.replace(/\*$/, "");
|
|
135
|
+
const baseAbs = path.resolve(repoRoot, aliases.baseUrl);
|
|
136
|
+
return path.resolve(baseAbs, targetPrefix + remainder);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// External package — skip
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Try to resolve a candidate path to an actual file by trying common extensions
|
|
146
|
+
* and index files (Node-style resolution).
|
|
147
|
+
*/
|
|
148
|
+
const CANDIDATE_EXTENSIONS = [
|
|
149
|
+
"",
|
|
150
|
+
".ts", ".tsx", ".js", ".jsx", ".vue", ".mjs", ".mts", ".d.ts",
|
|
151
|
+
];
|
|
152
|
+
const INDEX_NAMES = ["index.ts", "index.tsx", "index.js", "index.jsx", "index.vue", "index.mjs"];
|
|
153
|
+
|
|
154
|
+
export async function resolveToActualFile(candidate: string): Promise<string | null> {
|
|
155
|
+
// 1. Try the path as-is with each extension
|
|
156
|
+
for (const ext of CANDIDATE_EXTENSIONS) {
|
|
157
|
+
const p = candidate + ext;
|
|
158
|
+
try {
|
|
159
|
+
const stat = await fs.stat(p);
|
|
160
|
+
if (stat.isFile()) return p;
|
|
161
|
+
} catch { /* not found */ }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 2. Try as a directory + index file
|
|
165
|
+
try {
|
|
166
|
+
const stat = await fs.stat(candidate);
|
|
167
|
+
if (stat.isDirectory()) {
|
|
168
|
+
for (const name of INDEX_NAMES) {
|
|
169
|
+
const p = path.join(candidate, name);
|
|
170
|
+
try {
|
|
171
|
+
if ((await fs.stat(p)).isFile()) return p;
|
|
172
|
+
} catch { /* not found */ }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} catch { /* not a directory */ }
|
|
176
|
+
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── Import statement parsing ─────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Extract `<script>` and `<script setup>` block contents from a Vue SFC.
|
|
184
|
+
* Returns an array of [startLine, content] pairs so line numbers stay aligned
|
|
185
|
+
* with the original file.
|
|
186
|
+
*/
|
|
187
|
+
function extractVueScriptBlocks(source: string): Array<{ startLine: number; content: string }> {
|
|
188
|
+
const blocks: Array<{ startLine: number; content: string }> = [];
|
|
189
|
+
const re = /<script[^>]*>([\s\S]*?)<\/script>/g;
|
|
190
|
+
let m: RegExpExecArray | null;
|
|
191
|
+
while ((m = re.exec(source)) !== null) {
|
|
192
|
+
const before = source.slice(0, m.index);
|
|
193
|
+
const startLine = before.split("\n").length;
|
|
194
|
+
// Skip the opening <script ...> tag itself (find first newline after match start)
|
|
195
|
+
const tagEnd = source.indexOf(">", m.index) + 1;
|
|
196
|
+
const contentLine = source.slice(0, tagEnd).split("\n").length;
|
|
197
|
+
blocks.push({ startLine: contentLine, content: m[1] });
|
|
198
|
+
}
|
|
199
|
+
return blocks;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Parse all `import ... from '...'` statements in a JS/TS source.
|
|
204
|
+
* Returns ImportRefs with line numbers (1-indexed) relative to `source`.
|
|
205
|
+
*/
|
|
206
|
+
export function parseImports(source: string, fileRel: string): ImportRef[] {
|
|
207
|
+
const refs: ImportRef[] = [];
|
|
208
|
+
const lines = source.split("\n");
|
|
209
|
+
|
|
210
|
+
// Walk line-by-line for accurate line numbers (handles multi-line imports too)
|
|
211
|
+
// by joining continuation lines until we close the import.
|
|
212
|
+
for (let i = 0; i < lines.length; i++) {
|
|
213
|
+
const line = lines[i];
|
|
214
|
+
const trimmed = line.trim();
|
|
215
|
+
if (!trimmed.startsWith("import ") && trimmed !== "import" && !trimmed.startsWith("import{") && !trimmed.startsWith("import(")) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Greedily collect lines until we find the matching `from '...'` or `from "..."` or end-of-statement
|
|
220
|
+
let block = line;
|
|
221
|
+
let j = i;
|
|
222
|
+
while (j < lines.length - 1 && !/from\s+['"`]/.test(block) && !/^\s*import\s+['"`]/.test(block)) {
|
|
223
|
+
j++;
|
|
224
|
+
block += "\n" + lines[j];
|
|
225
|
+
if (block.length > 2000) break; // safety
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Match: import ... from '...'
|
|
229
|
+
const fromMatch = block.match(
|
|
230
|
+
/^\s*import\s+(type\s+)?([^'"`]*?)\s+from\s+['"`]([^'"`]+)['"`]/
|
|
231
|
+
);
|
|
232
|
+
// Match: import '...' (side-effect)
|
|
233
|
+
const sideEffectMatch = block.match(/^\s*import\s+['"`]([^'"`]+)['"`]/);
|
|
234
|
+
|
|
235
|
+
if (fromMatch) {
|
|
236
|
+
const isTypeOnly = !!fromMatch[1];
|
|
237
|
+
const importClause = fromMatch[2].trim();
|
|
238
|
+
const sourceSpec = fromMatch[3];
|
|
239
|
+
const { defaultName, named } = parseImportClause(importClause);
|
|
240
|
+
refs.push({
|
|
241
|
+
source: sourceSpec,
|
|
242
|
+
importedNames: named,
|
|
243
|
+
isTypeOnly,
|
|
244
|
+
hasDefault: !!defaultName,
|
|
245
|
+
defaultName,
|
|
246
|
+
file: fileRel,
|
|
247
|
+
line: i + 1,
|
|
248
|
+
});
|
|
249
|
+
} else if (sideEffectMatch) {
|
|
250
|
+
refs.push({
|
|
251
|
+
source: sideEffectMatch[1],
|
|
252
|
+
importedNames: [],
|
|
253
|
+
isTypeOnly: false,
|
|
254
|
+
hasDefault: false,
|
|
255
|
+
file: fileRel,
|
|
256
|
+
line: i + 1,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
i = j;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return refs;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Parse the part between `import` and `from`.
|
|
268
|
+
*
|
|
269
|
+
* "X" → default X, named []
|
|
270
|
+
* "{ A, B as C }" → default undef, named [A, B] (original names, not local bindings)
|
|
271
|
+
* "X, { A, B }" → default X, named [A, B]
|
|
272
|
+
* "* as ns" → default undef, named [] (namespace, treat as default-like)
|
|
273
|
+
*
|
|
274
|
+
* IMPORTANT: For `{ A as B }`, we return `A` (the ORIGINAL exported name), not `B`
|
|
275
|
+
* (the local binding). This is because the verifier uses these names to validate
|
|
276
|
+
* against the target file's exports — and the target exports A, not B.
|
|
277
|
+
*/
|
|
278
|
+
function parseImportClause(clause: string): { defaultName?: string; named: string[] } {
|
|
279
|
+
const result: { defaultName?: string; named: string[] } = { named: [] };
|
|
280
|
+
if (!clause) return result;
|
|
281
|
+
|
|
282
|
+
// Strip namespace import — we treat it as opaque and don't validate names
|
|
283
|
+
if (/\*\s+as\s+\w+/.test(clause)) {
|
|
284
|
+
return result;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Split on the first { to separate default from named
|
|
288
|
+
const bracePos = clause.indexOf("{");
|
|
289
|
+
let defaultPart = "";
|
|
290
|
+
let namedPart = "";
|
|
291
|
+
if (bracePos === -1) {
|
|
292
|
+
defaultPart = clause.trim();
|
|
293
|
+
} else {
|
|
294
|
+
// Strip trailing whitespace + comma (e.g. "React, " → "React")
|
|
295
|
+
defaultPart = clause.slice(0, bracePos).trim().replace(/,\s*$/, "").trim();
|
|
296
|
+
const closing = clause.indexOf("}", bracePos);
|
|
297
|
+
namedPart = clause.slice(bracePos + 1, closing === -1 ? undefined : closing);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (defaultPart && /^\w+$/.test(defaultPart)) {
|
|
301
|
+
result.defaultName = defaultPart;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (namedPart) {
|
|
305
|
+
const names = namedPart
|
|
306
|
+
.split(",")
|
|
307
|
+
.map((s) => s.trim())
|
|
308
|
+
.filter(Boolean)
|
|
309
|
+
.map((entry) => {
|
|
310
|
+
// "A as B" → use A (the ORIGINAL exported name, for export validation)
|
|
311
|
+
const asMatch = entry.match(/^(?:type\s+)?(\w+)\s+as\s+(\w+)$/);
|
|
312
|
+
if (asMatch) return asMatch[1];
|
|
313
|
+
// "type A" → A (drop the type modifier)
|
|
314
|
+
return entry.replace(/^type\s+/, "").trim();
|
|
315
|
+
})
|
|
316
|
+
.filter((n) => /^\w+$/.test(n));
|
|
317
|
+
result.named = names;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return result;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ─── Export parsing (for named export validation) ─────────────────────────────
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Extract all named exports from a JS/TS source file.
|
|
327
|
+
* Used to verify that imports reference real exports.
|
|
328
|
+
*
|
|
329
|
+
* export const X → X
|
|
330
|
+
* export function X → X
|
|
331
|
+
* export class X → X
|
|
332
|
+
* export interface X → X
|
|
333
|
+
* export type X → X
|
|
334
|
+
* export enum X → X
|
|
335
|
+
* export { A, B as C } → A, C
|
|
336
|
+
* export * from 'foo' → __star__ (treated as wildcard, accepts anything)
|
|
337
|
+
* export default ... → default
|
|
338
|
+
*/
|
|
339
|
+
export function parseNamedExports(source: string): { names: Set<string>; hasWildcard: boolean; hasDefault: boolean } {
|
|
340
|
+
const names = new Set<string>();
|
|
341
|
+
let hasWildcard = false;
|
|
342
|
+
let hasDefault = false;
|
|
343
|
+
|
|
344
|
+
// export const|let|var|function|class|interface|type|enum NAME
|
|
345
|
+
const declRe = /\bexport\s+(?:async\s+)?(?:const|let|var|function\*?|class|interface|type|enum)\s+(\w+)/g;
|
|
346
|
+
let m: RegExpExecArray | null;
|
|
347
|
+
while ((m = declRe.exec(source)) !== null) {
|
|
348
|
+
names.add(m[1]);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// export { A, B as C, type D }
|
|
352
|
+
const blockRe = /\bexport\s*(?:type\s*)?\{([^}]*)\}/g;
|
|
353
|
+
while ((m = blockRe.exec(source)) !== null) {
|
|
354
|
+
const inner = m[1];
|
|
355
|
+
for (const part of inner.split(",")) {
|
|
356
|
+
const t = part.trim();
|
|
357
|
+
if (!t) continue;
|
|
358
|
+
const asMatch = t.match(/^(?:type\s+)?(\w+)\s+as\s+(\w+)$/);
|
|
359
|
+
if (asMatch) {
|
|
360
|
+
names.add(asMatch[2]);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const plain = t.replace(/^type\s+/, "").match(/^(\w+)/);
|
|
364
|
+
if (plain) names.add(plain[1]);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// export * from 'foo'
|
|
369
|
+
if (/\bexport\s*\*\s*from\s+['"`]/.test(source)) {
|
|
370
|
+
hasWildcard = true;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// export default ...
|
|
374
|
+
if (/\bexport\s+default\b/.test(source)) {
|
|
375
|
+
hasDefault = true;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return { names, hasWildcard, hasDefault };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ─── Verification ─────────────────────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Verify all imports in the given files actually resolve to existing files
|
|
385
|
+
* and reference real exports.
|
|
386
|
+
*
|
|
387
|
+
* @param files Absolute paths of files to check (typically the freshly
|
|
388
|
+
* generated files from the codegen run).
|
|
389
|
+
* @param repoRoot Absolute path to the repo root (used for tsconfig + alias).
|
|
390
|
+
*/
|
|
391
|
+
export async function verifyImports(
|
|
392
|
+
files: string[],
|
|
393
|
+
repoRoot: string
|
|
394
|
+
): Promise<ImportVerificationReport> {
|
|
395
|
+
const aliases = await loadPathAliases(repoRoot);
|
|
396
|
+
|
|
397
|
+
let totalImports = 0;
|
|
398
|
+
let externalImports = 0;
|
|
399
|
+
let matchedImports = 0;
|
|
400
|
+
const broken: BrokenImport[] = [];
|
|
401
|
+
// Cache parsed exports per resolved file path
|
|
402
|
+
const exportsCache = new Map<string, ReturnType<typeof parseNamedExports>>();
|
|
403
|
+
|
|
404
|
+
// Build a set of generated file paths (resolved) so cross-file imports
|
|
405
|
+
// between fresh files can validate against each other even before they're
|
|
406
|
+
// physically written to disk in the same scan.
|
|
407
|
+
const generatedFileSet = new Set(files.map((f) => path.resolve(f)));
|
|
408
|
+
|
|
409
|
+
for (const fileAbs of files) {
|
|
410
|
+
let src: string;
|
|
411
|
+
try {
|
|
412
|
+
src = await fs.readFile(fileAbs, "utf-8");
|
|
413
|
+
} catch {
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const fileRel = path.relative(repoRoot, fileAbs);
|
|
417
|
+
|
|
418
|
+
// For .vue files, only parse imports inside <script> blocks
|
|
419
|
+
let refs: ImportRef[];
|
|
420
|
+
if (fileAbs.endsWith(".vue")) {
|
|
421
|
+
refs = [];
|
|
422
|
+
for (const block of extractVueScriptBlocks(src)) {
|
|
423
|
+
const blockRefs = parseImports(block.content, fileRel);
|
|
424
|
+
// Adjust line numbers to match the original file
|
|
425
|
+
for (const r of blockRefs) {
|
|
426
|
+
r.line = block.startLine + r.line - 1;
|
|
427
|
+
}
|
|
428
|
+
refs.push(...blockRefs);
|
|
429
|
+
}
|
|
430
|
+
} else {
|
|
431
|
+
refs = parseImports(src, fileRel);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
for (const ref of refs) {
|
|
435
|
+
totalImports++;
|
|
436
|
+
|
|
437
|
+
const candidate = resolveSpecifier(ref.source, fileAbs, repoRoot, aliases);
|
|
438
|
+
if (candidate === null) {
|
|
439
|
+
externalImports++;
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const resolved = await resolveToActualFile(candidate);
|
|
444
|
+
if (!resolved) {
|
|
445
|
+
broken.push({
|
|
446
|
+
ref,
|
|
447
|
+
reason: "file_not_found",
|
|
448
|
+
suggestion: `expected at: ${path.relative(repoRoot, candidate)}.{ts,tsx,js,jsx,vue} or ${path.relative(repoRoot, candidate)}/index.*`,
|
|
449
|
+
});
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
ref.resolvedPath = resolved;
|
|
454
|
+
|
|
455
|
+
// Validate named exports (skip when no named imports were used)
|
|
456
|
+
if (ref.importedNames.length > 0) {
|
|
457
|
+
let exports = exportsCache.get(resolved);
|
|
458
|
+
if (!exports) {
|
|
459
|
+
try {
|
|
460
|
+
const targetSrc = await fs.readFile(resolved, "utf-8");
|
|
461
|
+
// For .vue targets, parse exports from <script> blocks too
|
|
462
|
+
const sourceForExports = resolved.endsWith(".vue")
|
|
463
|
+
? extractVueScriptBlocks(targetSrc).map((b) => b.content).join("\n")
|
|
464
|
+
: targetSrc;
|
|
465
|
+
exports = parseNamedExports(sourceForExports);
|
|
466
|
+
exportsCache.set(resolved, exports);
|
|
467
|
+
} catch {
|
|
468
|
+
exports = { names: new Set(), hasWildcard: false, hasDefault: false };
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// If the target re-exports from another module via `export *`, we can't
|
|
473
|
+
// be sure what's actually exported without recursive resolution.
|
|
474
|
+
// Treat wildcard exports as "trust the import" to avoid false positives.
|
|
475
|
+
if (!exports.hasWildcard) {
|
|
476
|
+
const missing = ref.importedNames.filter((n) => !exports!.names.has(n));
|
|
477
|
+
if (missing.length > 0) {
|
|
478
|
+
broken.push({
|
|
479
|
+
ref,
|
|
480
|
+
reason: "missing_export",
|
|
481
|
+
missingExports: missing,
|
|
482
|
+
availableExports: [...exports.names],
|
|
483
|
+
suggestion: exports.names.size > 0
|
|
484
|
+
? `available exports: ${[...exports.names].slice(0, 8).join(", ")}${exports.names.size > 8 ? ", ..." : ""}`
|
|
485
|
+
: "target file has no named exports",
|
|
486
|
+
});
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
matchedImports++;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return {
|
|
497
|
+
totalFiles: files.length,
|
|
498
|
+
totalImports,
|
|
499
|
+
externalImports,
|
|
500
|
+
matchedImports,
|
|
501
|
+
brokenImports: broken,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ─── Display ──────────────────────────────────────────────────────────────────
|
|
506
|
+
|
|
507
|
+
export function printImportVerificationReport(
|
|
508
|
+
repoName: string,
|
|
509
|
+
report: ImportVerificationReport
|
|
510
|
+
): void {
|
|
511
|
+
console.log(chalk.cyan(`\n─── Import Verification [${repoName}] ─────────────────────────`));
|
|
512
|
+
console.log(
|
|
513
|
+
chalk.gray(
|
|
514
|
+
` Scanned ${report.totalFiles} generated file(s), checked ${report.totalImports} import(s)`
|
|
515
|
+
)
|
|
516
|
+
);
|
|
517
|
+
console.log(
|
|
518
|
+
chalk.gray(
|
|
519
|
+
` External (skipped): ${report.externalImports} · Internal verified: ${report.matchedImports}/${report.totalImports - report.externalImports}`
|
|
520
|
+
)
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
if (report.brokenImports.length === 0) {
|
|
524
|
+
console.log(chalk.green(` ✔ All imports resolve correctly — 0 broken references.`));
|
|
525
|
+
console.log(chalk.cyan("─".repeat(65)));
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
console.log(chalk.red(`\n ❌ ${report.brokenImports.length} broken import(s):`));
|
|
530
|
+
const grouped = new Map<string, BrokenImport[]>();
|
|
531
|
+
for (const b of report.brokenImports) {
|
|
532
|
+
const key = b.ref.file;
|
|
533
|
+
if (!grouped.has(key)) grouped.set(key, []);
|
|
534
|
+
grouped.get(key)!.push(b);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
let shownFiles = 0;
|
|
538
|
+
for (const [file, items] of grouped) {
|
|
539
|
+
if (shownFiles >= 10) {
|
|
540
|
+
console.log(chalk.gray(` ... and ${grouped.size - shownFiles} more file(s) with broken imports`));
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
console.log(chalk.yellow(` ${file}`));
|
|
544
|
+
for (const b of items.slice(0, 4)) {
|
|
545
|
+
const reasonLabel = b.reason === "file_not_found" ? "file not found" : "missing export";
|
|
546
|
+
const namesLabel = b.reason === "missing_export"
|
|
547
|
+
? ` { ${b.missingExports!.join(", ")} }`
|
|
548
|
+
: b.ref.importedNames.length > 0
|
|
549
|
+
? ` { ${b.ref.importedNames.slice(0, 3).join(", ")}${b.ref.importedNames.length > 3 ? ", ..." : ""} }`
|
|
550
|
+
: "";
|
|
551
|
+
console.log(
|
|
552
|
+
chalk.gray(` :${b.ref.line} `) +
|
|
553
|
+
chalk.red(`${reasonLabel}`) +
|
|
554
|
+
chalk.gray(`${namesLabel} from '${b.ref.source}'`)
|
|
555
|
+
);
|
|
556
|
+
if (b.suggestion) {
|
|
557
|
+
console.log(chalk.gray(` ↳ ${b.suggestion}`));
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (items.length > 4) {
|
|
561
|
+
console.log(chalk.gray(` ... and ${items.length - 4} more in this file`));
|
|
562
|
+
}
|
|
563
|
+
shownFiles++;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
console.log(chalk.gray(`\n Tip: broken imports usually mean the AI hallucinated a file/export.`));
|
|
567
|
+
console.log(chalk.gray(` Check whether the missing types/functions were declared inline elsewhere.`));
|
|
568
|
+
console.log(chalk.cyan("─".repeat(65)));
|
|
569
|
+
}
|
package/core/knowledge-memory.ts
CHANGED
|
@@ -73,10 +73,14 @@ const MEMORY_SECTION_MARKER = "## 9. 积累教训";
|
|
|
73
73
|
* Append review issues to the project constitution as accumulated lessons.
|
|
74
74
|
* Creates the section if it doesn't exist; appends if it does.
|
|
75
75
|
* Deduplicates by checking if a similar lesson already exists.
|
|
76
|
+
*
|
|
77
|
+
* @param reviewScore - The review score (0-10) at time of appending, embedded in each entry
|
|
78
|
+
* for future auditability ("was this lesson from a good or bad run?").
|
|
76
79
|
*/
|
|
77
80
|
export async function appendLessonsToConstitution(
|
|
78
81
|
projectRoot: string,
|
|
79
|
-
issues: ReviewIssue[]
|
|
82
|
+
issues: ReviewIssue[],
|
|
83
|
+
reviewScore?: number
|
|
80
84
|
): Promise<void> {
|
|
81
85
|
if (issues.length === 0) return;
|
|
82
86
|
|
|
@@ -85,8 +89,37 @@ export async function appendLessonsToConstitution(
|
|
|
85
89
|
try {
|
|
86
90
|
content = await fs.readFile(constitutionPath, "utf-8");
|
|
87
91
|
} catch {
|
|
88
|
-
|
|
89
|
-
|
|
92
|
+
// No project-level constitution exists. This can happen when:
|
|
93
|
+
// - ContextLoader reported "Constitution: found" based on a GLOBAL constitution
|
|
94
|
+
// merged into the prompt, while the project itself has no file.
|
|
95
|
+
// - The repo was never run through `ai-spec init`.
|
|
96
|
+
//
|
|
97
|
+
// Silently skipping accumulation means knowledge memory goes dark for this repo,
|
|
98
|
+
// so instead we create a minimal stub that subsequent runs can build on top of.
|
|
99
|
+
const stub = [
|
|
100
|
+
"# Project Constitution",
|
|
101
|
+
"",
|
|
102
|
+
"> Auto-generated stub. Run `ai-spec init` to populate §1–§8 with project-specific rules.",
|
|
103
|
+
"> §9 below is automatically accumulated from code review issues.",
|
|
104
|
+
"",
|
|
105
|
+
MEMORY_SECTION_HEADER.trim(),
|
|
106
|
+
"",
|
|
107
|
+
].join("\n");
|
|
108
|
+
try {
|
|
109
|
+
await fs.writeFile(constitutionPath, stub, "utf-8");
|
|
110
|
+
content = stub;
|
|
111
|
+
console.log(
|
|
112
|
+
chalk.cyan(` Created project constitution stub at ${constitutionPath}`)
|
|
113
|
+
);
|
|
114
|
+
console.log(
|
|
115
|
+
chalk.gray(` (knowledge memory needs a project-level file to persist §9 lessons)`)
|
|
116
|
+
);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.log(
|
|
119
|
+
chalk.yellow(` ⚠ Could not create constitution stub: ${(err as Error).message}`)
|
|
120
|
+
);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
90
123
|
}
|
|
91
124
|
|
|
92
125
|
// Check if section 9 already exists
|
|
@@ -105,7 +138,8 @@ export async function appendLessonsToConstitution(
|
|
|
105
138
|
issue.category === "performance" ? "⚡" :
|
|
106
139
|
issue.category === "bug" ? "🐛" :
|
|
107
140
|
issue.category === "pattern" ? "📐" : "📝";
|
|
108
|
-
|
|
141
|
+
const scoreTag = reviewScore !== undefined ? ` (r:${reviewScore.toFixed(1)})` : "";
|
|
142
|
+
newEntries.push(`- ${badge} **[${date}]**${scoreTag} ${issue.description}`);
|
|
109
143
|
}
|
|
110
144
|
|
|
111
145
|
if (newEntries.length === 0) {
|
|
@@ -203,6 +237,10 @@ export async function appendDirectLesson(
|
|
|
203
237
|
|
|
204
238
|
/**
|
|
205
239
|
* Full knowledge memory flow: extract issues from review → append to constitution.
|
|
240
|
+
*
|
|
241
|
+
* Quality gate: if the review score is ≥ 9.0, the run was excellent and the
|
|
242
|
+
* extracted issues are likely minor style nits. Skip accumulation to prevent
|
|
243
|
+
* constitution noise from near-perfect runs.
|
|
206
244
|
*/
|
|
207
245
|
export async function accumulateReviewKnowledge(
|
|
208
246
|
provider: AIProvider,
|
|
@@ -211,18 +249,29 @@ export async function accumulateReviewKnowledge(
|
|
|
211
249
|
): Promise<void> {
|
|
212
250
|
console.log(chalk.blue("\n─── Knowledge Memory ────────────────────────────"));
|
|
213
251
|
|
|
252
|
+
// Extract review score for quality gate and lesson annotation
|
|
253
|
+
const reviewScoreMatch = reviewText.match(/Score:\s*(\d+(?:\.\d+)?)\s*\/\s*10/i);
|
|
254
|
+
const reviewScore = reviewScoreMatch ? parseFloat(reviewScoreMatch[1]) : undefined;
|
|
255
|
+
|
|
256
|
+
// Quality gate: excellent runs (≥ 9.0) rarely produce actionable lessons
|
|
257
|
+
if (reviewScore !== undefined && reviewScore >= 9.0) {
|
|
258
|
+
console.log(chalk.gray(` Review score ${reviewScore}/10 — run quality excellent, skipping constitution update.`));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
214
262
|
const issues = extractIssuesFromReview(reviewText);
|
|
215
263
|
if (issues.length === 0) {
|
|
216
264
|
console.log(chalk.gray(" No actionable issues found in review. Skipping."));
|
|
217
265
|
return;
|
|
218
266
|
}
|
|
219
267
|
|
|
220
|
-
|
|
268
|
+
const scoreLabel = reviewScore !== undefined ? ` (review: ${reviewScore}/10)` : "";
|
|
269
|
+
console.log(chalk.gray(` Extracted ${issues.length} issue(s) from review${scoreLabel}:`));
|
|
221
270
|
for (const issue of issues) {
|
|
222
271
|
console.log(chalk.gray(` - [${issue.category}] ${issue.description.slice(0, 80)}`));
|
|
223
272
|
}
|
|
224
273
|
|
|
225
|
-
await appendLessonsToConstitution(projectRoot, issues);
|
|
274
|
+
await appendLessonsToConstitution(projectRoot, issues, reviewScore);
|
|
226
275
|
}
|
|
227
276
|
|
|
228
277
|
// ─── Auto-Consolidation ──────────────────────────────────────────────────────
|