Ctrl K
ComunidadGuías…

scripts/codegen-web-platform-schemas.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bun
/**
 * Generates the `.d.ts` trees under `web/src/_generated/` from the server-side
 * sources that own their shapes: the TypeBox schemas at
 * `src/platform/schemas/`, the connector-catalog wire types at
 * `src/connectors/catalog/types.ts`, and the REST response bodies at
 * `src/api/schemas/responses.ts`.
 *
 * Why this exists: web is a separate package (its own Dockerfile,
 * package.json, build context). The web shell needs the catalog's
 * derived TYPES — `ToolInput<S, T>`, `ToolSource`, `ToolName<S>` — for
 * the typed `callTool<S, T>(args)` API in `web/src/api/client.ts` and
 * the `CreateInput` alias in `web/src/pages/settings/SkillsTab.tsx`.
 * It does NOT need the runtime schemas (TypeBox values).
 *
 * Sharing this via a cross-tree TypeScript path alias would force every
 * Dockerfile, dockerignore, Makefile, docker-compose, and CI invocation
 * to know about the cross-tree relationship. Generating a flat .d.ts
 * tree inside web/ keeps web a self-contained package — its build
 * doesn't reach outside web/, and the generated artifact is a normal
 * source file in version control.
 *
 * The generated trees are checked in. `scripts/check-codegen.ts` runs
 * after this script and fails on anything git reports under
 * `web/src/_generated/` — including a file this generator emitted that
 * was never added to git, which a diff over tracked paths cannot see.
 * That is what stops a client-side copy of a server type from drifting:
 * add a field to `RemoteOAuthInstall` without regenerating and CI fails,
 * rather than the field being quietly invisible to the web shell.
 *
 * Run: `bun run codegen` (alias for this script — see package.json).
 */

import { cpSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import ts from "typescript";

const REPO_ROOT = resolve(import.meta.dirname ?? __dirname, "..");
const TMP_OUT = join(REPO_ROOT, ".tmp-codegen");
const WEB_DEST = join(REPO_ROOT, "web/src/_generated/platform-schemas");
const CONNECTOR_TYPES_TMP = join(REPO_ROOT, ".tmp-codegen-connectors");
const CONNECTOR_TYPES_DEST = join(REPO_ROOT, "web/src/_generated/connector-catalog");
const API_TYPES_TMP = join(REPO_ROOT, ".tmp-codegen-api");
const API_TYPES_DEST = join(REPO_ROOT, "web/src/_generated/api");
const IDENTITY_SOURCES_SRC = join(REPO_ROOT, "src/tools/identity-sources.ts");
/**
 * Zero-import runtime modules mirrored verbatim into the web tree.
 *
 * A `.d.ts` emit gives the web shell types; these are the cases where it needs
 * the server's actual LOGIC and a hand-written second copy would drift with
 * nothing to catch it. Every entry must import nothing — a copied import would
 * not resolve from `web/src/_generated/` — and `check:codegen` fails the build
 * when the mirrored copy stops matching its source.
 */
const MIRRORED_MODULES: Array<{ src: string; dest: string }> = [
  { src: "src/skills/loading.ts", dest: "web/src/_generated/skill-loading.ts" },
  { src: "src/skills/tokens.ts", dest: "web/src/_generated/skill-tokens.ts" },
];
const PERSONAL_CONNECTOR_PREFIX_DEST = join(
  REPO_ROOT,
  "web/src/_generated/personal-connector-prefix.ts",
);

/**
 * Per-file header injected at the top of every generated .d.ts.
 * No timestamp — would change every regen and defeat the CI drift
 * check (`git log <file>` is the authoritative regen record).
 */
function header(sourcePath: string): string {
  return `/**
 * AUTOGENERATED — DO NOT EDIT. Source: ${sourcePath}
 * Regenerate: bun run codegen. AI agents: edit the source, not this file.
 */
`;
}

/**
 * Recursively prepend the header to every .d.ts under `dir`, naming the source
 * each file came from. `treeRoot` is the emitted tree's own root and `sourceRoot`
 * the repo-relative directory its layout mirrors, so the path a reader is sent to
 * is the real one for whichever tree this is called on.
 */
function injectHeaders(dir: string, treeRoot: string, sourceRoot: string): void {
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
    const full = join(dir, entry.name);
    if (entry.isDirectory()) {
      injectHeaders(full, treeRoot, sourceRoot);
      continue;
    }
    if (!entry.name.endsWith(".d.ts")) continue;
    // Map output path back to source path for the header.
    const relFromOutput = full
      .slice(treeRoot.length + 1)
      .replaceAll("\\", "/")
      .replace(/\.d\.ts$/, ".ts");
    const body = readFileSync(full, "utf-8");
    writeFileSync(full, header(`${sourceRoot}/${relFromOutput}`) + body);
  }
}

