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
96 changes: 82 additions & 14 deletions packages/plugin/scripts/build-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,12 @@ import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promi
import { createRequire } from "node:module";
import { basename, dirname, join, relative } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { runtimeModuleId, TUI_RUNTIME_SPECIFIERS } from "../src/shared/tui-runtime-specifiers";

const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const sourceRoot = join(pluginRoot, "src/tui");
const outputRoot = join(pluginRoot, "src/tui-compiled");
const runtimeSpecifiers = new Set([
"@opentui/core",
"@opentui/core/testing",
"@opentui/solid",
"@opentui/solid/components",
"@opentui/solid/jsx-runtime",
"@opentui/solid/jsx-dev-runtime",
"solid-js",
"solid-js/store",
]);
const runtimeSpecifiers: Set<string> = new Set(TUI_RUNTIME_SPECIFIERS);

type TransformSolidSource = (
code: string,
Expand All @@ -30,8 +22,82 @@ type SolidTransformModule = {
transformSolidSource?: TransformSolidSource;
};

function runtimeModuleId(specifier: string): string {
return `opentui:runtime-module:${encodeURIComponent(specifier)}`;

/**
* The Solid transform emits every runtime helper AND every control-flow builtin
* (`For`, `Show`, `Index`, `Switch`, `Match`, `ErrorBoundary`, `Suspense`, ...)
* as an import from a single `moduleName`, which we set to `@opentui/solid`.
* But `@opentui/solid` only re-exports its own renderer helpers: the builtins
* live in `solid-js`. An emitted `import { For } from "@opentui/solid"` therefore
* resolves against the host's registry and throws
*
* Export named 'For' not found in module 'opentui:runtime-module:@opentui/solid'
*
* which the host swallows, leaving no sidebar and no /ctx-* commands.
*
* Rather than hardcode the split (it moves between OpenTUI versions), read the
* real export sets at build time and redirect only the names the OpenTUI runtime
* genuinely lacks. A name missing from BOTH modules fails the build instead of
* shipping a bundle that silently breaks the TUI.
*/
async function loadRuntimeExportSets(): Promise<{
openTui: Set<string>;
solid: Set<string>;
}> {
const [openTuiModule, solidModule] = await Promise.all([
import("@opentui/solid"),
import("solid-js"),
]);
return {
openTui: new Set(Object.keys(openTuiModule)),
solid: new Set(Object.keys(solidModule)),
};
}

const OPENTUI_SOLID_RUNTIME_ID = runtimeModuleId("@opentui/solid");
const SOLID_JS_RUNTIME_ID = runtimeModuleId("solid-js");

// The transform emits one specifier per import statement, e.g.
// import { For as _$For } from "opentui:runtime-module:%40opentui%2Fsolid";
const SINGLE_SPECIFIER_IMPORT =
/^import \{\s*([A-Za-z_$][\w$]*)(\s+as\s+[A-Za-z_$][\w$]*)?\s*\} from "([^"]+)";$/;

function redirectBuiltinImports(
code: string,
exportSets: { openTui: Set<string>; solid: Set<string> },
sourceFile: string,
): string {
const unresolved: string[] = [];

const rewritten = code
.split("\n")
.map((line) => {
const match = SINGLE_SPECIFIER_IMPORT.exec(line);
if (!match) return line;

const [, importedName, alias, moduleId] = match;
if (moduleId !== OPENTUI_SOLID_RUNTIME_ID) return line;
if (exportSets.openTui.has(importedName)) return line;

if (!exportSets.solid.has(importedName)) {
unresolved.push(importedName);
return line;
}

return `import {${alias ? ` ${importedName}${alias} ` : ` ${importedName} `}} from "${SOLID_JS_RUNTIME_ID}";`;
})
.join("\n");

if (unresolved.length > 0) {
throw new Error(
`${sourceFile}: compiled TUI imports ${unresolved
.map((name) => `'${name}'`)
.join(", ")} which neither @opentui/solid nor solid-js exports. ` +
"Shipping this bundle would make the host drop the sidebar silently.",
);
}

return rewritten;
}

