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,333 @@
1
+ /**
2
+ * fix-history.ts — Persistent ledger of import fixes, used to:
3
+ *
4
+ * 1. Feed past hallucinations back into the next codegen prompt
5
+ * (so the AI learns what NOT to do in this project)
6
+ * 2. Detect recurring patterns worth promoting to constitution §9
7
+ * 3. Provide `ai-spec fix-history` observability for users
8
+ *
9
+ * Storage: `<repoRoot>/.ai-spec-fix-history.json`
10
+ *
11
+ * Design:
12
+ * - Append-only ledger, never modified in place
13
+ * - patternKey = sha256(source + names.sort().join(","))[:12] for dedup
14
+ * - All operations are idempotent / safe to call repeatedly
15
+ * - Pruning is explicit (never automatic) to preserve audit trail
16
+ */
17
+
18
+ import * as fs from "fs-extra";
19
+ import * as path from "path";
20
+ import { createHash } from "crypto";
21
+
22
+ // ─── Types ────────────────────────────────────────────────────────────────────
23
+
24
+ export const FIX_HISTORY_FILE = ".ai-spec-fix-history.json";
25
+ export const FIX_HISTORY_VERSION = "1.0";
26
+
27
+ export interface FixHistoryEntry {
28
+ /** ISO 8601 timestamp */
29
+ ts: string;
30
+ /** Run ID that produced this fix */
31
+ runId: string;
32
+ /** Stable identity hash for deduplication + aggregation */
33
+ patternKey: string;
34
+ brokenImport: {
35
+ source: string;
36
+ names: string[];
37
+ reason: "file_not_found" | "missing_export";
38
+ file: string;
39
+ line: number;
40
+ };
41
+ fix: {
42
+ kind: "create_file" | "rewrite_import" | "append_to_file";
43
+ target: string;
44
+ stage: "deterministic" | "ai";
45
+ };
46
+ }
47
+
48
+ export interface FixHistoryFile {
49
+ version: string;
50
+ entries: FixHistoryEntry[];
51
+ }
52
+
53
+ export interface FixHistoryAggregate {
54
+ patternKey: string;
55
+ count: number;
56
+ firstSeen: string;
57
+ lastSeen: string;
58
+ uniqueRunIds: number;
59
+ /** Representative broken import (from the most recent entry) */
60
+ source: string;
61
+ names: string[];
62
+ reason: "file_not_found" | "missing_export";
63
+ /** Most recent fix applied for this pattern */
64
+ fix: {
65
+ kind: string;
66
+ target: string;
67
+ stage: string;
68
+ };
69
+ }
70
+
71
+ // ─── Identity hashing ─────────────────────────────────────────────────────────
72
+
73
+ /**
74
+ * Compute a stable 12-char hex identity for a broken import.
75
+ * Two entries with the same source module + same named symbols collapse
76
+ * into the same patternKey regardless of which file they appeared in.
77
+ */
78
+ export function computePatternKey(source: string, names: string[]): string {
79
+ const normalizedNames = [...names].sort().join(",");
80
+ return createHash("sha256")
81
+ .update(source + "\x00" + normalizedNames)
82
+ .digest("hex")
83
+ .slice(0, 12);
84
+ }
85
+
86
+ // ─── File I/O ─────────────────────────────────────────────────────────────────
87
+
88
+ export async function loadFixHistory(repoRoot: string): Promise<FixHistoryFile> {
89
+ const filePath = path.join(repoRoot, FIX_HISTORY_FILE);
90
+ if (!(await fs.pathExists(filePath))) {
91
+ return { version: FIX_HISTORY_VERSION, entries: [] };
92
+ }
93
+ try {
94
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
95
+ const data: any = await fs.readJson(filePath);
96
+ if (!data || typeof data !== "object" || !Array.isArray(data.entries)) {
97
+ return { version: FIX_HISTORY_VERSION, entries: [] };
98
+ }
99
+ return {
100
+ version: typeof data.version === "string" ? data.version : FIX_HISTORY_VERSION,
101
+ entries: data.entries as FixHistoryEntry[],
102
+ };
103
+ } catch {
104
+ return { version: FIX_HISTORY_VERSION, entries: [] };
105
+ }
106
+ }
107
+
108
+ async function saveFixHistory(repoRoot: string, history: FixHistoryFile): Promise<void> {
109
+ const filePath = path.join(repoRoot, FIX_HISTORY_FILE);
110
+ await fs.writeJson(filePath, history, { spaces: 2 });
111
+ }
112
+
113
+ /**
114
+ * Append a fix entry to the ledger. The patternKey is computed automatically
115
+ * from the broken import's source + names.
116
+ */
117
+ export async function appendFixEntry(
118
+ repoRoot: string,
119
+ input: Omit<FixHistoryEntry, "patternKey">
120
+ ): Promise<FixHistoryEntry> {
121
+ const history = await loadFixHistory(repoRoot);
122
+ const patternKey = computePatternKey(input.brokenImport.source, input.brokenImport.names);
123
+ const entry: FixHistoryEntry = { ...input, patternKey };
124
+ history.entries.push(entry);
125
+ await saveFixHistory(repoRoot, history);
126
+ return entry;
127
+ }
128
+
129
+ /**
130
+ * Remove entries older than `maxAgeDays` days. Returns the number removed.
131
+ * Used by `ai-spec fix-history --prune <days>`.
132
+ */
133
+ export async function pruneFixHistory(
134
+ repoRoot: string,
135
+ maxAgeDays: number
136
+ ): Promise<number> {
137
+ const history = await loadFixHistory(repoRoot);
138
+ if (history.entries.length === 0) return 0;
139
+ const cutoffMs = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
140
+ const kept: FixHistoryEntry[] = [];
141
+ let removed = 0;
142
+ for (const entry of history.entries) {
143
+ const entryMs = Date.parse(entry.ts);
144
+ if (Number.isFinite(entryMs) && entryMs < cutoffMs) {
145
+ removed++;
146
+ } else {
147
+ kept.push(entry);
148
+ }
149
+ }
150
+ if (removed > 0) {
151
+ await saveFixHistory(repoRoot, { ...history, entries: kept });
152
+ }
153
+ return removed;
154
+ }
155
+
156
+ // ─── Aggregation ──────────────────────────────────────────────────────────────
157
+
158
+ /**
159
+ * Group entries by patternKey and compute per-pattern stats.
160
+ * Returns patterns sorted by count descending, then by lastSeen descending.
161
+ */
162
+ export function aggregateFixPatterns(history: FixHistoryFile): FixHistoryAggregate[] {
163
+ const byKey = new Map<string, FixHistoryEntry[]>();
164
+ for (const entry of history.entries) {
165
+ if (!byKey.has(entry.patternKey)) byKey.set(entry.patternKey, []);
166
+ byKey.get(entry.patternKey)!.push(entry);
167
+ }
168
+
169
+ const aggregates: FixHistoryAggregate[] = [];
170
+ for (const [patternKey, entries] of byKey) {
171
+ // Sort entries by timestamp ascending so lastSeen = last element
172
+ entries.sort((a, b) => a.ts.localeCompare(b.ts));
173
+ const first = entries[0];
174
+ const last = entries[entries.length - 1];
175
+ const uniqueRunIds = new Set(entries.map((e) => e.runId)).size;
176
+ aggregates.push({
177
+ patternKey,
178
+ count: entries.length,
179
+ firstSeen: first.ts,
180
+ lastSeen: last.ts,
181
+ uniqueRunIds,
182
+ source: last.brokenImport.source,
183
+ names: last.brokenImport.names,
184
+ reason: last.brokenImport.reason,
185
+ fix: {
186
+ kind: last.fix.kind,
187
+ target: last.fix.target,
188
+ stage: last.fix.stage,
189
+ },
190
+ });
191
+ }
192
+
193
+ aggregates.sort((a, b) => {
194
+ if (b.count !== a.count) return b.count - a.count;
195
+ return b.lastSeen.localeCompare(a.lastSeen);
196
+ });
197
+
198
+ return aggregates;
199
+ }
200
+
201
+ // ─── Prompt injection ─────────────────────────────────────────────────────────
202
+
203
+ export interface InjectionOptions {
204
+ /** Only include patterns seen at least this many times. Default: 1 */
205
+ minCount?: number;
206
+ /** Max number of patterns to inject (prevents prompt bloat). Default: 10 */
207
+ maxItems?: number;
208
+ }
209
+
210
+ /**
211
+ * Build the "Prior Hallucinations" section that gets prepended to the codegen
212
+ * prompt. Returns null when there's nothing to inject (empty history or all
213
+ * below minCount).
214
+ *
215
+ * Format is deliberately structured as "DO NOT do X because Y (seen Nx)"
216
+ * to make it both human-readable and LLM-actionable.
217
+ */
218
+ export function buildHallucinationAvoidanceSection(
219
+ history: FixHistoryFile,
220
+ opts: InjectionOptions = {}
221
+ ): string | null {
222
+ const minCount = opts.minCount ?? 1;
223
+ const maxItems = opts.maxItems ?? 10;
224
+
225
+ const patterns = aggregateFixPatterns(history).filter((p) => p.count >= minCount);
226
+ if (patterns.length === 0) return null;
227
+
228
+ const top = patterns.slice(0, maxItems);
229
+
230
+ const lines: string[] = [
231
+ "=== Prior Hallucinations in This Project (DO NOT REPEAT) ===",
232
+ "",
233
+ "The following imports were previously hallucinated by AI codegen in this",
234
+ "project and had to be auto-fixed. When generating new files, actively avoid",
235
+ "these exact imports — they were wrong in the past and will be wrong again.",
236
+ "",
237
+ ];
238
+
239
+ for (const p of top) {
240
+ const namesLabel = p.names.length > 0 ? `{ ${p.names.join(", ")} }` : "(no names)";
241
+ const reasonLabel = p.reason === "file_not_found" ? "file did not exist" : "named export did not exist";
242
+ const countLabel = p.count === 1 ? "1x" : `${p.count}x`;
243
+ const dateLabel = p.lastSeen.slice(0, 10);
244
+ lines.push(`❌ Do NOT: import ${namesLabel} from '${p.source}'`);
245
+ lines.push(` Reason: ${reasonLabel} (seen ${countLabel}, last ${dateLabel})`);
246
+ if (p.fix.kind === "create_file") {
247
+ lines.push(` Previously fixed by creating: ${p.fix.target}`);
248
+ } else if (p.fix.kind === "rewrite_import") {
249
+ lines.push(` Previously fixed by rewriting the import path`);
250
+ } else {
251
+ lines.push(` Previously fixed by appending to: ${p.fix.target}`);
252
+ }
253
+ lines.push("");
254
+ }
255
+
256
+ if (patterns.length > maxItems) {
257
+ lines.push(`(${patterns.length - maxItems} more pattern(s) hidden — run \`ai-spec fix-history\` to see all)`);
258
+ lines.push("");
259
+ }
260
+
261
+ lines.push("=== End of Prior Hallucinations ===");
262
+ return lines.join("\n");
263
+ }
264
+
265
+ // ─── Promotion to constitution §9 ─────────────────────────────────────────────
266
+
267
+ export interface PromotionCandidate {
268
+ aggregate: FixHistoryAggregate;
269
+ /** The human-readable lesson suggested for §9 */
270
+ lessonText: string;
271
+ }
272
+
273
+ /**
274
+ * Detect patterns that have crossed the promotion threshold and should be
275
+ * offered up for inclusion in constitution §9.
276
+ *
277
+ * @param threshold Minimum repeat count before a pattern is a candidate.
278
+ */
279
+ export function detectPromotionCandidates(
280
+ history: FixHistoryFile,
281
+ threshold: number
282
+ ): PromotionCandidate[] {
283
+ const patterns = aggregateFixPatterns(history);
284
+ const candidates: PromotionCandidate[] = [];
285
+
286
+ for (const p of patterns) {
287
+ if (p.count < threshold) continue;
288
+ candidates.push({
289
+ aggregate: p,
290
+ lessonText: renderLessonFromPattern(p),
291
+ });
292
+ }
293
+
294
+ return candidates;
295
+ }
296
+
297
+ function renderLessonFromPattern(p: FixHistoryAggregate): string {
298
+ const namesLabel = p.names.length > 0 ? `{ ${p.names.join(", ")} }` : "";
299
+ if (p.reason === "file_not_found") {
300
+ return `避免从不存在的路径 '${p.source}' 引入 ${namesLabel}——此路径在本项目中已被 hallucinate ${p.count} 次。正确做法请参考同类型已有文件的 import 路径。`;
301
+ }
302
+ return `从 '${p.source}' 引入 ${namesLabel} 时,目标文件存在但未导出这些命名——此问题在本项目中已出现 ${p.count} 次。请先确认目标 module 的实际 exports。`;
303
+ }
304
+
305
+ // ─── Metrics helpers (for RunLogger / trend) ──────────────────────────────────
306
+
307
+ export interface FixHistoryStats {
308
+ totalEntries: number;
309
+ uniquePatterns: number;
310
+ uniqueRunIds: number;
311
+ lastEntryTs?: string;
312
+ byStage: { deterministic: number; ai: number };
313
+ byReason: { file_not_found: number; missing_export: number };
314
+ }
315
+
316
+ export function computeFixHistoryStats(history: FixHistoryFile): FixHistoryStats {
317
+ const stats: FixHistoryStats = {
318
+ totalEntries: history.entries.length,
319
+ uniquePatterns: new Set(history.entries.map((e) => e.patternKey)).size,
320
+ uniqueRunIds: new Set(history.entries.map((e) => e.runId)).size,
321
+ byStage: { deterministic: 0, ai: 0 },
322
+ byReason: { file_not_found: 0, missing_export: 0 },
323
+ };
324
+ for (const e of history.entries) {
325
+ stats.byStage[e.fix.stage]++;
326
+ stats.byReason[e.brokenImport.reason]++;
327
+ }
328
+ if (history.entries.length > 0) {
329
+ const sortedTs = history.entries.map((e) => e.ts).sort();
330
+ stats.lastEntryTs = sortedTs[sortedTs.length - 1];
331
+ }
332
+ return stats;
333
+ }