/**
 * Emits a `.d.ts` for every source file a tsconfig compiles (its roots and
 * their imports), each from a program rooted at that file alone.
 *
 * Why not one `tsc -p`: the declaration printer writes a union in the order
 * of its members' type ids, and a mapped type's properties in the order of
 * its keys' ids. The checker interns a literal type once per program and
 * numbers it when first seen, so in a shared program `"failed"` takes its id
 * from whichever file mentions it first. A schema that adds a literal then
 * reorders unions in files that never import it, and two schema changes in
 * flight invalidate each other's generated files. A program rooted at one
 * file holds only that file's import closure, so its `.d.ts` depends on that
 * closure and nothing else.
 *
 * A file whose closure is every schema (`catalog.ts`) still inlines literal
 * unions from modules it imports, so a literal one schema adds would reorder
 * another schema's union there. `sortLiteralUnions` writes the literal
 * members of every union the checker prints in value order, which no other
 * file can move.
 */
function emitDeclarations(configPath: string): void {
  const parsed = ts.getParsedCommandLineOfConfigFile(join(REPO_ROOT, configPath), undefined, {
    ...ts.sys,
    onUnRecoverableConfigFileDiagnostic: (d) => failOn([d]),
  });
  if (!parsed) throw new Error(`[codegen] could not read ${configPath}`);
  failOn(parsed.errors);

  const host = ts.createCompilerHost(parsed.options);
  const whole = ts.createProgram({ rootNames: parsed.fileNames, options: parsed.options, host });
  failOn(ts.getPreEmitDiagnostics(whole));
  const sources = whole
    .getSourceFiles()
    .filter((f) => !f.isDeclarationFile && !whole.isSourceFileFromExternalLibrary(f))
    .map((f) => f.fileName)
    .sort();

  let previous: ts.Program = whole;
  for (const file of sources) {
    const program = ts.createProgram({
      rootNames: [file],
      options: parsed.options,
      host,
      oldProgram: previous,
    });
    previous = program;
    const result = program.emit(program.getSourceFile(file), undefined, undefined, true, {
      afterDeclarations: [sortLiteralUnions],
    });
    failOn(result.diagnostics);
    if (result.emitSkipped) throw new Error(`[codegen] tsc skipped the emit of ${file}`);
  }
}

/**
 * Declaration transformer: reorders the literal members of each union the
 * checker printed from a type by value (numbers ascending, then strings by
 * code unit), leaving any other member (`null`, `string`, an object type) in
 * its slot. A union copied from a source annotation is already in its author's
 * order and is left alone. Union order carries no meaning to the type system;
 * this only makes the printed order canonical.
 */
function sortLiteralUnions(context: ts.TransformationContext) {
  const { factory } = context;
  const visit = (node: ts.Node): ts.Node => {
    const visited = ts.visitEachChild(node, visit, context);
    if (!ts.isUnionTypeNode(visited) || ts.getParseTreeNode(node) !== undefined) return visited;
    const slots = visited.types.flatMap((t, i) => (literalKey(t) === undefined ? [] : [i]));
    if (slots.length < 2) return visited;
    const sorted = slots
      .map((i) => visited.types[i] as ts.TypeNode)
      .sort((a, b) => compareKeys(literalKey(a) as LiteralKey, literalKey(b) as LiteralKey));
    const types = [...visited.types];
    slots.forEach((slot, k) => {
      types[slot] = sorted[k] as ts.TypeNode;
    });
    return factory.updateUnionTypeNode(visited, factory.createNodeArray(types));
  };
  return <T extends ts.Node>(root: T): T => ts.visitNode(root, visit) as T;
}