function asTransformSolidSource(mod: SolidTransformModule, from: string): TransformSolidSource {
Expand Down Expand Up @@ -113,6 +179,7 @@ async function compileTsx(
transformSolidSource: TransformSolidSource,
sourceFile: string,
outputFile: string,
exportSets: { openTui: Set<string>; solid: Set<string> },
): Promise<void> {
const code = await readFile(sourceFile, "utf8");
const compiled = await transformSolidSource(code, {
Expand All @@ -123,10 +190,11 @@ async function compileTsx(
});

await mkdir(dirname(outputFile), { recursive: true });
await writeFile(outputFile, compiled);
await writeFile(outputFile, redirectBuiltinImports(compiled, exportSets, sourceFile));
}

const transformSolidSource = await loadTransformSolidSource();
const runtimeExportSets = await loadRuntimeExportSets();
const files = await listSourceFiles(sourceRoot);

await rm(outputRoot, { recursive: true, force: true });
Expand All @@ -142,7 +210,7 @@ for (const sourceFile of files) {
// the sidebar freezes on its first paint. The virtual ids are required so
// the compiled package binds the host process's single OpenTUI/Solid
// runtime instead of loading a second copy from the plugin package.
await compileTsx(transformSolidSource, sourceFile, outputFile);
await compileTsx(transformSolidSource, sourceFile, outputFile, runtimeExportSets);
} else {
await copyPlainTypeScript(sourceFile, outputFile);
}
Expand Down
32 changes: 32 additions & 0 deletions packages/plugin/src/shared/tui-runtime-specifiers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* The module specifiers the compiled TUI is allowed to resolve through OpenCode's
* process-wide OpenTUI runtime registry (`opentui:runtime-module:<encoded>`).
*
* Single source of truth, shared by `scripts/build-tui.ts` (which rewrites these
* specifiers during the Solid transform) and
* `src/tui/tui-compiled-runtime-imports.test.ts` (which verifies every emitted
* specifier names a real export). Keeping one list means the guard test cannot
* drift into skipping a specifier the build actually rewrites — a skipped
* specifier is exactly the silent hole the test exists to close.
*
* Lives in `src/shared/` rather than `src/tui/` on purpose: `build-tui.ts` copies
* every file under `src/tui/` into the shipped `src/tui-compiled/` bundle, and
* this list is build/test tooling that the runtime bundle must not carry.
*/
export const TUI_RUNTIME_SPECIFIERS = [
"@opentui/core",
"@opentui/core/testing",
"@opentui/solid",
"@opentui/solid/components",
"@opentui/solid/jsx-runtime",
"@opentui/solid/jsx-dev-runtime",
"solid-js",
"solid-js/store",
] as const;

export type TuiRuntimeSpecifier = (typeof TUI_RUNTIME_SPECIFIERS)[number];

/** Virtual module id OpenCode registers for a runtime specifier. */
export function runtimeModuleId(specifier: string): string {
return `opentui:runtime-module:${encodeURIComponent(specifier)}`;
}
2 changes: 1 addition & 1 deletion packages/plugin/src/tui-compiled/slots/sidebar-content.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { For as _$For } from "opentui:runtime-module:%40opentui%2Fsolid";
import { For as _$For } from "opentui:runtime-module:solid-js";
import { createComponent as _$createComponent } from "opentui:runtime-module:%40opentui%2Fsolid";
import { createTextNode as _$createTextNode } from "opentui:runtime-module:%40opentui%2Fsolid";
import { effect as _$effect } from "opentui:runtime-module:%40opentui%2Fsolid";
Expand Down
151 changes: 151 additions & 0 deletions packages/plugin/src/tui/tui-compiled-runtime-imports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { describe, expect, test } from "bun:test";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";

import { TUI_RUNTIME_SPECIFIERS } from "../shared/tui-runtime-specifiers";

/**
* Every `opentui:runtime-module:*` import in the compiled TUI bundle must name an
* export the target module actually has.
*
* The Solid transform routes ALL of its emitted imports — renderer helpers and
* control-flow builtins (`For`, `Show`, `Index`, `Switch`, `Match`, ...) alike —
* through the single `moduleName` that `scripts/build-tui.ts` passes it, which is
* `@opentui/solid`. But `@opentui/solid` only re-exports its own renderer
* helpers; the builtins live in `solid-js`. So a bundle built without the
* redirect in `build-tui.ts` contains
*
* import { For as _$For } from "opentui:runtime-module:%40opentui%2Fsolid";
*
* and the host throws `Export named 'For' not found` while loading the plugin.
* OpenCode swallows that error, so the only visible symptom is a missing sidebar
* and missing /ctx-* commands — no crash and nothing on stderr.
*
* This test resolves each specifier against the real module export sets instead
* of a hardcoded list, so it keeps holding when OpenTUI changes which names it
* re-exports. It covers every specifier `build-tui.ts` rewrites (via the shared
* `TUI_RUNTIME_SPECIFIERS` list) and fails on any runtime module id outside that
* set, so a future import from e.g. `solid-js/store` cannot slip through
* unverified.
*/
describe("compiled TUI runtime imports", () => {
const COMPILED_ROOT = join(import.meta.dir, "..", "tui-compiled");
const IMPORT_PATTERN = /import \{([^}]+)\} from "opentui:runtime-module:([^"]+)"/g;

function compiledFiles(dir: string): string[] {
const found: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
found.push(...compiledFiles(full));
} else if (entry.endsWith(".ts") || entry.endsWith(".tsx")) {
found.push(full);
}
}
return found;
}

