ai-spec-dev 0.37.0 → 0.41.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 (67) hide show
  1. package/README.md +381 -1796
  2. package/RELEASE_LOG.md +231 -0
  3. package/cli/commands/create.ts +9 -1176
  4. package/cli/commands/dashboard.ts +1 -1
  5. package/cli/pipeline/helpers.ts +34 -0
  6. package/cli/pipeline/multi-repo.ts +483 -0
  7. package/cli/pipeline/single-repo.ts +755 -0
  8. package/cli/utils.ts +2 -0
  9. package/core/code-generator.ts +52 -341
  10. package/core/codegen/helpers.ts +219 -0
  11. package/core/codegen/topo-sort.ts +98 -0
  12. package/core/constitution-consolidator.ts +2 -2
  13. package/core/dsl-coverage-checker.ts +298 -0
  14. package/core/dsl-extractor.ts +19 -46
  15. package/core/dsl-feedback.ts +1 -1
  16. package/core/dsl-validator.ts +74 -0
  17. package/core/error-feedback.ts +95 -11
  18. package/core/frontend-context-loader.ts +27 -5
  19. package/core/knowledge-memory.ts +52 -0
  20. package/core/mock/fixtures.ts +89 -0
  21. package/core/mock/proxy.ts +380 -0
  22. package/core/mock-server-generator.ts +12 -460
  23. package/core/requirement-decomposer.ts +4 -28
  24. package/core/reviewer.ts +1 -1
  25. package/core/safe-json.ts +76 -0
  26. package/core/spec-updater.ts +5 -21
  27. package/core/token-budget.ts +124 -0
  28. package/core/vcr.ts +20 -1
  29. package/dist/cli/index.js +4110 -3534
  30. package/dist/cli/index.js.map +1 -1
  31. package/dist/cli/index.mjs +4237 -3661
  32. package/dist/cli/index.mjs.map +1 -1
  33. package/dist/index.d.mts +18 -16
  34. package/dist/index.d.ts +18 -16
  35. package/dist/index.js +310 -182
  36. package/dist/index.js.map +1 -1
  37. package/dist/index.mjs +308 -180
  38. package/dist/index.mjs.map +1 -1
  39. package/package.json +2 -2
  40. package/purpose.md +173 -33
  41. package/tests/auto-consolidation.test.ts +109 -0
  42. package/tests/combined-generator.test.ts +81 -0
  43. package/tests/constitution-consolidator.test.ts +161 -0
  44. package/tests/constitution-generator.test.ts +94 -0
  45. package/tests/contract-bridge.test.ts +201 -0
  46. package/tests/design-dialogue.test.ts +108 -0
  47. package/tests/dsl-coverage-checker.test.ts +230 -0
  48. package/tests/dsl-feedback.test.ts +45 -0
  49. package/tests/dsl-validator-xref.test.ts +99 -0
  50. package/tests/error-feedback-repair.test.ts +319 -0
  51. package/tests/error-feedback-validation.test.ts +91 -0
  52. package/tests/frontend-context-loader.test.ts +609 -0
  53. package/tests/global-constitution.test.ts +110 -0
  54. package/tests/key-store.test.ts +73 -0
  55. package/tests/knowledge-memory.test.ts +327 -0
  56. package/tests/project-index.test.ts +206 -0
  57. package/tests/prompt-hasher.test.ts +19 -0
  58. package/tests/requirement-decomposer.test.ts +171 -0
  59. package/tests/reviewer.test.ts +4 -1
  60. package/tests/run-logger.test.ts +289 -0
  61. package/tests/run-snapshot.test.ts +113 -0
  62. package/tests/safe-json.test.ts +63 -0
  63. package/tests/spec-updater.test.ts +161 -0
  64. package/tests/test-generator.test.ts +146 -0
  65. package/tests/token-budget.test.ts +124 -0
  66. package/tests/vcr-hash.test.ts +101 -0
  67. package/tests/workspace-loader.test.ts +277 -0
package/cli/utils.ts CHANGED
@@ -20,6 +20,8 @@ export interface AiSpecConfig {
20
20
  minHarnessScore?: number;
21
21
  /** Maximum error-feedback cycles before giving up (default: 2, TDD default: 3). */
22
22
  maxErrorCycles?: number;
23
+ /** §9 lesson count threshold for auto-consolidation (default: 12). */
24
+ autoConsolidateThreshold?: number;
23
25
  }
