/** * The `create-artifact` tool's pure half: what the model is told, and what the server * refuses to render. * * The tool itself lives in `tool-server.ts` beside `execute` and `skills` — * this module holds only the description text and the code guard, so both can * be tested without standing up an MCP server. */ import { PROVIDED_GLOBAL_NAMES } from "@executor-js/execution"; import { oldStyleAddressRejection } from "./artifact-bindings"; /** The MCP-Apps resource the ui-bearing tools render into. */export const MCP_APPS_SHELL_RESOURCE_URI = "ui://executor/shell.html"; /** * The web-app deep link for a saved artifact — the delivery path for clients * that can't render MCP Apps. One helper so every host emits the same shape; * hosts differ only in the origin and org slug they pass, and hosts with no * known origin (stdio) pass none and skip the link entirely. * * The console routes artifacts at `/{-$orgSlug}/artifacts/$artifactId`, where * the org segment is optional. Optional is not the same as absent: on a host * that scopes by org, a bare link resolves against whatever org the browser * happens to land on after auth, which is the wrong one as soon as a user * belongs to two. So every host that knows its session's org slug passes it, * exactly as `buildResumeApprovalUrl` does for the approval hand-off, and the * bare form is left to genuinely unscoped hosts. */export const artifactUrlFor = (webBaseUrl: string, organizationSlug?: string | null) => (artifactId: string): string => { const orgPath = organizationSlug ? `/${encodeURIComponent(organizationSlug)}` : ""; return new URL(`${orgPath}/artifacts/${encodeURIComponent(artifactId)}`, webBaseUrl).toString(); }; // ---------------------------------------------------------------------------// Code guard// ---------------------------------------------------------------------------//// Generated code is evaluated inside the shell with ~280 names already bound as// function parameters (React hooks, TanStack Query, every shadcn/Recharts/Lucide// export, `tools`). A `const Card = ...` in the model's source shadows the real// binding and the component renders as a blank frame with a confusing runtime// error — so we reject redeclarations before the code ever reaches the iframe,// with a message that tells the model what to do instead.//// The other rejection is `run(...)`. It was once the escape hatch for multi-step// composition and is now gone from the scope entirely: arbitrary code is opaque// to the invalidation helpers (a hand-rolled `queryKey` defeats// `queryFilter`-based refresh) and to artifact analysis, which reads// `tools.<integration>...` paths out of the source. Code that calls it would// fail at render time with a bare ReferenceError, so it is caught here where the// message can point at the declarative replacement.//// The third rejection is a hook called inside a loop body. Models that never// fetch the skill reach for pagination the imperative way — a `for` loop in the// component body with a `useQuery` per page, each one `enabled` by the previous// page's cursor. That is a rules-of-hooks violation (the hook count varies per// render) and a silently capped read, and `.infiniteQueryOptions` is the thing// they didn't know about. The error names it.//// This is deliberately NOT a general "is this good code" check. The donor branch// also rejected array literals whose variable name looked data-ish// (`const rows = [{...},{...}]`), which false-positives on legitimate display// constants — chart configs, tab definitions, column headers. That heuristic is// dropped; the "fetch live data with useQuery" guidance lives in the skill. const REACT_DESTRUCTURING_DECLARATION = /\b(?:const|let|var)\s*\{[^{}]*\}\s*=\s*React\b/s; /** A call to the removed `run` global. Not preceded by `.` or an identifier * character, so `foo.run(...)` and `rerun(...)` — a component's own helpers — * stay legal. */const REMOVED_RUN_CALL = /(?<![.\w$])run\s*\(/; const OBJECT_DESTRUCTURING_DECLARATION = /\b(?:const|let|var)\s*\{([^{}]*)\}\s*=/gs; const PROVIDED_GLOBAL_DECLARATION = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\b|\bfunction\s+([A-Za-z_$][\w$]*)\s*\(|\bclass\s+([A-Za-z_$][\w$]*)\b/g; const firstDefined = (...values: Array<string | undefined>): string | undefined => values.find((value): value is string => value !== undefined); const localDestructuredName = (part: string): string | undefined => { const binding = part .replace(/^\s*\.\.\./, "") .split("=")[0] ?.trim(); const alias = binding?.match(/:\s*([A-Za-z_$][\w$]*)\s*$/)?.[1]; return alias ?? binding?.match(/^([A-Za-z_$][\w$]*)\b/)?.[1];}; /** A component's own local `run` — legal, since it shadows nothing anymore. */const LOCAL_RUN_DECLARATION = /\b(?:const|let|var|function)\s+run\b/; // ---------------------------------------------------------------------------// Hooks inside loop bodies// ---------------------------------------------------------------------------//// Finding a loop's body needs brace matching, not a regex — a regex either stops// at the first `}` (missing the common nested-block shape) or runs to the last// one (flagging a `useQuery` that merely follows a closed loop). So: blank out// comments and string/template literals so their braces and the word "useQuery"// can't skew the scan, then walk braces from each loop header.//// Nothing here is a parser. Loops assembled dynamically, hooks reached through// a helper called from a loop, and other exotica slip past — false negatives are// fine. A false positive is not: legal code must never be refused. /** Replaces comments and quoted/template literals with same-length blanks, so * offsets stay valid and their contents can't be mistaken for code. */const withLiteralsBlanked = (code: string): string => { const blank = (text: string): string => text.replace(/[^\n]/g, " "); return code.replace( // Order matters: line comment, block comment, then each quote flavour. /\/\/[^\n]*|\/\*[\s\S]*?\*\/|'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|`(?:\\.|[^`\\])*`/g, blank, );}; /** A React hook call: `useFoo(`, not `x.useFoo(` and not `abusefoo(`. */const HOOK_CALL = /(?<![.\w$])(use[A-Z][\w$]*)\s*\(/; /** The head of a loop, up to where its body starts. `do` has no header parens. */const LOOP_HEAD = /\b(for|while|do)\b/g; /** The body of the loop whose keyword ends at `afterKeyword`, or `null` when it * can't be delimited confidently (unbalanced braces, generator-mangled source) * — in which case nothing is reported rather than something guessed. */const loopBody = (code: string, keyword: string, afterKeyword: number): string | null => { let index = afterKeyword; const skipSpace = (): void => { while (index < code.length && /\s/.test(code[index] ?? "")) index += 1; }; // `for (...)` / `while (...)`: step over the header's parens first. A hook in // the header itself is not the shape we're after. if (keyword !== "do") { skipSpace(); if (code[index] !== "(") return null; let depth = 0; for (; index < code.length; index += 1) { if (code[index] === "(") depth += 1; else if (code[index] === ")") { depth -= 1; if (depth === 0) { index += 1; break; } } } if (depth !== 0) return null; } skipSpace(); // Only a braced body is scanned. A braceless one has no delimiter to trust — // its end would have to be guessed at the next semicolon, which in JSX prose // can run far past the statement and reach an unrelated hook. Giving up costs // only `for (…) useQuery(…)`, which no generator writes. if (code[index] !== "{") return null; const start = index; let depth = 0; for (; index < code.length; index += 1) { if (code[index] === "{") depth += 1; else if (code[index] === "}") { depth -= 1; if (depth === 0) return code.slice(start + 1, index); } } return null;}; /** `do` is also an ordinary English word that can appear in JSX text ahead of an * expression container ("nothing to do {count}"), where `for`/`while` are * protected by their required header parens. Only a `do` that starts a * statement is a loop. */const startsStatement = (code: string, at: number): boolean => { const before = code.slice(0, at).trimEnd(); return before === "" || /[;{}()]$/.test(before);}; /** The first hook called inside any loop body, or `undefined`. */const hookCalledInLoop = (code: string): string | undefined => { const scannable = withLiteralsBlanked(code); for (const match of scannable.matchAll(LOOP_HEAD)) { const keyword = match[1] ?? ""; if (keyword === "do" && !startsStatement(scannable, match.index)) continue; const body = loopBody(scannable, keyword, match.index + match[0].length); const hook = body === null ? undefined : HOOK_CALL.exec(body)?.[1]; if (hook) return hook; } return undefined;}; /** Hooks whose in-loop use is almost always hand-rolled cursor pagination. */const PAGINATION_HOOKS = new Set(["useQuery", "useInfiniteQuery", "useSuspenseQuery"]); // ---------------------------------------------------------------------------// Edits// ---------------------------------------------------------------------------//// `edit-artifact`'s pure half. An edit is an exact find-and-replace against the// stored source, so a model changing one column of a 300-line dashboard sends// the changed lines rather than re-emitting the whole component — the tool// call's size scales with the edit, not the artifact.//// The semantics are the ones every model already knows from its own file-edit// tool: `oldText` must match verbatim (whitespace included) and exactly once,// unless `replaceAll` opts into every occurrence. Edits apply in order, each// against the result of the previous, and the whole batch is atomic — any// miss rejects the call and nothing is saved. Ambiguity is refused rather than// resolved by picking the first hit, because a wrong pick would save silently// and the user would see the wrong line change. export type ArtifactEdit = { /** Exact text to find in the current source. */ readonly oldText: string; /** What to put in its place. */ readonly newText: string; /** Replace every occurrence instead of requiring `oldText` to be unique. */ readonly replaceAll?: boolean | undefined;}; export type AppliedArtifactEdits = | { readonly ok: true; readonly code: string } | { readonly ok: false; readonly message: string }; const countOccurrences = (haystack: string, needle: string): number => haystack.split(needle).length - 1; /** The edited source, or the first edit that could not be applied. Messages * name the edit by position and say what to do, because the model retries * from the message alone. */export const applyArtifactEdits = ( code: string, edits: readonly ArtifactEdit[],): AppliedArtifactEdits => { let current = code; for (const [index, edit] of edits.entries()) { const position = `Edit ${index + 1} of ${edits.length}`; if (edit.oldText === "") { return { ok: false, message: `${position} has an empty oldText, which matches nothing.` }; } if (edit.oldText === edit.newText) { return { ok: false, message: `${position} is a no-op: oldText and newText are identical.`, }; } const occurrences = countOccurrences(current, edit.oldText); if (occurrences === 0) { return { ok: false, message: [ `${position} matched nothing: its oldText does not appear in the current source.`, "Copy oldText verbatim from the source, whitespace included", index > 0 ? "— and remember each edit sees the result of the earlier ones." : ".", ].join(" "), }; } if (occurrences > 1 && edit.replaceAll !== true) { return { ok: false, message: [ `${position} is ambiguous: its oldText appears ${occurrences} times in the current source.`, "Include enough surrounding text to make it unique, or set replaceAll: true to change every occurrence.", ].join(" "), }; } // split/join for both arms: `String.replace` gives `$&` and friends in the // replacement special meaning, and generated JSX is full of `$`. current = edit.replaceAll === true ? current.split(edit.oldText).join(edit.newText) : current.replace(edit.oldText, () => edit.newText); } return { ok: true, code: current };}; // ---------------------------------------------------------------------------// Create-time smoke render// --------------------------------------------------------------------------- /** * What a server-side trial render concluded, as this package sees it. * * Declared here rather than imported so the MCP host keeps no type dependency * on the shell package — `@executor-js/mcp-apps-shell`'s renderer satisfies this * structurally, and a host that has no renderer to inject does not have to * resolve the module at all. */export type ArtifactSmokeRenderResult = | { readonly status: "ok"; /** * The markup the render produced, when it produced any within its cap. * * The smoke render is driven with a transport that never settles, so this * is the artifact's LOADING state — its real composition with no data in * it — which is what makes it storable as the gallery's layout preview. * Absent whenever nothing could be concluded, and every reader treats * absent as "fall back", never as "empty". */ readonly markup?: string; } | { readonly status: "failed"; readonly message: string; readonly componentStack?: string }; /** The leading frames of a component stack: enough to locate the component that * threw, not so much that the message is a wall. */const COMPONENT_STACK_FRAMES = 6; const trimmedComponentStack = (stack: string): string => stack .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0) .slice(0, COMPONENT_STACK_FRAMES) .join("\n"); /** * Turn a failed smoke render into the message the model is refused with. * * The real error, verbatim, plus where it happened and what class of problem it * is. The last part matters: saying the render happened with every query still * pending tells the model that guarding the loading state is a valid fix, which * is not obvious from `Cannot read properties of undefined` alone. */export const smokeRenderRejection = (result: ArtifactSmokeRenderResult): string | null => { if (result.status === "ok") return null; return [ `This component threw on its first render, so the artifact would show an error instead of a UI: ${result.message}`, result.componentStack ? `\n${trimmedComponentStack(result.componentStack)}\n` : "", "The render happened with every query still pending and no data returned — exactly the state the user sees first — so guard the loading state (`if (query.isLoading) return <ArtifactLoading />`) and read possibly-absent data with `?.`.", "Fix the component and call create-artifact again.", ] .filter((part) => part.length > 0) .join(" ");}; // ---------------------------------------------------------------------------// Provider pairing// ---------------------------------------------------------------------------//// A handful of the components in scope read a React context that only their own// wrapper supplies, and throw outright when it is missing. `<ChartTooltipContent// />` outside a `<ChartContainer>` is the case this was written for: the// artifact saves, the row is fine, and then the page dies on first render with// `useChart must be used within a <ChartContainer />` — a failure the user sees// and the model never does.//// The check is a table, not an analysis. If a name that requires a wrapper// appears anywhere in the code and the wrapper's name appears nowhere, the code// cannot possibly be correct, so it is refused with the pairing spelled out.// That is deliberately weaker than the smoke render below (which catches the// same class properly, and much more): this pass is free, runs first, and gives// a message that names the fix rather than quoting a stack.//// Scope: `packages/react`'s components that `throw` on a missing context are// `useChart` (chart.tsx), `useCarousel` (carousel.tsx), `useFormField`// (form.tsx) and `useSidebar` (sidebar.tsx). Only the chart family is exported// from the shell's component barrel, so only it is listed. `Tooltip` needs a// `TooltipProvider` too, but the shell's inner renderer already wraps every// artifact in one, so requiring it here would refuse working code. /** Names whose presence obliges a wrapper, and the wrapper each one needs. */const PROVIDER_PAIRINGS: readonly { readonly required: string; readonly dependents: readonly string[]; readonly hint: string;}[] = [ { required: "ChartContainer", dependents: [ "useChart", "ChartTooltip", "ChartTooltipContent", "ChartLegend", "ChartLegendContent", ], hint: 'Wrap the chart in `<ChartContainer config={{ series: { label: "Series", color: "var(--chart-1)" } }} className="h-[240px] w-full">…</ChartContainer>`.', },]; /** * Whether `name` is INVOKED — rendered as `<Name`, or called as `name(`. * * Deliberately narrower than "the word appears". JSX text is ordinary prose * inside the source (`<CardContent>No ChartTooltipContent here</CardContent>`) * and is not blanked by `withLiteralsBlanked`, because it is not a literal — so * a bare-word match would refuse a component that merely names another one in * its copy. Requiring a `<` or a `(` costs only the alias case * (`const T = ChartTooltip`), which is a false negative, and false negatives are * the side to err on: the smoke render below catches it anyway. */const invokesName = (scannable: string, name: string): boolean => new RegExp(String.raw`<\s*${name}(?![\w$])|(?<![.\w$])${name}\s*\(`).test(scannable); /** Whether `name` appears at all as an identifier. Used for the WRAPPER side, * where a loose match is the safe direction: over-crediting a wrapper lets a * broken artifact through to the smoke render, while under-crediting one would * refuse working code. */const mentionsName = (scannable: string, name: string): boolean => new RegExp(String.raw`(?<![.\w$])${name}(?![\w$])`).test(scannable); /** The first pairing the code breaks, or `null`. Comments and string literals * are blanked first, so a name that only appears in a commented-out line * neither triggers a rejection nor satisfies one. */const providerPairingRejection = (code: string): string | null => { const scannable = withLiteralsBlanked(code); for (const pairing of PROVIDER_PAIRINGS) { if (mentionsName(scannable, pairing.required)) continue; const dependent = pairing.dependents.find((name) => invokesName(scannable, name)); if (!dependent) continue; return [ `\`${dependent}\` only works inside a \`<${pairing.required}>\`, which this code never renders.`, `It reads a React context that \`${pairing.required}\` provides, so it throws on the first render and the artifact shows an error instead of a UI.`, pairing.hint, ].join(" "); } return null;}; /** `null` when the code may be rendered, otherwise the reason to hand back. */export const validateArtifactCode = (code: string): string | null => { // Checked first: a five-segment address is the mistake a model that only read // the `execute` skill will make, and every other rejection below would be a // confusing thing to hear about code whose real problem is its addressing. const oldStyleAddress = oldStyleAddressRejection(code); if (oldStyleAddress) return oldStyleAddress; if (REMOVED_RUN_CALL.test(code) && !LOCAL_RUN_DECLARATION.test(code)) { return [ "`run(code)` no longer exists — artifact code is purely declarative `tools.*`.", "Read data with useQuery(tools.<ns>.<tool>.queryOptions(args)),", "page through a cursor with useInfiniteQuery(tools.<ns>.<tool>.infiniteQueryOptions(args, { getNextPageParam })),", "and write with useMutation(tools.<ns>.<tool>.mutationOptions({ onSuccess })).", ].join(" "); } const loopedHook = hookCalledInLoop(code); if (loopedHook) { return [ `\`${loopedHook}\` is called inside a loop body.`, "React hooks must run unconditionally at the top level of the component, in the same order every render.", PAGINATION_HOOKS.has(loopedHook) ? "To read every page of a cursor, use one useInfiniteQuery(tools.<ns>.<tool>.infiniteQueryOptions(args, { cursorKey, getNextPageParam })) and render data.pages — never a loop of useQuery calls, one per page." : "Move the hook out of the loop, and derive per-item values from its result instead.", ].join(" "); } const missingProvider = providerPairingRejection(code); if (missingProvider) return missingProvider; if (REACT_DESTRUCTURING_DECLARATION.test(code)) { return [ "Do not destructure React in create-artifact.", "Hooks such as useState are already in scope; use useState(...) directly or React.useState(...).", ].join(" "); } for (const match of code.matchAll(OBJECT_DESTRUCTURING_DECLARATION)) { const names = match[1]?.split(",").flatMap((part) => { const name = localDestructuredName(part); return name ? [name] : []; }); const providedName = names?.find((name) => PROVIDED_GLOBAL_NAMES.has(name)); if (providedName) { return [ `Provided global "${providedName}" is already in scope and cannot be redeclared.`, "Remove the destructuring declaration and use the provided global directly.", ].join(" "); } } for (const match of code.matchAll(PROVIDED_GLOBAL_DECLARATION)) { const name = firstDefined(match[1], match[2], match[3]); if (name && PROVIDED_GLOBAL_NAMES.has(name)) { return [ `Provided global "${name}" is already in scope and cannot be redeclared.`, "Remove the local declaration and use the provided global directly.", ].join(" "); } } return null;};
/**
* The `create-artifact` tool's pure half: what the model is told, and what the server
* refuses to render.
*
* The tool itself lives in `tool-server.ts` beside `execute` and `skills` —
* this module holds only the description text and the code guard, so both can
* be tested without standing up an MCP server.
*/
import { PROVIDED_GLOBAL_NAMES } from "@executor-js/execution";
import { oldStyleAddressRejection } from "./artifact-bindings";
/** The MCP-Apps resource the ui-bearing tools render into. */
export const MCP_APPS_SHELL_RESOURCE_URI = "ui://executor/shell.html";
/**
* The web-app deep link for a saved artifact — the delivery path for clients
* that can't render MCP Apps. One helper so every host emits the same shape;
* hosts differ only in the origin and org slug they pass, and hosts with no
* known origin (stdio) pass none and skip the link entirely.
*
* The console routes artifacts at `/{-$orgSlug}/artifacts/$artifactId`, where
* the org segment is optional. Optional is not the same as absent: on a host
* that scopes by org, a bare link resolves against whatever org the browser
* happens to land on after auth, which is the wrong one as soon as a user
* belongs to two. So every host that knows its session's org slug passes it,
* exactly as `buildResumeApprovalUrl` does for the approval hand-off, and the
* bare form is left to genuinely unscoped hosts.
*/
export const artifactUrlFor =
(webBaseUrl: string, organizationSlug?: string | null) =>
(artifactId: string): string => {
const orgPath = organizationSlug ? `/${encodeURIComponent(organizationSlug)}` : "";
return new URL(`${orgPath}/artifacts/${encodeURIComponent(artifactId)}`, webBaseUrl).toString();
};
// ---------------------------------------------------------------------------
// Code guard
// ---------------------------------------------------------------------------
//
// Generated code is evaluated inside the shell with ~280 names already bound as
// function parameters (React hooks, TanStack Query, every shadcn/Recharts/Lucide
// export, `tools`). A `const Card = ...` in the model's source shadows the real
// binding and the component renders as a blank frame with a confusing runtime
// error — so we reject redeclarations before the code ever reaches the iframe,
// with a message that tells the model what to do instead.
//
// The other rejection is `run(...)`. It was once the escape hatch for multi-step
// composition and is now gone from the scope entirely: arbitrary code is opaque
// to the invalidation helpers (a hand-rolled `queryKey` defeats
// `queryFilter`-based refresh) and to artifact analysis, which reads
// `tools.<integration>...` paths out of the source. Code that calls it would
// fail at render time with a bare ReferenceError, so it is caught here where the
// message can point at the declarative replacement.
//
// The third rejection is a hook called inside a loop body. Models that never
// fetch the skill reach for pagination the imperative way — a `for` loop in the
// component body with a `useQuery` per page, each one `enabled` by the previous
// page's cursor. That is a rules-of-hooks violation (the hook count varies per
// render) and a silently capped read, and `.infiniteQueryOptions` is the thing
// they didn't know about. The error names it.
//
// This is deliberately NOT a general "is this good code" check. The donor branch
// also rejected array literals whose variable name looked data-ish
// (`const rows = [{...},{...}]`), which false-positives on legitimate display
// constants — chart configs, tab definitions, column headers. That heuristic is
// dropped; the "fetch live data with useQuery" guidance lives in the skill.
const REACT_DESTRUCTURING_DECLARATION = /\b(?:const|let|var)\s*\{[^{}]*\}\s*=\s*React\b/s;
/** A call to the removed `run` global. Not preceded by `.` or an identifier
* character, so `foo.run(...)` and `rerun(...)` — a component's own helpers —
* stay legal. */
const REMOVED_RUN_CALL = /(?<![.\w$])run\s*\(/;
const OBJECT_DESTRUCTURING_DECLARATION = /\b(?:const|let|var)\s*\{([^{}]*)\}\s*=/gs;
const PROVIDED_GLOBAL_DECLARATION =
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\b|\bfunction\s+([A-Za-z_$][\w$]*)\s*\(|\bclass\s+([A-Za-z_$][\w$]*)\b/g;
const firstDefined = (...values: Array<string | undefined>): string | undefined =>
values.find((value): value is string => value !== undefined);
const localDestructuredName = (part: string): string | undefined => {
const binding = part
.replace(/^\s*\.\.\./, "")
.split("=")[0]
?.trim();
const alias = binding?.match(/:\s*([A-Za-z_$][\w$]*)\s*$/)?.[1];
return alias ?? binding?.match(/^([A-Za-z_$][\w$]*)\b/)?.[1];
};
/** A component's own local `run` — legal, since it shadows nothing anymore. */
const LOCAL_RUN_DECLARATION = /\b(?:const|let|var|function)\s+run\b/;
// ---------------------------------------------------------------------------
// Hooks inside loop bodies
// ---------------------------------------------------------------------------
//
// Finding a loop's body needs brace matching, not a regex — a regex either stops
// at the first `}` (missing the common nested-block shape) or runs to the last
// one (flagging a `useQuery` that merely follows a closed loop). So: blank out
// comments and string/template literals so their braces and the word "useQuery"
// can't skew the scan, then walk braces from each loop header.
//
// Nothing here is a parser. Loops assembled dynamically, hooks reached through
// a helper called from a loop, and other exotica slip past — false negatives are
// fine. A false positive is not: legal code must never be refused.
/** Replaces comments and quoted/template literals with same-length blanks, so
* offsets stay valid and their contents can't be mistaken for code. */
const withLiteralsBlanked = (code: string): string => {
const blank = (text: string): string => text.replace(/[^\n]/g, " ");
return code.replace(
// Order matters: line comment, block comment, then each quote flavour.
/\/\/[^\n]*|\/\*[\s\S]*?\*\/|'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|`(?:\\.|[^`\\])*`/g,
blank,
);
};
/** A React hook call: `useFoo(`, not `x.useFoo(` and not `abusefoo(`. */
const HOOK_CALL = /(?<![.\w$])(use[A-Z][\w$]*)\s*\(/;
/** The head of a loop, up to where its body starts. `do` has no header parens. */
const LOOP_HEAD = /\b(for|while|do)\b/g;
/** The body of the loop whose keyword ends at `afterKeyword`, or `null` when it
* can't be delimited confidently (unbalanced braces, generator-mangled source)
* — in which case nothing is reported rather than something guessed. */
const loopBody = (code: string, keyword: string, afterKeyword: number): string | null => {
let index = afterKeyword;
const skipSpace = (): void => {
while (index < code.length && /\s/.test(code[index] ?? "")) index += 1;
};
// `for (...)` / `while (...)`: step over the header's parens first. A hook in
// the header itself is not the shape we're after.
if (keyword !== "do") {
skipSpace();
if (code[index] !== "(") return null;
let depth = 0;
for (; index < code.length; index += 1) {
if (code[index] === "(") depth += 1;
else if (code[index] === ")") {
depth -= 1;
if (depth === 0) {
index += 1;
break;
}
}
}
if (depth !== 0) return null;
}
skipSpace();
// Only a braced body is scanned. A braceless one has no delimiter to trust —
// its end would have to be guessed at the next semicolon, which in JSX prose
// can run far past the statement and reach an unrelated hook. Giving up costs
// only `for (…) useQuery(…)`, which no generator writes.
if (code[index] !== "{") return null;
const start = index;
let depth = 0;
for (; index < code.length; index += 1) {
if (code[index] === "{") depth += 1;
else if (code[index] === "}") {
depth -= 1;
if (depth === 0) return code.slice(start + 1, index);
}
}
return null;
};
/** `do` is also an ordinary English word that can appear in JSX text ahead of an
* expression container ("nothing to do {count}"), where `for`/`while` are
* protected by their required header parens. Only a `do` that starts a
* statement is a loop. */
const startsStatement = (code: string, at: number): boolean => {
const before = code.slice(0, at).trimEnd();
return before === "" || /[;{}()]$/.test(before);
};
/** The first hook called inside any loop body, or `undefined`. */
const hookCalledInLoop = (code: string): string | undefined => {
const scannable = withLiteralsBlanked(code);
for (const match of scannable.matchAll(LOOP_HEAD)) {
const keyword = match[1] ?? "";
if (keyword === "do" && !startsStatement(scannable, match.index)) continue;
const body = loopBody(scannable, keyword, match.index + match[0].length);
const hook = body === null ? undefined : HOOK_CALL.exec(body)?.[1];
if (hook) return hook;
}
return undefined;
};
/** Hooks whose in-loop use is almost always hand-rolled cursor pagination. */
const PAGINATION_HOOKS = new Set(["useQuery", "useInfiniteQuery", "useSuspenseQuery"]);
// ---------------------------------------------------------------------------
// Edits
// ---------------------------------------------------------------------------
//
// `edit-artifact`'s pure half. An edit is an exact find-and-replace against the
// stored source, so a model changing one column of a 300-line dashboard sends
// the changed lines rather than re-emitting the whole component — the tool
// call's size scales with the edit, not the artifact.
//
// The semantics are the ones every model already knows from its own file-edit
// tool: `oldText` must match verbatim (whitespace included) and exactly once,
// unless `replaceAll` opts into every occurrence. Edits apply in order, each
// against the result of the previous, and the whole batch is atomic — any
// miss rejects the call and nothing is saved. Ambiguity is refused rather than
// resolved by picking the first hit, because a wrong pick would save silently
// and the user would see the wrong line change.
export type ArtifactEdit = {
/** Exact text to find in the current source. */
readonly oldText: string;
/** What to put in its place. */
readonly newText: string;
/** Replace every occurrence instead of requiring `oldText` to be unique. */
readonly replaceAll?: boolean | undefined;
};
export type AppliedArtifactEdits =
| { readonly ok: true; readonly code: string }
| { readonly ok: false; readonly message: string };
const countOccurrences = (haystack: string, needle: string): number =>
haystack.split(needle).length - 1;
/** The edited source, or the first edit that could not be applied. Messages
* name the edit by position and say what to do, because the model retries
* from the message alone. */
export const applyArtifactEdits = (
code: string,
edits: readonly ArtifactEdit[],
): AppliedArtifactEdits => {
let current = code;
for (const [index, edit] of edits.entries()) {
const position = `Edit ${index + 1} of ${edits.length}`;
if (edit.oldText === "") {
return { ok: false, message: `${position} has an empty oldText, which matches nothing.` };
}
if (edit.oldText === edit.newText) {
return {
ok: false,
message: `${position} is a no-op: oldText and newText are identical.`,
};
}
const occurrences = countOccurrences(current, edit.oldText);
if (occurrences === 0) {
return {
ok: false,
message: [
`${position} matched nothing: its oldText does not appear in the current source.`,
"Copy oldText verbatim from the source, whitespace included",
index > 0 ? "— and remember each edit sees the result of the earlier ones." : ".",
].join(" "),
};
}
if (occurrences > 1 && edit.replaceAll !== true) {
return {
ok: false,
message: [
`${position} is ambiguous: its oldText appears ${occurrences} times in the current source.`,
"Include enough surrounding text to make it unique, or set replaceAll: true to change every occurrence.",
].join(" "),
};
}
// split/join for both arms: `String.replace` gives `$&` and friends in the
// replacement special meaning, and generated JSX is full of `$`.
current =
edit.replaceAll === true
? current.split(edit.oldText).join(edit.newText)
: current.replace(edit.oldText, () => edit.newText);
}
return { ok: true, code: current };
};
// ---------------------------------------------------------------------------
// Create-time smoke render
// ---------------------------------------------------------------------------
/**
* What a server-side trial render concluded, as this package sees it.
*
* Declared here rather than imported so the MCP host keeps no type dependency
* on the shell package — `@executor-js/mcp-apps-shell`'s renderer satisfies this
* structurally, and a host that has no renderer to inject does not have to
* resolve the module at all.
*/
export type ArtifactSmokeRenderResult =
| {
readonly status: "ok";
/**
* The markup the render produced, when it produced any within its cap.
*
* The smoke render is driven with a transport that never settles, so this
* is the artifact's LOADING state — its real composition with no data in
* it — which is what makes it storable as the gallery's layout preview.
* Absent whenever nothing could be concluded, and every reader treats
* absent as "fall back", never as "empty".
*/
readonly markup?: string;
}
| { readonly status: "failed"; readonly message: string; readonly componentStack?: string };
/** The leading frames of a component stack: enough to locate the component that
* threw, not so much that the message is a wall. */
const COMPONENT_STACK_FRAMES = 6;
const trimmedComponentStack = (stack: string): string =>
stack
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
.slice(0, COMPONENT_STACK_FRAMES)
.join("\n");
/**
* Turn a failed smoke render into the message the model is refused with.
*
* The real error, verbatim, plus where it happened and what class of problem it
* is. The last part matters: saying the render happened with every query still
* pending tells the model that guarding the loading state is a valid fix, which
* is not obvious from `Cannot read properties of undefined` alone.
*/
export const smokeRenderRejection = (result: ArtifactSmokeRenderResult): string | null => {
if (result.status === "ok") return null;
return [
`This component threw on its first render, so the artifact would show an error instead of a UI: ${result.message}`,
result.componentStack ? `\n${trimmedComponentStack(result.componentStack)}\n` : "",
"The render happened with every query still pending and no data returned — exactly the state the user sees first — so guard the loading state (`if (query.isLoading) return <ArtifactLoading />`) and read possibly-absent data with `?.`.",
"Fix the component and call create-artifact again.",
]
.filter((part) => part.length > 0)
.join(" ");
};
// ---------------------------------------------------------------------------
// Provider pairing
// ---------------------------------------------------------------------------
//
// A handful of the components in scope read a React context that only their own
// wrapper supplies, and throw outright when it is missing. `<ChartTooltipContent
// />` outside a `<ChartContainer>` is the case this was written for: the
// artifact saves, the row is fine, and then the page dies on first render with
// `useChart must be used within a <ChartContainer />` — a failure the user sees
// and the model never does.
//
// The check is a table, not an analysis. If a name that requires a wrapper
// appears anywhere in the code and the wrapper's name appears nowhere, the code
// cannot possibly be correct, so it is refused with the pairing spelled out.
// That is deliberately weaker than the smoke render below (which catches the
// same class properly, and much more): this pass is free, runs first, and gives
// a message that names the fix rather than quoting a stack.
//
// Scope: `packages/react`'s components that `throw` on a missing context are
// `useChart` (chart.tsx), `useCarousel` (carousel.tsx), `useFormField`
// (form.tsx) and `useSidebar` (sidebar.tsx). Only the chart family is exported
// from the shell's component barrel, so only it is listed. `Tooltip` needs a
// `TooltipProvider` too, but the shell's inner renderer already wraps every
// artifact in one, so requiring it here would refuse working code.
/** Names whose presence obliges a wrapper, and the wrapper each one needs. */
const PROVIDER_PAIRINGS: readonly {
readonly required: string;
readonly dependents: readonly string[];
readonly hint: string;
}[] = [
{
required: "ChartContainer",
dependents: [
"useChart",
"ChartTooltip",
"ChartTooltipContent",
"ChartLegend",
"ChartLegendContent",
],
hint: 'Wrap the chart in `<ChartContainer config={{ series: { label: "Series", color: "var(--chart-1)" } }} className="h-[240px] w-full">…</ChartContainer>`.',
},
];
/**
* Whether `name` is INVOKED — rendered as `<Name`, or called as `name(`.
*
* Deliberately narrower than "the word appears". JSX text is ordinary prose
* inside the source (`<CardContent>No ChartTooltipContent here</CardContent>`)
* and is not blanked by `withLiteralsBlanked`, because it is not a literal — so
* a bare-word match would refuse a component that merely names another one in
* its copy. Requiring a `<` or a `(` costs only the alias case
* (`const T = ChartTooltip`), which is a false negative, and false negatives are
* the side to err on: the smoke render below catches it anyway.
*/
const invokesName = (scannable: string, name: string): boolean =>
new RegExp(String.raw`<\s*${name}(?![\w$])|(?<![.\w$])${name}\s*\(`).test(scannable);
/** Whether `name` appears at all as an identifier. Used for the WRAPPER side,
* where a loose match is the safe direction: over-crediting a wrapper lets a
* broken artifact through to the smoke render, while under-crediting one would
* refuse working code. */
const mentionsName = (scannable: string, name: string): boolean =>
new RegExp(String.raw`(?<![.\w$])${name}(?![\w$])`).test(scannable);
/** The first pairing the code breaks, or `null`. Comments and string literals
* are blanked first, so a name that only appears in a commented-out line
* neither triggers a rejection nor satisfies one. */
const providerPairingRejection = (code: string): string | null => {
const scannable = withLiteralsBlanked(code);
for (const pairing of PROVIDER_PAIRINGS) {
if (mentionsName(scannable, pairing.required)) continue;
const dependent = pairing.dependents.find((name) => invokesName(scannable, name));
if (!dependent) continue;
return [
`\`${dependent}\` only works inside a \`<${pairing.required}>\`, which this code never renders.`,
`It reads a React context that \`${pairing.required}\` provides, so it throws on the first render and the artifact shows an error instead of a UI.`,
pairing.hint,
].join(" ");
}
return null;
};
/** `null` when the code may be rendered, otherwise the reason to hand back. */
export const validateArtifactCode = (code: string): string | null => {
// Checked first: a five-segment address is the mistake a model that only read
// the `execute` skill will make, and every other rejection below would be a
// confusing thing to hear about code whose real problem is its addressing.
const oldStyleAddress = oldStyleAddressRejection(code);
if (oldStyleAddress) return oldStyleAddress;
if (REMOVED_RUN_CALL.test(code) && !LOCAL_RUN_DECLARATION.test(code)) {
return [
"`run(code)` no longer exists — artifact code is purely declarative `tools.*`.",
"Read data with useQuery(tools.<ns>.<tool>.queryOptions(args)),",
"page through a cursor with useInfiniteQuery(tools.<ns>.<tool>.infiniteQueryOptions(args, { getNextPageParam })),",
"and write with useMutation(tools.<ns>.<tool>.mutationOptions({ onSuccess })).",
].join(" ");
}
const loopedHook = hookCalledInLoop(code);
if (loopedHook) {
return [
`\`${loopedHook}\` is called inside a loop body.`,
"React hooks must run unconditionally at the top level of the component, in the same order every render.",
PAGINATION_HOOKS.has(loopedHook)
? "To read every page of a cursor, use one useInfiniteQuery(tools.<ns>.<tool>.infiniteQueryOptions(args, { cursorKey, getNextPageParam })) and render data.pages — never a loop of useQuery calls, one per page."
: "Move the hook out of the loop, and derive per-item values from its result instead.",
].join(" ");
}
const missingProvider = providerPairingRejection(code);
if (missingProvider) return missingProvider;
if (REACT_DESTRUCTURING_DECLARATION.test(code)) {
return [
"Do not destructure React in create-artifact.",
"Hooks such as useState are already in scope; use useState(...) directly or React.useState(...).",
].join(" ");
}
for (const match of code.matchAll(OBJECT_DESTRUCTURING_DECLARATION)) {
const names = match[1]?.split(",").flatMap((part) => {
const name = localDestructuredName(part);
return name ? [name] : [];
});
const providedName = names?.find((name) => PROVIDED_GLOBAL_NAMES.has(name));
if (providedName) {
return [
`Provided global "${providedName}" is already in scope and cannot be redeclared.`,
"Remove the destructuring declaration and use the provided global directly.",
].join(" ");
}
}
for (const match of code.matchAll(PROVIDED_GLOBAL_DECLARATION)) {
const name = firstDefined(match[1], match[2], match[3]);
if (name && PROVIDED_GLOBAL_NAMES.has(name)) {
return [
`Provided global "${name}" is already in scope and cannot be redeclared.`,
"Remove the local declaration and use the provided global directly.",
].join(" ");
}
}
return null;
};