Ctrl K
ComunidadGuías…

scripts/check-usage-record.ts

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bun
/**
 * Lint: LLM spend is recorded through exactly one seam.
 *
 * Spend is recorded at one seam so that attribution is *derived* from the
 * ambient request context rather than asserted by whoever happens to be making
 * the call. A second caller reintroduces the failure this design exists to
 * close, and reintroduces it silently: a call labeled with the wrong origin
 * still increments a counter, so nothing breaks, no test goes red, and the
 * number is simply wrong until someone reconciles it against an invoice.
 *
 * Usage derived from anything else — a storage side effect such as a
 * conversation file happening to exist — misses every call path that does not
 * produce that side effect, and that spend shows on no in-product surface.
 * `src/usage/record.ts` is the seam; this lint keeps it the only one.
 *
 * What this flags, as three rules with per-call owners:
 *
 *   `recordLlmUsage(...)` — allowed only in `src/usage/record.ts`.
 *   `llmCallsTotal.inc(...)` / `llmTokensTotal.inc(...)` — allowed only in
 *   `src/api/metrics.ts`, which defines them.
 *
 * The counter rules are what make the first one mean anything. Both counters are
 * `export const`, so pinning the function alone would leave the seam bypassable
 * by incrementing them directly — a path that skips derivation entirely and
 * mints a series with no `origin` at all. Matching is rooted at the chain's base
 * identifier, so prom-client's `counter.labels({...}).inc()` is caught alongside
 * the direct `counter.inc(...)`.
 *
 * Known gap: matching is syntactic, so an import rename (`recordLlmUsage as r`)
 * or a namespace import (`m.llmCallsTotal.inc`) slips past. Closing that needs a
 * symbol resolver; see the follow-up issue linked from the PR.
 *
 * Import statements are not calls and are not flagged; the check is on the call
 * expression, so re-exporting, type-importing, or *reading* a counter
 * (`.get()`, as tests do) is fine.
 *
 * Escape hatch: a `// lint-ok:usage-record` marker on a comment line within the
 * five lines above the call. Use it only for a call that genuinely must bypass
 * the seam; adding a second recording path is a design change, not a lint
 * exemption.
 *
 * Scope: `src/**\/*.ts`. Tests are out of scope — a unit test that drives the
 * counters directly is asserting on the wire format, not spending money.
 */

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

const ROOT = join(import.meta.dirname ?? __dirname, "..");
const SRC_ROOT = join(ROOT, "src");
const ALLOW_MARKER = "lint-ok:usage-record";

/**
 * Each guarded call, and the single file allowed to make it.
 *
 * The two owners are different on purpose: `api/metrics.ts` owns the Prometheus
 * counters and is the only place that may increment them, while
 * `usage/record.ts` owns "what a priced call is and who it was for" and is the
 * only place that may reach `recordLlmUsage`. Neither is allowed to do the
 * other's job.
 */
const RULES: ReadonlyArray<{ call: string; owner: string }> = [
  { call: "recordLlmUsage", owner: "src/usage/record.ts" },
  { call: "llmCallsTotal.inc", owner: "src/api/metrics.ts" },
  { call: "llmTokensTotal.inc", owner: "src/api/metrics.ts" },
];

/** `owner` paths as platform-native, for comparison against `relative()` output. */
const OWNERS = new Map(RULES.map((r) => [r.call, r.owner.split("/").join(sep)]));

interface Violation {
  file: string;
  line: number;
  column: number;
  snippet: string;
  call: string;
  owner: string;
}

/**
 * The identifier a property/call chain is rooted at, else `null`.
 *
 * Walks back through both property accesses and calls, so `a.b().c` and `a.b.c`
 * both resolve to `a`. That is what lets the counter rules catch prom-client's
 * two idiomatic increments — `counter.inc(labels)` and
 * `counter.labels({...}).inc()` — with one rule rather than enumerating chain
 * shapes.
 */
function rootIdentifier(node: ts.Expression): string | null {
  let current: ts.Expression = node;
  for (;;) {
    if (ts.isIdentifier(current)) return current.text;
    if (ts.isPropertyAccessExpression(current)) current = current.expression;
    else if (ts.isCallExpression(current)) current = current.expression;
    else return null;
  }
}

