diff --git a/.changeset/g5-materialize-build.md b/.changeset/g5-materialize-build.md new file mode 100644 index 000000000..37a4768c0 --- /dev/null +++ b/.changeset/g5-materialize-build.md @@ -0,0 +1,11 @@ +--- +"@linchkit/core": minor +--- + +G5 Phase 2 + 3 — proposal code materialization + build-gate (engine). + +- **Materializer (P3):** `materializeProposalChanges({ proposal, provider, qualityGate?, maxRetries?, context? })` generates TypeScript source for the irreducibly-code parts of a proposal (action / event / flow logic bodies) via a `CodeGenerationProvider`, attaching it to each change as the new optional `ProposalChange.generatedSource`. Declarative targets (entity / rule / view / state / overlay) and deletes are skipped. Generate → quality-gate → retry-with-feedback (default 3 attempts). Returns a COPY — never mutates the input, never writes files, runs code, approves, or graduates. +- **Build gate (P2):** `checkSourceSyntax()` / `createSyntaxQualityGate()` validate generated source SYNTACTICALLY via Bun's transpiler (no project-aware type resolution — that would false-positive on project symbol references; left to the graduation PR's CI). `validatePhase2()` runs this over a proposal's `generatedSource` and is now wired into `validateProposal` (previously a skipped stub). Warn-only by default; `ValidationContext.strictGeneratedBuild` escalates to blocking. All-declarative proposals still see Phase 2 "skipped" — existing callers are unaffected. +- New `@linchkit/core/server` exports: `materializeProposalChanges`, `isMaterializable`, `validatePhase2`, `checkSourceSyntax`, `createSyntaxQualityGate` (+ types). + +SAFETY: candidate source only — it flows through validation (Phase 2) and double human review (draft + graduation PR) before it can land. "AI never modifies production directly." Not yet wired into a live HTTP/draft path (a thin follow-up). diff --git a/packages/core/__tests__/barrel-public-surface.test.ts b/packages/core/__tests__/barrel-public-surface.test.ts index d4f39dbdc..0dc11f9ba 100644 --- a/packages/core/__tests__/barrel-public-surface.test.ts +++ b/packages/core/__tests__/barrel-public-surface.test.ts @@ -269,6 +269,7 @@ const EXPECTED_SERVER_EXPORTS = [ "checkActionPermission", "checkConnection", "checkCoreCompatibility", + "checkSourceSyntax", "clearDoctorChecks", "closeDatabase", "collectRules", @@ -320,6 +321,7 @@ const EXPECTED_SERVER_EXPORTS = [ "createStateMachine", "createStructuredLogger", "createSyncFlowEngine", + "createSyntaxQualityGate", "createTenantAwareDataProvider", "createTenantIsolationMiddleware", "createTestLogSink", @@ -356,11 +358,13 @@ const EXPECTED_SERVER_EXPORTS = [ "getDoctorChecks", "getObservability", "getTraceDepth", + "isMaterializable", "linchkitSchema", "livenessCheck", "maskRecord", "maskRecords", "maskValue", + "materializeProposalChanges", "mergeCapabilityPool", "noopAITraceSink", "noopMeter", @@ -396,6 +400,7 @@ const EXPECTED_SERVER_EXPORTS = [ "validateAIOutput", "validateAIProposal", "validatePhase1", + "validatePhase2", "validatePhase3", "validateProposal", "validateProposalExtended", diff --git a/packages/core/__tests__/proposal-file-writer.test.ts b/packages/core/__tests__/proposal-file-writer.test.ts index 719e837b2..66b2db33f 100644 --- a/packages/core/__tests__/proposal-file-writer.test.ts +++ b/packages/core/__tests__/proposal-file-writer.test.ts @@ -138,6 +138,57 @@ describe("ProposalFileWriter.writeApprovedProposal", () => { expect(contents).toContain("Capability:"); }); + it("writes AI-materialized generatedSource verbatim, not the codegen output (G5)", async () => { + const writer = new ProposalFileWriter({ rootDir: tmpDir }); + const GENERATED = [ + 'import { defineAction } from "@linchkit/core";', + "export const do_thing = defineAction({", + ' name: "do_thing",', + " handler: async () => ({ ok: true }),", + "});", + "", + ].join("\n"); + const proposal = makeApprovedProposal({ + changes: [ + { + target: "action", + operation: "create", + name: "do_thing", + definition: { name: "do_thing" } as never, + generatedSource: GENERATED, + }, + ], + }); + + const [written] = await writer.writeApprovedProposal(proposal); + expect(written).toBeDefined(); + const contents = await readFile(written as string, "utf8"); + // The materialized handler body lands verbatim — the deterministic codegen + // (which would only scaffold a declarative wrapper) is NOT used. + expect(contents).toBe(GENERATED); + expect(contents).toContain("handler: async () => ({ ok: true })"); + }); + + it("falls back to codegen for an empty generatedSource (never writes a blank file)", async () => { + const writer = new ProposalFileWriter({ rootDir: tmpDir }); + const proposal = makeApprovedProposal({ + changes: [ + { + target: "action", + operation: "create", + name: "do_thing", + definition: { name: "do_thing" } as never, + generatedSource: " \n", // whitespace-only → treated as absent + }, + ], + }); + + const [written] = await writer.writeApprovedProposal(proposal); + const contents = await readFile(written as string, "utf8"); + expect(contents.trim().length).toBeGreaterThan(0); // not blank + expect(contents).toContain("defineAction("); // deterministic scaffold used + }); + it("writes multiple changes in one proposal", async () => { const writer = new ProposalFileWriter({ rootDir: tmpDir }); const proposal = makeApprovedProposal({ diff --git a/packages/core/src/__tests__/engine/code-quality-gate.test.ts b/packages/core/src/__tests__/engine/code-quality-gate.test.ts new file mode 100644 index 000000000..2c4e4797a --- /dev/null +++ b/packages/core/src/__tests__/engine/code-quality-gate.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import { checkSourceSyntax, createSyntaxQualityGate } from "../../engine/code-quality-gate"; + +describe("checkSourceSyntax", () => { + test("returns no errors for syntactically valid TypeScript", () => { + const errors = checkSourceSyntax( + "export const x: number = 1;\nexport function f(a: string): string { return a; }", + ); + expect(errors).toEqual([]); + }); + + test("reports a syntax error for malformed source", () => { + const errors = checkSourceSyntax("export const a = {\n"); // unbalanced brace + expect(errors.length).toBeGreaterThan(0); + }); + + test("flags empty / whitespace-only source", () => { + expect(checkSourceSyntax("")).toEqual(["Generated source is empty."]); + expect(checkSourceSyntax(" \n\t")).toEqual(["Generated source is empty."]); + }); + + test("accepts tsx via filename loader", () => { + const errors = checkSourceSyntax("export const E = () =>
hi
;", "comp.tsx"); + expect(errors).toEqual([]); + }); +}); + +describe("createSyntaxQualityGate", () => { + test("aggregates errors across files, prefixed with the path", async () => { + const gate = createSyntaxQualityGate(); + const errors = await gate.check({ + "ok.ts": "export const x = 1;", + "bad.ts": "export const a = {", + }); + expect(errors.some((e) => e.startsWith("bad.ts: "))).toBe(true); + expect(errors.some((e) => e.startsWith("ok.ts: "))).toBe(false); + }); + + test("returns no errors when every file is valid", async () => { + const gate = createSyntaxQualityGate(); + const errors = await gate.check({ + "a.ts": "export const a = 1;", + "b.ts": "export const b = 2;", + }); + expect(errors).toEqual([]); + }); +}); diff --git a/packages/core/src/__tests__/engine/proposal-materializer.test.ts b/packages/core/src/__tests__/engine/proposal-materializer.test.ts new file mode 100644 index 000000000..5588e7404 --- /dev/null +++ b/packages/core/src/__tests__/engine/proposal-materializer.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, test } from "bun:test"; +import type { CodeGenerationProvider } from "../../ai/proposal-code-generator"; +import { createSyntaxQualityGate } from "../../engine/code-quality-gate"; +import { isMaterializable, materializeProposalChanges } from "../../engine/proposal-materializer"; +import type { ProposalChange, ProposalDefinition } from "../../types/proposal"; + +function makeProposal(changes: ProposalChange[]): ProposalDefinition { + const now = new Date(); + return { + id: "prop-mat-1", + title: "Add deduct_inventory action", + description: "When an order is approved, deduct inventory", + author: { type: "ai", id: "pattern-detector", name: "Pattern Detector" }, + capability: "cap-demo", + changeType: "minor", + changes, + impact: { + schemasAffected: [], + actionsAffected: [], + rulesAffected: [], + dependentsAffected: [], + migrationRequired: false, + }, + status: "draft", + createdAt: now, + updatedAt: now, + } as ProposalDefinition; +} + +/** Fake provider returning a fixed sequence (sticks on the last entry). */ +function makeProvider(responses: string[]): { + provider: CodeGenerationProvider; + calls: Array<{ prompt: string; context?: string }>; +} { + let i = 0; + const calls: Array<{ prompt: string; context?: string }> = []; + const provider: CodeGenerationProvider = { + async generateCode(prompt: string, context?: string): Promise { + calls.push({ prompt, context }); + const r = responses[Math.min(i, responses.length - 1)] ?? ""; + i += 1; + return r; + }, + }; + return { provider, calls }; +} + +const GOOD = "export const deduct_inventory = 1;"; +const BAD = "export const a = {"; // syntax error + +describe("isMaterializable", () => { + test("action create|update is materializable; declarative targets, flow, and delete are not", () => { + expect(isMaterializable({ target: "action", operation: "create", name: "a" })).toBe(true); + expect(isMaterializable({ target: "action", operation: "update", name: "a2" })).toBe(true); + // event is declarative (name + payload), flow has no defineFlow API yet. + expect(isMaterializable({ target: "event", operation: "update", name: "e" })).toBe(false); + expect(isMaterializable({ target: "flow", operation: "create", name: "f" })).toBe(false); + expect(isMaterializable({ target: "entity", operation: "create", name: "x" })).toBe(false); + expect(isMaterializable({ target: "rule", operation: "create", name: "r" })).toBe(false); + expect(isMaterializable({ target: "action", operation: "delete", name: "a" })).toBe(false); + }); +}); + +describe("materializeProposalChanges", () => { + test("attaches generatedSource to a materializable change without mutating the input", async () => { + const input = makeProposal([ + { target: "action", operation: "create", name: "deduct_inventory" }, + ]); + const { provider } = makeProvider([GOOD]); + + const result = await materializeProposalChanges({ + proposal: input, + provider, + qualityGate: createSyntaxQualityGate(), + }); + + expect(result.allMaterialized).toBe(true); + expect(result.outcomes[0]?.status).toBe("materialized"); + expect(result.proposal.changes[0]?.generatedSource).toBe(GOOD); + // input untouched + expect(input.changes[0]?.generatedSource).toBeUndefined(); + }); + + test("skips declarative targets and delete operations", async () => { + const input = makeProposal([ + { target: "rule", operation: "create", name: "late_fee" }, + { target: "action", operation: "delete", name: "old_action" }, + ]); + const { provider, calls } = makeProvider([GOOD]); + + const result = await materializeProposalChanges({ proposal: input, provider }); + + expect(result.outcomes.every((o) => o.status === "skipped")).toBe(true); + expect(calls).toHaveLength(0); // provider never called + expect(result.proposal.changes.every((c) => c.generatedSource === undefined)).toBe(true); + }); + + test("retries on a gate failure and succeeds on a later attempt", async () => { + const input = makeProposal([ + { target: "action", operation: "create", name: "deduct_inventory" }, + ]); + const { provider } = makeProvider([BAD, GOOD]); + + const result = await materializeProposalChanges({ + proposal: input, + provider, + qualityGate: createSyntaxQualityGate(), + maxRetries: 3, + }); + + expect(result.outcomes[0]?.status).toBe("materialized"); + expect(result.outcomes[0]?.attempts).toBe(2); + expect(result.proposal.changes[0]?.generatedSource).toBe(GOOD); + }); + + test("fails after exhausting retries when the gate never passes", async () => { + const input = makeProposal([ + { target: "action", operation: "create", name: "deduct_inventory" }, + ]); + const { provider } = makeProvider([BAD]); + + const result = await materializeProposalChanges({ + proposal: input, + provider, + qualityGate: createSyntaxQualityGate(), + maxRetries: 2, + }); + + expect(result.allMaterialized).toBe(false); + expect(result.outcomes[0]?.status).toBe("failed"); + expect(result.outcomes[0]?.attempts).toBe(2); + expect((result.outcomes[0]?.errors ?? []).length).toBeGreaterThan(0); + expect(result.proposal.changes[0]?.generatedSource).toBeUndefined(); + }); + + test("clears stale generatedSource when a re-materialization fails", async () => { + const input = makeProposal([ + { + target: "action", + operation: "create", + name: "deduct_inventory", + generatedSource: "export const STALE = 1;", + }, + ]); + const { provider } = makeProvider([BAD]); // every attempt fails the gate + + const result = await materializeProposalChanges({ + proposal: input, + provider, + qualityGate: createSyntaxQualityGate(), + maxRetries: 2, + }); + + expect(result.outcomes[0]?.status).toBe("failed"); + // The stale source must NOT survive a failed re-materialization. + expect(result.proposal.changes[0]?.generatedSource).toBeUndefined(); + }); + + test("strips a markdown code fence from the generated source", async () => { + const input = makeProposal([ + { target: "action", operation: "create", name: "deduct_inventory" }, + ]); + const { provider } = makeProvider(["```ts\nexport const deduct_inventory = 1;\n```"]); + + const result = await materializeProposalChanges({ + proposal: input, + provider, + qualityGate: createSyntaxQualityGate(), + }); + + expect(result.proposal.changes[0]?.generatedSource).toBe("export const deduct_inventory = 1;"); + }); + + test("extracts a fenced code block wrapped in conversational prose", async () => { + const input = makeProposal([ + { target: "action", operation: "create", name: "deduct_inventory" }, + ]); + const { provider } = makeProvider([ + "Sure! Here is the code:\n```ts\nexport const deduct_inventory = 1;\n```\nLet me know if you need changes.", + ]); + + const result = await materializeProposalChanges({ + proposal: input, + provider, + qualityGate: createSyntaxQualityGate(), + }); + + expect(result.outcomes[0]?.status).toBe("materialized"); + expect(result.proposal.changes[0]?.generatedSource).toBe("export const deduct_inventory = 1;"); + }); + + test("treats a non-finite maxRetries as the default instead of skipping generation", async () => { + const input = makeProposal([ + { target: "action", operation: "create", name: "deduct_inventory" }, + ]); + const { provider } = makeProvider([GOOD]); + + const result = await materializeProposalChanges({ + proposal: input, + provider, + qualityGate: createSyntaxQualityGate(), + maxRetries: Number.NaN, + }); + + expect(result.outcomes[0]?.status).toBe("materialized"); + expect(result.outcomes[0]?.attempts).toBe(1); + }); + + test("forwards context to the provider as the system message", async () => { + const input = makeProposal([ + { target: "action", operation: "create", name: "deduct_inventory" }, + ]); + const { provider, calls } = makeProvider([GOOD]); + + await materializeProposalChanges({ proposal: input, provider, context: "PROJECT CONVENTIONS" }); + + expect(calls[0]?.context).toBe("PROJECT CONVENTIONS"); + }); +}); diff --git a/packages/core/src/__tests__/engine/validation-phase2.test.ts b/packages/core/src/__tests__/engine/validation-phase2.test.ts new file mode 100644 index 000000000..03d528b5a --- /dev/null +++ b/packages/core/src/__tests__/engine/validation-phase2.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { validatePhase2 } from "../../engine/validation-phase2"; +import type { ProposalChange } from "../../types/proposal"; + +function change(overrides: Partial): ProposalChange { + return { target: "action", operation: "create", name: "do_thing", ...overrides }; +} + +describe("validatePhase2", () => { + test("skips when no change carries generatedSource", () => { + const result = validatePhase2({ + changes: [change({}), change({ target: "rule", name: "r1" })], + }); + expect(result.phase).toBe(2); + expect(result.status).toBe("skipped"); + expect(result.errors).toEqual([]); + expect(result.warnings).toEqual([]); + }); + + test("an empty generatedSource is flagged, not skipped", () => { + const result = validatePhase2({ + changes: [change({ name: "empty_action", generatedSource: "" })], + }); + // Empty string is still a materialized-source change → not skipped. + expect(result.status).toBe("passed"); // warn-only default + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]?.code).toBe("GENERATED_SOURCE_SYNTAX"); + }); + + test("passes when generated source is syntactically valid", () => { + const result = validatePhase2({ + changes: [change({ generatedSource: "export const x = 1;" })], + }); + expect(result.status).toBe("passed"); + expect(result.errors).toEqual([]); + expect(result.warnings).toEqual([]); + }); + + test("warn-only by default: bad source → warnings, status stays passed", () => { + const result = validatePhase2({ + changes: [change({ name: "broken", generatedSource: "export const a = {" })], + }); + expect(result.status).toBe("passed"); + expect(result.errors).toEqual([]); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]?.code).toBe("GENERATED_SOURCE_SYNTAX"); + expect(result.warnings[0]?.target).toBe("broken"); + }); + + test("strictGeneratedBuild: bad source → errors, status failed", () => { + const result = validatePhase2({ + changes: [change({ name: "broken", generatedSource: "export const a = {" })], + strictGeneratedBuild: true, + }); + expect(result.status).toBe("failed"); + expect(result.warnings).toEqual([]); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors[0]?.code).toBe("GENERATED_SOURCE_SYNTAX"); + }); +}); diff --git a/packages/core/src/engine/code-quality-gate.ts b/packages/core/src/engine/code-quality-gate.ts new file mode 100644 index 000000000..814c0d1e8 --- /dev/null +++ b/packages/core/src/engine/code-quality-gate.ts @@ -0,0 +1,73 @@ +/** + * Code quality gate for AI-generated proposal source (G5 Phase 2). + * + * Validates the SYNTACTIC validity of generated TypeScript via Bun's transpiler. + * It does NOT resolve types or imports — there is no project context at proposal + * time, so a full project-aware typecheck would false-positive on every + * reference to a project symbol. Syntactic validation catches the most common + * LLM failure modes (truncated output, unbalanced braces, invalid tokens) + * without those false positives. A full project-aware build pass is a deliberate + * later enhancement. + * + * The check runs only on the Bun runtime (where generation happens). Off Bun + * (e.g. a non-Bun consumer of the compiled npm package) it degrades to a no-op + * so it can never throw or block — the worst case is "not syntax-checked here", + * which the graduation PR's CI still catches. + */ + +import type { QualityGateRunner } from "../ai/proposal-code-generator"; + +/** Minimal structural view of the Bun transpiler we rely on (avoids a hard @types/bun dep). */ +interface BunTranspilerLike { + transformSync(code: string): string; +} +interface BunLike { + Transpiler: new (options: { loader: "ts" | "tsx" }) => BunTranspilerLike; +} + +/** Resolve Bun's transpiler at runtime, or `null` when not running on Bun. */ +function resolveBun(): BunLike | null { + const candidate = (globalThis as { Bun?: BunLike }).Bun; + return candidate && typeof candidate.Transpiler === "function" ? candidate : null; +} + +/** + * Synchronously check a source string for SYNTAX errors. + * + * Returns a list of human-readable error messages (empty = syntactically valid, + * or skipped because Bun is unavailable). Never throws. + */ +export function checkSourceSyntax(source: string, filename = "generated.ts"): string[] { + if (typeof source !== "string" || source.trim().length === 0) { + return ["Generated source is empty."]; + } + const bun = resolveBun(); + if (!bun) return []; // not on Bun → cannot syntax-check here; degrade to no-op. + + const loader: "ts" | "tsx" = filename.endsWith(".tsx") ? "tsx" : "ts"; + try { + new bun.Transpiler({ loader }).transformSync(source); + return []; + } catch (err) { + return [err instanceof Error ? err.message : String(err)]; + } +} + +/** + * Build a {@link QualityGateRunner} (async) over {@link checkSourceSyntax}, for + * the proposal materializer's generate→check→retry loop. Each file is checked + * independently; messages are prefixed with the file path. + */ +export function createSyntaxQualityGate(): QualityGateRunner { + return { + check(files: Record): Promise { + const errors: string[] = []; + for (const [path, source] of Object.entries(files)) { + for (const message of checkSourceSyntax(source, path)) { + errors.push(`${path}: ${message}`); + } + } + return Promise.resolve(errors); + }, + }; +} diff --git a/packages/core/src/engine/proposal-file-writer.ts b/packages/core/src/engine/proposal-file-writer.ts index 0d9897c3c..50a78ffa4 100644 --- a/packages/core/src/engine/proposal-file-writer.ts +++ b/packages/core/src/engine/proposal-file-writer.ts @@ -359,7 +359,20 @@ export class ProposalFileWriter { } const targetPath = await this.resolvePath(proposal, change, groupCache); - const rawSource = this.codegen(proposal, change); + // Prefer AI-materialized source (G5) when it has real content: the + // materializer attaches `generatedSource` for code targets (e.g. an action + // handler body) that the deterministic codegen can only scaffold. Without + // this, a materialized proposal would graduate to a PR containing a stub, + // silently dropping the generated logic. An empty / whitespace + // generatedSource is treated as absent so a malformed materialization can + // never write a blank file — it falls back to the deterministic scaffold + // (Phase 2 also flags the empty source upstream). Declarative changes have + // no generatedSource → codegen. + const hasGeneratedSource = + typeof change.generatedSource === "string" && change.generatedSource.trim().length > 0; + const rawSource = hasGeneratedSource + ? (change.generatedSource as string) + : this.codegen(proposal, change); const source = await this.maybeFormat(rawSource, targetPath, proposal); await mkdir(dirname(targetPath), { recursive: true }); diff --git a/packages/core/src/engine/proposal-materializer.ts b/packages/core/src/engine/proposal-materializer.ts new file mode 100644 index 000000000..9fa03ab05 --- /dev/null +++ b/packages/core/src/engine/proposal-materializer.ts @@ -0,0 +1,238 @@ +/** + * Proposal materializer (G5 Phase 3). + * + * Generates the irreducibly-code parts of a proposal — today the + * `ActionDefinition.handler` body, which a declarative `ChangeDefinition` cannot + * express — into TypeScript source, attaching it to each change as + * `generatedSource`. Declarative targets (entity / rule / view / state / event / + * overlay) are left to deterministic serialization and are skipped here. The + * materializable set is extensible (see {@link MATERIALIZABLE_TARGETS}). + * + * Pipeline per materializable change: build a prompt → `CodeGenerationProvider` + * generates source → (optional) `QualityGateRunner` build-checks it → on failure + * retry with the errors fed back, up to `maxRetries`. Generation is LAZY and + * caller-driven (no scheduler). + * + * SAFETY BOUNDARY ("AI never modifies production directly"): this returns a COPY + * of the proposal with candidate source attached. It NEVER writes files, runs + * the generated code, approves, or graduates anything. The attached source still + * flows through validation (Phase 2 build check) and double human review (draft + * review + graduation PR) before it can land. + */ + +import type { CodeGenerationProvider, QualityGateRunner } from "../ai/proposal-code-generator"; +import type { ProposalChange, ProposalChangeTarget, ProposalDefinition } from "../types/proposal"; + +/** + * Change targets whose logic must be AI-materialized (cannot be serialized + * declaratively). Today only `action` qualifies: `ActionDefinition.handler` is a + * real function body. Other current targets are declarative — `entity` / `rule` + * / `view` / `state` / `event` (an EventDefinition is name + payload, not logic) + * / `overlay` — and `flow` has no `defineFlow` API yet. Adding a target here (and + * to {@link TARGET_GUIDANCE}) is all it takes to extend scope when those land. + */ +const MATERIALIZABLE_TARGETS: ReadonlySet = new Set(["action"]); + +/** True when a change needs AI code generation (a code target, created or updated). */ +export function isMaterializable(change: ProposalChange): boolean { + return ( + MATERIALIZABLE_TARGETS.has(change.target) && + (change.operation === "create" || change.operation === "update") + ); +} + +export interface MaterializeProposalOptions { + /** Proposal to materialize. Not mutated — a copy is returned. */ + proposal: ProposalDefinition; + /** AI code generation provider (e.g. cap-ai-provider's createCodeGenerationProvider). */ + provider: CodeGenerationProvider; + /** + * Optional build/quality gate (Phase 2). When provided, generated source must + * pass it; a failure feeds the errors back into the next attempt. When absent, + * the first generation is accepted (validation still runs downstream). + */ + qualityGate?: QualityGateRunner; + /** Max generation attempts per change (default 3, floored at 1). */ + maxRetries?: number; + /** + * Project conventions / existing-entity context, passed as the system message + * to the provider on every attempt. + */ + context?: string; +} + +export interface MaterializeChangeOutcome { + changeName: string; + target: ProposalChangeTarget; + /** `materialized` = source attached; `skipped` = declarative target; `failed` = gate never passed. */ + status: "materialized" | "skipped" | "failed"; + /** Number of generation attempts made (0 for skipped). */ + attempts: number; + /** Quality-gate errors from the final failed attempt (only when status="failed"). */ + errors?: string[]; +} + +export interface MaterializeProposalResult { + /** A COPY of the input proposal with `generatedSource` attached to materialized changes. */ + proposal: ProposalDefinition; + /** Per-change outcome, in input order. */ + outcomes: MaterializeChangeOutcome[]; + /** True when no materializable change failed (skipped/declarative changes don't count against this). */ + allMaterialized: boolean; +} + +/** + * Generate source for each materializable change in a proposal, returning a copy + * with `generatedSource` attached. Never mutates the input. + */ +export async function materializeProposalChanges( + options: MaterializeProposalOptions, +): Promise { + const { proposal, provider, qualityGate, context } = options; + // Guard a non-finite maxRetries (NaN/Infinity) — Math.floor(NaN) would make the + // retry loop skip every attempt and report a spurious failure. + const requestedRetries = options.maxRetries; + const maxRetries = Math.max( + 1, + Math.floor( + typeof requestedRetries === "number" && Number.isFinite(requestedRetries) + ? requestedRetries + : 3, + ), + ); + + // Per-change shallow copies — we only ever set `generatedSource`. + const changes: ProposalChange[] = proposal.changes.map((c) => ({ ...c })); + const outcomes: MaterializeChangeOutcome[] = []; + + for (const change of changes) { + if (!isMaterializable(change)) { + outcomes.push({ + changeName: change.name, + target: change.target, + status: "skipped", + attempts: 0, + }); + continue; + } + + // Clear any pre-existing source up front so a failed (re-)materialization + // never leaves STALE code on the change — it is set again only on success. + change.generatedSource = undefined; + + let lastErrors: string[] = []; + let materialized = false; + let attempt = 0; + for (attempt = 1; attempt <= maxRetries; attempt++) { + const prompt = buildChangePrompt(change, proposal, lastErrors); + const raw = await provider.generateCode(prompt, context); + const source = stripCodeFence(raw); + + if (qualityGate) { + const errors = await qualityGate.check({ [`${change.name}.ts`]: source }); + if (errors.length > 0) { + lastErrors = errors; + continue; + } + } + + change.generatedSource = source; + materialized = true; + break; + } + + outcomes.push( + materialized + ? { + changeName: change.name, + target: change.target, + status: "materialized", + attempts: attempt, + } + : { + changeName: change.name, + target: change.target, + status: "failed", + attempts: maxRetries, + errors: lastErrors, + }, + ); + } + + const allMaterialized = outcomes.every((o) => o.status !== "failed"); + return { proposal: { ...proposal, changes }, outcomes, allMaterialized }; +} + +// ── Prompt building ────────────────────────────────────── + +const TARGET_GUIDANCE: Record = { + action: + 'Use defineAction() from "@linchkit/core". Implement the `handler: (ctx) => Promise<...>` body fully.', +}; + +/** Build the per-change generation prompt (with retry feedback when present). */ +function buildChangePrompt( + change: ProposalChange, + proposal: ProposalDefinition, + previousErrors: string[], +): string { + const lines: string[] = []; + + if (previousErrors.length > 0) { + lines.push("# RETRY — the previous attempt failed the build/syntax check:"); + for (const e of previousErrors) lines.push(`- ${e}`); + lines.push("Fix these and output a corrected, complete file."); + lines.push(""); + } + + lines.push("# Generate a complete LinchKit definition file"); + lines.push(""); + lines.push(`Target: ${change.target}`); + lines.push(`Operation: ${change.operation}`); + lines.push(`Name: ${change.name}`); + lines.push(`Capability: ${proposal.capability}`); + lines.push(`Proposal: ${proposal.title}`); + if (proposal.description) lines.push(`Description: ${proposal.description}`); + if (change.diff) lines.push(`Change summary: ${change.diff}`); + + if (change.definition) { + lines.push(""); + lines.push("Declarative definition (JSON) to implement:"); + lines.push("```json"); + lines.push(JSON.stringify(change.definition, null, 2)); + lines.push("```"); + } + + lines.push(""); + lines.push("Requirements:"); + lines.push("- Output ONLY the TypeScript source for a single file — no markdown, no commentary."); + lines.push('- Include all necessary imports from "@linchkit/core".'); + lines.push(`- ${TARGET_GUIDANCE[change.target] ?? "Implement the logic body fully."}`); + lines.push("- TypeScript strict mode; never use the `any` type."); + lines.push("- Action naming: verb_noun. Entity naming: snake_case."); + + return lines.join("\n"); +} + +/** + * Extract TypeScript source from a model response. Despite the "output only + * source" instruction, models sometimes wrap the code in a markdown fence or + * surround that fence with conversational prose ("Sure! Here is the code: ..."). + * Handles all three shapes — bare source, a fenced block, or a fenced block + * embedded in prose — and returns "" for a non-string input rather than throwing. + */ +function stripCodeFence(raw: unknown): string { + if (typeof raw !== "string") return ""; + const trimmed = raw.trim(); + // A fenced block anywhere (even wrapped in prose) — take the first block's body. + const fenced = trimmed.match(/```[a-zA-Z]*\r?\n([\s\S]*?)```/); + if (fenced) return (fenced[1] ?? "").trim(); + // An opening fence with no closing fence — drop the opener (and a dangling one). + if (trimmed.startsWith("```")) { + return trimmed + .replace(/^```[a-zA-Z]*\r?\n?/, "") + .replace(/\r?\n?```$/, "") + .trim(); + } + return trimmed; +} diff --git a/packages/core/src/engine/validation-engine.ts b/packages/core/src/engine/validation-engine.ts index 51cd9ebf5..9ad4a3e0e 100644 --- a/packages/core/src/engine/validation-engine.ts +++ b/packages/core/src/engine/validation-engine.ts @@ -25,6 +25,7 @@ import type { } from "../types/proposal"; import type { RuleDefinition } from "../types/rule"; import type { StateDefinition } from "../types/state"; +import { validatePhase2 } from "./validation-phase2"; import { validatePhase3 } from "./validation-phase3"; // ── Valid field types ──────────────────────────────────── @@ -70,6 +71,13 @@ export interface ValidationContext { * Mirrors the `strictValidation` env-feature pattern. */ strictCompatibility?: boolean; + /** + * Escalate Phase 2 (build/syntax) findings on AI-materialized `generatedSource` + * from WARN to BLOCK. Default (false / undefined) → Phase 2 is warn-only and + * does NOT affect `passed`. When no change carries generated source, Phase 2 is + * skipped regardless. Mirrors the `strictCompatibility` pattern. + */ + strictGeneratedBuild?: boolean; } // ── Phase 1: Static checks ────────────────────────────── @@ -653,14 +661,14 @@ export function validateProposal(options: { // Phase 1: Static checks const phase1 = validatePhase1({ changes: proposal.changes, context }); - // Phase 2: Skipped for M1 (build check) - const phase2: PhaseResult = { - phase: 2, - status: "skipped", - errors: [], - warnings: [], - duration: 0, - }; + // Phase 2: Build (syntax) check of any AI-materialized `generatedSource` on the + // changes (G5). Warn-only by default; gated to BLOCK via strictGeneratedBuild. + // When no change carries generated source, validatePhase2 returns "skipped" — + // existing all-declarative callers are unaffected. + const phase2 = validatePhase2({ + changes: proposal.changes, + strictGeneratedBuild: context?.strictGeneratedBuild, + }); // Phase 3: Compatibility (breaking-reference) check (Spec 09 §4.5). // Warn-only by default; gated to BLOCK via context.strictCompatibility. When diff --git a/packages/core/src/engine/validation-phase2.ts b/packages/core/src/engine/validation-phase2.ts new file mode 100644 index 000000000..c3ec3ed59 --- /dev/null +++ b/packages/core/src/engine/validation-phase2.ts @@ -0,0 +1,90 @@ +/** + * Validation Phase 2 — Build (syntax) check of AI-materialized source (G5). + * + * Phase 2 inspects any `generatedSource` attached to a proposal's changes (by the + * proposal materializer) and reports SYNTACTIC build failures. Declarative + * changes carry no generated source, so for an all-declarative proposal Phase 2 + * degrades to "skipped" — existing callers are unaffected. + * + * Severity / gating mirrors Phase 3 (low-regret): + * - DEFAULT: WARN-ONLY. Findings are `warnings`; `passed` is unaffected. + * - GATED: when `strictGeneratedBuild` is true, findings become `errors` + * (status "failed" → proposal `passed` = false → blocks). + * + * Scope: SYNTAX only (see {@link checkSourceSyntax}). Project-aware type + * resolution is out of scope here (no project context at proposal time) and is + * left to the graduation PR's CI / a later build-time pass. + */ + +import type { + PhaseResult, + ProposalChange, + ValidationError, + ValidationWarning, +} from "../types/proposal"; +import { checkSourceSyntax } from "./code-quality-gate"; + +// ── Options ────────────────────────────────────────────── + +export interface ValidatePhase2Options { + /** The proposal's changes to inspect for materialized `generatedSource`. */ + changes: ProposalChange[]; + /** + * When true, generated-source syntax findings become ERRORS (blocking). + * Default (false / undefined) → findings are WARNINGS only and do not affect + * `passed`. Mirrors the `strictCompatibility` gating of Phase 3. + */ + strictGeneratedBuild?: boolean; +} + +// ── Entry point ────────────────────────────────────────── + +/** + * Run Phase 2 (build/syntax) validation on a proposal's changes. + * + * Returns a PhaseResult: + * - no change carries `generatedSource` → status "skipped" (no findings) + * - findings + strictGeneratedBuild=false → status "passed" with `warnings` + * - findings + strictGeneratedBuild=true → status "failed" with `errors` + */ +export function validatePhase2(options: ValidatePhase2Options): PhaseResult { + const { changes, strictGeneratedBuild = false } = options; + const start = Date.now(); + + // Include EVERY change carrying a string `generatedSource`, even an empty one: + // an empty / whitespace materialization is itself a finding (checkSourceSyntax + // flags it) and must not bypass Phase 2 by being filtered out here. Changes + // with no generatedSource (undefined) are declarative → nothing to build. + const withSource = changes.filter((c) => typeof c.generatedSource === "string"); + + // Nothing materialized → no build surface → skipped (back-compat). + if (withSource.length === 0) { + return { phase: 2, status: "skipped", errors: [], warnings: [], duration: Date.now() - start }; + } + + const findings: Array<{ code: string; message: string; target?: string }> = []; + for (const change of withSource) { + const messages = checkSourceSyntax(change.generatedSource as string, `${change.name}.ts`); + for (const message of messages) { + findings.push({ + code: "GENERATED_SOURCE_SYNTAX", + message: `Generated source for ${change.target} "${change.name}" has a syntax error: ${message}`, + target: change.name, + }); + } + } + + const errors: ValidationError[] = []; + const warnings: ValidationWarning[] = []; + if (strictGeneratedBuild) { + for (const f of findings) errors.push(f); + } else { + for (const f of findings) warnings.push(f); + } + + // Warn-only by default: status stays "passed" even with warnings — `passed` is + // only dragged false when strictGeneratedBuild escalated findings to errors. + const status: PhaseResult["status"] = errors.length === 0 ? "passed" : "failed"; + + return { phase: 2, status, errors, warnings, duration: Date.now() - start }; +} diff --git a/packages/core/src/exports/server/engines.ts b/packages/core/src/exports/server/engines.ts index 27cc7aa86..4546ebd39 100644 --- a/packages/core/src/exports/server/engines.ts +++ b/packages/core/src/exports/server/engines.ts @@ -30,6 +30,8 @@ export { executeBatch, MAX_BATCH_SIZE, } from "../../engine/batch-action-engine"; +// G5 code materialization (Phase 2 build gate + Phase 3 materializer). +export { checkSourceSyntax, createSyntaxQualityGate } from "../../engine/code-quality-gate"; export { type CommandBatchExecuteOptions, type CommandContext, @@ -125,6 +127,13 @@ export { type ProposalGitCommitterRunResult, type ProposalGitRunner, } from "../../engine/proposal-git-committer"; +export { + isMaterializable, + type MaterializeChangeOutcome, + type MaterializeProposalOptions, + type MaterializeProposalResult, + materializeProposalChanges, +} from "../../engine/proposal-materializer"; export { createProposalOutcomeRecorder, type ProposalOutcomePayload, @@ -161,6 +170,10 @@ export { validatePhase1, validateProposal, } from "../../engine/validation-engine"; +export { + type ValidatePhase2Options, + validatePhase2, +} from "../../engine/validation-phase2"; export { type ValidatePhase3Options, validatePhase3, diff --git a/packages/core/src/types/proposal.ts b/packages/core/src/types/proposal.ts index 7144485eb..6995ef0ea 100644 --- a/packages/core/src/types/proposal.ts +++ b/packages/core/src/types/proposal.ts @@ -109,6 +109,18 @@ export interface ProposalChange { * a rollback can execute. This field NEVER triggers auto-execution. */ revertSha?: string; + /** + * AI-materialized TypeScript source for this change (G5 Phase 3). + * + * Only set for code targets whose logic body cannot be expressed declaratively + * in `definition` — today that is an `action` (its `handler` function). + * Produced by the proposal materializer, build-checked by validation Phase 2, + * and written verbatim by `ProposalFileWriter` at graduation (preferred over + * the deterministic codegen). Candidate source only — it never executes or + * lands without passing validation and double human review (draft + graduation + * PR). + */ + generatedSource?: string; } // ── Impact analysis ──────────────────────────────────────