scripts/diff-code-lines-lib.ts
Rama main · réplica de lectura del repositorio original.
/**
* 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");
};