// packages/ui-gen/src/check/type-checker.ts
//
// TypeScript type-checker for LLM-generated UI components. Uses the
// TypeScript compiler API with a virtual filesystem to type-check TSX
// code against React types and `@ggui-ai/design` primitives, giving
// the LLM familiar TS error feedback without writing anything to disk.
//
// The checker builds a virtual filesystem seeded with `typescript/lib`,
// `@types/react`, `@ggui-ai/design/dist`, and `@ggui-ai/wire/dist`
// `.d.ts` content, then drives `ts.createProgram` against a synthetic
// `Component.tsx`. Exposed on `@ggui-ai/ui-gen/check` so
// `createUiGenerator` runs the same tier-0 gate as the hosted
// generation path.
//
// Path-resolution note: the three workspace-rooted lookups —
// `.pnpm/@types+react@*`, `packages/design/dist`, and
// `packages/wire/dist` — are anchored relative to this file. tsup
// bundles `src/check/index.ts` (splitting: false) into a flat
// `dist/check/index.js`, so the src and dist locations sit at the same
// depth and both resolve correctly. `typescript/lib/typescript.js`
// keeps using `createRequire(import.meta.url).resolve` —
// position-independent, so no change.
//
// Used by:
// (1) Tier-0 `type_check` — blocking errors (see BLOCKING_CODES)
// map to PRODUCTION_FAILED; non-blocking TS diagnostics surface
// as warnings so the LLM can iterate on non-crash-risk findings
// without stalling the harness.
// (2) The `self_check` tool — called from generator SDKs (Anthropic /
// OpenAI / Google) to give the LLM TS feedback mid-generation.
// (3) The tier-0 orchestrator `runTier0Checks` — runs in parallel
// with the wire-preservation + lint checks under the same tier
// budget.
//
// Design notes that motivate non-obvious choices:
// - Classic React JSX mode (`ts.JsxEmit.React`). The synthetic
// prefix (see SYNTHETIC_PREFIX) supplies `import React from
// 'react'` for the classic JSX factory PLUS a self-contained
// global `JSX` namespace. `@types/react` v19 removed the global
// `JSX` namespace that classic mode resolves intrinsic elements +
// the `key`/`ref` carve-out through; without the shim, `
`
// degrades to `any` and every typed component falsely rejects the
// intrinsic `key` prop. Automatic mode was tried but its
// `react/jsx-runtime` resolution does not survive the VFS.
// - `strict: false` + `strictNullChecks: true`: we want the runtime
// crash classes (`undefined.foo()`, `null.bar`) to surface as
// blocking, but not the optional-chaining / exhaustive-check
// noise that full `strict` would emit on LLM code.
// - `types: []`: prevents the VFS from auto-pulling every
// `@types/*` package that happens to be hoisted — only react and
// the design/wire dist types should be reachable, matching the
// forbidden-import policy enforced by `react-linter` elsewhere.
// - TS2307 ("Cannot find module") is deliberately NOT blocking —
// Lambda bundles code without type declarations, so the VFS can't
// see every package that may exist at runtime. Forbidden imports
// are caught by `runSelfChecks` regex instead.
import ts from 'typescript';
import fs from 'fs';
import path from 'path';
import { createRequire } from 'module';
import { describeAllowedImports } from '../validation/allowed-imports.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface TypeCheckDiagnostic {
code: number;
line: number;
message: string;
fix: string;
}
export interface TypeCheckResult {
errors: TypeCheckDiagnostic[]; // Blocking
warnings: TypeCheckDiagnostic[]; // Non-blocking
}
// ---------------------------------------------------------------------------
// Blocking error codes — these would break at runtime
// ---------------------------------------------------------------------------
const BLOCKING_CODES = new Set([
2304, // Cannot find name
// 2307 (Cannot find module) is NOT blocking — Lambda bundles code without
// type declarations, so the VFS can't resolve react/@ggui-ai/design.
// Forbidden imports are caught by runSelfChecks regex instead.
2305, // Module has no exported member
2322, // Type not assignable
2339, // Property does not exist on type
2741, // Missing required property
2769, // No overload matches this call
17004, // Cannot use JSX unless '--jsx' flag
18047, // 'X' is possibly 'null' — causes runtime crash
18048, // 'X' is possibly 'undefined' — causes runtime crash
]);
// ---------------------------------------------------------------------------
// Fix suggestions per error code
// ---------------------------------------------------------------------------
function generateFix(code: number, message?: string, sourceLine?: string): string {
// Source-line hint: when the failing line is short and self-contained,
// appending it gives the LLM a precise target. Skip for very long lines
// (>140 chars — usually multi-prop JSX where the line itself is the
// diagnostic) since they bloat the violation envelope without helping.
const sourceHint =
sourceLine && sourceLine.length > 0 && sourceLine.length <= 140
? ` Offending line: \`${sourceLine}\``
: '';
// Detect event handler on non-interactive primitive
if ((code === 2322 || code === 2339) && message && /onClick|onDoubleClick|onMouseEnter|onMouseLeave|onPress/.test(message)) {
return `This structural primitive has no event handlers of its own — add the trait as a PROP: as={Clickable} (then onClick works), imported from @ggui-ai/design. Do NOT wrap it in
…; as is a prop, not a wrapper element.${sourceHint}`;
}
// Detect underscore-prefix-on-prop trap: `Property '_X' does not exist`.
// LLM saw an unused-var warning on `X` and tried to silence it by renaming
// the destructure to `_X`, breaking prop access. Steer back.
if (code === 2339 && message && /Property '_[a-zA-Z]/.test(message)) {
return `You renamed a prop with a leading underscore to silence \`no-unused-vars\`, but the prop on \`Props\` doesn't have that prefix. Restore the original name; better, don't destructure props you won't render — access them via \`props.fieldName\` only when needed.${sourceHint}`;
}
// Detect "Cannot find name" for destructured props
if (code === 2304 && message && !message.includes('module') && !message.includes('import')) {
return `This name is not defined in scope. Either you destructured props (use \`props.fieldName\` directly instead) OR you removed a helper declaration in this patch but kept a JSX/expression reference to it. Read your full patch and either restore the declaration or remove the reference.${sourceHint}`;
}
// Detect "unknown is not assignable to ReactNode"
if ((code === 2322 || code === 2769) && message && /unknown.*ReactNode|ReactNode.*unknown/.test(message)) {
return `This value has type 'unknown' and cannot be rendered in JSX. Cast it: String(value) or add a type annotation.${sourceHint}`;
}
switch (code) {
case 2307:
return `Only these imports are allowed: ${describeAllowedImports()}${sourceHint}`;
case 2322:
case 2769:
return `Type mismatch on this expression. Check the offending line below — the prop name in JSX is what TypeScript is rejecting; the message tells you the expected type.${sourceHint}`;
case 2339:
return `This prop doesn't exist on this component — check the available props on the component's interface (visible at the top of the file, or in the design-system reference).${sourceHint}`;
case 2305:
return `This name is not defined. Check your imports and variable declarations.${sourceHint}`;
case 2741:
return `A required prop is missing. Check the component's Props interface.${sourceHint}`;
case 18047:
case 18048:
return `This value might be null/undefined. Every dereference on the same nullable still needs \`?.\` — \`x?.foo && x?.bar\`, NOT \`x?.foo && x.bar\`. Or hoist: \`const v = x; if (!v) return null;\` then access \`v.foo\`/\`v.bar\` unguarded.${sourceHint}`;
default:
return `Review the TypeScript error and fix the type issue.${sourceHint}`;
}
}
// ---------------------------------------------------------------------------
// Virtual Filesystem — lazy singleton
// ---------------------------------------------------------------------------
interface VfsEntry {
content: string;
sourceFile: ts.SourceFile;
}
let vfsCache: Map
| null = null;
function parseAndStore(
vfs: Map,
virtualPath: string,
content: string,
): void {
const sourceFile = ts.createSourceFile(
virtualPath,
content,
ts.ScriptTarget.ES2020,
true,
virtualPath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
vfs.set(virtualPath, { content, sourceFile });
}
/**
* Recursively walk a directory and return all file paths matching a filter.
*/
function walkDir(dir: string, filter: (f: string) => boolean): string[] {
const results: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...walkDir(full, filter));
} else if (filter(full)) {
results.push(full);
}
}
return results;
}
/**
* Resolve `@types/react`'s root directory through Node's module
* resolution. Position-independent — walks the consumer's node_modules
* chain. Works in monorepo (where the type package is hoisted under
* `/node_modules/.pnpm/@types+react@*`) and in standalone
* `packages/`-only installs (e.g. the projected public OSS repo where
* the `packages/` prefix is stripped, so a hardcoded `../../../..`
* walk lands in the wrong place).
*/
function findReactTypesDir(): string | null {
try {
// `@types/react` doesn't expose `package.json` as a public subpath,
// so resolve via the always-present `index.d.ts`.
const indexDts = createRequire(import.meta.url).resolve('@types/react/index.d.ts');
return path.dirname(indexDts);
} catch {
return null;
}
}
/**
* Resolve a workspace package's `dist/` directory through Node's
* module resolution. Like `findReactTypesDir`, this works in both
* monorepo (where the package is hoisted/symlinked) and standalone
* installs — no relative-path math required.
*/
function findPackageDistDir(pkg: string): string | null {
try {
const pkgJson = createRequire(import.meta.url).resolve(`${pkg}/package.json`);
const dist = path.join(path.dirname(pkgJson), 'dist');
return fs.existsSync(dist) ? dist : null;
} catch {
return null;
}
}
async function loadVfs(): Promise