Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/actions/ci-cli-coverage-merge/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,23 @@ runs:
--coverage.exclude="test/**/*.ts"
npx tsx scripts/check-coverage-ratchet.ts coverage/cli/coverage-summary.json ci/coverage-threshold-cli.json "CLI coverage"

- name: Report changed CLI coverage
continue-on-error: true
shell: bash
env:
CHANGED_COVERAGE_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
run: |
if [ -z "$CHANGED_COVERAGE_BASE" ] || [ "$CHANGED_COVERAGE_BASE" = "0000000000000000000000000000000000000000" ]; then
echo "Changed CLI coverage is unavailable without a prior base commit."
exit 0
fi
git fetch --no-tags --depth=1 origin "$CHANGED_COVERAGE_BASE"
npx tsx scripts/report-changed-coverage.ts \
coverage/cli/coverage-summary.json \
"$CHANGED_COVERAGE_BASE" \
HEAD \
"Changed CLI coverage"

- name: Upload CLI coverage report
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
uses: actions/upload-code-coverage@abb5995db9e0199b0e2bb9dbd136fce4cb1ec4d3 # v1
Expand Down
17 changes: 17 additions & 0 deletions .github/actions/ci-plugin-coverage/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,23 @@ runs:
--coverage.exclude="**/*.test.ts"
npx tsx scripts/check-coverage-ratchet.ts coverage/plugin/coverage-summary.json ci/coverage-threshold-plugin.json "Plugin coverage"

- name: Report changed plugin coverage
continue-on-error: true
shell: bash
env:
CHANGED_COVERAGE_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
run: |
if [ -z "$CHANGED_COVERAGE_BASE" ] || [ "$CHANGED_COVERAGE_BASE" = "0000000000000000000000000000000000000000" ]; then
echo "Changed plugin coverage is unavailable without a prior base commit."
exit 0
fi
git fetch --no-tags --depth=1 origin "$CHANGED_COVERAGE_BASE"
npx tsx scripts/report-changed-coverage.ts \
coverage/plugin/coverage-summary.json \
"$CHANGED_COVERAGE_BASE" \
HEAD \
"Changed plugin coverage"

- name: Upload plugin coverage report
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
uses: actions/upload-code-coverage@abb5995db9e0199b0e2bb9dbd136fce4cb1ec4d3 # v1
Expand Down
2 changes: 2 additions & 0 deletions nemoclaw/vitest.project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type PluginVitestProjectOptions = {
alias: Array<{ find: RegExp; replacement: string }>;
env: Record<string, string>;
environment: "node";
expect: { requireAssertions: true };
setupFiles: string[];
include: string[];
};
Expand All @@ -39,6 +40,7 @@ const pluginVitestProjectOptions = {
NEMOCLAW_DISABLE_GATEWAY_DRIFT_PREFLIGHT: "1",
},
environment: "node",
expect: { requireAssertions: true },
setupFiles: ["test/helpers/normalize-fixture-umask.ts"],
include: ["nemoclaw/src/**/*.test.ts"],
},
Expand Down
40 changes: 28 additions & 12 deletions scripts/check-coverage-ratchet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,25 @@
// SPDX-License-Identifier: Apache-2.0
//
// Compares a Vitest coverage summary against a threshold file.
// Exits non-zero if any metric drops more than 1% below its threshold.
// Exits non-zero if any metric drops below its threshold.

import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

type MetricName = "lines" | "functions" | "branches" | "statements";

const METRICS: readonly MetricName[] = ["lines", "functions", "branches", "statements"];

type Thresholds = Record<MetricName, number>;
type CoverageSummary = { total: Record<MetricName, { pct: number }> };
export type Thresholds = Record<MetricName, number>;
export type CoverageSummary = { total: Record<MetricName, { pct: number }> };
export type CoverageFailure = {
metric: MetricName;
actual: number;
threshold: number;
};

const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const TOLERANCE = 1;

/** Read and JSON-parse a repo-relative file. */
function loadJSON<T>(repoRelative: string): T {
Expand Down Expand Up @@ -50,6 +54,17 @@ function isThresholds(value: Partial<Thresholds> | null | undefined): value is T
return METRICS.every((metric) => typeof value[metric] === "number");
}

export function findCoverageFailures(
summary: CoverageSummary,
thresholds: Thresholds,
): CoverageFailure[] {
return METRICS.map((metric) => ({
metric,
actual: summary.total[metric].pct,
threshold: thresholds[metric],
})).filter(({ actual, threshold }) => actual < threshold);
}

