ai-spec-dev 0.38.0 → 0.42.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/.ai-spec-workspace.json +17 -0
- package/.ai-spec.json +7 -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 +764 -0
- package/cli/utils.ts +2 -0
- package/core/cli-ui.ts +136 -0
- package/core/code-generator.ts +56 -343
- 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 +99 -13
- 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/provider-utils.ts +8 -7
- 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/demo-backend/.ai-spec-constitution.md +65 -0
- package/demo-backend/package.json +21 -0
- package/demo-backend/prisma/schema.prisma +22 -0
- package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.dsl.json +186 -0
- package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.md +211 -0
- package/demo-backend/src/controllers/bookmark.controller.test.ts +255 -0
- package/demo-backend/src/controllers/bookmark.controller.ts +187 -0
- package/demo-backend/src/index.ts +17 -0
- package/demo-backend/src/routes/bookmark.routes.test.ts +264 -0
- package/demo-backend/src/routes/bookmark.routes.ts +11 -0
- package/demo-backend/src/routes/index.ts +8 -0
- package/demo-backend/src/services/bookmark.service.test.ts +433 -0
- package/demo-backend/src/services/bookmark.service.ts +261 -0
- package/demo-backend/tsconfig.json +12 -0
- package/demo-frontend/.ai-spec-constitution.md +95 -0
- package/demo-frontend/package.json +23 -0
- package/demo-frontend/src/App.tsx +12 -0
- package/demo-frontend/src/main.tsx +9 -0
- package/demo-frontend/tsconfig.json +13 -0
- package/dist/cli/index.js +4351 -3666
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +3997 -3312
- 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 +388 -188
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +386 -186
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- 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/RELEASE_LOG.md +0 -2731
- package/purpose.md +0 -1294
package/core/dsl-extractor.ts
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
buildDslExtractionPrompt,
|
|
12
12
|
buildDslRetryPrompt,
|
|
13
13
|
} from "../prompts/dsl.prompt";
|
|
14
|
+
import { estimateTokens, getDefaultBudget } from "./token-budget";
|
|
15
|
+
import { parseJsonFromAiOutput } from "./safe-json";
|
|
14
16
|
|
|
15
17
|
// ─── DSL Sanitizer ───────────────────────────────────────────────────────────
|
|
16
18
|
|
|
@@ -50,8 +52,8 @@ function sanitizeDsl(raw: unknown): unknown {
|
|
|
50
52
|
/** Maximum AI attempts (1 initial + up to this many retries). */
|
|
51
53
|
const MAX_RETRIES = 2;
|
|
52
54
|
|
|
53
|
-
/**
|
|
54
|
-
const
|
|
55
|
+
/** Default maximum spec length passed to AI. Overridden by token budget when provider is known. */
|
|
56
|
+
const DEFAULT_MAX_SPEC_CHARS = 12_000;
|
|
55
57
|
|
|
56
58
|
// ─── DSL file naming ──────────────────────────────────────────────────────────
|
|
57
59
|
|
|
@@ -63,45 +65,8 @@ export function dslFilePath(specFilePath: string): string {
|
|
|
63
65
|
|
|
64
66
|
// ─── Parser ───────────────────────────────────────────────────────────────────
|
|
65
67
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
* Handles two cases:
|
|
69
|
-
* 1. Bare JSON object starting with `{`
|
|
70
|
-
* 2. JSON wrapped in a ```json ... ``` fence (model sometimes ignores instructions)
|
|
71
|
-
*
|
|
72
|
-
* Does NOT use eval or Function() — only JSON.parse().
|
|
73
|
-
*/
|
|
74
|
-
function parseJsonFromOutput(raw: string): unknown {
|
|
75
|
-
const trimmed = raw.trim();
|
|
76
|
-
|
|
77
|
-
// Case 1: bare JSON
|
|
78
|
-
if (trimmed.startsWith("{")) {
|
|
79
|
-
return JSON.parse(trimmed);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Case 2: fenced JSON — extract content between first ``` and last ```
|
|
83
|
-
const fenceStart = trimmed.indexOf("```");
|
|
84
|
-
if (fenceStart !== -1) {
|
|
85
|
-
const afterFence = trimmed.slice(fenceStart + 3);
|
|
86
|
-
// Skip optional language tag (e.g. "json\n")
|
|
87
|
-
const newlinePos = afterFence.indexOf("\n");
|
|
88
|
-
const jsonStart = newlinePos !== -1 ? newlinePos + 1 : 0;
|
|
89
|
-
const fenceEnd = afterFence.lastIndexOf("```");
|
|
90
|
-
if (fenceEnd > jsonStart) {
|
|
91
|
-
const jsonStr = afterFence.slice(jsonStart, fenceEnd).trim();
|
|
92
|
-
return JSON.parse(jsonStr);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Case 3: try to find the first `{` and last `}` pair
|
|
97
|
-
const objStart = trimmed.indexOf("{");
|
|
98
|
-
const objEnd = trimmed.lastIndexOf("}");
|
|
99
|
-
if (objStart !== -1 && objEnd > objStart) {
|
|
100
|
-
return JSON.parse(trimmed.slice(objStart, objEnd + 1));
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
throw new SyntaxError("No JSON object found in AI output");
|
|
104
|
-
}
|
|
68
|
+
// Uses shared parseJsonFromAiOutput from safe-json.ts
|
|
69
|
+
const parseJsonFromOutput = parseJsonFromAiOutput;
|
|
105
70
|
|
|
106
71
|
// ─── DslExtractor ────────────────────────────────────────────────────────────
|
|
107
72
|
|
|
@@ -125,12 +90,20 @@ export class DslExtractor {
|
|
|
125
90
|
specContent: string,
|
|
126
91
|
opts: { auto?: boolean; isFrontend?: boolean } = {}
|
|
127
92
|
): Promise<SpecDSL | null> {
|
|
93
|
+
// Compute dynamic spec char limit based on provider's token budget.
|
|
94
|
+
// Reserve ~30% of budget for DSL extraction prompt + response; use 70% for spec content.
|
|
95
|
+
const providerBudget = getDefaultBudget(this.provider.providerName);
|
|
96
|
+
const maxSpecChars = Math.max(
|
|
97
|
+
DEFAULT_MAX_SPEC_CHARS,
|
|
98
|
+
Math.floor(providerBudget * 0.7 * 3) // ~3 chars per token, 70% of budget
|
|
99
|
+
);
|
|
100
|
+
|
|
128
101
|
// Truncate very long specs to avoid token issues
|
|
129
102
|
const specForAI =
|
|
130
|
-
specContent.length >
|
|
103
|
+
specContent.length > maxSpecChars
|
|
131
104
|
? (() => {
|
|
132
|
-
console.log(chalk.yellow(` ⚠ Spec is ${specContent.length} chars — truncating to ${
|
|
133
|
-
return specContent.slice(0,
|
|
105
|
+
console.log(chalk.yellow(` ⚠ Spec is ${specContent.length} chars — truncating to ${maxSpecChars} for DSL extraction (${this.provider.providerName} budget: ${Math.round(providerBudget / 1000)}K tokens).`));
|
|
106
|
+
return specContent.slice(0, maxSpecChars) + "\n... (truncated for DSL extraction)";
|
|
134
107
|
})()
|
|
135
108
|
: specContent;
|
|
136
109
|
|
|
@@ -170,8 +143,8 @@ export class DslExtractor {
|
|
|
170
143
|
console.log(chalk.red(` ✘ Failed to parse JSON from AI output: ${(parseErr as Error).message}`));
|
|
171
144
|
const preview = rawOutput.slice(0, 500).replace(/\n/g, "\\n");
|
|
172
145
|
console.log(chalk.gray(` AI output preview (first 500 chars): ${preview}`));
|
|
173
|
-
if (rawOutput.length >
|
|
174
|
-
console.log(chalk.gray(` Note: spec was truncated to ${
|
|
146
|
+
if (rawOutput.length > maxSpecChars) {
|
|
147
|
+
console.log(chalk.gray(` Note: spec was truncated to ${maxSpecChars} chars — long specs may lose context`));
|
|
175
148
|
}
|
|
176
149
|
lastErrors = [{ path: "root", message: "Output is not valid JSON — see raw output above" }];
|
|
177
150
|
|
package/core/dsl-feedback.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { SpecDSL } from "./dsl-types";
|
|
|
21
21
|
|
|
22
22
|
export interface DslGap {
|
|
23
23
|
/** Short machine key for RunLog serialisation */
|
|
24
|
-
code: "sparse_model" | "missing_errors" | "generic_endpoint_desc" | "no_models_no_endpoints";
|
|
24
|
+
code: "sparse_model" | "missing_errors" | "generic_endpoint_desc" | "no_models_no_endpoints" | "uncovered_requirement";
|
|
25
25
|
/** Human-readable message shown to the user */
|
|
26
26
|
message: string;
|
|
27
27
|
/** Concrete suggestion injected into the refinement prompt */
|
package/core/dsl-validator.ts
CHANGED
|
@@ -119,6 +119,9 @@ export function validateDsl(raw: unknown): DslValidationResult {
|
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// ── Cross-reference checks ──────────────────────────────────────────────
|
|
123
|
+
crossReferenceChecks(obj, errors);
|
|
124
|
+
|
|
122
125
|
if (errors.length > 0) {
|
|
123
126
|
return { valid: false, errors };
|
|
124
127
|
}
|
|
@@ -428,6 +431,77 @@ function validateComponent(
|
|
|
428
431
|
}
|
|
429
432
|
}
|
|
430
433
|
|
|
434
|
+
// ─── Cross-reference checks ──────────────────────────────────────────────────
|
|
435
|
+
|
|
436
|
+
function crossReferenceChecks(
|
|
437
|
+
obj: Record<string, unknown>,
|
|
438
|
+
errors: DslValidationError[]
|
|
439
|
+
): void {
|
|
440
|
+
const models = Array.isArray(obj["models"]) ? (obj["models"] as Record<string, unknown>[]) : [];
|
|
441
|
+
const endpoints = Array.isArray(obj["endpoints"]) ? (obj["endpoints"] as Record<string, unknown>[]) : [];
|
|
442
|
+
const components = Array.isArray(obj["components"]) ? (obj["components"] as Record<string, unknown>[]) : [];
|
|
443
|
+
|
|
444
|
+
// 1. Duplicate path+method detection
|
|
445
|
+
const seenRoutes = new Map<string, number>();
|
|
446
|
+
for (let i = 0; i < endpoints.length; i++) {
|
|
447
|
+
const ep = endpoints[i];
|
|
448
|
+
if (typeof ep?.["method"] === "string" && typeof ep?.["path"] === "string") {
|
|
449
|
+
const route = `${(ep["method"] as string).toUpperCase()} ${ep["path"]}`;
|
|
450
|
+
if (seenRoutes.has(route)) {
|
|
451
|
+
errors.push({
|
|
452
|
+
path: `endpoints[${i}]`,
|
|
453
|
+
message: `Duplicate route "${route}" — also defined at endpoints[${seenRoutes.get(route)}]`,
|
|
454
|
+
});
|
|
455
|
+
} else {
|
|
456
|
+
seenRoutes.set(route, i);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// 2. Model relations reference existing model names
|
|
462
|
+
const modelNames = new Set(
|
|
463
|
+
models.filter((m) => typeof m?.["name"] === "string").map((m) => m["name"] as string)
|
|
464
|
+
);
|
|
465
|
+
for (let i = 0; i < models.length; i++) {
|
|
466
|
+
const m = models[i];
|
|
467
|
+
if (!Array.isArray(m?.["relations"])) continue;
|
|
468
|
+
for (const rel of m["relations"] as string[]) {
|
|
469
|
+
if (typeof rel !== "string") continue;
|
|
470
|
+
// Extract referenced model name: "User hasMany Post" → "Post", "belongsTo Category" → "Category"
|
|
471
|
+
const refMatch = rel.match(/(?:hasMany|hasOne|belongsTo|manyToMany)\s+(\w+)/i);
|
|
472
|
+
if (refMatch) {
|
|
473
|
+
const refName = refMatch[1];
|
|
474
|
+
if (!modelNames.has(refName)) {
|
|
475
|
+
errors.push({
|
|
476
|
+
path: `models[${i}].relations`,
|
|
477
|
+
message: `Relation references model "${refName}" which is not defined in models[]`,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// 3. Component apiCalls reference existing endpoint IDs
|
|
485
|
+
const endpointIds = new Set(
|
|
486
|
+
endpoints.filter((e) => typeof e?.["id"] === "string").map((e) => e["id"] as string)
|
|
487
|
+
);
|
|
488
|
+
if (endpointIds.size > 0) {
|
|
489
|
+
for (let i = 0; i < components.length; i++) {
|
|
490
|
+
const c = components[i];
|
|
491
|
+
if (!Array.isArray(c?.["apiCalls"])) continue;
|
|
492
|
+
for (const call of c["apiCalls"] as string[]) {
|
|
493
|
+
if (typeof call !== "string") continue;
|
|
494
|
+
if (!endpointIds.has(call)) {
|
|
495
|
+
errors.push({
|
|
496
|
+
path: `components[${i}].apiCalls`,
|
|
497
|
+
message: `References endpoint "${call}" which is not defined in endpoints[]`,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
431
505
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
432
506
|
|
|
433
507
|
function requireNonEmptyString(
|
package/core/error-feedback.ts
CHANGED
|
@@ -7,10 +7,11 @@ import { getCodeGenSystemPrompt } from "../prompts/codegen.prompt";
|
|
|
7
7
|
import { SpecDSL } from "./dsl-types";
|
|
8
8
|
import { buildDslContextSection } from "./dsl-extractor";
|
|
9
9
|
import { getActiveSnapshot } from "./run-snapshot";
|
|
10
|
+
import { startSpinner } from "./cli-ui";
|
|
10
11
|
|
|
11
12
|
// ─── Types ──────────────────────────────────────────────────────────────────────
|
|
12
13
|
|
|
13
|
-
interface ErrorEntry {
|
|
14
|
+
export interface ErrorEntry {
|
|
14
15
|
source: "test" | "lint" | "build";
|
|
15
16
|
message: string;
|
|
16
17
|
file?: string;
|
|
@@ -60,7 +61,7 @@ function runCommand(cmd: string, cwd: string): { success: boolean; output: strin
|
|
|
60
61
|
* Detect TypeScript type-check command for the given directory.
|
|
61
62
|
* Returns null for non-TS projects or projects without tsconfig.
|
|
62
63
|
*/
|
|
63
|
-
function detectBuildCommand(workingDir: string): string | null {
|
|
64
|
+
export function detectBuildCommand(workingDir: string): string | null {
|
|
64
65
|
// Only applies to Node.js / frontend TypeScript projects
|
|
65
66
|
if (!fs.existsSync(path.join(workingDir, "tsconfig.json"))) return null;
|
|
66
67
|
|
|
@@ -80,7 +81,7 @@ function detectBuildCommand(workingDir: string): string | null {
|
|
|
80
81
|
return "npx tsc --noEmit";
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
function detectTestCommand(workingDir: string): string | null {
|
|
84
|
+
export function detectTestCommand(workingDir: string): string | null {
|
|
84
85
|
// ── Go ──────────────────────────────────────────────────────────────────────
|
|
85
86
|
if (fs.existsSync(path.join(workingDir, "go.mod"))) return "go test ./...";
|
|
86
87
|
|
|
@@ -129,7 +130,7 @@ function detectTestCommand(workingDir: string): string | null {
|
|
|
129
130
|
return null;
|
|
130
131
|
}
|
|
131
132
|
|
|
132
|
-
function detectLintCommand(workingDir: string): string | null {
|
|
133
|
+
export function detectLintCommand(workingDir: string): string | null {
|
|
133
134
|
// ── Go ──────────────────────────────────────────────────────────────────────
|
|
134
135
|
if (fs.existsSync(path.join(workingDir, "go.mod"))) {
|
|
135
136
|
// golangci-lint is optional; fall back to go vet
|
|
@@ -183,7 +184,7 @@ function detectLintCommand(workingDir: string): string | null {
|
|
|
183
184
|
return null;
|
|
184
185
|
}
|
|
185
186
|
|
|
186
|
-
function parseErrors(output: string, source: ErrorEntry["source"]): ErrorEntry[] {
|
|
187
|
+
export function parseErrors(output: string, source: ErrorEntry["source"]): ErrorEntry[] {
|
|
187
188
|
const errors: ErrorEntry[] = [];
|
|
188
189
|
if (!output.trim()) return errors;
|
|
189
190
|
|
|
@@ -226,7 +227,7 @@ function parseErrors(output: string, source: ErrorEntry["source"]): ErrorEntry[]
|
|
|
226
227
|
* Returns paths normalized to project-root-relative form (no extension).
|
|
227
228
|
* Only considers relative imports (./foo, ../foo) — skips aliases and node_modules.
|
|
228
229
|
*/
|
|
229
|
-
function parseRelativeImports(content: string, fromFileRel: string): string[] {
|
|
230
|
+
export function parseRelativeImports(content: string, fromFileRel: string): string[] {
|
|
230
231
|
const relDir = path.dirname(fromFileRel);
|
|
231
232
|
const results: string[] = [];
|
|
232
233
|
|
|
@@ -253,7 +254,7 @@ function parseRelativeImports(content: string, fromFileRel: string): string[] {
|
|
|
253
254
|
* Example: if A exports a type used by B and C, fix A first so cycle 1 can cascade correctly.
|
|
254
255
|
* Uses Kahn's topological sort; cycles are appended at the end (unavoidable, fix last).
|
|
255
256
|
*/
|
|
256
|
-
async function buildRepairOrder(
|
|
257
|
+
export async function buildRepairOrder(
|
|
257
258
|
errorsByFile: Map<string, ErrorEntry[]>,
|
|
258
259
|
workingDir: string
|
|
259
260
|
): Promise<[string, ErrorEntry[]][]> {
|
|
@@ -369,22 +370,80 @@ ${fileContent}
|
|
|
369
370
|
|
|
370
371
|
Output ONLY the complete fixed file content. No markdown fences, no explanations.`;
|
|
371
372
|
|
|
373
|
+
const fixSpinner = startSpinner(`Fixing ${chalk.bold(file)} (${fileErrors.length} error(s))...`);
|
|
372
374
|
try {
|
|
373
375
|
const raw = await provider.generate(prompt, getCodeGenSystemPrompt());
|
|
374
376
|
const fixed = raw.replace(/^```\w*\n?/gm, "").replace(/\n?```$/gm, "").trim();
|
|
375
377
|
await getActiveSnapshot()?.snapshotFile(fullPath);
|
|
376
378
|
await fs.writeFile(fullPath, fixed, "utf-8");
|
|
377
379
|
results.push({ fixed: true, file, explanation: `Fixed ${fileErrors.length} error(s)` });
|
|
378
|
-
|
|
380
|
+
fixSpinner.succeed(`Auto-fixed: ${file}`);
|
|
379
381
|
} catch (err) {
|
|
380
382
|
results.push({ fixed: false, file, explanation: `AI fix failed: ${(err as Error).message}` });
|
|
381
|
-
|
|
383
|
+
fixSpinner.fail(`Could not auto-fix: ${file}`);
|
|
382
384
|
}
|
|
383
385
|
}
|
|
384
386
|
|
|
385
387
|
return results;
|
|
386
388
|
}
|
|
387
389
|
|
|
390
|
+
// ─── Test Existence Validation ──────────────────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
/** Patterns that indicate a file contains actual test cases (language-agnostic). */
|
|
393
|
+
const TEST_PATTERNS = [
|
|
394
|
+
/\bdescribe\s*\(/, // JS/TS (vitest, jest, mocha)
|
|
395
|
+
/\bit\s*\(/, // JS/TS
|
|
396
|
+
/\btest\s*\(/, // JS/TS
|
|
397
|
+
/\bfunc\s+Test\w*\s*\(/, // Go
|
|
398
|
+
/\bdef\s+test_/, // Python
|
|
399
|
+
/@Test\b/, // Java (JUnit)
|
|
400
|
+
/#\[test\]/, // Rust
|
|
401
|
+
];
|
|
402
|
+
|
|
403
|
+
export interface TestValidationResult {
|
|
404
|
+
valid: boolean;
|
|
405
|
+
fileCount: number;
|
|
406
|
+
reason?: string;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Check that generated test files exist on disk and contain actual test cases.
|
|
411
|
+
* Prevents false-positive "tests pass" when no tests were generated.
|
|
412
|
+
*/
|
|
413
|
+
export function validateTestFilesExist(
|
|
414
|
+
workingDir: string,
|
|
415
|
+
generatedTestFiles: string[]
|
|
416
|
+
): TestValidationResult {
|
|
417
|
+
if (generatedTestFiles.length === 0) {
|
|
418
|
+
return { valid: false, fileCount: 0, reason: "No test files were generated" };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
let validCount = 0;
|
|
422
|
+
for (const relPath of generatedTestFiles) {
|
|
423
|
+
const absPath = path.join(workingDir, relPath);
|
|
424
|
+
if (!fs.existsSync(absPath)) continue;
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
const content = fs.readFileSync(absPath, "utf-8");
|
|
428
|
+
if (TEST_PATTERNS.some((p) => p.test(content))) {
|
|
429
|
+
validCount++;
|
|
430
|
+
}
|
|
431
|
+
} catch {
|
|
432
|
+
// unreadable — skip
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (validCount === 0) {
|
|
437
|
+
return {
|
|
438
|
+
valid: false,
|
|
439
|
+
fileCount: generatedTestFiles.length,
|
|
440
|
+
reason: `${generatedTestFiles.length} test file(s) listed but none contain actual test cases`,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return { valid: true, fileCount: validCount };
|
|
445
|
+
}
|
|
446
|
+
|
|
388
447
|
// ─── Public API ─────────────────────────────────────────────────────────────────
|
|
389
448
|
|
|
390
449
|
export interface ErrorFeedbackOptions {
|
|
@@ -396,6 +455,8 @@ export interface ErrorFeedbackOptions {
|
|
|
396
455
|
skipLint?: boolean;
|
|
397
456
|
/** Whether to skip TypeScript type-check (tsc --noEmit / vue-tsc --noEmit) */
|
|
398
457
|
skipBuild?: boolean;
|
|
458
|
+
/** List of generated test files — used to validate tests actually exist */
|
|
459
|
+
generatedTestFiles?: string[];
|
|
399
460
|
}
|
|
400
461
|
|
|
401
462
|
/**
|
|
@@ -421,6 +482,19 @@ export async function runErrorFeedback(
|
|
|
421
482
|
return true;
|
|
422
483
|
}
|
|
423
484
|
|
|
485
|
+
// ── Pre-check: validate generated test files actually exist ──────────────
|
|
486
|
+
let testsValidated = true;
|
|
487
|
+
if (testCmd && opts.generatedTestFiles) {
|
|
488
|
+
const testValidation = validateTestFilesExist(workingDir, opts.generatedTestFiles);
|
|
489
|
+
if (!testValidation.valid) {
|
|
490
|
+
console.log(chalk.yellow(` ⚠ Test validation: ${testValidation.reason}`));
|
|
491
|
+
console.log(chalk.yellow(" Test results will be treated as unvalidated (not as 'pass')."));
|
|
492
|
+
testsValidated = false;
|
|
493
|
+
} else {
|
|
494
|
+
console.log(chalk.gray(` Test validation: ${testValidation.fileCount} valid test file(s) found.`));
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
424
498
|
if (buildCmd) console.log(chalk.gray(` Type-check: ${buildCmd}`));
|
|
425
499
|
|
|
426
500
|
let prevErrorCount = Infinity; // circuit-breaker: tracks error count from previous cycle
|
|
@@ -489,16 +563,28 @@ export async function runErrorFeedback(
|
|
|
489
563
|
}
|
|
490
564
|
|
|
491
565
|
if (allErrors.length === 0) {
|
|
566
|
+
if (!testsValidated) {
|
|
567
|
+
console.log(chalk.yellow(`\n ⚠ Build/lint passed but tests were not validated (no test files with actual test cases).`));
|
|
568
|
+
return true; // don't block pipeline, but the warning is logged
|
|
569
|
+
}
|
|
492
570
|
console.log(chalk.green(`\n ✔ All checks passed after ${cycle} cycle(s).`));
|
|
493
571
|
return true;
|
|
494
572
|
}
|
|
495
573
|
|
|
496
|
-
// Circuit breaker: if
|
|
497
|
-
|
|
498
|
-
|
|
574
|
+
// Circuit breaker: stop if error count didn't decrease or is increasing.
|
|
575
|
+
if (allErrors.length > prevErrorCount) {
|
|
576
|
+
console.log(
|
|
577
|
+
chalk.red(
|
|
578
|
+
`\n ✘ Auto-fix caused regression (${prevErrorCount} → ${allErrors.length} errors). Stopping immediately.`
|
|
579
|
+
)
|
|
580
|
+
);
|
|
581
|
+
console.log(chalk.gray(" The fix attempt introduced new errors. Manual intervention needed."));
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
if (allErrors.length === prevErrorCount) {
|
|
499
585
|
console.log(
|
|
500
586
|
chalk.yellow(
|
|
501
|
-
`\n ⚠ Auto-fix made no progress (${allErrors.length} error(s)
|
|
587
|
+
`\n ⚠ Auto-fix made no progress (${allErrors.length} error(s) remain). Stopping early.`
|
|
502
588
|
)
|
|
503
589
|
);
|
|
504
590
|
console.log(chalk.gray(" Manual intervention needed."));
|
|
@@ -4,7 +4,10 @@ import { glob } from "glob";
|
|
|
4
4
|
|
|
5
5
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
6
6
|
|
|
7
|
-
export type FrontendFramework =
|
|
7
|
+
export type FrontendFramework =
|
|
8
|
+
| "react" | "next" | "vue" | "react-native"
|
|
9
|
+
| "svelte" | "sveltekit" | "solid" | "qwik" | "remix" | "astro"
|
|
10
|
+
| "unknown";
|
|
8
11
|
|
|
9
12
|
export interface FrontendContext {
|
|
10
13
|
framework: FrontendFramework;
|
|
@@ -88,7 +91,7 @@ interface ImportStatement {
|
|
|
88
91
|
* - Returns structured objects so callers can inspect specifiers vs module path
|
|
89
92
|
* without re-running a second regex
|
|
90
93
|
*/
|
|
91
|
-
function parseImportStatements(content: string): ImportStatement[] {
|
|
94
|
+
export function parseImportStatements(content: string): ImportStatement[] {
|
|
92
95
|
// 1. Strip block comments (/* ... */) to avoid matching imports inside comments
|
|
93
96
|
const stripped = content.replace(/\/\*[\s\S]*?\*\//g, (m) => "\n".repeat(m.split("\n").length - 1));
|
|
94
97
|
|
|
@@ -137,7 +140,7 @@ const HTTP_MODULE_PATTERNS: RegExp[] = [
|
|
|
137
140
|
* - Module-path matching is done on the resolved path string, not the full line,
|
|
138
141
|
* so a long specifier list doesn't prevent a match
|
|
139
142
|
*/
|
|
140
|
-
function findHttpClientImport(content: string): string | undefined {
|
|
143
|
+
export function findHttpClientImport(content: string): string | undefined {
|
|
141
144
|
for (const stmt of parseImportStatements(content)) {
|
|
142
145
|
if (HTTP_MODULE_PATTERNS.some((p) => p.test(stmt.modulePath))) {
|
|
143
146
|
return stmt.line;
|
|
@@ -190,6 +193,8 @@ const ROUTING_LIBS: Array<[string, string]> = [
|
|
|
190
193
|
["react-navigation", "react-navigation"],
|
|
191
194
|
["expo-router", "expo-router"],
|
|
192
195
|
["vue-router", "vue-router"],
|
|
196
|
+
["@solidjs/router", "solid-router"],
|
|
197
|
+
["@builder.io/qwik-city", "qwik-city"],
|
|
193
198
|
];
|
|
194
199
|
|
|
195
200
|
// ─── Main function ─────────────────────────────────────────────────────────────
|
|
@@ -231,9 +236,21 @@ export async function loadFrontendContext(
|
|
|
231
236
|
const depKeys = Object.keys(allDeps);
|
|
232
237
|
const has = (name: string) => depKeys.includes(name);
|
|
233
238
|
|
|
234
|
-
// Framework
|
|
239
|
+
// Framework (order matters: more specific before generic)
|
|
235
240
|
if (has("react-native") || has("expo")) {
|
|
236
241
|
ctx.framework = "react-native";
|
|
242
|
+
} else if (has("@sveltejs/kit")) {
|
|
243
|
+
ctx.framework = "sveltekit";
|
|
244
|
+
} else if (has("svelte")) {
|
|
245
|
+
ctx.framework = "svelte";
|
|
246
|
+
} else if (has("@builder.io/qwik")) {
|
|
247
|
+
ctx.framework = "qwik";
|
|
248
|
+
} else if (has("@remix-run/react") || has("@remix-run/node")) {
|
|
249
|
+
ctx.framework = "remix";
|
|
250
|
+
} else if (has("astro")) {
|
|
251
|
+
ctx.framework = "astro";
|
|
252
|
+
} else if (has("solid-js")) {
|
|
253
|
+
ctx.framework = "solid";
|
|
237
254
|
} else if (has("next")) {
|
|
238
255
|
ctx.framework = "next";
|
|
239
256
|
} else if (has("react")) {
|
|
@@ -266,9 +283,14 @@ export async function loadFrontendContext(
|
|
|
266
283
|
|
|
267
284
|
// Routing
|
|
268
285
|
if (ctx.framework === "next") {
|
|
269
|
-
// Detect app router vs pages router
|
|
270
286
|
const hasAppDir = await fs.pathExists(path.join(projectRoot, "app"));
|
|
271
287
|
ctx.routingPattern = hasAppDir ? "next-app-router" : "next-pages-router";
|
|
288
|
+
} else if (ctx.framework === "sveltekit") {
|
|
289
|
+
ctx.routingPattern = "sveltekit-file-router";
|
|
290
|
+
} else if (ctx.framework === "remix") {
|
|
291
|
+
ctx.routingPattern = "remix-file-router";
|
|
292
|
+
} else if (ctx.framework === "astro") {
|
|
293
|
+
ctx.routingPattern = "astro-file-router";
|
|
272
294
|
} else {
|
|
273
295
|
for (const [lib, label] of ROUTING_LIBS) {
|
|
274
296
|
if (has(lib)) {
|
package/core/knowledge-memory.ts
CHANGED
|
@@ -4,6 +4,7 @@ import * as path from "path";
|
|
|
4
4
|
import { AIProvider } from "./spec-generator";
|
|
5
5
|
import { CONSTITUTION_FILE } from "./constitution-generator";
|
|
6
6
|
import { parseConstitutionStats } from "../prompts/consolidate.prompt";
|
|
7
|
+
import { ConstitutionConsolidator } from "./constitution-consolidator";
|
|
7
8
|
|
|
8
9
|
// ─── Types ──────────────────────────────────────────────────────────────────────
|
|
9
10
|
|
|
@@ -223,3 +224,54 @@ export async function accumulateReviewKnowledge(
|
|
|
223
224
|
|
|
224
225
|
await appendLessonsToConstitution(projectRoot, issues);
|
|
225
226
|
}
|
|
227
|
+
|
|
228
|
+
// ─── Auto-Consolidation ──────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
const DEFAULT_AUTO_CONSOLIDATE_THRESHOLD = 12;
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Check if §9 has grown past the threshold and auto-consolidate if so.
|
|
234
|
+
* Non-blocking, creates backups, uses the same ConstitutionConsolidator.
|
|
235
|
+
* Returns true if consolidation was performed.
|
|
236
|
+
*/
|
|
237
|
+
export async function maybeAutoConsolidate(
|
|
238
|
+
provider: AIProvider,
|
|
239
|
+
projectRoot: string,
|
|
240
|
+
opts: { threshold?: number } = {}
|
|
241
|
+
): Promise<boolean> {
|
|
242
|
+
const threshold = opts.threshold ?? DEFAULT_AUTO_CONSOLIDATE_THRESHOLD;
|
|
243
|
+
const constitutionPath = path.join(projectRoot, CONSTITUTION_FILE);
|
|
244
|
+
|
|
245
|
+
let content: string;
|
|
246
|
+
try {
|
|
247
|
+
content = await fs.readFile(constitutionPath, "utf-8");
|
|
248
|
+
} catch {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const stats = parseConstitutionStats(content);
|
|
253
|
+
if (stats.lessonCount < threshold) return false;
|
|
254
|
+
|
|
255
|
+
console.log(
|
|
256
|
+
chalk.cyan(
|
|
257
|
+
` §9 has ${stats.lessonCount} lessons (threshold: ${threshold}) — auto-consolidating...`
|
|
258
|
+
)
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const consolidator = new ConstitutionConsolidator(provider);
|
|
263
|
+
const result = await consolidator.consolidate(projectRoot, { minLessons: threshold });
|
|
264
|
+
if (result.written) {
|
|
265
|
+
console.log(
|
|
266
|
+
chalk.green(
|
|
267
|
+
` ✔ Auto-consolidated: ${result.before.lessonCount} → ${result.after!.lessonCount} lessons. Backup: ${result.backupPath}`
|
|
268
|
+
)
|
|
269
|
+
);
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
} catch (err) {
|
|
273
|
+
console.log(chalk.yellow(` ⚠ Auto-consolidation failed: ${(err as Error).message}`));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { SpecDSL, ApiEndpoint, FieldMap } from "../dsl-types";
|
|
2
|
+
|
|
3
|
+
// ─── Fixture Generator ────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Convert a type-description string to a fixture value (JavaScript literal).
|
|
7
|
+
*/
|
|
8
|
+
function typeToFixture(fieldName: string, typeDesc: string): unknown {
|
|
9
|
+
const t = typeDesc.toLowerCase();
|
|
10
|
+
|
|
11
|
+
if (t.includes("boolean") || t === "bool") return true;
|
|
12
|
+
if (t.includes("int") || t.includes("number") || t.includes("float") || t.includes("decimal")) {
|
|
13
|
+
if (fieldName.toLowerCase().includes("id")) return 1;
|
|
14
|
+
if (fieldName.toLowerCase().includes("count") || fieldName.toLowerCase().includes("total")) return 42;
|
|
15
|
+
if (fieldName.toLowerCase().includes("price") || fieldName.toLowerCase().includes("amount")) return 9.99;
|
|
16
|
+
return 1;
|
|
17
|
+
}
|
|
18
|
+
if (t.includes("datetime") || t.includes("date") || t.includes("timestamp")) {
|
|
19
|
+
return "2024-01-15T10:30:00.000Z";
|
|
20
|
+
}
|
|
21
|
+
if (t.includes("[]") || t.includes("array") || t.includes("list")) return [];
|
|
22
|
+
if (t.includes("object") || t.includes("json") || t.includes("record")) return {};
|
|
23
|
+
|
|
24
|
+
// String heuristics by field name
|
|
25
|
+
const name = fieldName.toLowerCase();
|
|
26
|
+
if (name === "id" || name.endsWith("id")) return "abc123";
|
|
27
|
+
if (name.includes("email")) return "user@example.com";
|
|
28
|
+
if (name.includes("phone")) return "+1-555-0100";
|
|
29
|
+
if (name.includes("url") || name.includes("image") || name.includes("avatar")) return "https://example.com/sample.jpg";
|
|
30
|
+
if (name.includes("token") || name.includes("secret")) return "mock-token-xyz";
|
|
31
|
+
if (name.includes("name")) return "Example Name";
|
|
32
|
+
if (name.includes("title")) return "Example Title";
|
|
33
|
+
if (name.includes("description") || name.includes("content") || name.includes("body")) return "Example description text";
|
|
34
|
+
if (name.includes("status")) return "active";
|
|
35
|
+
if (name.includes("type") || name.includes("role")) return "default";
|
|
36
|
+
if (name.includes("code")) return "CODE001";
|
|
37
|
+
|
|
38
|
+
return `example_${fieldName}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildFixtureObject(fields: FieldMap): Record<string, unknown> {
|
|
42
|
+
const obj: Record<string, unknown> = {};
|
|
43
|
+
for (const [name, type] of Object.entries(fields)) {
|
|
44
|
+
obj[name] = typeToFixture(name, type);
|
|
45
|
+
}
|
|
46
|
+
return obj;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build a fixture response object for an endpoint.
|
|
51
|
+
* For endpoints without explicit response schemas, generate minimal fixtures from model context.
|
|
52
|
+
*/
|
|
53
|
+
export function buildEndpointFixture(endpoint: ApiEndpoint, dsl: SpecDSL): unknown {
|
|
54
|
+
const method = endpoint.method;
|
|
55
|
+
const status = endpoint.successStatus;
|
|
56
|
+
|
|
57
|
+
// DELETE with 204 → no body
|
|
58
|
+
if (status === 204) return null;
|
|
59
|
+
|
|
60
|
+
// Try to derive fixture from model names mentioned in endpoint description
|
|
61
|
+
const descLower = endpoint.description.toLowerCase();
|
|
62
|
+
const matchedModel = dsl.models.find((m) =>
|
|
63
|
+
descLower.includes(m.name.toLowerCase())
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
if (matchedModel) {
|
|
67
|
+
const fields: FieldMap = {};
|
|
68
|
+
for (const f of matchedModel.fields) {
|
|
69
|
+
fields[f.name] = f.type;
|
|
70
|
+
}
|
|
71
|
+
const item = buildFixtureObject(fields);
|
|
72
|
+
|
|
73
|
+
// List endpoints return arrays
|
|
74
|
+
if (method === "GET" && (descLower.includes("list") || descLower.includes("all") || descLower.includes("paginate"))) {
|
|
75
|
+
return {
|
|
76
|
+
data: [item, { ...item, id: "def456" }],
|
|
77
|
+
total: 2,
|
|
78
|
+
page: 1,
|
|
79
|
+
pageSize: 10,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return { data: item };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Fallback based on method
|
|
86
|
+
if (method === "POST") return { data: { id: "abc123", createdAt: "2024-01-15T10:30:00.000Z" } };
|
|
87
|
+
if (method === "GET") return { data: { id: "abc123" } };
|
|
88
|
+
return { success: true };
|
|
89
|
+
}
|