ai-spec-dev 0.46.0 → 0.56.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.
Files changed (41) hide show
  1. package/README.md +60 -30
  2. package/cli/commands/config.ts +129 -1
  3. package/cli/commands/create.ts +14 -0
  4. package/cli/commands/fix-history.ts +176 -0
  5. package/cli/commands/init.ts +36 -1
  6. package/cli/index.ts +2 -6
  7. package/cli/pipeline/helpers.ts +6 -0
  8. package/cli/pipeline/multi-repo.ts +300 -26
  9. package/cli/pipeline/single-repo.ts +103 -2
  10. package/cli/utils.ts +23 -0
  11. package/core/code-generator.ts +63 -14
  12. package/core/cross-stack-verifier.ts +482 -0
  13. package/core/fix-history.ts +333 -0
  14. package/core/import-fixer.ts +827 -0
  15. package/core/import-verifier.ts +569 -0
  16. package/core/knowledge-memory.ts +55 -6
  17. package/core/self-evaluator.ts +44 -7
  18. package/core/spec-generator.ts +3 -3
  19. package/core/types-generator.ts +2 -2
  20. package/dist/cli/index.js +3968 -2353
  21. package/dist/cli/index.js.map +1 -1
  22. package/dist/cli/index.mjs +3810 -2195
  23. package/dist/cli/index.mjs.map +1 -1
  24. package/dist/index.d.mts +14 -0
  25. package/dist/index.d.ts +14 -0
  26. package/dist/index.js +249 -128
  27. package/dist/index.js.map +1 -1
  28. package/dist/index.mjs +249 -128
  29. package/dist/index.mjs.map +1 -1
  30. package/package.json +2 -2
  31. package/tests/cross-stack-verifier.test.ts +402 -0
  32. package/tests/fix-history.test.ts +335 -0
  33. package/tests/import-fixer.test.ts +944 -0
  34. package/tests/import-verifier.test.ts +420 -0
  35. package/tests/knowledge-memory.test.ts +40 -0
  36. package/tests/self-evaluator.test.ts +97 -0
  37. package/.ai-spec-workspace.json +0 -17
  38. package/.ai-spec.json +0 -7
  39. package/cli/commands/model.ts +0 -152
  40. package/cli/commands/scan.ts +0 -99
  41. 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
+ }
@@ -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
- console.log(chalk.gray(" No constitution file skipping knowledge memory."));
89
- return;
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
- newEntries.push(`- ${badge} **[${date}]** ${issue.description}`);
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
- console.log(chalk.gray(` Extracted ${issues.length} issue(s) from review:`));
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 ──────────────────────────────────────────────────────