scripts/unit-tests-report-lib.ts
Rama main · réplica de lectura del repositorio original.
/**
* Unit-test coverage report — the pure core.
*
* The project is working towards a unit test (with 100% mutation coverage) for
* every source file, on top of the integration/e2e tests that already exercise
* the app end-to-end. This report measures progress towards that goal by a
* simple, mechanical convention: a source file at `src/<path>.ts` is considered
* to have a unit test when a test file lives at the *mirror* location under
* `test/`, either as a single file or — when one source needs several test
* files — inside a directory named after the source:
*
* src/shared/accounting/store.ts -> test/shared/accounting/store.test.ts
* or test/shared/accounting/store/*.test.ts
*
* From that one rule the report derives three things a human (or a future
* agent) can act on directly:
*
* 1. Source files with no mirrored test at all — the untested list.
* 2. Source files that *have* tests but a high source-lines-to-test-lines
* ratio — the thinly-tested list, a good place to add cases.
* 3. Test files that don't sit at any source's mirror location — either a
* test for something that isn't a single source file, or a test that
* belongs somewhere else and should be moved.
*
* Everything here is a pure function of the file lists (path + line count) it is
* handed; the walking and line-counting live in the thin shell
* `scripts/unit-tests-report.ts`, so every rule below is unit-testable with
* crafted inputs.
*/
import { filter, map, pipe, sort, sumOf } from "#fp";
import { endsWithAny } from "#fp-strings";
import { nonBlankLines } from "./lines.ts";
/** A source or test file paired with its line count. */
export type FileLines = {
path: string;
lines: number;
};
/** How to read the tree and which files are exempt from the "needs a test"
* rule. Roots are configurable so the logic can be tested against a temp
* tree, and default to the real `src`/`test` roots. */
export type ReportOptions = {
srcRoot: string;
testRoot: string;
/** Path prefixes under `src` that are not expected to have their own unit
* test (e.g. generated data, translation tables). */
exemptSourcePrefixes: string[];
/** Path prefixes under `test` that are not expected to mirror a source file
* (e.g. end-to-end suites, script tests, shared test helpers). */
exemptTestPrefixes: string[];
};
/** A single source file enriched with the tests that cover it. */
export type SourceReport = {
path: string;
lines: number;
/** Mirror-located test files covering this source, in path order. */
testFiles: string[];
/** Combined line count of `testFiles`. */
testLines: number;
tested: boolean;
/** Source lines per test line — how thin the tests are. `Infinity` when the
* source has no test at all, so untested files always sort to the top. */
ratio: number;
};
/** The whole report, ready to format or serialise. */
export type Report = {
totalSources: number;
testedCount: number;
/** Untested source files, biggest first (the largest gap is the best
* target). */
untested: SourceReport[];
/** Tested source files, thinnest coverage first. */
ranked: SourceReport[];
/** Test files that don't sit at any source's mirror location. */
orphanTests: string[];
};
/** Default roots and exemptions for a real run against this repo. */
export const DEFAULT_OPTIONS: ReportOptions = {
// Translation tables are data, not logic, so they carry no unit test.
exemptSourcePrefixes: ["src/locales/"],
// These test trees exercise the app as a whole or test the tooling itself,
// so they deliberately don't mirror a single source file.
exemptTestPrefixes: [
"test/e2e/",
"test/integration/",
"test/scripts/",
"test/test-utils/",
"test/setup.ts",
],
srcRoot: "src",
testRoot: "test",
};
/** Whether a path is on an exempt prefix (an exact match, or inside a
* `dir/`-style prefix). Shared with the import-attribution module. */
export const hasExemptPrefix = (path: string, prefixes: string[]): boolean =>
prefixes.some((prefix) => path === prefix || path.startsWith(prefix));
/** Count non-blank lines, so whitespace padding doesn't inflate the ratio. */
export const countLines = (text: string): number => nonBlankLines(text).length;
/** A `.ts`/`.tsx` file of any kind, including declaration files. */
const isTypeScriptPath = endsWithAny([".ts", ".tsx"]);
/** A `.ts`/`.tsx` file that isn't a type-declaration file. */
export const isSourcePath = (path: string): boolean =>
isTypeScriptPath(path) && !path.endsWith(".d.ts");
/** A `.test.ts`/`.test.tsx` test file. */
export const isTestPath = endsWithAny([".test.ts", ".test.tsx"]);
/** Whether a source file is one we expect to carry its own unit test. Type
* declaration files and anything on an exempt prefix are left out. */
export const isTestableSource = (
path: string,
options: ReportOptions,
): boolean =>
!path.endsWith(".d.ts") &&
!hasExemptPrefix(path, options.exemptSourcePrefixes);
/**
* The mirror location a source file's test(s) must live at, with the source
* root swapped for the test root and the extension dropped. A `src/a/b/name.ts`
* maps to the prefix `test/a/b/name`, matched either as `<prefix>.test.ts(x)`
* or as any file inside `<prefix>/`.
*/
export const mirrorPrefix = (
srcPath: string,
options: ReportOptions,
): string => {
const withoutRoot = srcPath.slice(options.srcRoot.length + 1);
const withoutExt = withoutRoot.replace(/\.(?:ts|tsx)$/, "");
return `${options.testRoot}/${withoutExt}`;
};
/**
* The mirror prefix of the source a test belongs to, or `null` when no source
* owns it. A test at `test/a/b/name.test.ts` is owned by the source whose
* mirror is the longest path-prefix of it that actually exists: its own direct
* mirror `test/a/b/name` when `src/a/b/name.ts` exists, otherwise the nearest
* ancestor directory that mirrors a source (the directory-suite convention).
*
* Picking the longest matching prefix is what stops a child's own mirror from
* being counted against its parent: given both `src/db/attendees.ts` and
* `src/db/attendees/kind.ts`, the test `test/db/attendees/kind.test.ts` matches
* the child prefix `test/db/attendees/kind` (longer) over the parent
* `test/db/attendees`, so it counts only for the child and never hides a
* missing parent test. The trailing-slash check keeps a sibling like
* `test/db/attendees-notes.test.ts` from matching the `test/db/attendees` prefix.
*/
export const owningSourcePrefix = (
testPath: string,
sourcePrefixes: Iterable<string>,
): string | null => {
const base = testPath.replace(/\.test\.(?:ts|tsx)$/, "");
let best: string | null = null;
for (const prefix of sourcePrefixes) {
const owns = base === prefix || base.startsWith(`${prefix}/`);
if (owns && (best === null || prefix.length > best.length)) {
best = prefix;
}
}
return best;
};
/** Build the per-source view from the tests already assigned to it. */
const describeSource = (
source: FileLines,
ownedTests: FileLines[],
): SourceReport => {
const testLines = sumOf((test: FileLines) => test.lines)(ownedTests);
return {
lines: source.lines,
path: source.path,
ratio:
testLines === 0 ? Number.POSITIVE_INFINITY : source.lines / testLines,
tested: ownedTests.length > 0,
testFiles: pipe(
map((test: FileLines) => test.path),
sort((a: string, b: string) => a.localeCompare(b)),
)(ownedTests),
testLines,
};
};
/** Biggest source file first — the largest untested file is the best target. */
const byLinesDesc = (a: SourceReport, b: SourceReport): number =>
b.lines - a.lines || a.path.localeCompare(b.path);
/** Thinnest coverage first, then biggest source as a tie-breaker. */
const byRatioDesc = (a: SourceReport, b: SourceReport): number =>
b.ratio - a.ratio || b.lines - a.lines || a.path.localeCompare(b.path);
/**
* Fold the raw source and test file lists into the full report: which sources
* are (un)tested, how thin the tested ones are, and which test files don't
* mirror any source. Each non-exempt test is assigned to exactly one owning
* source (its nearest mirror), so a test is never double-counted or credited to
* a parent when it really covers a child.
*/
export const buildReport = (
allSources: FileLines[],
allTests: FileLines[],
options: ReportOptions,
): Report => {
const sources = filter((s: FileLines) => isTestableSource(s.path, options))(
allSources,
);
// Pre-seed a bucket per source (keyed by its mirror prefix) so every owned
// test lands in an existing list and every source has one to describe from.
const prefixes = sources.map((s) => mirrorPrefix(s.path, options));
const ownedTests = new Map<string, FileLines[]>(prefixes.map((p) => [p, []]));
// Assign each non-exempt test to its owning source; the rest are orphans.
// Exempt trees (e2e, integration, …) sit outside the mirror system entirely.
const orphans: string[] = [];
for (const test of allTests) {
if (hasExemptPrefix(test.path, options.exemptTestPrefixes)) continue;
const owner = owningSourcePrefix(test.path, prefixes);
if (owner === null) orphans.push(test.path);
else ownedTests.get(owner)!.push(test);
}
const described = sources.map((s, i) =>
describeSource(s, ownedTests.get(prefixes[i]!)!),
);
return {
orphanTests: sort((a: string, b: string) => a.localeCompare(b))(orphans),
ranked: pipe(
filter((s: SourceReport) => s.tested),
sort(byRatioDesc),
)(described),
testedCount: filter((s: SourceReport) => s.tested)(described).length,
totalSources: described.length,
untested: pipe(
filter((s: SourceReport) => !s.tested),
sort(byLinesDesc),
)(described),
};
};
/** The single best file to write tests for next: the largest untested file if
* any remain, otherwise the thinnest-tested one. `null` only when every source
* is exempt (an empty tree). */
export const suggestedTarget = (report: Report): SourceReport | null =>
report.untested[0] ?? report.ranked[0] ?? null;
/** A source entry shaped for JSON: `ratio` becomes `null` for untested files,
* because JSON has no infinity. The untested signal is still carried by
* `tested: false` and `testLines: 0`. */
export type JsonSourceReport = Omit<SourceReport, "ratio"> & {
ratio: number | null;
};
/** The report shaped for JSON output. */
export type JsonReport = Omit<Report, "ranked" | "untested"> & {
ranked: JsonSourceReport[];
untested: JsonSourceReport[];
};
const toJsonSource = (source: SourceReport): JsonSourceReport => ({
...source,
ratio: Number.isFinite(source.ratio) ? source.ratio : null,
});
/**
* Convert a report to a JSON-safe shape. `JSON.stringify` turns the infinite
* ratio of untested files into `null` on its own, but doing it here keeps the
* numeric contract explicit (a `ratio` is a number or `null`, never a silently
* dropped `Infinity`) rather than relying on that quirk.
*/
export const toJsonReport = (report: Report): JsonReport => ({
...report,
ranked: report.ranked.map(toJsonSource),
untested: report.untested.map(toJsonSource),
});
/* -------------------------------------------------------------------------- *
* Formatting *
* -------------------------------------------------------------------------- */
/** How many rows each list shows before it is truncated, unless `--all`. */
export const DEFAULT_LIMIT = 25;
const pct = (part: number, whole: number): string =>
whole === 0 ? "0.0" : ((part / whole) * 100).toFixed(1);
/** A ratio for display: `∞` for untested, else two decimals. */
export const formatRatio = (ratio: number): string =>
ratio === Number.POSITIVE_INFINITY ? "∞" : ratio.toFixed(2);
const limitRows = <T>(rows: T[], limit: number | null): T[] =>
limit === null ? rows : rows.slice(0, limit);
const truncationNote = (shown: number, total: number): string[] =>
shown < total ? [` … and ${total - shown} more (use --all to list)`] : [];
/**
* Render one of the report's lists: a "none" note when it is empty, else the
* rows (with an optional header line on top) plus a "… and N more" note when
* the limit hid some. `pickRows` reads the list off the report so each list
* only names its own field once.
*/
const listSection =
<T>(
pickRows: (report: Report) => T[],
look: { whenEmpty: string; header?: string; renderRow: (row: T) => string },
) =>
(report: Report, limit: number | null): string[] => {
const rows = pickRows(report);
if (rows.length === 0) return [look.whenEmpty];
const shown = limitRows(rows, limit);
return [
...(look.header ? [look.header] : []),
...shown.map(look.renderRow),
...truncationNote(shown.length, rows.length),
];
};
const untestedLines = listSection((report) => report.untested, {
renderRow: (s) => ` ${String(s.lines).padStart(5)} ${s.path}`,
whenEmpty: " (none — every source has a test)",
});
const rankedLines = listSection((report) => report.ranked, {
header: ` ${"ratio".padStart(6)} ${"src".padStart(5)} ${"test".padStart(5)} file`,
renderRow: (s) =>
` ${formatRatio(s.ratio).padStart(6)} ${String(s.lines).padStart(
5,
)} ${String(s.testLines).padStart(5)} ${s.path}`,
whenEmpty: " (none)",
});
const orphanLines = listSection((report) => report.orphanTests, {
renderRow: (path) => ` ${path}`,
whenEmpty: " (none)",
});
/**
* Render the report as plain text lines. `limit` caps each list (null shows
* everything); the thinnest-tested and largest-untested files come first so the
* top of each list is the best place to spend effort next.
*/
export const formatReport = (
report: Report,
limit: number | null,
): string[] => {
const target = suggestedTarget(report);
return [
"Unit-test coverage report",
"=========================",
`Source files needing a test: ${report.totalSources}`,
` with a mirrored test: ${report.testedCount} (${pct(
report.testedCount,
report.totalSources,
)}%)`,
` untested: ${report.untested.length}`,
`Orphan test files (mirror no source): ${report.orphanTests.length}`,
"",
target
? `👉 Suggested next target: ${target.path} (${
target.tested
? `ratio ${formatRatio(target.ratio)}, ${target.lines} src / ${target.testLines} test lines`
: `untested, ${target.lines} lines`
})`
: "👉 Nothing to do — no testable sources found.",
"",
"Untested source files (largest first):",
...untestedLines(report, limit),
"",
"Thinnest-tested source files (highest src:test ratio first):",
...rankedLines(report, limit),
"",
`Orphan test files (not at any source's mirror path):`,
...orphanLines(report, limit),
];
};