function main(): void {
const [summaryPath, thresholdPath, label = "coverage"] = process.argv.slice(2);
if (!summaryPath || !thresholdPath) {
Expand All @@ -68,20 +83,21 @@ function main(): void {
throw new Error(`Invalid coverage threshold: ${thresholdPath}`);
}

const failures = METRICS.map((metric) => ({
metric,
actual: summaryValue.total[metric].pct,
threshold: thresholdValue[metric],
})).filter((r) => r.actual < r.threshold - TOLERANCE);
const failures = findCoverageFailures(summaryValue, thresholdValue);

if (failures.length === 0) return;

console.error(`${label} ratchet failed:\n`);
for (const { metric, actual, threshold } of failures) {
console.error(` ${metric}: ${actual}% < ${threshold}% (tolerance ±${TOLERANCE}%)`);
console.error(` ${metric}: ${actual}% < ${threshold}%`);
}
console.error("\nAdd tests to bring coverage back above the threshold.");
process.exitCode = 1;
}

main();
const isDirectExecution = process.argv[1]
? resolve(process.argv[1]) === fileURLToPath(import.meta.url)
: false;
if (isDirectExecution) {
main();
}
101 changes: 101 additions & 0 deletions scripts/report-changed-coverage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env -S npx tsx
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { execFileSync } from "node:child_process";
import { appendFileSync, readFileSync } from "node:fs";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";

type MetricName = "lines" | "functions" | "branches" | "statements";
type FileCoverage = Record<MetricName, { pct: number }>;
type CoverageSummary = Record<string, FileCoverage | unknown>;

const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const METRICS: readonly MetricName[] = ["statements", "branches", "functions", "lines"];

function normalizeRepoPath(file: string): string {
const repoRelative = isAbsolute(file) ? relative(REPO_ROOT, file) : file;
return repoRelative.replaceAll("\\", "/").replace(/^\.\//u, "");
}

function isFileCoverage(value: unknown): value is FileCoverage {
if (!value || typeof value !== "object") return false;
return METRICS.every((metric) => {
const entry = (value as Partial<FileCoverage>)[metric];
return typeof entry?.pct === "number";
});
}

function formatPercentage(value: number): string {
return `${value.toFixed(2).replace(/\.00$/u, "")}%`;
}

export function renderChangedCoverageReport(
summary: CoverageSummary,
changedFiles: string[],
label: string,
): string {
const coverageByPath = new Map<string, FileCoverage>();
for (const [file, coverage] of Object.entries(summary)) {
if (file !== "total" && isFileCoverage(coverage)) {
coverageByPath.set(normalizeRepoPath(file), coverage);
}
}

const coveredChanges = [...new Set(changedFiles.map(normalizeRepoPath))]
.filter((file) => coverageByPath.has(file))
.sort();
if (coveredChanges.length === 0) {
return `## ${label}\n\nNo changed covered source files were found.`;
}

const rows = coveredChanges.map((file) => {
const coverage = coverageByPath.get(file);
if (!coverage) throw new Error(`Missing normalized coverage entry for ${file}`);
return `| \`${file}\` | ${METRICS.map((metric) => formatPercentage(coverage[metric].pct)).join(" | ")} |`;
});

return [
`## ${label}`,
"",
"This report is advisory. The aggregate and security-sensitive coverage ratchets remain the merge gates.",
"",
"| Changed file | Statements | Branches | Functions | Lines |",
"|---|---:|---:|---:|---:|",
...rows,
].join("\n");
}

function main(): void {
const [summaryPath, baseRef, headRef = "HEAD", label = "Changed-file coverage"] =
process.argv.slice(2);
if (!summaryPath || !baseRef) {
throw new Error(
"Usage: report-changed-coverage.ts <coverage-summary.json> <base-ref> [head-ref] [label]",
);
}

const summaryFile = resolve(REPO_ROOT, summaryPath);
const summary = JSON.parse(readFileSync(summaryFile, "utf8")) as CoverageSummary;
const changedFiles = execFileSync(
"git",
["diff", "--name-only", "--diff-filter=ACMR", baseRef, headRef],
{ cwd: REPO_ROOT, encoding: "utf8" },
)
.split("\n")
.filter(Boolean);
const report = renderChangedCoverageReport(summary, changedFiles, label);

console.log(report);
if (process.env.GITHUB_STEP_SUMMARY) {
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `\n${report}\n`, "utf8");
}
}

const isDirectExecution = process.argv[1]
? resolve(process.argv[1]) === fileURLToPath(import.meta.url)
: false;
if (isDirectExecution) {
main();
}
103 changes: 103 additions & 0 deletions test/coverage-ratchet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it } from "vitest";

