Ctrl K
ComunidadGuías…

scripts/check-runner.ts

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

Archivo original ↗Preguntar a KiBot
/**
 * Building block for a whole-tree check built from one per-file rule: scan
 * the trees, hand each file to the rule, and report what it found through the
 * house shape. The rule returns each finding with its line and the words
 * `formatFinding` prints.
 */

import { fromFileUrl } from "@std/path";
import * as v from "valibot";
import { notCoveredBy } from "#fp";
import { integerAtLeast } from "#shared/validation/number.ts";
import {
  type CheckOutput,
  consoleOutput,
  formatFinding,
  reportCheck,
} from "./check-report.ts";
import { readJsonOrThrow, writeJsonFile } from "./read-json.ts";
import { collectFromFiles } from "./walk-files.ts";

/** A registry of `{ path: count }`. */
export type Counts = Record<string, number>;

/** One whole-tree counts check's run: the roots it reads, the recorded
 * counts, and where the report goes. The one signature every
 * flat-counts-ratchet check exposes. */
export type CountsCheck = (
  roots: readonly string[],
  accepted: Counts,
  output: CheckOutput,
) => Promise<number>;

/** One count per path of the findings a whole-tree rule reports, so a file the
 * fix cleared drops its registry entry. */
export const countsByPath = (findings: readonly { path: string }[]): Counts => {
  const counts: Counts = {};
  for (const finding of findings) {
    counts[finding.path] = (counts[finding.path] ?? 0) + 1;
  }
  return counts;
};

/** Whether any count in `fresh` rose above the number `recorded` holds for
 * the same key — a count that was never recorded starts at zero, so a new
 * entry above zero is a rise too. */
export const countsRose = (recorded: Counts, fresh: Counts): boolean =>
  Object.entries(fresh).some(([key, count]) => count > (recorded[key] ?? 0));

/** One file's findings against its recorded counts: only the findings that
 * sit past their recorded count, in source order, or the one prompt that
 * asks for a fallen count to be recorded. The key decides what one count
 * stands for: a rule for the comment check, a rule-plus-prose identity for
 * the Markdown check. */
/** The findings of the rules the recorded state does not allow, in source
 * order, one per count past its record. */
const risenFindings = <F extends PerFileFinding>(
  findings: readonly F[],
  excess: Map<string, number>,
  keyOf: (finding: F) => string,
  whereOf: (finding: F) => string,
): string[] => {
  const reported = new Map<string, number>();
  const risen: string[] = [];
  for (const finding of findings) {
    const key = keyOf(finding);
    const past = excess.get(key);
    if (past === undefined) continue;
    const shown = reported.get(key) ?? 0;
    if (shown >= past) continue;
    reported.set(key, shown + 1);
    risen.push(formatFinding(whereOf(finding), finding));
  }
  return risen;
};

export const compareCounts = <F extends PerFileFinding>(input: {
  file: string;
  findings: readonly F[];
  current: Counts;
  recorded: Counts;
  keyOf: (finding: F) => string;
  whereOf: (finding: F) => string;
  updateCommand: string;
}): string[] => {
  if (countsRose(input.recorded, input.current)) {
    // How many findings of each rule the recorded state does not allow.
    const excess = new Map<string, number>();
    for (const [key, count] of Object.entries(input.current)) {
      const past = count - (input.recorded[key] ?? 0);
      if (past > 0) excess.set(key, past);
    }
    return risenFindings(input.findings, excess, input.keyOf, input.whereOf);
  }
  const fell = Object.entries(input.recorded).some(
    ([key, count]) => count > (input.current[key] ?? 0),
  );
  if (!fell) return [];
  const parts = Object.entries(input.current).map(
    ([key, count]) => `${key}: ${count}`,
  );
  const summary = parts.length === 0 ? "none" : parts.join(", ");
  return [
    formatFinding(input.file, {
      fix: `run \`${input.updateCommand}\` to record the step`,
      problem: `fewer findings than recorded (${summary})`,
      rule: "improved",
    }),
  ];
};

/** The counts one rule holds in one file: a whole count per rule, never
 * negative. */
const COUNTS_SCHEMA = v.record(v.string(), integerAtLeast(0));

/** The shape a registry file holds: one entry per path. */
const REGISTRY_SCHEMA = v.record(v.string(), COUNTS_SCHEMA);

/** The counts registry at `path`, or a loud failure naming it. */
export const readCounts = async (path: string): Promise<Counts> =>
  await readJsonOrThrow(path, COUNTS_SCHEMA);

/** One per-file ratchet's records: one entry per path, one count per rule
 * or finding identity. */
export type Registry = Record<string, Counts>;

/** Which registry-recording mode the caller asked for on the command line. */
export interface UpdateMode {
  update: boolean;
}

/** The error a contributor meets for the retired `--seed` flag. */
const SEED_ERROR =
  "The --seed flag is gone. A registry only falls. Use --update after a fix, " +
  "or change the registry in a reviewed commit.";

/** Read the recording flag from a command line: `--update` records a step
 * and refuses a rise. The retired `--seed` is refused loudly. */
export const updateMode = (args: readonly string[]): UpdateMode => {
  if (args.includes("--seed")) throw new Error(SEED_ERROR);
  return { update: args.includes("--update") };
};

/** The absolute path of a registry file a check keeps beside its entry
 * script. */
export const registryPath = (from: string, relative: string): string =>
  fromFileUrl(new URL(relative, from));

