diff --git a/scripts/lib/risk-coverage-files.ts b/scripts/lib/risk-coverage-files.ts index df245c6..f756325 100644 --- a/scripts/lib/risk-coverage-files.ts +++ b/scripts/lib/risk-coverage-files.ts @@ -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}`); diff --git a/scripts/lib/risk-coverage.ts b/scripts/lib/risk-coverage.ts index edd77a0..953c25f 100644 --- a/scripts/lib/risk-coverage.ts +++ b/scripts/lib/risk-coverage.ts @@ -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; +type ReadableFileHandle = Readonly<{ + stat(): Promise; + readFile(encoding: "utf8"): Promise; + close(): Promise; +}>; 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 { 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 { @@ -377,6 +481,8 @@ export async function buildProductionModuleInventory( } const allModules: string[] = []; + const executableModules: string[] = []; + const nonExecutableModules: string[] = []; async function visit(relativeDirectory: string): Promise { 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()), diff --git a/tests/fixtures/coverage/repository-omission.json b/tests/fixtures/coverage/repository-omission.json index 5a1a3e5..dde669d 100644 --- a/tests/fixtures/coverage/repository-omission.json +++ b/tests/fixtures/coverage/repository-omission.json @@ -1,50 +1,50 @@ { "total": { - "lines": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, - "statements": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, - "functions": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, - "branches": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 } + "lines": { "total": 14, "covered": 14, "skipped": 0, "pct": 100 }, + "statements": { "total": 14, "covered": 14, "skipped": 0, "pct": 100 }, + "functions": { "total": 14, "covered": 14, "skipped": 0, "pct": 100 }, + "branches": { "total": 14, "covered": 14, "skipped": 0, "pct": 100 } }, "src/adapters/http/bounded-body-reader.ts": { - "lines": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "statements": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "functions": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "branches": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 } + "lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 } }, "src/adapters/http/bounded-json.ts": { - "lines": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "statements": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "functions": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "branches": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 } + "lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 } }, "src/adapters/http/http-execution-v3.ts": { - "lines": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "statements": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "functions": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "branches": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 } + "lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 } }, "src/adapters/http/request-builder.ts": { - "lines": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "statements": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "functions": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "branches": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 } + "lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 } }, "src/adapters/http/retry-policy.ts": { - "lines": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "statements": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "functions": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "branches": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 } + "lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 } }, "src/adapters/query-cache/server-state-scope-runtime.ts": { - "lines": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "statements": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "functions": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "branches": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 } + "lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 } }, "src/adapters/service-worker/service-worker-lifecycle.ts": { - "lines": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "statements": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "functions": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "branches": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 } + "lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 } }, "src/adapters/storage/browser-storage-adapter.ts": { - "lines": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "statements": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "functions": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "branches": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 } + "lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 } }, "src/adapters/telemetry/best-effort-telemetry.ts": { - "lines": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "statements": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "functions": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "branches": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 } + "lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 } }, "src/application/create-application.ts": { - "lines": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "statements": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "functions": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "branches": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 } + "lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 } }, "src/application/policies/compatibility.ts": { - "lines": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "statements": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "functions": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "branches": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 } + "lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 } }, "src/application/policies/performance-budgets.ts": { - "lines": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "statements": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "functions": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "branches": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 } + "lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 } }, "src/application/policies/promotion-readiness.ts": { - "lines": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "statements": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "functions": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "branches": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 } + "lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 } }, "src/application/use-cases/decide-chunk-recovery.ts": { - "lines": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "statements": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "functions": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 }, "branches": { "total": 0, "covered": 0, "skipped": 0, "pct": 100 } + "lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 } } } diff --git a/tests/unit/risk-coverage-files.test.ts b/tests/unit/risk-coverage-files.test.ts index 845d998..f587e8f 100644 --- a/tests/unit/risk-coverage-files.test.ts +++ b/tests/unit/risk-coverage-files.test.ts @@ -131,6 +131,36 @@ describe("risk coverage CLI files", () => { ).rejects.toThrow(/changed during validation/u); }); + it("fails closed when an opened input has no stable file identity", async () => { + const repositoryRoot = await fixture(); + await expect( + readRiskCoverageInput( + { + repositoryRoot, + relativePath: "config/testing/policy.json", + label: "policy", + }, + { + openFile: async (target, flags) => { + const handle = await open(target, flags); + return { + stat: async () => { + const metadata = await handle.stat(); + Object.defineProperties(metadata, { + dev: { value: 0 }, + ino: { value: 0 }, + }); + return metadata; + }, + readFile: async (encoding) => handle.readFile(encoding), + close: async () => handle.close(), + }; + }, + }, + ), + ).rejects.toThrow(/stable file identity unavailable/u); + }); + it("confines artifact output and rejects input overwrite or symlink ancestors", async () => { const repositoryRoot = await fixture(); await expect( diff --git a/tests/unit/risk-coverage.test.ts b/tests/unit/risk-coverage.test.ts index ce06c18..3c1d60c 100644 --- a/tests/unit/risk-coverage.test.ts +++ b/tests/unit/risk-coverage.test.ts @@ -48,11 +48,16 @@ const fullMetrics = metrics(); function inventory( files: readonly string[], generatedExclusions: readonly string[] = [], + nonExecutableModules: readonly string[] = [], ): ProductionModuleInventory { return { files, preExclusionTotal: files.length + generatedExclusions.length, generatedExclusions, + executableModules: files.filter( + (file) => !nonExecutableModules.includes(file), + ), + nonExecutableModules, }; } @@ -155,6 +160,8 @@ describe("repository-aware risk coverage", () => { files: ["src/a.ts", "src/nested/b.tsx"], preExclusionTotal: 3, generatedExclusions: ["src/generated.ts"], + executableModules: ["src/a.ts", "src/nested/b.tsx"], + nonExecutableModules: [], }); expect(result).toMatchObject({ status: "FAIL", @@ -318,6 +325,170 @@ describe("repository-aware risk coverage", () => { ).toThrow(/coverage total\.lines does not match recomputed inventory total/u); }); + it("fails an exact-consistent all-zero coverage universe", () => { + const parsedPolicy = parseRiskCoveragePolicy( + policy({ repositoryBaseline: 1, generatedPaths: [] }), + { now }, + ); + const result = evaluateRiskCoverage({ + repositoryRoot: "/repository", + inventory: inventory(["src/a.ts"]), + policy: parsedPolicy, + summary: { + total: metrics(0), + "src/a.ts": metrics(0), + }, + }); + + expect(result).toMatchObject({ + status: "FAIL", + selectedTotal: 0, + repositoryTotal: 1, + uncoveredModules: ["src/a.ts"], + }); + expect(result.failures).toContain( + "production module has zero coverage totals: src/a.ts", + ); + expect(result.failures).toEqual( + expect.arrayContaining([ + "total.lines coverage total must be greater than 0", + "total.statements coverage total must be greater than 0", + "total.functions coverage total must be greater than 0", + "total.branches coverage total must be greater than 0", + ]), + ); + }); + + it("accepts exact all-zero rows only for statically non-executable modules", () => { + const parsedPolicy = parseRiskCoveragePolicy( + policy({ repositoryBaseline: 2, generatedPaths: [] }), + { now }, + ); + const productionInventory = inventory( + ["src/a.ts", "src/type-only.ts"], + [], + ["src/type-only.ts"], + ); + const result = evaluateRiskCoverage({ + repositoryRoot: "/repository", + inventory: productionInventory, + policy: parsedPolicy, + summary: { + total: fullMetrics, + "src/a.ts": fullMetrics, + "src/type-only.ts": metrics(0), + }, + }); + + expect(result).toMatchObject({ + status: "PASS", + selectedTotal: 2, + repositoryTotal: 2, + executableTotal: 1, + instrumentedExecutableTotal: 1, + nonExecutableTotal: 1, + nonExecutableModules: ["src/type-only.ts"], + uncoveredModules: [], + }); + + const mismatch = evaluateRiskCoverage({ + repositoryRoot: "/repository", + inventory: productionInventory, + policy: parsedPolicy, + summary: { + total: metrics(2), + "src/a.ts": fullMetrics, + "src/type-only.ts": fullMetrics, + }, + }); + expect(mismatch.failures).toContain( + "non-executable module has coverage counters: src/type-only.ts", + ); + }); + + it("forbids critical and high-risk modules from being non-executable", () => { + const parsedPolicy = parseRiskCoveragePolicy( + policy({ repositoryBaseline: 1, generatedPaths: [] }), + { now }, + ); + const result = evaluateRiskCoverage({ + repositoryRoot: "/repository", + inventory: inventory(["src/a.ts"], [], ["src/a.ts"]), + policy: parsedPolicy, + summary: { total: metrics(0), "src/a.ts": metrics(0) }, + }); + + expect(result.failures).toEqual( + expect.arrayContaining([ + "critical module cannot be non-executable: src/a.ts", + "high-risk module cannot be non-executable: src/a.ts", + ]), + ); + }); + + it("fails a critical threshold metric with a zero total even when pct is 100", () => { + const parsedPolicy = parseRiskCoveragePolicy( + policy({ repositoryBaseline: 2, generatedPaths: [] }), + { now }, + ); + const criticalMetrics = { ...fullMetrics, lines: counter(0) }; + const result = evaluateRiskCoverage({ + repositoryRoot: "/repository", + inventory: inventory(["src/a.ts", "src/nested/b.tsx"]), + policy: parsedPolicy, + summary: { + total: { + lines: counter(1), + statements: counter(2), + functions: counter(2), + branches: counter(2), + }, + "src/a.ts": criticalMetrics, + "src/nested/b.tsx": fullMetrics, + }, + }); + + expect(result.status).toBe("FAIL"); + expect(result.selectedTotal).toBe(2); + expect(result.failures).toContain( + "src/a.ts.lines coverage total must be greater than 0", + ); + expect( + result.results.find( + ({ scope, metric }) => scope === "src/a.ts" && metric === "lines", + ), + ).toMatchObject({ received: 100, passed: false }); + }); + + it("keeps a noncritical zero-function row when other metrics and repository functions exist", () => { + const parsedPolicy = parseRiskCoveragePolicy( + policy({ repositoryBaseline: 2, generatedPaths: [] }), + { now }, + ); + const noncriticalMetrics = { ...fullMetrics, functions: counter(0) }; + const result = evaluateRiskCoverage({ + repositoryRoot: "/repository", + inventory: inventory(["src/a.ts", "src/nested/b.tsx"]), + policy: parsedPolicy, + summary: { + total: { + lines: counter(2), + statements: counter(2), + functions: counter(1), + branches: counter(2), + }, + "src/a.ts": fullMetrics, + "src/nested/b.tsx": noncriticalMetrics, + }, + }); + + expect(result).toMatchObject({ + status: "PASS", + selectedTotal: 2, + uncoveredModules: [], + }); + }); + it("accepts and validates Vitest branchesTrue without evaluating it", () => { const parsedPolicy = parseRiskCoveragePolicy( policy({ repositoryBaseline: 1, generatedPaths: [] }), @@ -369,6 +540,75 @@ describe("repository-aware risk coverage", () => { expect(observedFlags & constants.O_NOFOLLOW).toBe(constants.O_NOFOLLOW); }); + it("fails closed when an opened inventory file has no stable identity", async () => { + const repositoryRoot = await repositoryFixture(); + await expect( + buildProductionModuleInventory({ + repositoryRoot, + openFile: async (target, flags) => { + const handle = await open(target, flags); + return { + stat: async () => { + const metadata = await handle.stat(); + Object.defineProperties(metadata, { + dev: { value: 0 }, + ino: { value: 0 }, + }); + return metadata; + }, + readFile: async (encoding) => handle.readFile(encoding), + close: async () => handle.close(), + }; + }, + }), + ).rejects.toThrow(/stable file identity unavailable/u); + }); + + it("classifies type-only and barrel modules separately from runtime statements", async () => { + const repositoryRoot = await repositoryFixture(); + await Promise.all([ + writeFile( + path.join(repositoryRoot, "src/type-only.ts"), + "export interface Shape { readonly id: string }\nexport type ShapeId = Shape['id'];\n", + ), + writeFile( + path.join(repositoryRoot, "src/barrel.ts"), + "export type { Shape, ShapeId } from './type-only.ts';\nexport { type Shape as PublicShape } from './type-only.ts';\n", + ), + writeFile( + path.join(repositoryRoot, "src/runtime-export.ts"), + "export const runtimeValue = 1;\n", + ), + writeFile( + path.join(repositoryRoot, "src/side-effect.ts"), + "void globalThis;\n", + ), + writeFile( + path.join(repositoryRoot, "src/side-effect-import.ts"), + "import './a.ts';\n", + ), + ]); + + const productionInventory = await buildProductionModuleInventory({ + repositoryRoot, + generatedPaths: ["src/generated.ts"], + }); + + expect(productionInventory.nonExecutableModules).toEqual([ + "src/barrel.ts", + "src/type-only.ts", + ]); + expect(productionInventory.executableModules).toEqual( + expect.arrayContaining([ + "src/a.ts", + "src/nested/b.tsx", + "src/runtime-export.ts", + "src/side-effect-import.ts", + "src/side-effect.ts", + ]), + ); + }); + it("rejects a post-lstat file identity swap even without relying on O_NOFOLLOW", async () => { const repositoryRoot = await repositoryFixture(); const outside = await mkdtemp(path.join(tmpdir(), "risk-coverage-race-"));