Ctrl K
ComunidadGuías…

scripts/diff-code-lines-lib.ts

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

Archivo original ↗Preguntar a KiBot
/**
 * Classify the changed lines of a `git diff` by area (src / test / other) and
 * by kind (real code vs import / comment / blank), then format the tallies.
 *
 * "Real code" is what's left after dropping import lines (including multi-line
 * `import { … } from …` blocks and multi-line `export { … } from …`
 * re-exports), comment lines (`//`, and `/* … *\/` block comments including
 * their `*` continuation lines), and blank lines. Added and removed files are
 * both counted against their real area: a deletion names its path only on the
 * `--- a/…` header, so both file headers are read.
 *
 * This module is pure — it takes the diff text and returns strings — so the CLI
 * shell (diff-code-lines.ts) only has to run `git diff` and print.
 *
 * The classifier is a line-based heuristic, not a parser, so treat the numbers
 * as a close estimate rather than an exact AST count. Its known blind spots:
 *  - `git diff --unified=0` shows only the changed lines, so adding or removing
 *    a single member inside an existing multi-line import (the `import {` opener
 *    stays unchanged and off-diff) reads as a plain identifier and counts as
 *    code;
 *  - a multi-line *local* `export { … }` block with no `from` counts as import,
 *    not code: its opener is identical to a barrel re-export's, and telling them
 *    apart would need line lookahead this single-pass classifier does not do.
 *    Both are rare, and this stays a rough estimate rather than a parser.
 */

import { reduce } from "#fp";

export type Area = "src" | "test" | "other";
export type Kind = "code" | "import" | "comment" | "blank";

/** Ask Git for parser-safe patch text even when the user configured an
 * external diff renderer or coloured output. */
export const gitDiffArgs = (base: string): string[] => [
  "diff",
  "--no-ext-diff",
  "--no-color",
  "--unified=0",
  `${base}...HEAD`,
];

export type Tally = Record<Kind, number>;
const emptyTally = (): Tally => ({ blank: 0, code: 0, comment: 0, import: 0 });

/** A fresh per-area tally set (one empty tally for each area). */
export const emptyAreaTally = (): Record<Area, Tally> => ({
  other: emptyTally(),
  src: emptyTally(),
  test: emptyTally(),
});

/** Running state for one side of the diff (added lines vs removed lines), so a
 * multi-line import block or block comment keeps its kind across its lines. */
export type SideState = { inImport: boolean; inBlockComment: boolean };
export const freshState = (): SideState => ({
  inBlockComment: false,
  inImport: false,
});

const areaOf = (path: string): Area =>
  path.startsWith("src/") ? "src" : path.startsWith("test/") ? "test" : "other";

/** The area a `git diff` file header names, or `null` for its `/dev/null`
 * side. A created or deleted file names its real path on only one of its two
 * headers (`--- a/…` for a deletion, `+++ b/…` for a creation), so both header
 * lines are read and the `/dev/null` side is skipped. Returns `undefined` when
 * the line is not a file header. */
const fileHeaderArea = (line: string): Area | null | undefined => {
  if (line === "--- /dev/null" || line === "+++ /dev/null") return null;
  if (line.startsWith("--- a/")) return areaOf(line.slice("--- a/".length));
  if (line.startsWith("+++ b/")) return areaOf(line.slice("+++ b/".length));
  return;
};

/** A line opens a multi-line block if it starts one it doesn't also close. */
const opensBlock = (line: string, open: string, close: string): boolean =>
  line.includes(open) && !line.includes(close);

/** While a block comment or import block is open, classify its continuation
 * lines and close the run when its terminator arrives. Returns null when no run
 * is open, so the caller falls through to single-line classification. */
const continueOpenRun = (line: string, state: SideState): Kind | null => {
  if (state.inBlockComment) {
    if (line.includes("*/")) state.inBlockComment = false;
    return "comment";
  }
  if (state.inImport) {
    if (line.includes("}")) state.inImport = false;
    return "import";
  }
  return null;
};

/** True when a changed line is (or opens) an import / re-export statement:
 *  - a static `import` declaration — `import x from …`, `import { … } from …`,
 *    `import * as …`, a side-effect `import "…"`, or `import type …`. The
 *    `import.meta` property and a dynamic `import(…)` / `import (…)` call are
 *    executable code, so a `.` or `(` after `import` (any spacing) keeps them as
 *    code;
 *  - a single-line `export … from "…"` re-export (the quoted module name is the
 *    real module specifier);
 *  - an `export {` / `export type {` that opens a multi-line block, which barrel
 *    files close with `} from "…"` on a later line (that closing line is counted
 *    by the open-run handler, so the opener only needs to start the run). A
 *    multi-line *local* `export { … }` with no `from` is indistinguishable from
 *    a barrel opener without lookahead, so it also counts as import — see the
 *    module docstring's known blind spots. Requiring `*` or `{` right after
 *    `export` (optionally `type`) keeps ordinary exported code out: `export
 *    const note = 'from "x"'` starts with `const`, so it never counts.
 */
