Ctrl K
ComunidadGuías…

scripts/check-codegen.ts

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bun
/**
 * Verifies that `web/src/_generated/` matches what the generator just
 * produced — including a file the generator emitted that was never
 * added to git.
 *
 * Why `git status --porcelain` and not `git diff`: a diff compares
 * tracked paths only. Adding a schema source under
 * `src/platform/schemas/` makes the generator emit a matching
 * `.d.ts` that no diff can see, so the guard passes with the artifact
 * missing and it lands later in whatever unrelated change next runs
 * codegen. `--porcelain` (v1) is git's documented stable-for-scripts
 * format, and `-uall` names each untracked file instead of collapsing
 * a directory to `dir/`.
 *
 * Why this is a script and not a shell one-liner: a `git status | grep`
 * pipeline reports grep's exit status, not git's. A git that cannot run
 * at all — no repository, unreadable index — produces empty output, no
 * match, and a passing check. A guard that reports "clean" when it
 * cannot see is the same defect as one that cannot see untracked files,
 * so `verdictFor` fails closed on every path that is not an explicit
 * clean answer from a git that succeeded.
 *
 * Run: `bun run check:codegen` (regenerates first — see package.json).
 */

import { spawnSync } from "node:child_process";
import { join } from "node:path";

const ROOT = join(import.meta.dirname ?? __dirname, "..");

/** The generated tree this guard watches, as a pathspec from the repo root. */
export const GENERATED_DIR = "web/src/_generated/";

export interface DriftEntry {
  /** Two-character porcelain-v1 status code, e.g. `??`, ` M`, `D `. */
  code: string;
  /** Path as git reported it, quoted by git when it holds special characters. */
  path: string;
}

export type Verdict =
  | { ok: true }
  | { ok: false; reason: "git-failed"; detail: string }
  | { ok: false; reason: "drift"; entries: DriftEntry[] };

/** Shape of the git call's result, narrowed to what the verdict depends on. */
export interface GitResult {
  error?: Error | undefined;
  status: number | null;
  signal?: string | null;
  stdout: string;
  stderr: string;
}

/**
 * Splits `git status --porcelain` output into one entry per reported path.
 *
 * Porcelain v1 lines are `XY<space><path>`, so the code is the first two
 * characters and the path begins at index 3. A rename reports
 * `orig -> new`; the tail is kept verbatim, since naming the path is all
 * this guard does with it.
 */
export function parseStatus(output: string): DriftEntry[] {
  return output
    .split("\n")
    .filter((line) => line.length > 0)
    .map((line) => ({ code: line.slice(0, 2), path: line.slice(3) }));
}

/**
 * Plain-language reason for a porcelain status code.
 *
 * `??` is named distinctly rather than folded into "changed" because it
 * is the case a diff over tracked paths cannot report at all, and the
 * fix a reader reaches for differs: the file needs adding, not
 * regenerating.
 */
export function describeCode(code: string): string {
  if (code === "??") return "generated but never added to git";
  if (code.includes("D")) return "missing from the working tree";
  if (code.includes("R")) return "renamed";
  if (code.includes("A")) return "added but not committed";
  if (code.includes("M")) return "differs from the committed copy";
  return "reported as changed";
}

/**
 * Decides the guard's verdict from the git call's raw result.
 *
 * `ok: true` requires a git that exited 0 AND reported nothing. Anything
 * else — a git that failed to spawn, a non-zero exit, a signal, or any
 * reported path — is a failure. Pinning that here is the point of the
 * split: it is the property a shell pipeline silently gives up.
 */
export function verdictFor(result: GitResult): Verdict {
  if (result.error) {
    return { ok: false, reason: "git-failed", detail: result.error.message };
  }
  if (result.status !== 0) {
    const how =
      result.status === null ? `signal ${result.signal ?? "unknown"}` : `exit ${result.status}`;
    const stderr = result.stderr.trim();
    return {
      ok: false,
      reason: "git-failed",
      detail: stderr ? `git status ${how}\n${stderr}` : `git status ${how}`,
    };
  }
  const entries = parseStatus(result.stdout);
  return entries.length === 0 ? { ok: true } : { ok: false, reason: "drift", entries };
}

function main(): void {
  const result = spawnSync("git", ["status", "--porcelain", "-uall", "--", GENERATED_DIR], {
    cwd: ROOT,
    encoding: "utf-8",
  });
  const verdict = verdictFor(result);

  if (verdict.ok) {
    console.log(`✓ ${GENERATED_DIR} matches the generator's output`);
    return;
  }

  if (verdict.reason === "git-failed") {
    console.error(`✗ could not read the state of ${GENERATED_DIR}:`);
    console.error(verdict.detail);
    process.exit(1);
  }

  console.error(`✗ ${GENERATED_DIR} does not match the generator's output:\n`);
  for (const { code, path } of verdict.entries) {
    console.error(`  ${path} — ${describeCode(code)}`);
  }
  console.error("\nRun `bun run codegen` and commit the result.");
  process.exit(1);
}

// Gate the side effect on direct invocation so unit tests can import the
// predicates above without spawning git or exiting the process.
if (import.meta.main) {
  main();
}