24
26
 
25
27
  export const CONFIG_FILE = ".ai-spec.json";
@@ -10,242 +10,25 @@ import { loadDslForSpec, buildDslContextSection } from "./dsl-extractor";
10
10
  import { loadFrontendContext, buildFrontendContextSection } from "./frontend-context-loader";
11
11
  import { getActiveSnapshot } from "./run-snapshot";
12
12
  import { getActiveLogger } from "./run-logger";
13
-
14
- // ─── Shared Config Helper ───────────────────────────────────────────────────
15
-
16
- function buildSharedConfigSection(context?: ProjectContext): string {
17
- if (!context?.sharedConfigFiles || context.sharedConfigFiles.length === 0) return "";
18
-
19
- const lines: string[] = [
20
- "\n=== Existing Shared Config Files (study these to learn project conventions) ===",
21
- "These are real files from the project. Use them as ground truth for naming, structure, and registration patterns.",
22
- "Modify them in-place when adding new entries. Do NOT create parallel files for the same purpose.\n",
23
- ];
24
-
25
- for (const f of context.sharedConfigFiles) {
26
- lines.push(`--- File: ${f.path} [${f.category}] ---`);
27
- lines.push(f.preview);
28
- lines.push("");
29
- }
30
- return lines.join("\n") + "\n";
31
- }
32
-
33
- function buildInstalledPackagesSection(context?: ProjectContext): string {
34
- if (!context?.dependencies || context.dependencies.length === 0) return "";
35
- return `\n=== Installed Packages (ONLY use packages from this list — NEVER import anything not listed here) ===\n${context.dependencies.join(", ")}\n`;
36
- }
37
-
38
- /**
39
- * Extract a behavioral contract summary from a generated file.
40
- *
41
- * Captures:
42
- * - export interface / type / enum — full multi-line blocks (the actual TS contracts)
43
- * - export function / const / class — opening signature line
44
- * - Throw statements — error codes & validation constraints
45
- *
46
- * Multi-line blocks (interface, type alias with {}) are captured in full so
47
- * downstream tasks see complete method signatures and field shapes, not just
48
- * a single-line "export interface Foo {" that conveys nothing.
49
- *
50
- * Falls back to first 3000 chars for CommonJS files with no explicit exports.
51
- */
52
- export function extractBehavioralContract(content: string): string {
53
- const lines = content.split("\n");
54
- const contractLines: string[] = [];
55
- const throwLines: string[] = [];
56
- let i = 0;
57
-
58
- while (i < lines.length) {
59
- const line = lines[i];
60
- const trimmed = line.trim();
61
-
62
- // ── Multi-line block exports: interface / type X = { / class / enum ──────
63
- // Capture the full block so downstream tasks see the complete contract.
64
- if (/^export\s+(interface|type|class|abstract\s+class|enum)\s/.test(trimmed)) {
65
- contractLines.push(line.trimEnd());
66
- if (trimmed.includes("{")) {
67
- let depth =
68
- (trimmed.match(/\{/g) ?? []).length -
69
- (trimmed.match(/\}/g) ?? []).length;
70
- i++;
71
- while (i < lines.length && depth > 0) {
72
- const inner = lines[i];
73
- contractLines.push(inner.trimEnd());
74
- depth += (inner.match(/\{/g) ?? []).length;
75
- depth -= (inner.match(/\}/g) ?? []).length;
76
- i++;
77
- }
78
- } else {
79
- i++;
80
- }
81
- continue;
82
- }
83
-
84
- // ── export const X = defineStore(...) — capture full block ───────────────
85
- // Pinia stores wrap all actions inside defineStore(). Without the full block
86
- // the consumer only sees "export const useTaskStore = defineStore(" and has
87
- // to guess every action name — the primary source of fetchTasks→fetchTaskList
88
- // hallucinations. Capture the complete defineStore(...) call so the return
89
- // object (public API) is visible.
90
- if (/^export\s+const\s+\w+\s*=\s*(defineStore|createStore|createSlice)\s*\(/.test(trimmed)) {
91
- contractLines.push(line.trimEnd());
92
- let depth = (trimmed.match(/\(/g) ?? []).length - (trimmed.match(/\)/g) ?? []).length;
93
- i++;
94
- while (i < lines.length && depth > 0) {
95
- const inner = lines[i];
96
- contractLines.push(inner.trimEnd());
97
- depth += (inner.match(/\(/g) ?? []).length;
98
- depth -= (inner.match(/\)/g) ?? []).length;
99
- i++;
100
- }
101
- continue;
102
- }
103
-
104
- // ── return { ... } — composable/store public API surface ─────────────────
105
- // In Pinia composition-API stores and Vue composables the return object is
106
- // the definitive list of exposed names. Capture it so consumers see the
107
- // exact exported identifiers (e.g. "fetchTasks" not "fetchTaskList").
108
- if (/^return\s*\{/.test(trimmed)) {
109
- contractLines.push("// public API (return object):");
110
- contractLines.push(line.trimEnd());
111
- let depth = (trimmed.match(/\{/g) ?? []).length - (trimmed.match(/\}/g) ?? []).length;
112
- i++;
113
- while (i < lines.length && depth > 0) {
114
- const inner = lines[i];
115
- contractLines.push(inner.trimEnd());
116
- depth += (inner.match(/\{/g) ?? []).length;
117
- depth -= (inner.match(/\}/g) ?? []).length;
118
- i++;
119
- }
120
- continue;
121
- }
122
-
123
- // ── export default function/class — capture full block ───────────────────
124
- // Needed for React components (export default function Foo()) and Vue
125
- // composables (export default class Foo {}). Without full-block capture the
126
- // consumer only sees the opening line and can't know the return shape.
127
- if (/^export\s+default\s+(async\s+)?(function|class)\b/.test(trimmed)) {
128
- contractLines.push(line.trimEnd());
129
- if (trimmed.includes("{")) {
130
- let depth =
131
- (trimmed.match(/\{/g) ?? []).length -
132
- (trimmed.match(/\}/g) ?? []).length;
133
- i++;
134
- while (i < lines.length && depth > 0) {
135
- const inner = lines[i];
136
- contractLines.push(inner.trimEnd());
137
- depth += (inner.match(/\{/g) ?? []).length;
138
- depth -= (inner.match(/\}/g) ?? []).length;
139
- i++;
140
- }
141
- } else {
142
- i++;
143
- }
144
- continue;
145
- }
146
-
147
- // ── Single-line export declarations (functions, consts, re-exports) ───────
148
- if (/^export\s/.test(trimmed)) {
149
- contractLines.push(line.trimEnd());
150
- }
151
-
152
- // ── Throw patterns — validation constraints and named error codes ─────────
153
- if (
154
- /throw\s+(new\s+)?\w*[Ee]rror\b|throw\s+create[A-Z]\w*|@throws/.test(line) &&
155
- throwLines.length < 20
156
- ) {
157
- throwLines.push(" // " + trimmed);
158
- }
159
-
160
- i++;
161
- }
162
-
163
- if (contractLines.length === 0 && throwLines.length === 0) {
164
- return content.slice(0, 3000);
165
- }
166
-
167
- const parts: string[] = [...contractLines];
168
- if (throwLines.length > 0) {
169
- parts.push("", "// Error contracts (throws / validation):", ...throwLines);
170
- }
171
- return parts.join("\n");
172
- }
173
-
174
- /**
175
- * Build a context section from files already written in this generation run.
176
- * Injected before generating files that may import from those paths (e.g., route files
177
- * importing from API files generated in an earlier task).
178
- */
179
- function buildGeneratedFilesSection(cache: Map<string, string>): string {
180
- if (cache.size === 0) return "";
181
- const lines = [
182
- "\n=== Files Already Generated in This Run — USE EXACT EXPORTS (do not rename or invent alternatives) ===",
183
- "// CRITICAL: function/action names and file paths below are ground truth. Copy them EXACTLY.",
184
- "// Do NOT add suffixes (List, Data, All, Info) or change casing.",
185
- "// For '// exists:' entries: use the EXACT filename shown — do NOT substitute index.vue or other defaults.",
186
- ];
187
- for (const [filePath, content] of cache) {
188
- // View/page components: only show the path as a name sentinel.
189
- // The router needs to know the exact filename (e.g. TaskManagement.vue, NOT index.vue).
190
- const isViewFile = /src[\\/](views?|pages?)[\\/]/i.test(filePath);
191
- if (isViewFile) {
192
- lines.push(`\n// exists: ${filePath}`);
193
- continue;
194
- }
195
- lines.push(`\n--- ${filePath} ---`);
196
- // Store and composable files: pass full content — the entire file IS the contract
197
- const isStoreOrComposable = /src[\\/](stores?|composables?)[\\/]/i.test(filePath);
198
- lines.push(isStoreOrComposable ? content : extractBehavioralContract(content));
199
- }
200
- return lines.join("\n") + "\n";
201
- }
13
+ import {
14
+ buildSharedConfigSection,
15
+ buildInstalledPackagesSection,
16
+ buildGeneratedFilesSection,
17
+ extractBehavioralContract,
18
+ stripCodeFences,
19
+ parseJsonArray,
20
+ isRtkAvailable,
21
+ FileAction,
22
+ } from "./codegen/helpers";
23
+ import { topoSortLayerTasks, printTaskProgress, LAYER_ICONS } from "./codegen/topo-sort";
24
+ import { estimateTokens, getDefaultBudget } from "./token-budget";
25
+
26
+ // Re-export public symbols for backward compatibility
27
+ export { extractBehavioralContract } from "./codegen/helpers";
28
+ export { printTaskProgress } from "./codegen/topo-sort";
202
29
 
203
30
  export type CodeGenMode = "claude-code" | "api" | "plan";
204
31
 
205
- // ─── RTK Helper ────────────────────────────────────────────────────────────────
206
- // RTK (Rust Token Killer) saves tokens by filtering verbose CLI output.
207
- // When available, prefix 'claude' with 'rtk' for token savings.
208
-
209
- function isRtkAvailable(): boolean {
210
- try {
211
- execSync("rtk --version", { stdio: "ignore" });
212
- return true;
213
- } catch {
214
- return false;
215
- }
216
- }
217
-
218
- interface FileAction {
219
- file: string;
220
- action: "create" | "modify";
221
- description: string;
222
- }
223
-
224
- // ─── Helpers ───────────────────────────────────────────────────────────────────
225
-
226
- function stripCodeFences(output: string): string {
227
- // Remove ```lang ... ``` wrapping if present
228
- const fenced = output.match(/^```(?:\w+)?\n([\s\S]*?)```\s*$/m);
229
- if (fenced) return fenced[1].trim();
230
- const lines = output.split("\n");
231
- if (lines[0].startsWith("```")) lines.shift();
232
- if (lines[lines.length - 1].trim() === "```") lines.pop();
233
- return lines.join("\n").trim();
234
- }
235
-
236
- function parseJsonArray(text: string): FileAction[] {
237
- // Try a JSON code fence first
238
- const fenced = text.match(/```(?:json)?\n(\[[\s\S]*?\])\n```/);
239
- const raw = fenced ? fenced[1] : text.match(/\[[\s\S]*?\]/)?.[0] ?? "";
240
- try {
241
- const parsed = JSON.parse(raw);
242
- if (Array.isArray(parsed)) return parsed as FileAction[];
243
- } catch {
244
- // fall through
245
- }
246
- return [];
247
- }
248
-
249
32
  // ─── CodeGenerator ────────────────────────────────────────────────────────────
250
33
 
251
34
  export interface CodeGenOptions {
@@ -301,7 +84,7 @@ export class CodeGenerator {
301
84
 
302
85
  private isClaudeCLIAvailable(): boolean {
303
86
  try {
304
- execSync("claude --version", { stdio: "ignore" });
87
+ execSync("claude --version", { stdio: "ignore", timeout: 10_000 });
305
88
  return true;
306
89
  } catch {
307
90
  return false;
@@ -457,7 +240,7 @@ export class CodeGenerator {
457
240
  }
458
241
 
459
242
  const spec = await fs.readFile(specFilePath, "utf-8");
460
- const constitutionSection = context?.constitution
243
+ let constitutionSection = context?.constitution
461
244
  ? `\n=== Project Constitution (MUST follow) ===\n${context.constitution}\n`
462
245
  : "";
463
246
  const contextSummary = context
@@ -484,6 +267,27 @@ export class CodeGenerator {
484
267
  console.log(chalk.gray(` Frontend context: ${fctx.framework} / ${fctx.httpClient} | hooks:${fctx.hookFiles.length} stores:${fctx.storeFiles.length}`));
485
268
  }
486
269
 
270
+ // Token budget check — warn if context sections are large
271
+ const allContextText = spec + constitutionSection + dslSection + frontendSection + installedPackagesSection + sharedConfigSection;
272
+ const estimatedTokenCount = estimateTokens(allContextText);
273
+ const budget = getDefaultBudget(this.provider.providerName);
274
+ if (estimatedTokenCount > budget * 0.7) {
275
+ console.log(
276
+ chalk.yellow(
277
+ ` ⚠ Context size: ~${Math.round(estimatedTokenCount / 1000)}K tokens (budget: ${Math.round(budget / 1000)}K for ${this.provider.providerName})`
278
+ )
279
+ );
280
+ // Trim constitution §9 if it's the largest contributor
281
+ if (constitutionSection.length > 4000) {
282
+ const s9Start = constitutionSection.indexOf("## 9.");
283
+ if (s9Start > 0) {
284
+ constitutionSection = constitutionSection.slice(0, s9Start) +
285
+ "## 9. 积累教训 (Accumulated Lessons)\n[Trimmed for context budget — run `ai-spec init --consolidate` to prune]\n";
286
+ console.log(chalk.gray(" → §9 trimmed from constitution to save tokens."));
287
+ }
288
+ }
289
+ }
290
+
487
291
  // Use tasks if available for finer-grained generation with resume support
488
292
  const tasks = await loadTasksForSpec(specFilePath);
489
293
  if (tasks && tasks.length > 0) {
@@ -724,16 +528,19 @@ Output ONLY a valid JSON array:
724
528
 
725
529
  for (const batch of taskBatches) {
726
530
  const batchIsParallel = batch.length > 1;
727
- // Wrap each task in .catch() so a single unexpected failure (disk full,
728
- // provider timeout, mkdir error) degrades gracefully instead of rejecting
729
- // the entire Promise.all and aborting all sibling tasks in the batch.
730
- const batchResultPromises = batch.map((task) =>
731
- executeTask(task, batchIsParallel).catch((err): TaskResult => {
732
- console.log(chalk.yellow(` ⚠ ${task.id} threw unexpectedly: ${(err as Error).message}`));
733
- return { task, files: [], createdFiles: [], success: 0, total: 0, impliesRegistration: false };
734
- })
735
- );
736
- const batchResults = await Promise.all(batchResultPromises);
531
+ const batchResultPromises = batch.map((task) => executeTask(task, batchIsParallel));
532
+ const settled = await Promise.allSettled(batchResultPromises);
533
+ const batchResults: TaskResult[] = [];
534
+ for (let i = 0; i < settled.length; i++) {
535
+ const outcome = settled[i];
536
+ if (outcome.status === "fulfilled") {
537
+ batchResults.push(outcome.value);
538
+ } else {
539
+ const task = batch[i];
540
+ console.log(chalk.yellow(` ⚠ ${task.id} threw unexpectedly: ${outcome.reason?.message ?? outcome.reason}`));
541
+ batchResults.push({ task, files: [], createdFiles: [], success: 0, total: 0, impliesRegistration: false });
542
+ }
543
+ }
737
544
  layerResults.push(...batchResults);
738
545
  // Update cache after each batch so the next batch sees the exports.
739
546
  await updateCacheFromBatch(batchResults);
@@ -893,99 +700,3 @@ ${spec}`,
893
700
  console.log(chalk.cyan("\n") + plan);
894
701
  }
895
702
  }
896
-
897
- // ─── Topological Batch Sort ────────────────────────────────────────────────────
898
-
899
- /**
900
- * Partition tasks within a layer into ordered batches that respect the
901
- * `dependencies` field. Tasks in the same batch have no intra-layer
902
- * dependencies on each other and can run in parallel. Tasks in later batches
903
- * wait for earlier batches to complete.
904
- *
905
- * Only intra-layer dependencies (i.e. deps whose IDs also appear in `tasks`)
906
- * are considered — cross-layer ordering is already handled by LAYER_ORDER.
907
- *
908
- * Returns at least one batch. On circular-dependency detection the remaining
909
- * tasks are dumped into a final batch so execution always completes.
910
- */
911
- function topoSortLayerTasks(tasks: SpecTask[]): SpecTask[][] {
912
- if (tasks.length <= 1) return [tasks];
913
-
914
- const idSet = new Set(tasks.map((t) => t.id));
915
- const taskById = new Map(tasks.map((t) => [t.id, t]));
916
- const inDegree = new Map<string, number>();
917
- const dependents = new Map<string, string[]>(); // dep → tasks that depend on it
918
-
919
- for (const task of tasks) {
920
- inDegree.set(task.id, 0);
921
- dependents.set(task.id, []);
922
- }
923
-
924
- for (const task of tasks) {
925
- const intraDeps = task.dependencies.filter((dep) => idSet.has(dep));
926
- inDegree.set(task.id, intraDeps.length);
927
- for (const dep of intraDeps) {
928
- dependents.get(dep)!.push(task.id);
929
- }
930
- }
931
-
932
- const batches: SpecTask[][] = [];
933
- const remaining = new Set(tasks.map((t) => t.id));
934
-
935
- while (remaining.size > 0) {
936
- const batch = [...remaining]
937
- .filter((id) => inDegree.get(id) === 0)
938
- .map((id) => taskById.get(id)!);
939
-
940
- if (batch.length === 0) {
941
- // Circular dependency — run all remaining tasks in parallel to avoid deadlock
942
- batches.push([...remaining].map((id) => taskById.get(id)!));
943
- break;
944
- }
945
-
946
- batches.push(batch);
947
- for (const task of batch) {
948
- remaining.delete(task.id);
949
- for (const dependent of dependents.get(task.id)!) {
950
- inDegree.set(dependent, inDegree.get(dependent)! - 1);
951
- }
952
- }
953
- }
954
-
955
- return batches;
956
- }
957
-
958
- // ─── Progress Bar Helper ───────────────────────────────────────────────────────
959
-
960
- const LAYER_ICONS: Record<string, string> = {
961
- data: "💾",
962
- infra: "⚙️ ",
963
- service: "🔧",
964
- api: "🌐",
965
- view: "🖥️ ",
966
- route: "🗺️ ",
967
- test: "🧪",
968
- };
969
-
970
- export function printTaskProgress(
971
- completed: number,
972
- total: number,
973
- task: SpecTask,
974
- mode: "run" | "skip"
975
- ): void {
976
- const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
977
- const barWidth = 20;
978
- const filled = Math.round((pct / 100) * barWidth);
979
- const bar = chalk.green("█".repeat(filled)) + chalk.gray("░".repeat(barWidth - filled));
980
- const icon = LAYER_ICONS[task.layer] ?? " ";
981
-
982
- if (mode === "skip") {
983
- console.log(
984
- chalk.gray(`\n [${bar}] ${pct}% ✓ ${task.id} ${icon} ${task.title} — already done`)
985
- );
986
- } else {
987
- console.log(
988
- chalk.bold(`\n [${bar}] ${pct}% → ${task.id} ${icon} ${task.title}`)
989
- );
990
- }
991
- }
@@ -0,0 +1,219 @@
1
+ import chalk from "chalk";
2
+ import { execSync } from "child_process";
3
+ import { ProjectContext } from "../context-loader";
4
+
5
+ // ─── Types ───────────────────────────────────────────────────────────────────
6
+
7
+ export interface FileAction {
8
+ file: string;
9
+ action: "create" | "modify";
10
+ description: string;
11
+ }
12
+
13
+ // ─── Shared Config Helper ───────────────────────────────────────────────────
14
+
15
+ export function buildSharedConfigSection(context?: ProjectContext): string {
16
+ if (!context?.sharedConfigFiles || context.sharedConfigFiles.length === 0) return "";
17
+
18
+ const lines: string[] = [
19
+ "\n=== Existing Shared Config Files (study these to learn project conventions) ===",
20
+ "These are real files from the project. Use them as ground truth for naming, structure, and registration patterns.",
21
+ "Modify them in-place when adding new entries. Do NOT create parallel files for the same purpose.\n",
22
+ ];
23
+
24
+ for (const f of context.sharedConfigFiles) {
25
+ lines.push(`--- File: ${f.path} [${f.category}] ---`);
26
+ lines.push(f.preview);
27
+ lines.push("");
28
+ }
29
+ return lines.join("\n") + "\n";
30
+ }
31
+
32
+ export function buildInstalledPackagesSection(context?: ProjectContext): string {
33
+ if (!context?.dependencies || context.dependencies.length === 0) return "";
34
+ return `\n=== Installed Packages (ONLY use packages from this list — NEVER import anything not listed here) ===\n${context.dependencies.join(", ")}\n`;
35
+ }
36
+
37
+ // ─── Behavioral Contract Extractor ──────────────────────────────────────────
38
+
39
+ /**
40
+ * Extract a behavioral contract summary from a generated file.
41
+ *
42
+ * Captures:
43
+ * - export interface / type / enum — full multi-line blocks (the actual TS contracts)
44
+ * - export function / const / class — opening signature line
45
+ * - Throw statements — error codes & validation constraints
46
+ *
47
+ * Multi-line blocks (interface, type alias with {}) are captured in full so
48
+ * downstream tasks see complete method signatures and field shapes, not just
49
+ * a single-line "export interface Foo {" that conveys nothing.
50
+ *
51
+ * Falls back to first 3000 chars for CommonJS files with no explicit exports.
52
+ */
53
+ export function extractBehavioralContract(content: string): string {
54
+ const lines = content.split("\n");
55
+ const contractLines: string[] = [];
56
+ const throwLines: string[] = [];
57
+ let i = 0;
58
+
59
+ while (i < lines.length) {
60
+ const line = lines[i];
61
+ const trimmed = line.trim();
62
+
63
+ // ── Multi-line block exports: interface / type X = { / class / enum ──────
64
+ if (/^export\s+(interface|type|class|abstract\s+class|enum)\s/.test(trimmed)) {
65
+ contractLines.push(line.trimEnd());
66
+ if (trimmed.includes("{")) {
67
+ let depth =
68
+ (trimmed.match(/\{/g) ?? []).length -
69
+ (trimmed.match(/\}/g) ?? []).length;
70
+ i++;
71
+ while (i < lines.length && depth > 0) {
72
+ const inner = lines[i];
73
+ contractLines.push(inner.trimEnd());
74
+ depth += (inner.match(/\{/g) ?? []).length;
75
+ depth -= (inner.match(/\}/g) ?? []).length;
76
+ i++;
77
+ }
78
+ } else {
79
+ i++;
80
+ }
81
+ continue;
82
+ }
83
+
84
+ // ── export const X = defineStore(...) — capture full block ───────────────
85
+ if (/^export\s+const\s+\w+\s*=\s*(defineStore|createStore|createSlice)\s*\(/.test(trimmed)) {
86
+ contractLines.push(line.trimEnd());
87
+ let depth = (trimmed.match(/\(/g) ?? []).length - (trimmed.match(/\)/g) ?? []).length;
88
+ i++;
89
+ while (i < lines.length && depth > 0) {
90
+ const inner = lines[i];
91
+ contractLines.push(inner.trimEnd());
92
+ depth += (inner.match(/\(/g) ?? []).length;
93
+ depth -= (inner.match(/\)/g) ?? []).length;
94
+ i++;
95
+ }
96
+ continue;
97
+ }
98
+
99
+ // ── return { ... } — composable/store public API surface ─────────────────
100
+ if (/^return\s*\{/.test(trimmed)) {
101
+ contractLines.push("// public API (return object):");
102
+ contractLines.push(line.trimEnd());
103
+ let depth = (trimmed.match(/\{/g) ?? []).length - (trimmed.match(/\}/g) ?? []).length;
104
+ i++;
105
+ while (i < lines.length && depth > 0) {
106
+ const inner = lines[i];
107
+ contractLines.push(inner.trimEnd());
108
+ depth += (inner.match(/\{/g) ?? []).length;
109
+ depth -= (inner.match(/\}/g) ?? []).length;
110
+ i++;
111
+ }
112
+ continue;
113
+ }
114
+
115
+ // ── export default function/class — capture full block ───────────────────
116
+ if (/^export\s+default\s+(async\s+)?(function|class)\b/.test(trimmed)) {
117
+ contractLines.push(line.trimEnd());
118
+ if (trimmed.includes("{")) {
119
+ let depth =
120
+ (trimmed.match(/\{/g) ?? []).length -
121
+ (trimmed.match(/\}/g) ?? []).length;
122
+ i++;
123
+ while (i < lines.length && depth > 0) {
124
+ const inner = lines[i];
125
+ contractLines.push(inner.trimEnd());
126
+ depth += (inner.match(/\{/g) ?? []).length;
127
+ depth -= (inner.match(/\}/g) ?? []).length;
128
+ i++;
129
+ }
130
+ } else {
131
+ i++;
132
+ }
133
+ continue;
134
+ }
135
+
136
+ // ── Single-line export declarations (functions, consts, re-exports) ───────
137
+ if (/^export\s/.test(trimmed)) {
138
+ contractLines.push(line.trimEnd());
139
+ }
140
+
141
+ // ── Throw patterns — validation constraints and named error codes ─────────
142
+ if (
143
+ /throw\s+(new\s+)?\w*[Ee]rror\b|throw\s+create[A-Z]\w*|@throws/.test(line) &&
144
+ throwLines.length < 20
145
+ ) {
146
+ throwLines.push(" // " + trimmed);
147
+ }
148
+
149
+ i++;
150
+ }
151
+
152
+ if (contractLines.length === 0 && throwLines.length === 0) {
153
+ return content.slice(0, 3000);
154
+ }
155
+
156
+ const parts: string[] = [...contractLines];
157
+ if (throwLines.length > 0) {
158
+ parts.push("", "// Error contracts (throws / validation):", ...throwLines);
159
+ }
160
+ return parts.join("\n");
161
+ }
162
+
163
+ /**
164
+ * Build a context section from files already written in this generation run.
165
+ */
166
+ export function buildGeneratedFilesSection(cache: Map<string, string>): string {
167
+ if (cache.size === 0) return "";
168
+ const lines = [
169
+ "\n=== Files Already Generated in This Run — USE EXACT EXPORTS (do not rename or invent alternatives) ===",
170
+ "// CRITICAL: function/action names and file paths below are ground truth. Copy them EXACTLY.",
171
+ "// Do NOT add suffixes (List, Data, All, Info) or change casing.",
172
+ "// For '// exists:' entries: use the EXACT filename shown — do NOT substitute index.vue or other defaults.",
173
+ ];
174
+ for (const [filePath, content] of cache) {
175
+ const isViewFile = /src[\\/](views?|pages?)[\\/]/i.test(filePath);
176
+ if (isViewFile) {
177
+ lines.push(`\n// exists: ${filePath}`);
178
+ continue;
179
+ }
180
+ lines.push(`\n--- ${filePath} ---`);
181
+ const isStoreOrComposable = /src[\\/](stores?|composables?)[\\/]/i.test(filePath);
182
+ lines.push(isStoreOrComposable ? content : extractBehavioralContract(content));
183
+ }
184
+ return lines.join("\n") + "\n";
185
+ }
186
+
187
+ // ─── RTK Helper ────────────────────────────────────────────────────────────────
188
+
189
+ export function isRtkAvailable(): boolean {
190
+ try {
191
+ execSync("rtk --version", { stdio: "ignore", timeout: 10_000 });
192
+ return true;
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
197
+
198
+ // ─── Parser Helpers ──────────────────────────────────────────────────────────
199
+
200
+ export function stripCodeFences(output: string): string {
201
+ const fenced = output.match(/^```(?:\w+)?\n([\s\S]*?)```\s*$/m);
202
+ if (fenced) return fenced[1].trim();
203
+ const lines = output.split("\n");
204
+ if (lines[0].startsWith("```")) lines.shift();
205
+ if (lines[lines.length - 1].trim() === "```") lines.pop();
206
+ return lines.join("\n").trim();
207
+ }
208
+
209
+ export function parseJsonArray(text: string): FileAction[] {
210
+ const fenced = text.match(/```(?:json)?\n(\[[\s\S]*?\])\n```/);
211
+ const raw = fenced ? fenced[1] : text.match(/\[[\s\S]*?\]/)?.[0] ?? "";
212
+ try {
213
+ const parsed = JSON.parse(raw);
214
+ if (Array.isArray(parsed)) return parsed as FileAction[];
215
+ } catch {
216
+ // fall through
217
+ }
218
+ return [];
219
+ }