diff --git a/action/index.mjs b/action/index.mjs index ed6f24f..252dc89 100644 --- a/action/index.mjs +++ b/action/index.mjs @@ -229102,7 +229102,7 @@ var init_discover3 = __esm({ }); // src/cli.ts -import { readFileSync as readFileSync2 } from "node:fs"; +import { readFileSync as readFileSync2, writeFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; // src/config/load.ts @@ -233539,6 +233539,123 @@ function shouldFail(report, failOn) { } } +// src/report/compliance.ts +function buildComplianceReport(results, audit) { + const cycles = []; + const errored = []; + const deferred = []; + const modeSet = /* @__PURE__ */ new Set(); + let drift = 0; + let guardrailTrips = 0; + let guardrailBlocked = 0; + let applied = 0; + let failed = 0; + for (const result of results) { + modeSet.add(result.mode); + for (const cr of result.cycles) { + const total = cr.counts.create + cr.counts.update + cr.counts.delete; + const tripped = cr.guardrails.ok ? [] : cr.guardrails.diagnostics.map((d) => d.guardrail); + cycles.push({ + cycle: cr.name, + org: cr.org, + drift: { ...cr.counts, total }, + guardrails: { ok: cr.guardrails.ok, blocked: cr.guardrailBlocked, tripped }, + applied: cr.applied.length, + failed: cr.failed.length + }); + drift += total; + if (!cr.guardrails.ok) guardrailTrips++; + if (cr.guardrailBlocked) guardrailBlocked++; + applied += cr.applied.length; + failed += cr.failed.length; + } + for (const ce of result.errored) { + errored.push({ cycle: ce.name, org: ce.org, stage: ce.stage, error: ce.error }); + } + deferred.push(...result.deferred.skippedCycles); + } + let auditCompliance; + let auditMergeWorthy = 0; + if (audit) { + auditMergeWorthy = audit.totals.quickWin + audit.totals.needsReview; + auditCompliance = { + total: audit.totals.total, + quickWin: audit.totals.quickWin, + needsReview: audit.totals.needsReview, + reportOnly: audit.totals.reportOnly, + mergeWorthy: auditMergeWorthy + }; + } + const clean = drift === 0 && guardrailTrips === 0 && failed === 0 && errored.length === 0 && deferred.length === 0 && auditMergeWorthy === 0; + return { + modes: [...modeSet], + cycles, + audit: auditCompliance, + totals: { + drift, + guardrailTrips, + guardrailBlocked, + applied, + failed, + cyclesReporting: cycles.length, + auditMergeWorthy + }, + errored, + deferred, + clean + }; +} +function renderComplianceReport(report) { + const lines = []; + lines.push("=== compliance report ==="); + if (report.generatedAt) lines.push(`generated: ${report.generatedAt}`); + if (report.modes.length > 0) lines.push(`modes: ${report.modes.join(", ")}`); + lines.push(""); + if (report.cycles.length === 0) { + lines.push(" (no cycle results)"); + } else { + for (const c of report.cycles) { + const d = c.drift; + let line = ` ${c.cycle}@${c.org} drift=${d.total} (c${d.create}/u${d.update}/d${d.delete}) applied=${c.applied} failed=${c.failed}`; + if (c.guardrails.blocked) line += ` GUARDRAIL-BLOCKED[${c.guardrails.tripped.join(",")}]`; + else if (!c.guardrails.ok) line += ` guardrail-trip[${c.guardrails.tripped.join(",")}]`; + lines.push(line); + } + } + if (report.errored.length > 0) { + lines.push(""); + lines.push("--- errored cycles ---"); + for (const e of report.errored) { + lines.push(` ${e.cycle}@${e.org} (${e.stage}): ${e.error}`); + } + } + if (report.deferred.length > 0) { + lines.push(""); + lines.push(`--- deferred (budget) ---`); + lines.push(` ${report.deferred.join(", ")}`); + } + if (report.audit) { + lines.push(""); + lines.push("--- audit ---"); + lines.push( + ` total=${report.audit.total} merge-worthy=${report.audit.mergeWorthy} (quick-win=${report.audit.quickWin}, needs-review=${report.audit.needsReview}, report-only=${report.audit.reportOnly})` + ); + } + lines.push(""); + lines.push("--- totals ---"); + const t = report.totals; + lines.push(` drift=${t.drift} applied=${t.applied} failed=${t.failed}`); + lines.push(` guardrail-trips=${t.guardrailTrips} guardrail-blocked=${t.guardrailBlocked}`); + lines.push(` cycles-reporting=${t.cyclesReporting} audit-merge-worthy=${t.auditMergeWorthy}`); + lines.push(` status: ${report.clean ? "CLEAN" : "ATTENTION NEEDED"}`); + lines.push(""); + return lines.join("\n"); +} +function complianceArtifact(report) { + return `${JSON.stringify(report, null, 2)} +`; +} + // src/cli.ts var CliError = class extends Error { constructor(code, message) { @@ -233723,6 +233840,96 @@ function parseAuditArgs(argv) { } return args; } +function parseReportArgs(argv) { + const args = { + config: "", + appIdEnv: void 0, + installationIdEnv: void 0, + tokenEnv: void 0, + cycles: [], + out: void 0, + audit: false, + failOn: "none" + }; + const knownFlags = /* @__PURE__ */ new Set([ + "--config", + "--token-env", + "--app-id-env", + "--installation-id-env", + "--cycles", + "--out", + "--audit", + "--fail-on" + ]); + let i = 0; + while (i < argv.length) { + const flag = argv[i]; + if (!flag.startsWith("--")) throw new CliError(2, `unexpected positional argument: ${flag}`); + if (!knownFlags.has(flag)) throw new CliError(2, `unknown flag: ${flag}`); + switch (flag) { + case "--config": { + const val = argv[++i]; + if (val === void 0 || val.startsWith("--")) throw new CliError(2, "--config requires a value"); + args.config = val; + break; + } + case "--token-env": { + const val = argv[++i]; + if (val === void 0 || val.startsWith("--")) throw new CliError(2, "--token-env requires a value"); + args.tokenEnv = val; + break; + } + case "--app-id-env": { + const val = argv[++i]; + if (val === void 0 || val.startsWith("--")) throw new CliError(2, "--app-id-env requires a value"); + args.appIdEnv = val; + break; + } + case "--installation-id-env": { + const val = argv[++i]; + if (val === void 0 || val.startsWith("--")) + throw new CliError(2, "--installation-id-env requires a value"); + args.installationIdEnv = val; + break; + } + case "--cycles": { + const val = argv[++i]; + if (val === void 0 || val.startsWith("--")) throw new CliError(2, "--cycles requires a value"); + args.cycles = val.split(",").map((s) => s.trim()).filter(Boolean); + break; + } + case "--out": { + const val = argv[++i]; + if (val === void 0 || val.startsWith("--")) throw new CliError(2, "--out requires a value"); + args.out = val; + break; + } + case "--audit": { + args.audit = true; + break; + } + case "--fail-on": { + const val = argv[++i]; + if (val !== "none" && val !== "attention") { + throw new CliError(2, `--fail-on must be "none" or "attention", got: ${val ?? "(missing)"}`); + } + args.failOn = val; + break; + } + } + i++; + } + if (!args.config) throw new CliError(2, "--config is required"); + const hasTokenAuth = !!args.tokenEnv; + const hasAppAuth = !!(args.appIdEnv && args.installationIdEnv); + if (!hasTokenAuth && !hasAppAuth) { + throw new CliError( + 2, + "auth is required: supply --token-env , or both --app-id-env and --installation-id-env " + ); + } + return args; +} function buildClient(args) { if (args.tokenEnv) { const token = env(args.tokenEnv); @@ -233774,8 +233981,12 @@ async function main(argv = process.argv.slice(2)) { await runAudit(argv.slice(1)); return; } + if (subcommand === "report") { + await runReport(argv.slice(1)); + return; + } if (subcommand !== "reconcile") { - die(2, `unknown subcommand: ${subcommand}. Did you mean "reconcile" or "audit"?`); + die(2, `unknown subcommand: ${subcommand}. Did you mean "reconcile", "audit", or "report"?`); } let args; try { @@ -234089,6 +234300,97 @@ async function runAudit(argv) { } process.exit(0); } +async function runReport(argv) { + let reportArgs; + try { + reportArgs = parseReportArgs(argv); + } catch (err) { + if (err instanceof CliError) die(err.code, err.message); + throw err; + } + let rawConfig; + try { + const text = readFileSync2(reportArgs.config, "utf-8"); + rawConfig = parseConfigFile(reportArgs.config, text); + } catch (err) { + die(3, `failed to read config file "${reportArgs.config}": ${errMsg(err)}`); + } + let config2; + try { + config2 = loadGovernanceConfig(rawConfig); + } catch (err) { + die(2, `invalid governance config: ${errMsg(err)}`); + } + let client; + try { + client = buildClient(reportArgs); + } catch (err) { + die(3, `auth setup failed: ${errMsg(err)}`); + } + let cycles; + if (reportArgs.cycles.length === 0) { + cycles = Object.values(CYCLE_REGISTRY); + } else { + cycles = []; + for (const name of reportArgs.cycles) { + const cycle = CYCLE_REGISTRY[name]; + if (!cycle) { + die(2, `unknown cycle: "${name}". Known cycles: ${Object.keys(CYCLE_REGISTRY).join(", ")}`); + } + cycles.push(cycle); + } + } + let result; + try { + result = await runReconcile({ config: config2, client, cycles, mode: "dry-run" }); + } catch (err) { + die(3, `reconcile failed: ${errMsg(err)}`); + } + let auditReport; + if (reportArgs.audit) { + const repoUrls = []; + for (const [orgName, orgCfg] of Object.entries(config2.orgs)) { + for (const repoName of Object.keys(orgCfg.repos ?? {})) { + repoUrls.push(`https://github.com/${orgName}/${repoName}`); + } + } + if (repoUrls.length > 0) { + try { + let token; + if (reportArgs.tokenEnv) { + token = process.env[reportArgs.tokenEnv]; + } else { + const appId = env(reportArgs.appIdEnv); + const installationId = env(reportArgs.installationIdEnv); + const privateKeyPem = process.env["GOVERNANCE_APP_PRIVATE_KEY"] ?? process.env["GITHUB_APP_PRIVATE_KEY"] ?? die(2, "private key not found: set GOVERNANCE_APP_PRIVATE_KEY (or GITHUB_APP_PRIVATE_KEY) to the PEM"); + const { mintInstallationToken: mintInstallationToken2 } = await Promise.resolve().then(() => (init_app_client(), app_client_exports)); + const { token: minted } = await mintInstallationToken2({ appId, installationId, privateKeyPem }); + token = minted; + } + auditReport = await auditRepos(repoUrls, token); + } catch (err) { + die(3, `audit failed: ${errMsg(err)}`); + } + } + } + const report = buildComplianceReport([result], auditReport); + report.generatedAt = (/* @__PURE__ */ new Date()).toISOString(); + process.stdout.write(renderComplianceReport(report)); + if (reportArgs.out) { + try { + writeFileSync(reportArgs.out, complianceArtifact(report), "utf-8"); + process.stdout.write(`wrote artifact: ${reportArgs.out} +`); + } catch (err) { + die(3, `failed to write artifact "${reportArgs.out}": ${errMsg(err)}`); + } + } + if (reportArgs.failOn === "attention" && !report.clean) { + process.stderr.write("github-warden: compliance report needs attention (--fail-on attention)\n"); + process.exit(4); + } + process.exit(0); +} function printUsage() { process.stdout.write( [ @@ -234097,6 +234399,7 @@ function printUsage() { "Subcommands:", " reconcile Load config, authenticate, and run governance cycles.", " audit Audit managed repos for security/correctness posture.", + " report Aggregate cycle drift (+ optional audit) into a compliance snapshot.", "", "Flags (reconcile):", " --config Path to governance config file (YAML or JSON).", @@ -234115,12 +234418,20 @@ function printUsage() { " --fail-on merge-worthy|any|none", " Exit 4 when findings exceed threshold (default: none).", "", + "Flags (report):", + " --config Path to governance config file (YAML or JSON).", + " --token-env / --app-id-env / --installation-id-env Auth (as reconcile).", + " --cycles Cycles to include (default: all).", + " --out Write the JSON compliance artifact to this path.", + " --audit Include an audit pass in the report.", + " --fail-on none|attention Exit 4 when the report needs attention (default: none).", + "", "Exit codes:", " 0 Success.", " 1 Guardrail block (apply mode, override not set).", " 2 Argument or config error.", " 3 Runtime error.", - " 4 Audit: findings exceed --fail-on threshold.", + " 4 Audit/report: threshold exceeded or report needs attention.", "" ].join("\n") ); diff --git a/src/cli.ts b/src/cli.ts index d178575..8abbb3b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -35,7 +35,7 @@ * 4 Audit: findings exceed --fail-on threshold. */ -import { readFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; import { loadGovernanceConfig } from "./config/load.js"; import { createAppClient } from "./auth/app-client.js"; @@ -43,7 +43,8 @@ import { runReconcile } from "./reconcile/runner.js"; import { CYCLE_REGISTRY } from "./cli/registry.js"; import { auditRepos } from "./audit/engine.js"; import { renderPostureSummary, shouldFail, type FailOn } from "./audit/summary.js"; -import type { Cycle } from "./reconcile/runner.js"; +import type { Cycle, ReconcileResult } from "./reconcile/runner.js"; +import { buildComplianceReport, renderComplianceReport, complianceArtifact } from "./report/compliance.js"; // --------------------------------------------------------------------------- // Arg parser @@ -305,6 +306,133 @@ export function parseAuditArgs(argv: string[]): AuditArgs { return args; } +// --------------------------------------------------------------------------- +// Report subcommand +// --------------------------------------------------------------------------- + +export interface ReportArgs { + config: string; + appIdEnv: string | undefined; + installationIdEnv: string | undefined; + tokenEnv: string | undefined; + cycles: string[]; + /** Path to write the committable JSON artifact (optional). */ + out: string | undefined; + /** Include an audit pass in the report. */ + audit: boolean; + /** Exit non-zero when the report needs attention. */ + failOn: "none" | "attention"; +} + +/** + * Parse report argv. Pure: throws `CliError` (carrying an exit code) on any + * parse error instead of touching `process`. + * + * Accepted flags: + * --config Governance config file (YAML/JSON). Required. + * --token-env / --app-id-env / --installation-id-env Auth (same as reconcile). + * --cycles Cycles to include (default: all). + * --out Write the JSON compliance artifact to this path. + * --audit Include an audit pass in the report. + * --fail-on none|attention Exit 4 when the report needs attention. Default: none. + */ +export function parseReportArgs(argv: string[]): ReportArgs { + const args: ReportArgs = { + config: "", + appIdEnv: undefined, + installationIdEnv: undefined, + tokenEnv: undefined, + cycles: [], + out: undefined, + audit: false, + failOn: "none", + }; + + const knownFlags = new Set([ + "--config", + "--token-env", + "--app-id-env", + "--installation-id-env", + "--cycles", + "--out", + "--audit", + "--fail-on", + ]); + + let i = 0; + while (i < argv.length) { + const flag = argv[i]; + if (!flag.startsWith("--")) throw new CliError(2, `unexpected positional argument: ${flag}`); + if (!knownFlags.has(flag)) throw new CliError(2, `unknown flag: ${flag}`); + + switch (flag) { + case "--config": { + const val = argv[++i]; + if (val === undefined || val.startsWith("--")) throw new CliError(2, "--config requires a value"); + args.config = val; + break; + } + case "--token-env": { + const val = argv[++i]; + if (val === undefined || val.startsWith("--")) throw new CliError(2, "--token-env requires a value"); + args.tokenEnv = val; + break; + } + case "--app-id-env": { + const val = argv[++i]; + if (val === undefined || val.startsWith("--")) throw new CliError(2, "--app-id-env requires a value"); + args.appIdEnv = val; + break; + } + case "--installation-id-env": { + const val = argv[++i]; + if (val === undefined || val.startsWith("--")) + throw new CliError(2, "--installation-id-env requires a value"); + args.installationIdEnv = val; + break; + } + case "--cycles": { + const val = argv[++i]; + if (val === undefined || val.startsWith("--")) throw new CliError(2, "--cycles requires a value"); + args.cycles = val.split(",").map((s) => s.trim()).filter(Boolean); + break; + } + case "--out": { + const val = argv[++i]; + if (val === undefined || val.startsWith("--")) throw new CliError(2, "--out requires a value"); + args.out = val; + break; + } + case "--audit": { + args.audit = true; + break; + } + case "--fail-on": { + const val = argv[++i]; + if (val !== "none" && val !== "attention") { + throw new CliError(2, `--fail-on must be "none" or "attention", got: ${val ?? "(missing)"}`); + } + args.failOn = val; + break; + } + } + i++; + } + + if (!args.config) throw new CliError(2, "--config is required"); + + const hasTokenAuth = !!args.tokenEnv; + const hasAppAuth = !!(args.appIdEnv && args.installationIdEnv); + if (!hasTokenAuth && !hasAppAuth) { + throw new CliError( + 2, + "auth is required: supply --token-env , or both --app-id-env and --installation-id-env ", + ); + } + + return args; +} + // --------------------------------------------------------------------------- // Auth client builder // --------------------------------------------------------------------------- @@ -320,7 +448,7 @@ export function parseAuditArgs(argv: string[]): AuditArgs { * * Accepts both ReconcileArgs and AuditArgs (both carry the same auth fields). */ -function buildClient(args: ReconcileArgs | AuditArgs) { +function buildClient(args: ReconcileArgs | AuditArgs | ReportArgs) { if (args.tokenEnv) { const token = env(args.tokenEnv); // Wrap the pre-minted token in a minimal AppClient. @@ -390,8 +518,13 @@ async function main(argv: string[] = process.argv.slice(2)) { return; } + if (subcommand === "report") { + await runReport(argv.slice(1)); + return; + } + if (subcommand !== "reconcile") { - die(2, `unknown subcommand: ${subcommand}. Did you mean "reconcile" or "audit"?`); + die(2, `unknown subcommand: ${subcommand}. Did you mean "reconcile", "audit", or "report"?`); } let args: ReconcileArgs; @@ -852,6 +985,124 @@ async function runAudit(argv: string[]): Promise { process.exit(0); } +/** + * Run the `report` subcommand: run all (or selected) cycles in dry-run, + * optionally run an audit pass, aggregate into a compliance snapshot, print it, + * and optionally write a committable JSON artifact. + * + * Detect-and-report only — never mutates (cycles run in dry-run). + * + * Exits 4 when `--fail-on attention` is set and the report needs attention. + */ +async function runReport(argv: string[]): Promise { + let reportArgs: ReportArgs; + try { + reportArgs = parseReportArgs(argv); + } catch (err) { + if (err instanceof CliError) die(err.code, err.message); + throw err; + } + + // ── Load config ──────────────────────────────────────────────────────────── + let rawConfig: unknown; + try { + const text = readFileSync(reportArgs.config, "utf-8"); + rawConfig = parseConfigFile(reportArgs.config, text); + } catch (err) { + die(3, `failed to read config file "${reportArgs.config}": ${errMsg(err)}`); + } + + let config; + try { + config = loadGovernanceConfig(rawConfig); + } catch (err) { + die(2, `invalid governance config: ${errMsg(err)}`); + } + + // ── Build client ─────────────────────────────────────────────────────────── + let client; + try { + client = buildClient(reportArgs); + } catch (err) { + die(3, `auth setup failed: ${errMsg(err)}`); + } + + // ── Resolve cycles ───────────────────────────────────────────────────────── + let cycles: Cycle[]; + if (reportArgs.cycles.length === 0) { + cycles = Object.values(CYCLE_REGISTRY); + } else { + cycles = []; + for (const name of reportArgs.cycles) { + const cycle = CYCLE_REGISTRY[name]; + if (!cycle) { + die(2, `unknown cycle: "${name}". Known cycles: ${Object.keys(CYCLE_REGISTRY).join(", ")}`); + } + cycles.push(cycle); + } + } + + // ── Reconcile in dry-run (detect-only) ───────────────────────────────────── + let result: ReconcileResult; + try { + result = await runReconcile({ config, client, cycles, mode: "dry-run" }); + } catch (err) { + die(3, `reconcile failed: ${errMsg(err)}`); + } + + // ── Optional audit pass ──────────────────────────────────────────────────── + let auditReport; + if (reportArgs.audit) { + const repoUrls: string[] = []; + for (const [orgName, orgCfg] of Object.entries(config.orgs)) { + for (const repoName of Object.keys(orgCfg.repos ?? {})) { + repoUrls.push(`https://github.com/${orgName}/${repoName}`); + } + } + if (repoUrls.length > 0) { + try { + let token: string | undefined; + if (reportArgs.tokenEnv) { + token = process.env[reportArgs.tokenEnv]; + } else { + const appId = env(reportArgs.appIdEnv!); + const installationId = env(reportArgs.installationIdEnv!); + const privateKeyPem = + process.env["GOVERNANCE_APP_PRIVATE_KEY"] ?? + process.env["GITHUB_APP_PRIVATE_KEY"] ?? + die(2, "private key not found: set GOVERNANCE_APP_PRIVATE_KEY (or GITHUB_APP_PRIVATE_KEY) to the PEM"); + const { mintInstallationToken } = await import("./auth/app-client.js"); + const { token: minted } = await mintInstallationToken({ appId, installationId, privateKeyPem }); + token = minted; + } + auditReport = await auditRepos(repoUrls, token); + } catch (err) { + die(3, `audit failed: ${errMsg(err)}`); + } + } + } + + // ── Aggregate + output ───────────────────────────────────────────────────── + const report = buildComplianceReport([result], auditReport); + report.generatedAt = new Date().toISOString(); + process.stdout.write(renderComplianceReport(report)); + + if (reportArgs.out) { + try { + writeFileSync(reportArgs.out, complianceArtifact(report), "utf-8"); + process.stdout.write(`wrote artifact: ${reportArgs.out}\n`); + } catch (err) { + die(3, `failed to write artifact "${reportArgs.out}": ${errMsg(err)}`); + } + } + + if (reportArgs.failOn === "attention" && !report.clean) { + process.stderr.write("github-warden: compliance report needs attention (--fail-on attention)\n"); + process.exit(4); + } + process.exit(0); +} + function printUsage() { process.stdout.write( [ @@ -860,6 +1111,7 @@ function printUsage() { "Subcommands:", " reconcile Load config, authenticate, and run governance cycles.", " audit Audit managed repos for security/correctness posture.", + " report Aggregate cycle drift (+ optional audit) into a compliance snapshot.", "", "Flags (reconcile):", " --config Path to governance config file (YAML or JSON).", @@ -878,12 +1130,20 @@ function printUsage() { " --fail-on merge-worthy|any|none", " Exit 4 when findings exceed threshold (default: none).", "", + "Flags (report):", + " --config Path to governance config file (YAML or JSON).", + " --token-env / --app-id-env / --installation-id-env Auth (as reconcile).", + " --cycles Cycles to include (default: all).", + " --out Write the JSON compliance artifact to this path.", + " --audit Include an audit pass in the report.", + " --fail-on none|attention Exit 4 when the report needs attention (default: none).", + "", "Exit codes:", " 0 Success.", " 1 Guardrail block (apply mode, override not set).", " 2 Argument or config error.", " 3 Runtime error.", - " 4 Audit: findings exceed --fail-on threshold.", + " 4 Audit/report: threshold exceeded or report needs attention.", "", ].join("\n"), ); diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index dd41e01..4697457 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -16,7 +16,7 @@ import { CYCLE_REGISTRY } from "./registry.js"; // input; `main()` catches it and exits non-zero. // --------------------------------------------------------------------------- -import { parseReconcileArgs, CliError } from "../cli.js"; +import { parseReconcileArgs, parseReportArgs, CliError } from "../cli.js"; // --------------------------------------------------------------------------- // Arg parsing tests @@ -121,6 +121,55 @@ describe("parseReconcileArgs", () => { }); }); +// --------------------------------------------------------------------------- +// parseReportArgs +// --------------------------------------------------------------------------- + +describe("parseReportArgs", () => { + it("parses a basic report invocation with defaults", () => { + const args = parseReportArgs(["--config", "g.yml", "--token-env", "GH_TOKEN"]); + expect(args.config).toBe("g.yml"); + expect(args.tokenEnv).toBe("GH_TOKEN"); + expect(args.audit).toBe(false); + expect(args.failOn).toBe("none"); + expect(args.out).toBeUndefined(); + expect(args.cycles).toEqual([]); + }); + + it("parses --out, --audit, --cycles, and --fail-on attention", () => { + const args = parseReportArgs([ + "--config", "g.yml", + "--token-env", "GH_TOKEN", + "--out", "compliance.json", + "--audit", + "--cycles", "org-settings,membership", + "--fail-on", "attention", + ]); + expect(args.out).toBe("compliance.json"); + expect(args.audit).toBe(true); + expect(args.cycles).toEqual(["org-settings", "membership"]); + expect(args.failOn).toBe("attention"); + }); + + it("throws code 2 when auth is missing", () => { + expect(() => parseReportArgs(["--config", "g.yml"])).toThrow( + expect.objectContaining({ code: 2 }), + ); + }); + + it("throws code 2 for an invalid --fail-on value", () => { + expect(() => + parseReportArgs(["--config", "g.yml", "--token-env", "GH_TOKEN", "--fail-on", "bad"]), + ).toThrow(expect.objectContaining({ code: 2 })); + }); + + it("throws CliError for an unknown flag", () => { + expect(() => + parseReportArgs(["--config", "g.yml", "--token-env", "GH_TOKEN", "--nope"]), + ).toThrow(CliError); + }); +}); + // --------------------------------------------------------------------------- // Cycle registry tests // --------------------------------------------------------------------------- diff --git a/src/index.ts b/src/index.ts index 43e5d3b..c1a0613 100644 --- a/src/index.ts +++ b/src/index.ts @@ -109,3 +109,12 @@ export { dumpOrg, serializeToYaml } from "./reconcile/dump.js"; // Pipeline emitter: governance CI workflow generator export type { GovernancePipelineOptions, CycleFilter } from "./emit/pipeline.js"; export { governancePipeline } from "./emit/pipeline.js"; + +// Compliance reporting (aggregator) +export type { + ComplianceReport, + CycleComplianceEntry, + AuditCompliance, + ComplianceError, +} from "./report/compliance.js"; +export { buildComplianceReport, renderComplianceReport, complianceArtifact } from "./report/compliance.js"; diff --git a/src/report/compliance.test.ts b/src/report/compliance.test.ts new file mode 100644 index 0000000..9757b09 --- /dev/null +++ b/src/report/compliance.test.ts @@ -0,0 +1,215 @@ +/** + * Tests for the compliance reporting aggregator. + * + * Pure unit tests over mock ReconcileResult / PostureReport — no network. + */ + +import { describe, it, expect } from "vitest"; +import { + buildComplianceReport, + renderComplianceReport, + complianceArtifact, +} from "./compliance.js"; +import type { ReconcileResult, CycleResult } from "../reconcile/runner.js"; +import type { PostureReport } from "../audit/engine.js"; + +// --------------------------------------------------------------------------- +// Builders for mock run results +// --------------------------------------------------------------------------- + +function cycleResult(overrides: Partial = {}): CycleResult { + return { + name: "branch-protection", + org: "test-org", + counts: { create: 0, update: 0, delete: 0 }, + guardrails: { ok: true }, + applied: [], + failed: [], + plan: "Plan", + guardrailBlocked: false, + ...overrides, + }; +} + +function reconcileResult(overrides: Partial = {}): ReconcileResult { + return { + mode: "dry-run", + completed: true, + cycles: [], + errored: [], + deferred: { skippedCycles: [], skippedEntries: [] }, + budgetRemaining: 1000, + ...overrides, + }; +} + +function postureReport(totals: Partial = {}): PostureReport { + return { + repos: [], + totals: { quickWin: 0, needsReview: 0, reportOnly: 0, total: 0, ...totals }, + }; +} + +// --------------------------------------------------------------------------- +// buildComplianceReport +// --------------------------------------------------------------------------- + +describe("buildComplianceReport", () => { + it("reports clean when there is no drift, no audit findings, no failures", () => { + const report = buildComplianceReport([reconcileResult({ cycles: [cycleResult()] })]); + expect(report.clean).toBe(true); + expect(report.totals.drift).toBe(0); + expect(report.totals.cyclesReporting).toBe(1); + }); + + it("aggregates drift counts across cycles and orgs", () => { + const report = buildComplianceReport([ + reconcileResult({ + cycles: [ + cycleResult({ name: "org-settings", counts: { create: 1, update: 2, delete: 0 } }), + cycleResult({ name: "membership", org: "org-b", counts: { create: 0, update: 1, delete: 3 } }), + ], + }), + ]); + expect(report.totals.drift).toBe(7); + expect(report.cycles[0]!.drift).toEqual({ create: 1, update: 2, delete: 0, total: 3 }); + expect(report.cycles[1]!.drift.total).toBe(4); + expect(report.clean).toBe(false); + }); + + it("captures guardrail trips and blocks", () => { + const report = buildComplianceReport([ + reconcileResult({ + mode: "apply", + cycles: [ + cycleResult({ + counts: { create: 1, update: 0, delete: 0 }, + guardrails: { ok: false, diagnostics: [{ guardrail: "adminFloor", message: "m" }] }, + guardrailBlocked: true, + }), + ], + }), + ]); + expect(report.totals.guardrailTrips).toBe(1); + expect(report.totals.guardrailBlocked).toBe(1); + expect(report.cycles[0]!.guardrails.tripped).toEqual(["adminFloor"]); + expect(report.modes).toEqual(["apply"]); + }); + + it("tallies applied and failed entries", () => { + const report = buildComplianceReport([ + reconcileResult({ + mode: "apply", + cycles: [ + cycleResult({ + counts: { create: 2, update: 0, delete: 0 }, + applied: [{ kind: "create", resourceType: "member", key: "a" }], + failed: [{ entry: { kind: "create", resourceType: "member", key: "b" }, error: "boom" }], + }), + ], + }), + ]); + expect(report.totals.applied).toBe(1); + expect(report.totals.failed).toBe(1); + expect(report.clean).toBe(false); + }); + + it("collects errored and deferred cycles", () => { + const report = buildComplianceReport([ + reconcileResult({ + completed: false, + errored: [{ name: "teams", org: "test-org", stage: "fetchLive", error: "rate limited" }], + deferred: { skippedCycles: ["rulesets@test-org"], skippedEntries: [] }, + }), + ]); + expect(report.errored).toHaveLength(1); + expect(report.deferred).toEqual(["rulesets@test-org"]); + expect(report.clean).toBe(false); + }); + + it("folds in the audit report and computes merge-worthy", () => { + const report = buildComplianceReport( + [reconcileResult({ cycles: [cycleResult()] })], + postureReport({ quickWin: 2, needsReview: 1, reportOnly: 5, total: 8 }), + ); + expect(report.audit).toEqual({ + total: 8, + quickWin: 2, + needsReview: 1, + reportOnly: 5, + mergeWorthy: 3, + }); + expect(report.totals.auditMergeWorthy).toBe(3); + expect(report.clean).toBe(false); // merge-worthy findings present + }); + + it("stays clean when audit has only report-only findings", () => { + const report = buildComplianceReport( + [reconcileResult({ cycles: [cycleResult()] })], + postureReport({ reportOnly: 4, total: 4 }), + ); + expect(report.totals.auditMergeWorthy).toBe(0); + expect(report.clean).toBe(true); + }); + + it("merges multiple reconcile results and dedupes modes", () => { + const report = buildComplianceReport([ + reconcileResult({ mode: "dry-run", cycles: [cycleResult()] }), + reconcileResult({ mode: "dry-run", cycles: [cycleResult({ name: "teams" })] }), + ]); + expect(report.modes).toEqual(["dry-run"]); + expect(report.totals.cyclesReporting).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// renderComplianceReport / complianceArtifact +// --------------------------------------------------------------------------- + +describe("renderComplianceReport", () => { + it("renders a clean report with CLEAN status", () => { + const out = renderComplianceReport(buildComplianceReport([reconcileResult({ cycles: [cycleResult()] })])); + expect(out).toContain("=== compliance report ==="); + expect(out).toContain("status: CLEAN"); + }); + + it("flags attention and shows guardrail blocks + audit", () => { + const report = buildComplianceReport( + [ + reconcileResult({ + mode: "apply", + cycles: [ + cycleResult({ + counts: { create: 0, update: 0, delete: 4 }, + guardrails: { ok: false, diagnostics: [{ guardrail: "removalDeltaCap", message: "m" }] }, + guardrailBlocked: true, + }), + ], + }), + ], + postureReport({ quickWin: 1, total: 1 }), + ); + const out = renderComplianceReport({ ...report, generatedAt: "2026-06-19T00:00:00Z" }); + expect(out).toContain("generated: 2026-06-19T00:00:00Z"); + expect(out).toContain("GUARDRAIL-BLOCKED[removalDeltaCap]"); + expect(out).toContain("--- audit ---"); + expect(out).toContain("status: ATTENTION NEEDED"); + }); + + it("handles an empty result set", () => { + const out = renderComplianceReport(buildComplianceReport([])); + expect(out).toContain("(no cycle results)"); + expect(out).toContain("status: CLEAN"); + }); +}); + +describe("complianceArtifact", () => { + it("produces stable, parseable JSON ending in a newline", () => { + const report = buildComplianceReport([reconcileResult({ cycles: [cycleResult()] })]); + const json = complianceArtifact(report); + expect(json.endsWith("\n")).toBe(true); + const parsed = JSON.parse(json); + expect(parsed.totals.cyclesReporting).toBe(1); + expect(parsed.clean).toBe(true); + }); +}); diff --git a/src/report/compliance.ts b/src/report/compliance.ts new file mode 100644 index 0000000..d04c0b8 --- /dev/null +++ b/src/report/compliance.ts @@ -0,0 +1,259 @@ +/** + * Compliance reporting (aggregator). + * + * Produces a unified posture snapshot across ALL cycles plus the audit engine. + * Unlike a reconcile cycle, this does NOT touch GitHub — it is a pure, + * detect-and-report pass over the STRUCTURED RESULTS the other cycles already + * produced (`ReconcileResult` from `reconcile/runner.ts`) and, optionally, the + * audit report (`PostureReport` from `audit/engine.ts`). + * + * The output is rendered for stdout / a check-run summary + * (`renderComplianceReport`) and serialized as a committable JSON artifact + * (`complianceArtifact`). + * + * Pure and deterministic: no I/O, no clock. The caller stamps `generatedAt`. + */ + +import type { ReconcileResult } from "../reconcile/runner.js"; +import type { PostureReport } from "../audit/engine.js"; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** Per-cycle (per-org) compliance line. */ +export interface CycleComplianceEntry { + cycle: string; + org: string; + /** Change-set counts (drift detected). */ + drift: { create: number; update: number; delete: number; total: number }; + /** Guardrail status for this cycle's apply. */ + guardrails: { ok: boolean; blocked: boolean; tripped: string[] }; + /** Entries applied (apply mode only). */ + applied: number; + /** Entries that failed to apply. */ + failed: number; +} + +/** Aggregated audit totals (carried through from the audit engine). */ +export interface AuditCompliance { + total: number; + quickWin: number; + needsReview: number; + reportOnly: number; + /** Quick-win + needs-review (the "merge-worthy" tier). */ + mergeWorthy: number; +} + +/** A cycle that errored during fetchLive/buildDesired. */ +export interface ComplianceError { + cycle: string; + org: string; + stage: string; + error: string; +} + +/** The unified compliance snapshot. */ +export interface ComplianceReport { + /** ISO timestamp, stamped by the caller (the aggregator is clock-free). */ + generatedAt?: string; + /** Distinct run modes observed across the inputs. */ + modes: Array<"dry-run" | "apply">; + /** Per-cycle compliance lines. */ + cycles: CycleComplianceEntry[]; + /** Audit totals, when an audit report was supplied. */ + audit?: AuditCompliance; + /** Cross-cutting roll-ups. */ + totals: { + /** Total change-set entries across all cycles (total drift). */ + drift: number; + /** Cycles whose guardrails tripped. */ + guardrailTrips: number; + /** Cycles whose apply was blocked by guardrails. */ + guardrailBlocked: number; + /** Entries applied across all cycles. */ + applied: number; + /** Entries that failed to apply across all cycles. */ + failed: number; + /** Number of cycle/org results aggregated. */ + cyclesReporting: number; + /** Audit merge-worthy findings (0 when no audit supplied). */ + auditMergeWorthy: number; + }; + /** Cycles that errored (fetchLive/buildDesired). */ + errored: ComplianceError[]; + /** Cycles deferred due to budget exhaustion (by "@"). */ + deferred: string[]; + /** + * True when nothing needs attention: no drift, no guardrail trips, no + * failures, no errors, no deferrals, and no merge-worthy audit findings. + */ + clean: boolean; +} + +// --------------------------------------------------------------------------- +// buildComplianceReport +// --------------------------------------------------------------------------- + +/** + * Aggregate one or more reconcile results (and an optional audit report) into a + * single compliance snapshot. Pass the results from each `runReconcile` call + * (e.g. one per cycle group, or a single all-cycles run). + */ +export function buildComplianceReport( + results: ReconcileResult[], + audit?: PostureReport, +): ComplianceReport { + const cycles: CycleComplianceEntry[] = []; + const errored: ComplianceError[] = []; + const deferred: string[] = []; + const modeSet = new Set<"dry-run" | "apply">(); + + let drift = 0; + let guardrailTrips = 0; + let guardrailBlocked = 0; + let applied = 0; + let failed = 0; + + for (const result of results) { + modeSet.add(result.mode); + + for (const cr of result.cycles) { + const total = cr.counts.create + cr.counts.update + cr.counts.delete; + const tripped = cr.guardrails.ok ? [] : cr.guardrails.diagnostics.map((d) => d.guardrail); + + cycles.push({ + cycle: cr.name, + org: cr.org, + drift: { ...cr.counts, total }, + guardrails: { ok: cr.guardrails.ok, blocked: cr.guardrailBlocked, tripped }, + applied: cr.applied.length, + failed: cr.failed.length, + }); + + drift += total; + if (!cr.guardrails.ok) guardrailTrips++; + if (cr.guardrailBlocked) guardrailBlocked++; + applied += cr.applied.length; + failed += cr.failed.length; + } + + for (const ce of result.errored) { + errored.push({ cycle: ce.name, org: ce.org, stage: ce.stage, error: ce.error }); + } + deferred.push(...result.deferred.skippedCycles); + } + + let auditCompliance: AuditCompliance | undefined; + let auditMergeWorthy = 0; + if (audit) { + auditMergeWorthy = audit.totals.quickWin + audit.totals.needsReview; + auditCompliance = { + total: audit.totals.total, + quickWin: audit.totals.quickWin, + needsReview: audit.totals.needsReview, + reportOnly: audit.totals.reportOnly, + mergeWorthy: auditMergeWorthy, + }; + } + + const clean = + drift === 0 && + guardrailTrips === 0 && + failed === 0 && + errored.length === 0 && + deferred.length === 0 && + auditMergeWorthy === 0; + + return { + modes: [...modeSet], + cycles, + audit: auditCompliance, + totals: { + drift, + guardrailTrips, + guardrailBlocked, + applied, + failed, + cyclesReporting: cycles.length, + auditMergeWorthy, + }, + errored, + deferred, + clean, + }; +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +/** + * Render the compliance report to a human-readable string for stdout or a + * check-run summary. Mirrors the audit summary's plain layout. + */ +export function renderComplianceReport(report: ComplianceReport): string { + const lines: string[] = []; + + lines.push("=== compliance report ==="); + if (report.generatedAt) lines.push(`generated: ${report.generatedAt}`); + if (report.modes.length > 0) lines.push(`modes: ${report.modes.join(", ")}`); + lines.push(""); + + if (report.cycles.length === 0) { + lines.push(" (no cycle results)"); + } else { + for (const c of report.cycles) { + const d = c.drift; + let line = + ` ${c.cycle}@${c.org}` + + ` drift=${d.total} (c${d.create}/u${d.update}/d${d.delete})` + + ` applied=${c.applied} failed=${c.failed}`; + if (c.guardrails.blocked) line += ` GUARDRAIL-BLOCKED[${c.guardrails.tripped.join(",")}]`; + else if (!c.guardrails.ok) line += ` guardrail-trip[${c.guardrails.tripped.join(",")}]`; + lines.push(line); + } + } + + if (report.errored.length > 0) { + lines.push(""); + lines.push("--- errored cycles ---"); + for (const e of report.errored) { + lines.push(` ${e.cycle}@${e.org} (${e.stage}): ${e.error}`); + } + } + + if (report.deferred.length > 0) { + lines.push(""); + lines.push(`--- deferred (budget) ---`); + lines.push(` ${report.deferred.join(", ")}`); + } + + if (report.audit) { + lines.push(""); + lines.push("--- audit ---"); + lines.push( + ` total=${report.audit.total} merge-worthy=${report.audit.mergeWorthy}` + + ` (quick-win=${report.audit.quickWin}, needs-review=${report.audit.needsReview}, report-only=${report.audit.reportOnly})`, + ); + } + + lines.push(""); + lines.push("--- totals ---"); + const t = report.totals; + lines.push(` drift=${t.drift} applied=${t.applied} failed=${t.failed}`); + lines.push(` guardrail-trips=${t.guardrailTrips} guardrail-blocked=${t.guardrailBlocked}`); + lines.push(` cycles-reporting=${t.cyclesReporting} audit-merge-worthy=${t.auditMergeWorthy}`); + lines.push(` status: ${report.clean ? "CLEAN" : "ATTENTION NEEDED"}`); + lines.push(""); + + return lines.join("\n"); +} + +/** + * Serialize the report as a committable JSON artifact (stable two-space + * indentation, deterministic key order from the object literal). + */ +export function complianceArtifact(report: ComplianceReport): string { + return `${JSON.stringify(report, null, 2)}\n`; +}