/**
 * The guarded call `node` makes, else `null`.
 *
 * `f(...)` matches by identifier. A chain ending in `.f(...)` matches on
 * `<root>.f` — so a rule naming `llmCallsTotal.inc` covers the direct
 * increment and the `.labels({...}).inc()` form alike, while leaving every
 * other `.inc` in the tree alone.
 */
function guardedCall(node: ts.CallExpression): string | null {
  const callee = node.expression;
  if (ts.isIdentifier(callee)) {
    return OWNERS.has(callee.text) ? callee.text : null;
  }
  if (ts.isPropertyAccessExpression(callee)) {
    const root = rootIdentifier(callee.expression);
    if (root === null) return null;
    const dotted = `${root}.${callee.name.text}`;
    return OWNERS.has(dotted) ? dotted : null;
  }
  return null;
}

/** True iff a `lint-ok:usage-record` marker sits in the comment block above `node`. */
function hasAllowMarker(node: ts.Node, sourceFile: ts.SourceFile, src: string): boolean {
  const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
  if (line === 0) return false;
  const lines = src.split("\n");
  for (let i = line - 1; i >= Math.max(0, line - 5); i--) {
    const lineText = lines[i] ?? "";
    if (lineText.includes(ALLOW_MARKER)) return true;
    const trimmed = lineText.trim();
    if (
      trimmed === "" ||
      trimmed.startsWith("//") ||
      trimmed.startsWith("*") ||
      trimmed.startsWith("/*")
    ) {
      continue;
    }
    return false;
  }
  return false;
}

function scanFile(absPath: string, violations: Violation[]): void {
  const relPath = relative(ROOT, absPath);
  const src = readFileSync(absPath, "utf-8");
  if (!RULES.some((r) => src.includes(r.call.split(".")[0] ?? r.call))) return;

  const sourceFile = ts.createSourceFile(
    absPath,
    src,
    ts.ScriptTarget.Latest,
    true,
    ts.ScriptKind.TS,
  );

  function visit(node: ts.Node): void {
    if (ts.isCallExpression(node)) {
      const call = guardedCall(node);
      const owner = call === null ? undefined : OWNERS.get(call);
      if (call !== null && owner !== relPath && !hasAllowMarker(node, sourceFile, src)) {
        const { line, character } = sourceFile.getLineAndCharacterOfPosition(
          node.getStart(sourceFile),
        );
        violations.push({
          file: relPath,
          line: line + 1,
          column: character + 1,
          snippet: (src.split("\n")[line] ?? "").trim(),
          call,
          owner: owner ?? "?",
        });
      }
    }
    ts.forEachChild(node, visit);
  }

  visit(sourceFile);
}

async function main(): Promise<void> {
  const violations: Violation[] = [];
  const glob = new Glob("**/*.ts");
  let scanned = 0;

  for await (const rel of glob.scan({ cwd: SRC_ROOT })) {
    const abs = join(SRC_ROOT, rel);
    if (abs.includes("/node_modules/") || abs.includes("/dist/")) continue;
    if (abs.endsWith(".d.ts")) continue;
    scanned++;
    scanFile(abs, violations);
  }

  if (violations.length > 0) {
    console.error(`✗ Found ${violations.length} guarded usage call(s) outside their owner:\n`);
    for (const v of violations) {
      console.error(`  ${v.file}:${v.line}:${v.column} — ${v.call}() belongs to ${v.owner}`);
      console.error(`    ${v.snippet}\n`);
    }
    console.error(
      "Spend is recorded at one seam so attribution is derived from the request context,",
    );
    console.error("not asserted by the caller. Call `recordLlmCall()` from src/usage/record.ts.");
    console.error(
      `A genuine exception requires a // ${ALLOW_MARKER} comment within the five lines above the call.`,
    );
    process.exit(1);
  }

  console.log(
    `✓ ${RULES.length} guarded call shapes, each confined to its owner, across ${scanned} src/ files`,
  );
}

// Gate the scan on direct invocation, matching the sibling check scripts, so
// importing this module never runs it or calls process.exit.
if (import.meta.main) {
  main().catch((err: unknown) => {
    console.error(err);
    process.exit(1);
  });
}