type LiteralKey = { rank: 0; value: number } | { rank: 1; value: string };

function literalKey(node: ts.TypeNode): LiteralKey | undefined {
  if (!ts.isLiteralTypeNode(node)) return undefined;
  const lit = node.literal;
  if (ts.isNumericLiteral(lit)) return { rank: 0, value: Number(lit.text) };
  if (
    ts.isPrefixUnaryExpression(lit) &&
    lit.operator === ts.SyntaxKind.MinusToken &&
    ts.isNumericLiteral(lit.operand)
  ) {
    return { rank: 0, value: -Number(lit.operand.text) };
  }
  if (ts.isStringLiteral(lit)) return { rank: 1, value: lit.text };
  return undefined;
}

function compareKeys(a: LiteralKey, b: LiteralKey): number {
  if (a.rank !== b.rank) return a.rank - b.rank;
  if (a.value === b.value) return 0;
  return a.value < b.value ? -1 : 1;
}

function failOn(diagnostics: readonly ts.Diagnostic[]): void {
  const errors = diagnostics.filter((d) => d.category === ts.DiagnosticCategory.Error);
  if (errors.length === 0) return;
  const text = ts.formatDiagnosticsWithColorAndContext(errors, {
    getCanonicalFileName: (f) => f,
    getCurrentDirectory: () => REPO_ROOT,
    getNewLine: () => "\n",
  });
  throw new Error(`[codegen] tsc reported errors:\n${text}`);
}

// Mirrored string constants: a runtime `.ts` carrying a literal exported from
// `src/`, which the web tier needs as a value (a `.d.ts` would give it only a
// type). CI's `check:codegen` step catches drift between source and copy.

function extractStringLiteral(source: string, name: string): string {
  // Match `export const <name> = "<value>";` with the value as a
  // double-quoted JS string literal. No escapes are interpreted; if a
  // mirrored source ever adds escapes, this regex needs to widen accordingly.
  const re = new RegExp(`export\\s+const\\s+${name}\\s*=\\s*"([^"\\\\]*)"`);
  const m = re.exec(source);
  if (!m || typeof m[1] !== "string") {
    throw new Error(
      `[codegen] could not extract ${name} — the source file format may have changed`,
    );
  }
  return m[1];
}

console.log(
  "[codegen] personal-connector-prefix → web/src/_generated/personal-connector-prefix.ts",
);

{
  // Mirrored because the web tier needs the value, and with real stakes: this
  // marker is what separates the caller's own connector from a
  // same-named workspace source. A hand-copied copy that drifts stops the web
  // tier recognising a marked name, which mounts a workspace app's iframe for a
  // connector's tool call — the exact collision the marker exists to prevent,
  // and nothing else would catch it. `check:codegen` turns drift into a build
  // failure instead of a comment asking for discipline.
  const src = readFileSync(IDENTITY_SOURCES_SRC, "utf-8");
  const prefix = extractStringLiteral(src, "PERSONAL_CONNECTOR_PREFIX");

  const body = `${header("src/tools/identity-sources.ts")}
// Personal-connector wire marker, mirrored from the server-side source.
// See that file's header for why the marker sits outside slugify's alphabet.

export const PERSONAL_CONNECTOR_PREFIX = ${JSON.stringify(prefix)};
`;

  mkdirSync(dirname(PERSONAL_CONNECTOR_PREFIX_DEST), { recursive: true });
  writeFileSync(PERSONAL_CONNECTOR_PREFIX_DEST, body);
  console.log(`[codegen] OK → ${PERSONAL_CONNECTOR_PREFIX_DEST.replace(REPO_ROOT, ".")}`);
}

