fix: reject empty coverage counters
This commit is contained in:
@@ -90,16 +90,16 @@ 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 TypeError("stable file identity unavailable");
|
||||
}
|
||||
return before.dev === after.dev && before.ino === after.ino;
|
||||
}
|
||||
|
||||
async function rejectSymlinkAncestors(
|
||||
@@ -251,16 +251,20 @@ export async function resolveRiskCoverageArtifactPath(input: Readonly<{
|
||||
if (!hasErrorCode(error, "ENOENT")) throw error;
|
||||
}
|
||||
if (destinationMetadata) {
|
||||
if (!hasStableIdentity(destinationMetadata)) {
|
||||
throw new TypeError("artifact stable file identity unavailable");
|
||||
}
|
||||
const destinationRealpath = await realpath(absolutePath);
|
||||
for (const inputPath of normalizedInputs) {
|
||||
const inputAbsolutePath = path.resolve(repositoryRoot, inputPath);
|
||||
const inputRealpath = await realpath(inputAbsolutePath);
|
||||
const inputMetadata = await lstat(inputAbsolutePath);
|
||||
if (!hasStableIdentity(inputMetadata)) {
|
||||
throw new TypeError(`input stable file identity unavailable: ${inputPath}`);
|
||||
}
|
||||
if (
|
||||
inputRealpath === destinationRealpath ||
|
||||
(hasStableIdentity(inputMetadata) &&
|
||||
hasStableIdentity(destinationMetadata) &&
|
||||
inputMetadata.dev === destinationMetadata.dev &&
|
||||
(inputMetadata.dev === destinationMetadata.dev &&
|
||||
inputMetadata.ino === destinationMetadata.ino)
|
||||
) {
|
||||
throw new TypeError(`artifact path is the same file as an input: ${inputPath}`);
|
||||
|
||||
+204
-12
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user