import {
type CoverageSummary,
findCoverageFailures,
type Thresholds,
} from "../scripts/check-coverage-ratchet";
import { renderChangedCoverageReport } from "../scripts/report-changed-coverage";
import {
securityCoverageThresholds,
securityCoverageThresholdsForRun,
} from "./helpers/security-coverage-thresholds";

const thresholds: Thresholds = {
lines: 70,
functions: 71,
branches: 62,
statements: 70,
};

function summary(overrides: Partial<Thresholds> = {}): CoverageSummary {
const values = { ...thresholds, ...overrides };
return {
total: {
lines: { pct: values.lines },
functions: { pct: values.functions },
branches: { pct: values.branches },
statements: { pct: values.statements },
},
};
}

describe("coverage regression safeguards", () => {
it("accepts metrics that exactly meet the aggregate ratchet (#6692)", () => {
expect(findCoverageFailures(summary(), thresholds)).toEqual([]);
});

it("rejects any aggregate drop below the committed floor (#6692)", () => {
expect(findCoverageFailures(summary({ lines: 69.99 }), thresholds)).toEqual([
{ metric: "lines", actual: 69.99, threshold: 70 },
]);
});

it("applies native per-file floors to each security-sensitive surface (#6692)", () => {
expect(securityCoverageThresholds).toEqual({
perFile: true,
"nemoclaw/src/blueprint/ssrf.ts": {
lines: 96,
functions: 100,
branches: 95,
statements: 96,
},
"src/lib/security/{credential-filter,redact,redact-url}.ts": {
lines: 98,
functions: 92,
branches: 86,
statements: 96,
},
"src/lib/policy/index.ts": {
lines: 66,
functions: 68,
branches: 57,
statements: 66,
},
"src/lib/shields/transition-lock.ts": {
lines: 85,
functions: 82,
branches: 78,
statements: 83,
},
});
});

it("defers per-file floors until partial coverage shards are merged (#6692)", () => {
expect(
securityCoverageThresholdsForRun({ CLI_SHARD: "2", CLI_SHARD_COUNT: "8" }),
).toBeUndefined();
expect(securityCoverageThresholdsForRun({ CLI_SHARD: "2" })).toBeUndefined();
expect(securityCoverageThresholdsForRun({})).toBe(securityCoverageThresholds);
});

it("renders changed-file coverage as advisory feedback (#6692)", () => {
const fileCoverage = {
lines: { pct: 91.25 },
functions: { pct: 100 },
branches: { pct: 87.5 },
statements: { pct: 90 },
};
const report = renderChangedCoverageReport(
{ total: fileCoverage, "src/lib/security/redact.ts": fileCoverage },
["README.md", "src/lib/security/redact.ts"],
"Changed CLI coverage",
);

expect(report).toContain("This report is advisory");
expect(report).toContain("`src/lib/security/redact.ts`");
expect(report).toContain("| 90% | 87.50% | 100% | 91.25% |");
expect(report).not.toContain("README.md");
});
});
44 changes: 44 additions & 0 deletions test/helpers/security-coverage-thresholds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

// Keep security-sensitive coverage from hiding behind the aggregate ratchet.
// Values are conservative integer floors below the July 12, 2026 main baseline.
export const securityCoverageThresholds = {
perFile: true,
"nemoclaw/src/blueprint/ssrf.ts": {
lines: 96,
functions: 100,
branches: 95,
statements: 96,
},
"src/lib/security/{credential-filter,redact,redact-url}.ts": {
lines: 98,
functions: 92,
branches: 86,
statements: 96,
},
"src/lib/policy/index.ts": {
lines: 66,
functions: 68,
branches: 57,
statements: 66,
},
"src/lib/shields/transition-lock.ts": {
lines: 85,
functions: 82,
branches: 78,
statements: 83,
},
};

export function securityCoverageThresholdsForRun(
env: NodeJS.ProcessEnv,
): typeof securityCoverageThresholds | undefined {
// Coverage shards only own part of the test suite. Enforce per-file floors
// after Vitest merges every shard, when each protected file has its complete
// coverage map.
if (env.CLI_SHARD || env.CLI_SHARD_COUNT) {
return undefined;
}
return securityCoverageThresholds;
}
Loading
Loading