const isImportLine = (line: string): boolean =>
  (/^import\b/.test(line) && !/^import\s*[.(]/.test(line)) ||
  (/^export\s+(type\s+)?[*{]/.test(line) &&
    (/\bfrom\s+["']/.test(line) || opensBlock(line, "{", "}")));

/** Classify one changed line, advancing `state` for multi-line constructs. */
export const classify = (raw: string, state: SideState): Kind => {
  const line = raw.trim();

  const open = continueOpenRun(line, state);
  if (open !== null) return open;

  if (line.startsWith("/*")) {
    state.inBlockComment = opensBlock(line, "/*", "*/");
    return "comment";
  }
  if (line === "") return "blank";
  if (line.startsWith("//") || line.startsWith("*")) return "comment";

  if (isImportLine(line)) {
    state.inImport = opensBlock(line, "{", "}");
    return "import";
  }
  return "code";
};

export type Counts = {
  added: Record<Area, Tally>;
  removed: Record<Area, Tally>;
};

/** Everything the diff walk carries from one line to the next: the tallies, the
 * current file's area for each side, the add/remove classification runs, and
 * whether we are still in a file's header section.
 *
 * The added and removed sides get their own area because a cross-area rename
 * with edits (e.g. `src/a.ts` → `test/a.test.ts`) names one area on `--- a/…`
 * and the other on `+++ b/…`, so removed lines must count against the old area
 * and added lines against the new. */
type WalkState = {
  counts: Counts;
  addArea: Area;
  removeArea: Area;
  addState: SideState;
  removeState: SideState;
  inHeader: boolean;
};

/** Handle a file-header or hunk-boundary line, updating the walk state. Returns
 * true when the line was header/boundary metadata the caller should skip.
 *
 * `--- a/…` / `+++ b/…` only name the file inside the header section between a
 * `diff --git` marker and the first `@@` hunk. Scoping header handling to that
 * section keeps a dash-prefixed content line (e.g. a removed SQL `-- comment`,
 * which diffs to `--- comment`) from being mistaken for a header and dropped. */
const applyBoundaryLine = (line: string, state: WalkState): boolean => {
  if (line.startsWith("diff --git ")) {
    state.inHeader = true;
    return true;
  }
  if (state.inHeader) {
    const headerArea = fileHeaderArea(line);
    if (headerArea !== undefined) {
      // Each header sets its own side's area: `+++ b/…` the added side, `--- a/…`
      // the removed side. A `/dev/null` side (null) leaves that side's area as
      // set by the file's other header — a deletion has no added lines and a
      // creation no removed lines, so the stale side is never tallied against.
      if (headerArea !== null) {
        if (line.startsWith("+++")) state.addArea = headerArea;
        else state.removeArea = headerArea;
      }
      return true;
    }
  }
  if (line.startsWith("@@")) {
    // The first hunk ends the header; a new hunk also breaks any open
    // import/comment run carried over from the previous hunk.
    state.inHeader = false;
    state.addState = freshState();
    state.removeState = freshState();
    return true;
  }
  return false;
};

/** Tally one changed content line under its side's current area, by its kind. */
const tallyContentLine = (line: string, state: WalkState): void => {
  if (line.startsWith("+")) {
    const kind = classify(line.slice(1), state.addState);
    state.counts.added[state.addArea][kind] += 1;
  } else if (line.startsWith("-")) {
    const kind = classify(line.slice(1), state.removeState);
    state.counts.removed[state.removeArea][kind] += 1;
  }
};

/** Fold one diff line into the running walk state: boundary lines update the
 * state and are skipped, content lines are tallied. */
const foldLine = (state: WalkState, line: string): WalkState => {
  if (!applyBoundaryLine(line, state)) tallyContentLine(line, state);
  return state;
};

const freshWalkState = (): WalkState => ({
  addArea: "other",
  addState: freshState(),
  counts: { added: emptyAreaTally(), removed: emptyAreaTally() },
  inHeader: false,
  removeArea: "other",
  removeState: freshState(),
});

export const tallyDiff = (diff: string): Counts =>
  reduce(foldLine, freshWalkState())(diff.split("\n")).counts;

const codeCount = (t: Tally): number => t.code;

const pad = (s: string, n: number): string => s.padEnd(n);
const num = (n: number): string => String(n).padStart(6);

const areaRow = (a: Area, counts: Counts): string => {
  const add = counts.added[a];
  const rem = counts.removed[a];
  return (
    `${pad(a, 7)}` +
    `${num(add.code)}/${num(rem.code)}    ` +
    `${num(add.import)}/${num(rem.import)}  ` +
    `${num(add.comment)}/${num(rem.comment)}  ` +
    `${num(add.blank)}/${num(rem.blank)}`
  );
};

/** Format the whole report — per-area table plus the code-only src/test totals
 * and their ratio — as one string ready to print. */
export const formatReport = (counts: Counts): string => {
  const areas: Area[] = ["src", "test", "other"];
  const srcCode = codeCount(counts.added.src) + codeCount(counts.removed.src);
  const testCode =
    codeCount(counts.added.test) + codeCount(counts.removed.test);

  const lines = [
    `${pad("area", 7)}${pad("code +/-", 18)}${pad("import +/-", 16)}${pad(
      "comment +/-",
      16,
    )}${pad("blank +/-", 14)}`,
    ...areas.map((a) => areaRow(a, counts)),
    "",
    "Code lines changed (added + removed), imports/comments/blanks excluded:",
    `  src : ${srcCode}`,
    `  test: ${testCode}`,
  ];
  if (srcCode > 0) {
    lines.push(`  test/src ratio: ${(testCode / srcCode).toFixed(2)}`);
  }
  return lines.join("\n");
};