/** Export sets for EVERY specifier build-tui.ts rewrites, keyed by module id.
* Built from the shared list so the test cannot cover fewer modules than the
* build rewrites. */
async function loadExportSets(): Promise<Record<string, Set<string>>> {
const entries = await Promise.all(
TUI_RUNTIME_SPECIFIERS.map(
async (specifier) =>
[specifier, new Set(Object.keys(await import(specifier)))] as const,
),
);
return Object.fromEntries(entries);
}

test("every runtime specifier exists in the module it is imported from", async () => {
const exportSets = await loadExportSets();

const unresolved: string[] = [];
let checked = 0;

for (const file of compiledFiles(COMPILED_ROOT)) {
const source = readFileSync(file, "utf8");
for (const match of source.matchAll(IMPORT_PATTERN)) {
const moduleId = decodeURIComponent(match[2] ?? "");
const exports = exportSets[moduleId];
if (!exports) {
// A runtime module id outside the rewritten set means the build
// emitted something this guard does not know how to verify.
// Failing is the only safe answer: skipping it silently is the
// exact hole that let the `For` misroute ship.
unresolved.push(
`${file}: imports from unknown runtime module '${moduleId}' — ` +
"add it to TUI_RUNTIME_SPECIFIERS so it can be verified",
);
continue;
}

for (const specifier of (match[1] ?? "").split(",")) {
const importedName = specifier
.trim()
.split(/\s+as\s+/)[0]
?.trim();
if (!importedName) continue;
checked += 1;
if (!exports.has(importedName)) {
unresolved.push(
`${file}: '${importedName}' is not exported by ${moduleId}`,
);
}
}
}
}

expect(checked).toBeGreaterThan(0);
expect(unresolved).toEqual([]);
});

test("every rewritten specifier resolves to a non-empty module", async () => {
// A specifier that stops resolving already fails loudly on its own: the
// dynamic import inside loadExportSets throws and takes the test with it.
// What that does NOT catch is a specifier that resolves to an empty module
// (a renamed or emptied subpath export), which would make the guard above
// report every import from it as unresolved for a misleading reason.
const exportSets = await loadExportSets();

const empty = TUI_RUNTIME_SPECIFIERS.filter(
(specifier) => (exportSets[specifier]?.size ?? 0) === 0,
);
expect(empty).toEqual([]);
});

test("TUI_RUNTIME_SPECIFIERS has no duplicates", () => {
// Object.fromEntries silently collapses duplicate keys, so a duplicated
// entry would shrink the export-set map without any other signal.
expect([...new Set(TUI_RUNTIME_SPECIFIERS)]).toEqual([...TUI_RUNTIME_SPECIFIERS]);
});

test("solid control-flow builtins are imported from solid-js, not @opentui/solid", async () => {
const openTuiExports = new Set(Object.keys(await import("@opentui/solid")));
const solidExports = new Set(Object.keys(await import("solid-js")));

// The builtins that belong to solid-js alone — the exact set the
// transform would otherwise misroute to @opentui/solid.
const misroutable = [...solidExports].filter((name) => !openTuiExports.has(name));

const violations: string[] = [];
for (const file of compiledFiles(COMPILED_ROOT)) {
const source = readFileSync(file, "utf8");
for (const match of source.matchAll(IMPORT_PATTERN)) {
if (decodeURIComponent(match[2] ?? "") !== "@opentui/solid") continue;
for (const specifier of (match[1] ?? "").split(",")) {
const importedName = specifier
.trim()
.split(/\s+as\s+/)[0]
?.trim();
if (importedName && misroutable.includes(importedName)) {
violations.push(`${file}: '${importedName}' must come from solid-js`);
}
}
}
}

expect(violations).toEqual([]);
});
});
Loading