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.
- package/README.md +381 -1796
- package/RELEASE_LOG.md +231 -0
- package/cli/commands/create.ts +9 -1176
- package/cli/commands/dashboard.ts +1 -1
- package/cli/pipeline/helpers.ts +34 -0
- package/cli/pipeline/multi-repo.ts +483 -0
- package/cli/pipeline/single-repo.ts +755 -0
- package/cli/utils.ts +2 -0
- package/core/code-generator.ts +52 -341
- package/core/codegen/helpers.ts +219 -0
- package/core/codegen/topo-sort.ts +98 -0
- package/core/constitution-consolidator.ts +2 -2
- package/core/dsl-coverage-checker.ts +298 -0
- package/core/dsl-extractor.ts +19 -46
- package/core/dsl-feedback.ts +1 -1
- package/core/dsl-validator.ts +74 -0
- package/core/error-feedback.ts +95 -11
- package/core/frontend-context-loader.ts +27 -5
- package/core/knowledge-memory.ts +52 -0
- package/core/mock/fixtures.ts +89 -0
- package/core/mock/proxy.ts +380 -0
- package/core/mock-server-generator.ts +12 -460
- package/core/requirement-decomposer.ts +4 -28
- package/core/reviewer.ts +1 -1
- package/core/safe-json.ts +76 -0
- package/core/spec-updater.ts +5 -21
- package/core/token-budget.ts +124 -0
- package/core/vcr.ts +20 -1
- package/dist/cli/index.js +4110 -3534
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +4237 -3661
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +18 -16
- package/dist/index.d.ts +18 -16
- package/dist/index.js +310 -182
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +308 -180
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/purpose.md +173 -33
- package/tests/auto-consolidation.test.ts +109 -0
- package/tests/combined-generator.test.ts +81 -0
- package/tests/constitution-consolidator.test.ts +161 -0
- package/tests/constitution-generator.test.ts +94 -0
- package/tests/contract-bridge.test.ts +201 -0
- package/tests/design-dialogue.test.ts +108 -0
- package/tests/dsl-coverage-checker.test.ts +230 -0
- package/tests/dsl-feedback.test.ts +45 -0
- package/tests/dsl-validator-xref.test.ts +99 -0
- package/tests/error-feedback-repair.test.ts +319 -0
- package/tests/error-feedback-validation.test.ts +91 -0
- package/tests/frontend-context-loader.test.ts +609 -0
- package/tests/global-constitution.test.ts +110 -0
- package/tests/key-store.test.ts +73 -0
- package/tests/knowledge-memory.test.ts +327 -0
- package/tests/project-index.test.ts +206 -0
- package/tests/prompt-hasher.test.ts +19 -0
- package/tests/requirement-decomposer.test.ts +171 -0
- package/tests/reviewer.test.ts +4 -1
- package/tests/run-logger.test.ts +289 -0
- package/tests/run-snapshot.test.ts +113 -0
- package/tests/safe-json.test.ts +63 -0
- package/tests/spec-updater.test.ts +161 -0
- package/tests/test-generator.test.ts +146 -0
- package/tests/token-budget.test.ts +124 -0
- package/tests/vcr-hash.test.ts +101 -0
- 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";
|
package/core/code-generator.ts
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
|
|
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
|
+
}
|