fix: reject empty coverage counters

This commit is contained in:
DongHyeonka
2026-08-02 09:10:35 +09:00
parent 8d6fbb97e9
commit 6e05a35790
5 changed files with 505 additions and 39 deletions
+204 -12
View File
@@ -4,9 +4,9 @@ import {
open,
readdir,
realpath,
type FileHandle,
} from "node:fs/promises";
import path from "node:path";
import babelParser from "@babel/eslint-parser";
import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts";
@@ -63,12 +63,18 @@ export type ProductionModuleInventory = Readonly<{
files: readonly string[];
preExclusionTotal: number;
generatedExclusions: readonly string[];
executableModules: readonly string[];
nonExecutableModules: readonly string[];
}>;
export type RiskCoverageResult = Readonly<{
status: "PASS" | "FAIL";
selectedTotal: number;
repositoryTotal: number;
executableTotal: number;
instrumentedExecutableTotal: number;
nonExecutableTotal: number;
nonExecutableModules: readonly string[];
preExclusionTotal: number;
generatedExclusionCount: number;
generatedExclusions: readonly string[];
@@ -86,7 +92,11 @@ export type RiskCoverageResult = Readonly<{
failures: readonly string[];
}>;
type ReadableFileHandle = Pick<FileHandle, "close" | "stat">;
type ReadableFileHandle = Readonly<{
stat(): Promise<Stats>;
readFile(encoding: "utf8"): Promise<string>;
close(): Promise<unknown>;
}>;
type InventoryOptions = Readonly<{
repositoryRoot?: string;
generatedPaths?: readonly string[];
@@ -97,6 +107,7 @@ type InventoryOptions = Readonly<{
}>;
class FileIdentityChangedError extends Error {}
class StableFileIdentityUnavailableError extends Error {}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -333,16 +344,18 @@ function hasStableIdentity(metadata: Stats): boolean {
return (
Number.isSafeInteger(metadata.dev) &&
Number.isSafeInteger(metadata.ino) &&
(metadata.dev !== 0 || metadata.ino !== 0)
metadata.dev > 0 &&
metadata.ino > 0
);
}
function sameFileIdentity(before: Stats, after: Stats): boolean {
return (
!hasStableIdentity(before) ||
!hasStableIdentity(after) ||
(before.dev === after.dev && before.ino === after.ino)
);
if (!hasStableIdentity(before) || !hasStableIdentity(after)) {
throw new StableFileIdentityUnavailableError(
"stable file identity unavailable",
);
}
return before.dev === after.dev && before.ino === after.ino;
}
export function isProductionModulePath(relativePath: string): boolean {
@@ -353,6 +366,97 @@ export function isProductionModulePath(relativePath: string): boolean {
);
}
type TypeScriptAstNode = Readonly<{
type?: unknown;
body?: unknown;
declaration?: unknown;
specifiers?: unknown;
declare?: unknown;
const?: unknown;
importKind?: unknown;
}>;
function statementIsExecutable(value: unknown): boolean {
if (!isRecord(value) || typeof value.type !== "string") {
throw new TypeError("TypeScript parser returned an invalid statement");
}
const statement = value as TypeScriptAstNode;
if (
[
"EmptyStatement",
"TSDeclareFunction",
"TSInterfaceDeclaration",
"TSNamespaceExportDeclaration",
"TSTypeAliasDeclaration",
].includes(statement.type as string)
) {
return false;
}
if (statement.type === "ImportDeclaration") {
return Array.isArray(statement.specifiers) && statement.specifiers.length === 0;
}
if (
statement.type === "ExportAllDeclaration" ||
statement.type === "TSExportAssignment"
) {
return statement.type === "TSExportAssignment";
}
if (
statement.type === "ExportNamedDeclaration" ||
statement.type === "ExportDefaultDeclaration"
) {
return statement.declaration !== null && statement.declaration !== undefined
? statementIsExecutable(statement.declaration)
: false;
}
if (
statement.type === "FunctionDeclaration" ||
statement.type === "VariableDeclaration" ||
statement.type === "ClassDeclaration" ||
statement.type === "TSModuleDeclaration"
) {
return statement.declare !== true &&
(statement.type !== "FunctionDeclaration" || statement.body !== null);
}
if (statement.type === "TSEnumDeclaration") {
return statement.declare !== true && statement.const !== true;
}
if (statement.type === "TSImportEqualsDeclaration") {
return statement.importKind !== "type" && statement.declare !== true;
}
return true;
}
export function hasExecutableTypeScriptStatements(
source: string,
relativePath: string,
): boolean {
let parsed: unknown;
try {
parsed = babelParser.parse(source, {
sourceType: "module",
requireConfigFile: false,
filePath: relativePath,
babelOptions: {
parserOpts: {
plugins: [
"typescript",
...(relativePath.endsWith(".tsx") ? ["jsx"] : []),
],
},
},
});
} catch (error) {
throw new TypeError(`production module has invalid TypeScript syntax: ${relativePath}`, {
cause: error,
});
}
if (!isRecord(parsed) || !Array.isArray(parsed.body)) {
throw new TypeError("TypeScript parser returned an invalid program");
}
return parsed.body.some(statementIsExecutable);
}
export async function buildProductionModuleInventory(
options: InventoryOptions = {},
): Promise<ProductionModuleInventory> {
@@ -377,6 +481,8 @@ export async function buildProductionModuleInventory(
}
const allModules: string[] = [];
const executableModules: string[] = [];
const nonExecutableModules: string[] = [];
async function visit(relativeDirectory: string): Promise<void> {
const absoluteDirectory = path.join(repositoryRoot, relativeDirectory);
const entries = await readDirectory(absoluteDirectory);
@@ -403,6 +509,7 @@ export async function buildProductionModuleInventory(
throw new TypeError(`production inventory path is outside repository: ${relativeTarget}`);
}
let handle: ReadableFileHandle | undefined;
let source: string;
try {
handle = await openFile(
absoluteTarget,
@@ -415,7 +522,14 @@ export async function buildProductionModuleInventory(
if (!sameFileIdentity(metadata, openedMetadata)) {
throw new FileIdentityChangedError("opened file identity changed");
}
source = await handle.readFile("utf8");
} catch (error) {
if (error instanceof StableFileIdentityUnavailableError) {
throw new Error(
`production inventory stable file identity unavailable: ${relativeTarget}`,
{ cause: error },
);
}
if (error instanceof FileIdentityChangedError) {
throw new Error(
`production inventory file changed during validation: ${relativeTarget}`,
@@ -429,6 +543,11 @@ export async function buildProductionModuleInventory(
await handle?.close();
}
allModules.push(relativeTarget);
if (hasExecutableTypeScriptStatements(source, relativeTarget)) {
executableModules.push(relativeTarget);
} else {
nonExecutableModules.push(relativeTarget);
}
}
}
await visit("src");
@@ -446,6 +565,12 @@ export async function buildProductionModuleInventory(
files: Object.freeze(inventory),
preExclusionTotal: allModules.length,
generatedExclusions: Object.freeze([...generatedPaths].sort()),
executableModules: Object.freeze(
executableModules.filter((file) => !generated.has(file)).sort(),
),
nonExecutableModules: Object.freeze(
nonExecutableModules.filter((file) => !generated.has(file)).sort(),
),
});
}
@@ -593,6 +718,23 @@ export function evaluateRiskCoverage(input: Readonly<{
if (new Set(inventory).size !== inventory.length) {
throw new TypeError("production module inventory contains a duplicate path");
}
const executableModules = input.inventory.executableModules.map((file) =>
exactSourcePath(file, "executable inventory path"),
);
const nonExecutableModules = input.inventory.nonExecutableModules.map((file) =>
exactSourcePath(file, "non-executable inventory path"),
);
const executableSet = new Set(executableModules);
const nonExecutableSet = new Set(nonExecutableModules);
const partition = [...executableModules, ...nonExecutableModules].sort();
if (
executableSet.size !== executableModules.length ||
nonExecutableSet.size !== nonExecutableModules.length ||
executableModules.some((file) => nonExecutableSet.has(file)) ||
partition.join("\n") !== [...inventory].sort().join("\n")
) {
throw new TypeError("production inventory executable provenance is inconsistent");
}
const generatedExclusions = input.inventory.generatedExclusions.map((file) =>
exactSourcePath(file, "generated exclusion"),
);
@@ -630,6 +772,25 @@ export function evaluateRiskCoverage(input: Readonly<{
const metrics = selected.get(file);
return metrics ? [metrics] : [];
});
const zeroCoverageModules = inventory.filter((file) => {
const metrics = selected.get(file);
return (
metrics !== undefined &&
coverageMetrics.every((metric) => metrics[metric].total === 0)
);
});
const zeroCoverageSet = new Set(zeroCoverageModules);
const zeroExecutableModules = zeroCoverageModules.filter((file) =>
executableSet.has(file),
);
const nonExecutableWithCounters = nonExecutableModules.filter((file) => {
const metrics = selected.get(file);
return metrics !== undefined && !zeroCoverageSet.has(file);
});
const instrumentedExecutableTotal = executableModules.filter((file) => {
const metrics = selected.get(file);
return metrics !== undefined && !zeroCoverageSet.has(file);
}).length;
assertMatchingTotal(totalMetrics, aggregateCoverage([...selected.values()]));
const recomputedInventoryMetrics = aggregateCoverage(inventoryMetrics);
@@ -645,9 +806,14 @@ export function evaluateRiskCoverage(input: Readonly<{
for (const metric of coverageMetrics) {
const threshold = minimum[metric];
const received = actual[metric].pct;
const passed = received >= threshold;
const hasCoverageTotal = actual[metric].total > 0;
const passed = hasCoverageTotal && received >= threshold;
results.push({ scope, metric, threshold, received, passed });
if (!passed) failures.push(`${scope}.${metric} expected >= ${threshold}, received ${received}`);
if (!hasCoverageTotal) {
failures.push(`${scope}.${metric} coverage total must be greater than 0`);
} else if (!passed) {
failures.push(`${scope}.${metric} expected >= ${threshold}, received ${received}`);
}
}
}
@@ -657,13 +823,32 @@ export function evaluateRiskCoverage(input: Readonly<{
`repository module baseline expected >= ${input.policy.repositoryBaseline}, received ${inventory.length}`,
);
}
const uncoveredModules = inventory.filter((file) => !selected.has(file)).sort();
failures.push(...uncoveredModules.map((file) => `production module missing from coverage: ${file}`));
const uncoveredModules = inventory
.filter(
(file) =>
!selected.has(file) ||
(executableSet.has(file) && zeroCoverageSet.has(file)),
)
.sort();
failures.push(
...inventory
.filter((file) => !selected.has(file))
.map((file) => `production module missing from coverage: ${file}`),
...zeroExecutableModules.map(
(file) => `production module has zero coverage totals: ${file}`,
),
...nonExecutableWithCounters.map(
(file) => `non-executable module has coverage counters: ${file}`,
),
);
for (const modulePolicy of input.policy.criticalModules) {
if (!inventorySet.has(modulePolicy.path)) {
failures.push(`critical module is outside production inventory: ${modulePolicy.path}`);
continue;
}
if (nonExecutableSet.has(modulePolicy.path)) {
failures.push(`critical module cannot be non-executable: ${modulePolicy.path}`);
}
const actual = selected.get(modulePolicy.path);
if (!actual) {
failures.push(`critical module missing from coverage: ${modulePolicy.path}`);
@@ -678,6 +863,9 @@ export function evaluateRiskCoverage(input: Readonly<{
if (!inventorySet.has(highRiskPath)) {
failures.push(`high-risk module is outside production inventory: ${highRiskPath}`);
}
if (nonExecutableSet.has(highRiskPath)) {
failures.push(`high-risk module cannot be non-executable: ${highRiskPath}`);
}
}
for (const waiver of input.policy.waivers) {
if (!inventorySet.has(waiver.path)) failures.push(`coverage waiver is stale: ${waiver.path}`);
@@ -693,6 +881,10 @@ export function evaluateRiskCoverage(input: Readonly<{
status: failures.length === 0 ? "PASS" : "FAIL",
selectedTotal: inventory.length - uncoveredModules.length,
repositoryTotal: inventory.length,
executableTotal: executableModules.length,
instrumentedExecutableTotal,
nonExecutableTotal: nonExecutableModules.length,
nonExecutableModules: Object.freeze([...nonExecutableModules].sort()),
preExclusionTotal: input.inventory.preExclusionTotal,
generatedExclusionCount: generatedExclusions.length,
generatedExclusions: Object.freeze([...generatedExclusions].sort()),