Ctrl K
ComunidadGuías…

scripts/check-rest-responses.ts

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bun
/**
 * Lint: an HTTP route writes a JSON body only through `json<T>()`, with `T`
 * a named type from `src/api/schemas/responses.ts`.
 *
 * A route's body is a contract the web shell and the tests read. Written
 * inline, it is described again by each reader, and those copies drift from the
 * handler the first time it changes. `json<T>()` (`src/api/types.ts`) does not
 * compile without `T`; this script closes the ways around it.
 *
 * What this script flags, in any `.ts` file under `src/` outside the platform
 * app UI packages:
 *
 * - a `.json(...)` call with an argument: Hono's `c.json(body)` and
 *   `Response.json(body)` (reading a body, `res.json()`, takes none);
 * - `new Response(JSON.stringify(...))`, except inside `src/api/types.ts`,
 *   where `json` itself is defined;
 * - a `json<T>(...)` call whose `T` is not a name imported from
 *   `schemas/responses.ts`, such as an inline `json<{ ok: boolean }>`.
 *
 * Rule and examples: CODE_STYLE.md, "A route's JSON body is a named response
 * type".
 */

import { readFileSync } from "node:fs";
import { join, sep } from "node:path";
import { Glob } from "bun";
import * as ts from "typescript";

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

/** Where `json` is defined: its own `new Response(JSON.stringify(...))` is the one allowed. */
const HELPER_FILE = ["src", "api", "types.ts"].join(sep);

/** The module every response type is imported from. */
const RESPONSES_MODULE = /(^|\/)schemas\/responses\.ts$/;

export interface Violation {
  file: string;
  line: number;
  snippet: string;
}

/** Names this file imports from `schemas/responses.ts`. */
function responseTypeNames(source: ts.SourceFile): Set<string> {
  const names = new Set<string>();
  for (const s of source.statements) {
    if (!ts.isImportDeclaration(s) || !ts.isStringLiteral(s.moduleSpecifier)) continue;
    if (!RESPONSES_MODULE.test(s.moduleSpecifier.text)) continue;
    const bindings = s.importClause?.namedBindings;
    if (bindings && ts.isNamedImports(bindings)) {
      for (const el of bindings.elements) names.add(el.name.text);
    }
  }
  return names;
}

function isJsonStringifyCall(node: ts.Node): boolean {
  return (
    ts.isCallExpression(node) &&
    ts.isPropertyAccessExpression(node.expression) &&
    ts.isIdentifier(node.expression.expression) &&
    node.expression.expression.text === "JSON" &&
    node.expression.name.text === "stringify"
  );
}

/** Why `node` writes a JSON body outside the contract, or null when it does not. */
function violationOf(node: ts.Node, file: string, allowed: Set<string>): string | null {
  if (ts.isCallExpression(node)) {
    const callee = node.expression;
    if (
      ts.isPropertyAccessExpression(callee) &&
      callee.name.text === "json" &&
      node.arguments.length > 0
    ) {
      return "writes a JSON body with .json(...)";
    }
    if (ts.isIdentifier(callee) && callee.text === "json" && node.typeArguments?.length === 1) {
      const arg = node.typeArguments[0]!;
      const named =
        ts.isTypeReferenceNode(arg) &&
        ts.isIdentifier(arg.typeName) &&
        allowed.has(arg.typeName.text);
      if (!named) return "json<T> with a T not imported from schemas/responses.ts";
    }
  }
  if (
    ts.isNewExpression(node) &&
    ts.isIdentifier(node.expression) &&
    node.expression.text === "Response" &&
    node.arguments?.[0] &&
    isJsonStringifyCall(node.arguments[0]) &&
    file !== HELPER_FILE
  ) {
    return "writes a JSON body with new Response(JSON.stringify(...))";
  }
  return null;
}

/** Every JSON body in one source file written outside `json<NamedType>()`. */
export function findUntypedJson(file: string, text: string): Violation[] {
  const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
  const allowed = responseTypeNames(source);
  const lines = text.split("\n");
  const violations: Violation[] = [];
  function visit(node: ts.Node): void {
    if (violationOf(node, file, allowed)) {
      const { line } = source.getLineAndCharacterOfPosition(node.getStart(source));
      violations.push({ file, line: line + 1, snippet: (lines[line] ?? "").trim() });
    }
    ts.forEachChild(node, visit);
  }
  visit(source);
  return violations;
}

/** Scan `<root>/src`. Paths in the result are relative to `root`. */
export function scan(root: string): Violation[] {
  const violations: Violation[] = [];
  for (const rel of new Glob("**/*.ts").scanSync({ cwd: join(root, "src") })) {
    const file = join("src", rel);
    const parts = file.split(sep);
    if (parts.includes("node_modules") || file.endsWith(".d.ts")) continue;
    // The platform app UIs are browser packages: they read responses, never write them.
    if (parts[1] === "platform" && parts[3] === "ui") continue;
    violations.push(...findUntypedJson(file, readFileSync(join(root, file), "utf-8")));
  }
  return violations;
}

if (import.meta.main) {
  const violations = scan(process.argv[2] ?? ROOT);
  if (violations.length > 0) {
    console.error(`✗ Found ${violations.length} JSON response(s) without a named type:\n`);
    for (const v of violations) console.error(`  ${v.file}:${v.line}  ${v.snippet}`);
    console.error(
      "\nDeclare the body in src/api/schemas/responses.ts and send it with json<ThatType>(body) from src/api/types.ts. See CODE_STYLE.md.",
    );
    process.exit(1);
  }
  console.log("✓ Every JSON response is a named response type");
}