scripts/unit-tests-report-imports.ts
Rama main · réplica de lectura del repositorio original.
/**
* Unit-test coverage report — import-based attribution.
*
* The path-mirror rule in `unit-tests-report-lib.ts` only sees a test as
* covering a source when it *sits* at the mirror location. A test can sit away
* from the code it covers — an old flat name, or a suite that grew where it was
* first written — so the path rule calls it an orphan and its source untested.
* Reading what each test actually
* `import`s recovers the real source→test link the paths have lost, and turns it
* into an actionable list: a test that imports exactly one source but doesn't
* live at that source's mirror is a file to *move* so the tree lines up.
*
* This module is pure: it resolves `#`-aliased import specifiers to `src/` paths
* against a supplied import map, then folds the resolved test imports and the
* source list into the misplaced-test worklist. The filesystem reads and the
* import map itself come from the thin shell `unit-tests-report.ts`.
*/
import { sort } from "#fp";
import { replacing } from "#shared/replacements.ts";
import {
hasExemptPrefix,
isTestableSource,
mirrorPrefix,
owningSourcePrefix,
type ReportOptions,
} from "./unit-tests-report-lib.ts";
/** A test file paired with the `src/` paths it imports, resolved from
* aliases, and whether it drives the app through a helper it reaches. */
export type TestImports = {
/** True when a helper the test reaches imports the `#routes` app entry or
* a route module under `#routes/…`: the test drives real pages, so it is
* an integration suite however it loads them (issue #2312). */
loadsApp: boolean;
path: string;
imports: readonly string[];
};
/** The `deno.json` import map: each `#`-alias to the path it maps to. */
export type ImportMap = Record<string, string>;
/**
* A test that imports exactly one source but does not live at that source's
* mirror location — a candidate to move so `test/` lines up with `src/`.
*/
export type MisplacedTest = {
test: string;
source: string;
/** The mirror location the test should move to (a `test/...` prefix). */
suggestedPrefix: string;
/** The test's own basename matches the source's, so it is very likely that
* source's real unit test rather than an integration test that merely
* imports it. Higher-confidence moves sort first. */
basenameMatch: boolean;
};
/** Static `import … from "…"` and re-export `… from "…"` specifiers. The
* `(?!type\b)` skips `import type` / `export type`: those are erased at runtime,
* so they don't identify the code a test exercises. */
const STATIC_IMPORT_RE =
/\b(?:import|export)\s+(?!type\b)[\s\S]*?from\s*["']([^"']+)["']/g;
/** Runtime dynamic `import("…")` specifiers. Many tests load the module under
* test with `await import(...)` after setting up their environment, so a
* source-under-test is frequently a dynamic import rather than a static one.
* The lookbehind skips `typeof import("x")` and the lookahead skips
* `import("x").Member` — both TypeScript type-query forms that are erased at
* runtime, so they don't name code a test exercises. (No runtime dynamic import
* in this suite is followed directly by a member access, so the lookahead can't
* drop a real one.) */
const DYNAMIC_IMPORT_RE =
/(?<!\btypeof )\bimport\s*\(\s*["']([^"']+)["']\s*\)(?!\s*\.)/g;
/** Drop line and block comments so a commented-out or documented import
* specifier can't be mistaken for a real one. Import specifiers never contain
* a double slash, so stripping line comments can't truncate a real alias. */
const stripComments = (text: string): string =>
text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
/** Capture group 1 of every match of `re` in `text`. */
const captures = (re: RegExp, text: string): string[] =>
[...text.matchAll(re)].map((match) => match[1]!);
/** Pull the specifiers out of a file's text — static, re-export, and dynamic
* imports — ignoring comments and type-only imports. */
export const parseImportSpecifiers = (text: string): string[] => {
const code = stripComments(text);
return [
...captures(STATIC_IMPORT_RE, code),
...captures(DYNAMIC_IMPORT_RE, code),
];
};
/** Whether `alias` maps `spec`: a directory alias (`#shared/`) owns anything
* beneath it; an exact alias (`#fp`) owns only the specifier itself. */
const aliasOwns = (alias: string, spec: string): boolean =>
alias.endsWith("/") ? spec.startsWith(alias) : spec === alias;
/**
* Resolve one import specifier to the `src/` path it refers to, or `null` when
* it isn't a source import (a bare npm/jsr module, a `#test-utils/` helper, a
* `#locales/` table). Directory aliases (`#shared/`) map their tail; exact
* aliases (`#fp`, `#routes`) map the whole specifier. When several aliases own
* the specifier, the longest (most specific) one wins — picked by a linear scan
* so the result never depends on entry order.
*/
export const resolveImportToSourceOrNull = (
spec: string,
importMap: ImportMap,
srcRoot: string,
): string | null => {
let best: { alias: string; target: string } | null = null;
for (const [alias, rawTarget] of Object.entries(importMap)) {
const target = rawTarget.replace(/^\.\//, "");
if (!target.startsWith(`${srcRoot}/`)) continue;
if (!aliasOwns(alias, spec)) continue;
if (best === null || alias.length > best.alias.length)
best = { alias, target };
}
if (best === null) return null;
// Works for both alias kinds: an exact alias owns only `spec === alias`, so
// its tail (`spec.slice(alias.length)`) is empty and this is just the target.
return `${best.target}${spec.slice(best.alias.length)}`;
};
/** Every distinct `src/` path a test file imports, resolved from its text. */
export const resolveTestImports = (
text: string,
importMap: ImportMap,
srcRoot: string,
): string[] => [
...new Set(
parseImportSpecifiers(text)
.map((spec) => resolveImportToSourceOrNull(spec, importMap, srcRoot))
.filter((path): path is string => path !== null),
),
];
/** The bare filename of a path, minus its extension and any `.test` marker, so a
* test's stem can be compared to its source's (`name.test.ts` ↔ `name.ts`). */
const stem = replacing(
[/^.*\//, ""],
[/\.test\.(?:ts|tsx)$/, ""],
[/\.(?:ts|tsx)$/, ""],
);
/** Sort weight: basename matches (rank 0) come before non-matches (rank 1). */
const confidenceRank = (entry: MisplacedTest): number =>
entry.basenameMatch ? 0 : 1;
/** Higher-confidence (basename-matching) moves first, then by test path. Uses a
* symmetric rank difference so the order never depends on comparator call
* orientation. */
const byConfidence = (a: MisplacedTest, b: MisplacedTest): number =>
confidenceRank(a) - confidenceRank(b) || a.test.localeCompare(b.test);
/**
* The tests that import exactly one testable source but don't live at that
* source's mirror — the reliable, reviewable list of files to move so the tree
* lines up with `src/`. A test that imports the app entry point, or more than
* one source, is left out: those are integration tests that mirror no single
* source and belong under an exempt tree (`test/integration/`), not a move here.
*/
export const findMisplacedTests = (
tests: TestImports[],
sourcePaths: string[],
options: ReportOptions,
appEntry: string,
): MisplacedTest[] => {
const testable = sourcePaths.filter((path) =>
isTestableSource(path, options),
);
const sourceSet = new Set(testable);
const prefixOf = new Map(
testable.map((path) => [path, mirrorPrefix(path, options)]),
);
// A for-of with guard `continue`s (a form AGENTS.md sanctions alongside
// filter/map): the eligibility chain threads computed values —
// subjects → source → prefix — that a filter/map split would either recompute
// or funnel through a nullish-dropping map, which makes the guards'
// `null`-returns equivalent mutants. The loop keeps every guard individually
// mutation-killable.
const misplaced: MisplacedTest[] = [];
for (const test of tests) {
if (hasExemptPrefix(test.path, options.exemptTestPrefixes)) continue;
// Importing the app makes it an integration test, never a single-source
// unit — directly, or through a helper that drives real pages.
if (test.imports.includes(appEntry) || test.loadsApp) continue;
const subjects = test.imports.filter((path) => sourceSet.has(path));
if (subjects.length !== 1) continue;
const source = subjects[0]!;
const prefix = prefixOf.get(source)!;
// Already sitting at (or inside) its source's mirror — nothing to move.
if (owningSourcePrefix(test.path, [prefix]) === prefix) continue;
misplaced.push({
basenameMatch: stem(test.path) === stem(source),
source,
suggestedPrefix: prefix,
test: test.path,
});
}
return sort(byConfidence)(misplaced);
};
/** Render the misplaced-test worklist as plain text lines. `limit` caps the
* list (null shows everything); a ✓ marks a basename match. */
export const formatMisplacedSection = (
misplaced: MisplacedTest[],
limit: number | null,
): string[] => {
if (misplaced.length === 0)
return [" (none — every unit test sits at its source's mirror)"];
const shown = limit === null ? misplaced : misplaced.slice(0, limit);
const lines = shown.map(
(entry) =>
` ${entry.basenameMatch ? "✓" : " "} ${entry.test}\n → ${entry.suggestedPrefix} (imports ${entry.source})`,
);
const extra = misplaced.length - shown.length;
return extra > 0
? [...lines, ` … and ${extra} more (use --all to list)`]
: lines;
};