Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/g5-materialize-build.md
Original file line number Diff line number Diff line change
@@ -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).
5 changes: 5 additions & 0 deletions packages/core/__tests__/barrel-public-surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ const EXPECTED_SERVER_EXPORTS = [
"checkActionPermission",
"checkConnection",
"checkCoreCompatibility",
"checkSourceSyntax",
"clearDoctorChecks",
"closeDatabase",
"collectRules",
Expand Down Expand Up @@ -320,6 +321,7 @@ const EXPECTED_SERVER_EXPORTS = [
"createStateMachine",
"createStructuredLogger",
"createSyncFlowEngine",
"createSyntaxQualityGate",
"createTenantAwareDataProvider",
"createTenantIsolationMiddleware",
"createTestLogSink",
Expand Down Expand Up @@ -356,11 +358,13 @@ const EXPECTED_SERVER_EXPORTS = [
"getDoctorChecks",
"getObservability",
"getTraceDepth",
"isMaterializable",
"linchkitSchema",
"livenessCheck",
"maskRecord",
"maskRecords",
"maskValue",
"materializeProposalChanges",
"mergeCapabilityPool",
"noopAITraceSink",
"noopMeter",
Expand Down Expand Up @@ -396,6 +400,7 @@ const EXPECTED_SERVER_EXPORTS = [
"validateAIOutput",
"validateAIProposal",
"validatePhase1",
"validatePhase2",
"validatePhase3",
"validateProposal",
"validateProposalExtended",
Expand Down
51 changes: 51 additions & 0 deletions packages/core/__tests__/proposal-file-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
47 changes: 47 additions & 0 deletions packages/core/src/__tests__/engine/code-quality-gate.test.ts
Original file line number Diff line number Diff line change
@@ -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 = () => <div>hi</div>;", "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([]);
});
});
219 changes: 219 additions & 0 deletions packages/core/src/__tests__/engine/proposal-materializer.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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");
});
});
Loading
Loading