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
6 changes: 6 additions & 0 deletions .ai/memory/lessons.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ Entries are reverse-chronological (newest first).

---

## 2026-05-28 — parse AI-generated MDX before marking proposals valid

**Rule:** AI proposal validation must run generated body MDX through the Studio MDX parser family, not only scan component tags or props.
**Why:** JSX string attributes do not treat `\'` as a safe escape inside single-quoted MDX attributes, so JSON-looking prop strings can pass ad hoc catalog validation while crashing Studio during document parse.
**How to apply:** For every proposal operation that writes body text (`create_document.body`, `insert_block.bodyMdx`, `replace_selection.replacementText`), validate with micromark/mdast MDX parsing before returning `valid`; use entities such as `'` or JSX expression props for apostrophes inside single-quoted attributes.

## 2026-05-22 — keep model-visible MDX body text raw

**Rule:** Active draft body and selected markdown sent to the AI model must preserve literal MDX syntax such as `<Box>` and `<Text>`; bound it with explicit content markers instead of XML entity escaping it.
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions packages/modules/core.ai/src/server/validate-proposal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,41 @@ describe("createSchemaAwareProposalValidator — other kinds", () => {
assert.equal(result.status, "valid");
});

test("rejects insert_block MDX that the Studio parser cannot parse", async () => {
const validator = createSchemaAwareProposalValidator({
schemaLookup: lookup,
});
const result = await validator({
proposalId: "p1",
kind: "insert_block",
project: "demo",
environment: "draft",
type: "page",
locale: "en",
summary: "Insert feature grid",
operations: [
{
op: "insert_block",
bodyMdx:
'<FeatureGrid features=\'[{"title":"Drop","description":"Once they\\\'re gone, they\\\'re gone."}]\' />',
},
],
expiresAt: "2026-05-15T00:05:00.000Z",
provider: {
providerId: "echo",
model: "echo-1",
promptTemplateId: "chat_tools.v1",
},
});

assert.equal(result.status, "invalid");
if (result.status === "invalid") {
assert.equal(result.errors[0]?.code, "MDX_PARSE_FAILED");
assert.equal(result.errors[0]?.path, "operations[0].bodyMdx");
assert.match(result.errors[0]?.message ?? "", /Studio MDX parser/);
}
});

test("delete_document is shape-valid (chat-tools handles published-version check)", async () => {
const validator = createSchemaAwareProposalValidator({
schemaLookup: lookup,
Expand Down
94 changes: 83 additions & 11 deletions packages/modules/core.ai/src/server/validate-proposal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import type {
SchemaRegistryFieldSnapshot,
SchemaRegistryTypeSnapshot,
} from "@mdcms/shared";
import { fromMarkdown } from "mdast-util-from-markdown";
import { mdxFromMarkdown } from "mdast-util-mdx";
import { mdx } from "micromark-extension-mdx";

/**
* Looks up a content type's schema for a given project + environment.
Expand Down Expand Up @@ -64,8 +67,8 @@ export type DocumentLookup = (input: {
* Replace-selection body-anchor validation is layered in per active
* document by `createReplaceSelectionApplyabilityValidator`, because
* the current draft body is turn-specific rather than schema state.
* Insert-block proposals are left shape-valid here; full MDX
* validation is supplied by `createMdxCatalogProposalValidator`.
* MDX-bearing operations are parsed here with the same parser family
* as Studio so syntax-invalid proposals cannot be marked valid.
*/
export function createSchemaAwareProposalValidator(input: {
schemaLookup: SchemaLookup;
Expand All @@ -79,11 +82,14 @@ export function createSchemaAwareProposalValidator(input: {
): Promise<AiProposalValidation> => {
switch (candidate.kind) {
case "create_document":
return validateCreateDocument(
candidate,
schemaLookup,
pathExists,
documentExists,
return mergeValidation(
await validateCreateDocument(
candidate,
schemaLookup,
pathExists,
documentExists,
),
validateMdxTargetsAgainstStudioParser(collectMdxTargets(candidate)),
);
case "update_frontmatter":
return validateUpdateFrontmatter(
Expand All @@ -94,12 +100,13 @@ export function createSchemaAwareProposalValidator(input: {
case "delete_document":
case "replace_selection":
case "insert_block":
// Shape-only for now. delete_document's published-version
// delete_document's published-version
// check is already done by chat-tools at proposal-build time
// and re-enforced by apply.ts at apply time. replace_selection
// anchor checks and MDX catalog checks are layered separately
// because both require per-request context.
return { status: "valid" };
// anchor checks and MDX catalog checks are layered separately.
return validationFromErrors(
validateMdxTargetsAgainstStudioParser(collectMdxTargets(candidate)),
);
}
};
}
Expand Down Expand Up @@ -186,6 +193,71 @@ function mergeValidation(
return { status: "invalid", errors };
}

function validationFromErrors(errors: ValidationError[]): AiProposalValidation {
return errors.length === 0
? { status: "valid" }
: { status: "invalid", errors };
}

function validateMdxTargetsAgainstStudioParser(
targets: readonly MdxValidationTarget[],
): ValidationError[] {
const errors: ValidationError[] = [];

for (const target of targets) {
try {
fromMarkdown(target.source, {
extensions: [mdx()],
mdastExtensions: [mdxFromMarkdown()],
});
} catch (error) {
errors.push({
code: "MDX_PARSE_FAILED",
message: formatMdxParseFailure(error),
path: target.path,
});
}
}

return errors;
}

function formatMdxParseFailure(error: unknown): string {
const reason =
typeof error === "object" &&
error !== null &&
"reason" in error &&
typeof error.reason === "string"
? error.reason
: error instanceof Error && error.message
? error.message
: "Unknown MDX parse error.";
const place =
typeof error === "object" && error !== null && "place" in error
? error.place
: undefined;
const line =
typeof place === "object" &&
place !== null &&
"line" in place &&
typeof place.line === "number"
? place.line
: undefined;
const column =
typeof place === "object" &&
place !== null &&
"column" in place &&
typeof place.column === "number"
? place.column
: undefined;

if (typeof line === "number" && typeof column === "number") {
return `Generated MDX failed Studio MDX parser validation at line ${line}, column ${column}: ${reason}`;
}

return `Generated MDX failed Studio MDX parser validation: ${reason}`;
}

/** RFC4122-ish UUID literal — mirrors the apply-time check in `reference-validation.ts`. */
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
Expand Down
3 changes: 3 additions & 0 deletions packages/modules/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
"@ai-sdk/provider": "^3.0.10",
"@mdcms/shared": "workspace:*",
"ai": "^6.0.173",
"mdast-util-from-markdown": "^2.0.3",
"mdast-util-mdx": "^3.0.0",
"micromark-extension-mdx": "^2.0.0",
"tslib": "^2.3.0"
},
"license": "MIT"
Expand Down
Loading