for (const { src, dest } of MIRRORED_MODULES) {
  console.log(`[codegen] ${src} → ${dest}`);
  const body = readFileSync(join(REPO_ROOT, src), "utf-8");
  // An import in the source would land in the copy and fail to resolve from
  // the web tree — caught here rather than as a mystery build error in `web/`.
  if (/^\s*import\s/m.test(body)) {
    throw new Error(
      `[codegen] ${src} is mirrored into the web tree and must import nothing, but it does. ` +
        "Move the imported dependency out, or drop the file from MIRRORED_MODULES.",
    );
  }
  const destPath = join(REPO_ROOT, dest);
  mkdirSync(dirname(destPath), { recursive: true });
  writeFileSync(destPath, header(src) + body);
  console.log(`[codegen] OK → ${dest}`);
}

console.log("[codegen] platform-schemas → web/src/_generated/platform-schemas/");

// Clean previous outputs so removed schemas don't leave stale .d.ts files.
rmSync(TMP_OUT, { recursive: true, force: true });
rmSync(WEB_DEST, { recursive: true, force: true });

// Emit declarations only via the dedicated tsconfig. The dedicated config
// keeps the generation independent of the project tsconfig (which uses
// `noEmit` and other compile-time-only settings).
emitDeclarations("scripts/tsconfig.codegen-web.json");

// The tsc output preserves the source dir structure under `outDir`.
// Move just the schemas tree into web/src/_generated/.
mkdirSync(dirname(WEB_DEST), { recursive: true });
cpSync(TMP_OUT, WEB_DEST, { recursive: true });
rmSync(TMP_OUT, { recursive: true, force: true });

// Inject the do-not-edit header into every generated file.
injectHeaders(WEB_DEST, WEB_DEST, "src/platform/schemas");

console.log(`[codegen] OK → ${WEB_DEST.replace(REPO_ROOT, ".")}`);

// ── connector-catalog wire types ──────────────────────────────────
//
// The web shell imports `CatalogListing` / `ConnectorCatalogEntry` from here
// rather than re-declaring them, because a hand-kept copy drifts silently: a
// wire field missing from the client hides what it carries from the UI with
// nothing to notice. `check:codegen` fails the build when the server type moves
// without a regen.
//
// Only the type closure of `src/connectors/catalog/types.ts` is emitted — a small set
// of leaf type modules. `ConnectorCatalogEntry` lives there beside
// `CatalogListing` for that reason: the projection module that builds it also
// imports half the runtime, and rooting the emit at a module with runtime
// imports pulls ~80 files into a tree the web shell has no use for.

console.log("[codegen] connector-catalog → web/src/_generated/connector-catalog/");

rmSync(CONNECTOR_TYPES_TMP, { recursive: true, force: true });
rmSync(CONNECTOR_TYPES_DEST, { recursive: true, force: true });

emitDeclarations("scripts/tsconfig.codegen-web-connectors.json");

mkdirSync(dirname(CONNECTOR_TYPES_DEST), { recursive: true });
cpSync(CONNECTOR_TYPES_TMP, CONNECTOR_TYPES_DEST, { recursive: true });
rmSync(CONNECTOR_TYPES_TMP, { recursive: true, force: true });

injectHeaders(CONNECTOR_TYPES_DEST, CONNECTOR_TYPES_DEST, "src");

console.log(`[codegen] OK → ${CONNECTOR_TYPES_DEST.replace(REPO_ROOT, ".")}`);

// ── REST response bodies and SSE events ───────────────────────────────────────────
//
// The body of every JSON route (`responses.ts`) and every SSE event
// (`events.ts`). Both import nothing outside their directory, and the config's
// `rootDir` is that directory, so tsc refuses an outside import rather than
// emitting its graph here.

console.log("[codegen] api → web/src/_generated/api/");

rmSync(API_TYPES_TMP, { recursive: true, force: true });
rmSync(API_TYPES_DEST, { recursive: true, force: true });

emitDeclarations("scripts/tsconfig.codegen-web-api.json");

mkdirSync(dirname(API_TYPES_DEST), { recursive: true });
cpSync(API_TYPES_TMP, API_TYPES_DEST, { recursive: true });
rmSync(API_TYPES_TMP, { recursive: true, force: true });

injectHeaders(API_TYPES_DEST, API_TYPES_DEST, "src/api/schemas");

console.log(`[codegen] OK → ${API_TYPES_DEST.replace(REPO_ROOT, ".")}`);