// packages/ui-gen/src/internal/extract-call-signatures.ts // // Internal gadget-signature extractor. // // Uses the TypeScript Compiler API to parse an in-memory wrapper `.d.ts` // STRING and, for each requested export, print a prompt-ready type: // // - `extractCallSignaturesFromDts` — for HOOK exports, prints the // callable signature as a self-contained TS function-type // expression (`(options?: { center: [number, number]; zoom: number }) // => GadgetHookResult<...>`). // - `extractComponentPropsFromDts` (GG.8.5) — for COMPONENT exports, // prints the props-object shape (the first parameter's type, // structurally expanded) so the prompt can teach the LLM the JSX // props of ``. // // Both take the wrapper's already-built `.d.ts` content AS A STRING — // the render handler fetches each non-stdlib gadget's `.d.ts` over HTTPS // and threads a `package -> dtsContent` map into the generator // (`UiGenerateInput.gadgetTypes`). The code-gen prompt builder calls // these helpers to render a `Type:` / `Props:` line per third-party // gadget so the LLM sees the real shape it otherwise can't know. // // Internal only — NOT re-exported from any public package entry. Lives // under `src/internal/` deliberately. import ts from 'typescript'; /** * Map of `hookName → call-signature string` produced by * {@link extractCallSignaturesFromDts}. Each value is a printed * function-type expression suitable for verbatim interpolation into a * prompt `Type:` line. * * Hooks whose signature could not be resolved are omitted — the caller * renders a `Type:` line only for hooks that appear here. */ export type CallSignatureMap = Record; /** * Map of `componentName → props-object type string` produced by * {@link extractComponentPropsFromDts}. Each value is a printed object * type (`{ data: …; height?: number }`) suitable for a prompt `Props:` * line. Components whose props could not be resolved are omitted. */ export type ComponentPropsMap = Record; // In-memory `.d.ts` is parsed under a synthetic file name. The leading // `/` matches the convention `getCurrentDirectory()` (also `/`) so the // compiler host's path probes line up. const VIRTUAL_DTS_PATH = '/__gadget__.d.ts'; const EXTRACTOR_COMPILER_OPTIONS: ts.CompilerOptions = { target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler, allowJs: false, jsx: ts.JsxEmit.ReactJSX, noEmit: true, esModuleInterop: true, // skipLibCheck so an unresolvable wrapper-internal import (the // sandbox doesn't carry the wrapper's transitive deps) doesn't abort // the program before we can read the symbol's callable signature. skipLibCheck: true, strict: true, }; /** * Process-lifetime memos. Building a `ts.Program` — including * default-lib reads — on every call is expensive, and each extractor is * invoked once per coding-agent turn while the `.d.ts` content + name * set stay constant across a generation. Keying on `(dtsContent, names)` * collapses the repeated work. The key set is tiny in practice (a * handful of gadgets per process). */ const callSignatureCache = new Map(); const componentPropsCache = new Map(); /** * One-shot extractor program for a wrapper `.d.ts`, with the resolved * call signatures of every requested export. Shared by both public * extractors — they differ only in how they format the result. */ interface ExtractorContext { readonly checker: ts.TypeChecker; /** Render a `ts.Type` as a self-contained, prompt-ready expression. */ readonly renderType: (t: ts.Type, depth: number) => string; /** * Requested export name → its resolved call signatures, in * declaration order. Only exports that are callable (a hook or a * function component) appear; non-callable exports are omitted. */ readonly signaturesByName: ReadonlyMap; } /** * Build a single-file TS Program over `dtsContent`, resolve the call * signatures of every requested export name, and expose the checker + * a structural type renderer. Returns `undefined` when the `.d.ts` * cannot be parsed. */ function buildExtractorContext( dtsContent: string, names: readonly string[], ): ExtractorContext | undefined { // Build a fresh `.d.ts` source file. `setParentNodes: true` is // required so the checker can walk the AST. const sourceFile = ts.createSourceFile( VIRTUAL_DTS_PATH, dtsContent, ts.ScriptTarget.ESNext, /* setParentNodes */ true, ts.ScriptKind.TS, ); // Minimal in-memory compiler host. Only the synthetic `.d.ts` plus // the TS default lib files are resolvable; everything else is absent // (skipLibCheck keeps that from aborting the program). The checker // still resolves intra-file named types, which is all structural // fallback needs to inline a self-contained signature. const defaultLibName = ts.getDefaultLibFileName(EXTRACTOR_COMPILER_OPTIONS); const host: ts.CompilerHost = { getSourceFile(fileName) { if (fileName === VIRTUAL_DTS_PATH) return sourceFile; // Default lib(s) — load from the real `typescript` install so the // checker has `lib.d.ts` ambient types. The dts may not need // them, but the program won't run without a default lib. const libContent = ts.sys.readFile( ts.getDefaultLibFilePath(EXTRACTOR_COMPILER_OPTIONS).replace( /[^/\\]+$/, fileName, ), ); if (libContent !== undefined) { return ts.createSourceFile( fileName, libContent, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS, ); } return undefined; }, getDefaultLibFileName() { return defaultLibName; }, writeFile() { // no-op — noEmit }, getCurrentDirectory() { return '/'; }, getCanonicalFileName(f) { return f; }, useCaseSensitiveFileNames() { return true; }, getNewLine() { return '\n'; }, fileExists(f) { if (f === VIRTUAL_DTS_PATH) return true; return ts.sys.fileExists(f); }, readFile(f) { if (f === VIRTUAL_DTS_PATH) return dtsContent; return ts.sys.readFile(f); }, }; const program = ts.createProgram( [VIRTUAL_DTS_PATH], EXTRACTOR_COMPILER_OPTIONS, host, ); const checker = program.getTypeChecker(); const parsed = program.getSourceFile(VIRTUAL_DTS_PATH); if (parsed === undefined) { return undefined; } const requested = new Set(names); // True when a symbol's declaration originates from the wrapper's own // `.d.ts`. Only THESE named types get structurally expanded — types // from the TS DOM lib (`HTMLDivElement`), `@ggui-ai/gadgets` // (`GadgetHookResult`), or any external package keep their name, // because (a) the LLM already knows DOM / ggui types and (b) // expanding `HTMLDivElement` would dump hundreds of DOM members into // the prompt. function isWrapperLocal(symbol: ts.Symbol | undefined): boolean { const decls = symbol?.getDeclarations(); if (decls === undefined) return false; return decls.some((d) => d.getSourceFile().fileName === VIRTUAL_DTS_PATH); } // Render a single `ts.Type` as a self-contained TS type expression. // // The TS node builder keeps a type's NAME whenever that name is // reachable in the program — for a `.d.ts` whose option/value types // are `export interface`s, that means the printed signature ends up // full of `LeafletMapOptions` references the LLM cannot resolve. To // produce a prompt-ready, self-contained signature we structurally // expand WRAPPER-LOCAL named object types one level: enumerate the // apparent properties and render each property's type (recursively, // depth-bounded). Non-local named types, primitives, unions, arrays, // tuples, and anonymous objects keep `typeToString`'s rendering. const MAX_DEPTH = 4; function renderType(t: ts.Type, depth: number): string { const plain = (): string => checker.typeToString(t, undefined, ts.TypeFormatFlags.NoTruncation); // Bail to the plain stringifier once we hit the depth cap so a // cyclic / deeply-nested type can't blow the stack. if (depth >= MAX_DEPTH) return plain(); // `boolean` is internally `true | false` — `isUnion()` is true for // it. Catch it before union-splitting so it prints as `boolean`. if ((t.getFlags() & ts.TypeFlags.Boolean) !== 0) return 'boolean'; // Union types (e.g. `LeafletMapOptions | undefined` from an // optional param) — recurse into each constituent so a wrapper- // local named object member still expands structurally. if (t.isUnion()) { return t.types.map((member) => renderType(member, depth)).join(' | '); } // Intersections — same treatment. if (t.isIntersection()) { return t.types.map((member) => renderType(member, depth)).join(' & '); } const symbol = t.getSymbol() ?? t.aliasSymbol; // Callable types (e.g. `containerRef: (el) => void`) — render the // first call signature inline. const callSigs = t.getCallSignatures(); if (callSigs.length > 0) { const cs = callSigs[0]!; const params = cs .getParameters() .map((p) => { const decl = p.valueDeclaration ?? p.declarations?.[0]; const pType = decl !== undefined ? checker.getTypeOfSymbolAtLocation(p, decl) : checker.getDeclaredTypeOfSymbol(p); const optional = decl !== undefined && ts.isParameter(decl) && (decl.questionToken !== undefined || decl.initializer !== undefined); return `${p.getName()}${optional ? '?' : ''}: ${renderType(pType, depth + 1)}`; }) .join(', '); const ret = renderType(cs.getReturnType(), depth + 1); return `(${params}) => ${ret}`; } const isObject = (t.getFlags() & ts.TypeFlags.Object) !== 0; const isArrayOrTuple = checker.isArrayType(t) || checker.isTupleType(t); // Generic instantiation of a non-local type (e.g. // `GadgetHookResult`) — keep the outer name (the // LLM knows the ggui types) but recurse into the type arguments so // a nested wrapper-local type still expands. const typeArgs = (t as ts.TypeReference).typeArguments; if ( symbol !== undefined && isObject && !isArrayOrTuple && !isWrapperLocal(symbol) && typeArgs !== undefined && typeArgs.length > 0 ) { const args = typeArgs.map((a) => renderType(a, depth + 1)).join(', '); return `${symbol.getName()}<${args}>`; } // Wrapper-local named object — expand its property shape inline. if ( symbol !== undefined && isObject && !isArrayOrTuple && isWrapperLocal(symbol) ) { const props = checker.getPropertiesOfType(t); if (props.length > 0) { const body = props .map((p) => { const decl = p.valueDeclaration ?? p.declarations?.[0]; const pType = decl !== undefined ? checker.getTypeOfSymbolAtLocation(p, decl) : checker.getDeclaredTypeOfSymbol(p); const optional = (p.getFlags() & ts.SymbolFlags.Optional) !== 0; return `${p.getName()}${optional ? '?' : ''}: ${renderType(pType, depth + 1)}`; }) .join('; '); return `{ ${body} }`; } } return plain(); } // The exported binding may surface as either: // - `export declare const useLeafletMap: GguiGadget<...>` — a // variable statement, or // - `export declare function useLeafletMap(...): ...` — a function // declaration, or // - `export { useLeafletMap } from '...'` — a re-export. // The module-symbol export table covers all three uniformly, so we // walk the module symbol's exports rather than top-level statements. const moduleSymbol = checker.getSymbolAtLocation(parsed); const exportSymbols: ts.Symbol[] = moduleSymbol !== undefined ? checker.getExportsOfModule(moduleSymbol) : []; const signaturesByName = new Map(); for (const symbol of exportSymbols) { const name = symbol.getName(); if (!requested.has(name)) continue; if (signaturesByName.has(name)) continue; // Resolve aliases (re-exports) so we read the real declaration. const resolved = (symbol.flags & ts.SymbolFlags.Alias) !== 0 ? checker.getAliasedSymbol(symbol) : symbol; const declarations = resolved.getDeclarations(); if (declarations === undefined || declarations.length === 0) continue; const declaration = declarations[0]!; const type = checker.getTypeOfSymbolAtLocation(resolved, declaration); const callSignatures = type.getCallSignatures(); if (callSignatures.length === 0) continue; signaturesByName.set(name, callSignatures); } return { checker, renderType, signaturesByName }; } /** * Print one call signature as a self-contained `(params) => ret` * expression. */ function printCallSignature(sig: ts.Signature, ctx: ExtractorContext): string { const params = sig .getParameters() .map((p) => { const decl = p.valueDeclaration ?? p.declarations?.[0]; const pType = decl !== undefined ? ctx.checker.getTypeOfSymbolAtLocation(p, decl) : ctx.checker.getDeclaredTypeOfSymbol(p); const optional = decl !== undefined && ts.isParameter(decl) && (decl.questionToken !== undefined || decl.initializer !== undefined); return `${p.getName()}${optional ? '?' : ''}: ${ctx.renderType(pType, 0)}`; }) .join(', '); const ret = ctx.renderType(sig.getReturnType(), 0); return `(${params}) => ${ret}`.trim(); } /** * Render a function component's props — the first parameter's type, * structurally expanded into a self-contained object type. A * zero-parameter component yields `{}`. Returns `undefined` when the * expander bottoms out on an unresolvable token. */ function printComponentProps( sig: ts.Signature, ctx: ExtractorContext, ): string | undefined { const params = sig.getParameters(); // A zero-parameter component takes no props. if (params.length === 0) return '{}'; const propsParam = params[0]!; const decl = propsParam.valueDeclaration ?? propsParam.declarations?.[0]; const propsType = decl !== undefined ? ctx.checker.getTypeOfSymbolAtLocation(propsParam, decl) : ctx.checker.getDeclaredTypeOfSymbol(propsParam); const rendered = ctx.renderType(propsType, 0); // A surviving virtual-file qualifier means the structural expander // bottomed out on something it couldn't inline — drop it rather than // feed the LLM an unresolvable `import("/__gadget__")` token. if (rendered.length === 0 || rendered.includes('import("')) return undefined; return rendered; } /** * Print the inferred call signature of every requested HOOK found in * the supplied wrapper `.d.ts` string. * * For each exported declaration whose name matches a requested hook, * the checker's callable signature is printed with structural-fallback * so wrapper-specific named types expand to their structural form — the * resulting string is self-contained and safe to drop into a prompt. A * hook declared with multiple overloads renders each, joined with ` | `. * * Hooks that aren't found, or that resolve to a value with no callable * signature, are omitted from the result (no throw) — the caller is * expected to render a `Type:` line only for hooks that resolved. * * Memoized on `(dtsContent, hookNames)`. */ export function extractCallSignaturesFromDts( dtsContent: string, hookNames: readonly string[], ): CallSignatureMap { if (hookNames.length === 0 || dtsContent.trim().length === 0) { return {}; } const cacheKey = `${dtsContent}${[...hookNames].sort().join(',')}`; const cached = callSignatureCache.get(cacheKey); if (cached !== undefined) return cached; const result: CallSignatureMap = {}; const ctx = buildExtractorContext(dtsContent, hookNames); if (ctx === undefined) { callSignatureCache.set(cacheKey, result); return result; } for (const [name, signatures] of ctx.signaturesByName) { // A hook declared with multiple overloads renders ALL of them, // joined as a union of function types. A surviving virtual-file // qualifier means the structural expander bottomed out on something // it couldn't inline — drop that overload rather than feed the LLM // an unresolvable `import("/__gadget__")` token. const printed = signatures .map((sig) => printCallSignature(sig, ctx)) .filter((s) => s.length > 0 && !s.includes('import("')) .join(' | '); if (printed.length > 0) { result[name] = printed; } } callSignatureCache.set(cacheKey, result); return result; } /** * Print the props-object shape of every requested COMPONENT found in * the supplied wrapper `.d.ts` string (GG.8.5). * * A function component's props are its first parameter's type. The * type is structurally expanded the same way hook signatures are, so * the printed value (`{ data: …; height?: number }`) is self-contained * and prompt-ready — the code-gen prompt renders it as a `Props:` line * so the LLM knows the JSX attributes of ``. * * Components not found, or that resolve to a non-callable value, are * omitted (no throw). Memoized on `(dtsContent, componentNames)`. */ export function extractComponentPropsFromDts( dtsContent: string, componentNames: readonly string[], ): ComponentPropsMap { if (componentNames.length === 0 || dtsContent.trim().length === 0) { return {}; } const cacheKey = `${dtsContent}${[...componentNames].sort().join(',')}`; const cached = componentPropsCache.get(cacheKey); if (cached !== undefined) return cached; const result: ComponentPropsMap = {}; const ctx = buildExtractorContext(dtsContent, componentNames); if (ctx === undefined) { componentPropsCache.set(cacheKey, result); return result; } for (const [name, signatures] of ctx.signaturesByName) { // A component is single-signature by convention — use the first. const first = signatures[0]; if (first === undefined) continue; const props = printComponentProps(first, ctx); if (props !== undefined) { result[name] = props; } } componentPropsCache.set(cacheKey, result); return result; }