#!/usr/bin/env node /** Reject erased type-only imports used as runtime JSX component tags. */ import { readFileSync, readdirSync, existsSync } from "node:fs"; import { createRequire } from "node:module"; import { resolve, relative } from "node:path"; import { fileURLToPath } from "node:url"; const workspaceRoot = resolve(import.meta.dirname, "../../.."); const require = createRequire(resolve(workspaceRoot, "govoplan-core/webui/package.json")); const ts = require("typescript"); function isTypeOnlyImport(declaration) { if (ts.isImportSpecifier(declaration)) return declaration.isTypeOnly || declaration.parent.parent.isTypeOnly; if (ts.isNamespaceImport(declaration)) return declaration.parent.isTypeOnly; return (ts.isImportClause(declaration) || ts.isImportEqualsDeclaration(declaration)) && declaration.isTypeOnly; } /** Resolve lexical bindings, including shadowing; do not typecheck unrelated * optional dependencies or report ordinary application diagnostics. */ export function findTypeOnlyJsxImports(sources) { const files = new Map(sources.map(({ path, source }) => [resolve(path), ts.createSourceFile(resolve(path), source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)])); const options = { noEmit: true, noResolve: true, noLib: true, types: [], jsx: ts.JsxEmit.Preserve }; const host = ts.createCompilerHost(options); host.getSourceFile = (path) => files.get(resolve(path)); const program = ts.createProgram([...files.keys()], options, host); const checker = program.getTypeChecker(); const findings = []; for (const [path, source] of files) { function visit(node) { if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { let root = node.tagName; // Lower-case direct tags are intrinsic HTML, not runtime bindings. if (!(ts.isIdentifier(root) && /^[a-z]/.test(root.text))) { while (ts.isPropertyAccessExpression(root)) root = root.expression; const declarations = checker.getSymbolAtLocation(root)?.declarations ?? []; if (declarations.some(isTypeOnlyImport)) { const position = source.getLineAndCharacterOfPosition(node.tagName.getStart(source)); findings.push({ path, line: position.line + 1, column: position.character + 1, component: node.tagName.getText(source) }); } } } ts.forEachChild(node, visit); } visit(source); } return findings; } function sourceFiles(directory) { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const path = resolve(directory, entry.name); return entry.isDirectory() ? sourceFiles(path) : entry.name.endsWith(".tsx") ? [path] : []; }); } export function checkWorkspace(root = workspaceRoot) { const modules = readdirSync(root, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && entry.name.startsWith("govoplan")) .map((entry) => resolve(root, entry.name, "webui/src")).filter(existsSync); const paths = modules.flatMap(sourceFiles); const findings = findTypeOnlyJsxImports(paths.map((path) => ({ path, source: readFileSync(path, "utf8") }))); for (const finding of findings) { console.error(`${relative(root, finding.path)}:${finding.line}:${finding.column}: JSX component ${finding.component} is imported type-only and will be erased at runtime.`); } if (!findings.length) console.log(`JSX runtime-import contract passed: ${paths.length} TSX files across ${modules.length} WebUI modules.`); return findings.length ? 1 : 0; } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { process.exitCode = checkWorkspace(); }