/**
 * One per-file ratchet's entry wiring: the registry lives beside the
 * calling module at `relative`, the read is validated against the shared
 * registry shape, and the record step runs for the mode `args` name, with
 * the two-level registry rose refusing a rise.
 */
export const ratchetedState = async (
  moduleUrl: string,
  relative: string,
  args: readonly string[],
  fresh: () => Promise<Registry>,
): Promise<Registry> => {
  const path = registryPath(moduleUrl, relative);
  return await recordedState(
    path,
    updateMode(args),
    await readJsonOrThrow(path, REGISTRY_SCHEMA),
    fresh,
    // A count rose in any file, including a file the records never held.
    (recorded, now) =>
      Object.entries(now).some(([path, counts]) =>
        countsRose(recorded[path] ?? {}, counts),
      ),
  );
};

/**
 * One ratchet's whole record step. Without a recording flag it changes
 * nothing. Otherwise it builds the fresh state, records it at `path` when
 * nothing rose, and answers what the check must now compare against: the
 * recorded state when a rise was refused — so the run that follows names
 * the rise for what it is.
 */
export const recordedState = async <T>(
  path: string,
  mode: UpdateMode,
  recorded: T,
  fresh: () => Promise<T>,
  rose: (recorded: T, fresh: T) => boolean,
): Promise<T> => {
  if (!mode.update) return recorded;
  const freshState = await fresh();
  if (!rose(recorded, freshState)) {
    await writeJsonFile(path, freshState);
    return freshState;
  }
  return recorded;
};

/** One flat-counts check's entry wiring: the registry lives beside the
 * calling module at `relative`, the record step runs for the mode `args`
 * name with `countsRose` refusing a rise, and the check runs over `roots`
 * against the state that survives. One mechanism behind every
 * flat-counts-ratchet entry, so the entry scripts cannot drift apart. */
export const countsCheckExit = async (
  moduleUrl: string,
  relative: string,
  args: readonly string[],
  roots: readonly string[],
  fresh: (roots: readonly string[]) => Promise<Counts>,
  run: CountsCheck,
  output: CheckOutput = consoleOutput,
): Promise<number> => {
  const path = registryPath(moduleUrl, relative);
  const accepted = await recordedState(
    path,
    updateMode(args),
    await readCounts(path),
    () => fresh(roots),
    countsRose,
  );
  return await run(roots, accepted, output);
};

/** One finding a per-file rule reports. The path is added while scanning. */
export interface PerFileFinding {
  /** How to put it right. */
  fix: string;
  /** The line the finding sits on, 1-based. */
  line: number;
  /** What was found. */
  problem: string;
  rule: string;
}

/** A check's own words for its report: where the rule is written down, what
 * one finding is called, and what to say when nothing was found. */
export interface CheckWords {
  guide: string;
  noun: string;
  success: string;
}

/** Every finding of one file, written in the house format. */
export const fileFindingLines = (
  file: string,
  findings: readonly PerFileFinding[],
): string[] =>
  findings.map((issue) => formatFinding(`${file}:${issue.line}`, issue));

/**
 * Every registry entry whose key names nothing the check now reads. The
 * lists only shrink, so a stale entry reports itself.
 */
export const staleEntryLines = (
  read: readonly string[],
  keys: readonly string[],
  registry: string,
  fix: string,
): string[] =>
  notCoveredBy(
    (path: string) => path,
    read,
  )(keys)
    .sort()
    .map((path) =>
      formatFinding(path, {
        fix,
        problem: `entry names nothing the check reads (${registry})`,
        rule: "stale-entry",
      }),
    );

/** One collector a whole-tree check reads its files through. */
type Collector = (root: string) => Promise<string[]>;

/** One per-file rule a registry-free whole-tree check runs. */
type FileRule = (file: string, content: string) => PerFileFinding[];

/** One per-file rule that also reads the check's counts registry. */
type CountedFileRule = (
  file: string,
  content: string,
  accepted: Counts,
) => PerFileFinding[];

/** Report one whole-tree check's findings in the house shape. */
const reportFound = (
  output: CheckOutput,
  found: string[],
  words: CheckWords,
): number =>
  reportCheck({
    ...output,
    found,
    ...words,
  });

/** Build a whole-tree check from one per-file rule over one collector. */
export const perFileCheck =
  (
    collect: Collector,
    rule: FileRule,
    words: CheckWords,
  ): ((roots: readonly string[], output: CheckOutput) => Promise<number>) =>
  async (roots, output) =>
    reportFound(
      output,
      await collectFromFiles(roots, collect, (file, content) =>
        fileFindingLines(file, rule(file, content)),
      ),
      words,
    );

/**
 * Build a whole-tree check whose per-file rule reads a counts registry: the
 * rule sees the recorded counts, and every registry entry whose key names
 * nothing the check now reads reports itself, since a registry only falls.
 * One mechanism behind every counts-ratchet check, so the IO shells cannot
 * drift apart.
 */
export const registryCheck =
  (
    collect: Collector,
    rule: CountedFileRule,
    registry: string,
    words: CheckWords,
  ): CountsCheck =>
  async (roots, accepted, output) => {
    const paths: string[] = [];
    const found = await collectFromFiles(roots, collect, (file, content) => {
      paths.push(file);
      return fileFindingLines(file, rule(file, content, accepted));
    });
    const stale = staleEntryLines(
      paths,
      Object.keys(accepted),
      registry,
      "delete the entry",
    );
    return reportFound(output, [...found, ...stale].sort(), words);
  };