diff --git a/bin/pushgate.mjs b/bin/pushgate.mjs index 8c7f9c5..6bf96c1 100755 --- a/bin/pushgate.mjs +++ b/bin/pushgate.mjs @@ -12579,13 +12579,13 @@ var $ZodType = /* @__PURE__ */ $constructor("$ZodType", (inst, def) => { canary.aborted = true; return canary; } - const checkResult = runChecks(payload, checks, ctx); - if (checkResult instanceof Promise) { + const checkResult2 = runChecks(payload, checks, ctx); + if (checkResult2 instanceof Promise) { if (ctx.async === false) throw new $ZodAsyncError(); - return checkResult.then((checkResult2) => inst._zod.parse(checkResult2, ctx)); + return checkResult2.then((checkResult3) => inst._zod.parse(checkResult3, ctx)); } - return inst._zod.parse(checkResult, ctx); + return inst._zod.parse(checkResult2, ctx); }; inst._zod.run = (payload, ctx) => { if (ctx.skipChecks) { @@ -26908,86 +26908,6 @@ function transcriptEventsForChangedFileGuardrail(decision) { ]; } -// src/runner/policies.ts -var import_ignore2 = __toESM(require_ignore(), 1); -var FORBIDDEN_PATH_DETAIL_LIMIT = 5; -function countBuiltInPolicies(policies) { - return Number(Boolean(policies.diff_size)) + Number(Boolean(policies.forbidden_paths)); -} -function runBuiltInPolicies(policies, changedFiles) { - const results = []; - if (policies.diff_size) { - results.push(runDiffSizePolicy(policies.diff_size, changedFiles)); - } - if (policies.forbidden_paths) { - results.push( - runForbiddenPathsPolicy(policies.forbidden_paths, changedFiles) - ); - } - return results; -} -function runDiffSizePolicy(policy, changedFiles) { - const changedLines = changedFiles.reduce((total, file2) => { - return total + (file2.additions ?? 0) + (file2.deletions ?? 0); - }, 0); - if (changedLines <= policy.max_changed_lines) { - return { - name: "policy:diff_size", - status: "passed", - detail: `${String(changedLines)} changed line(s) within max_changed_lines ${String(policy.max_changed_lines)}` - }; - } - return violationResult( - policy.mode, - "policy:diff_size", - [ - `${String(changedLines)} changed line(s) exceed max_changed_lines`, - `${String(policy.max_changed_lines)}; split the push or raise`, - "policies.diff_size.max_changed_lines if this is intentional" - ].join(" ") - ); -} -function runForbiddenPathsPolicy(policy, changedFiles) { - const matches = changedFiles.filter((file2) => file2.status !== "deleted").flatMap((file2) => { - const pattern = firstMatchingPattern(policy.patterns, file2.path); - return pattern ? [{ path: file2.path, pattern }] : []; - }); - if (matches.length === 0) { - return { - name: "policy:forbidden_paths", - status: "passed", - detail: "no changed live paths match forbidden patterns" - }; - } - return violationResult( - policy.mode, - "policy:forbidden_paths", - [ - `${String(matches.length)} changed path(s) match forbidden patterns:`, - `${formatForbiddenPathMatches(matches)}; remove them from the push`, - "or update policies.forbidden_paths.patterns if this is intentional" - ].join(" ") - ); -} -function firstMatchingPattern(patterns, path) { - return patterns.find((pattern) => (0, import_ignore2.default)().add(pattern).ignores(path)); -} -function formatForbiddenPathMatches(matches) { - const formatted = matches.slice(0, FORBIDDEN_PATH_DETAIL_LIMIT).map((match) => `${match.path} (${match.pattern})`); - const remaining = matches.length - formatted.length; - if (remaining > 0) { - formatted.push(`${String(remaining)} more`); - } - return formatted.join(", "); -} -function violationResult(mode, name, detail) { - return { - detail, - name, - status: mode === "warning" ? "warning" : "blocked" - }; -} - // src/runner/plugins/gitleaks.ts import { mkdtemp, readFile as readFile3, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -27141,6 +27061,272 @@ function numberValue(value) { return typeof value === "number" && Number.isFinite(value) ? value : void 0; } +// src/runner/policies.ts +var import_ignore2 = __toESM(require_ignore(), 1); +var FORBIDDEN_PATH_DETAIL_LIMIT = 5; +function runBuiltInPolicies(policies, changedFiles) { + const results = []; + if (policies.diff_size) { + results.push(runDiffSizePolicy(policies.diff_size, changedFiles)); + } + if (policies.forbidden_paths) { + results.push( + runForbiddenPathsPolicy(policies.forbidden_paths, changedFiles) + ); + } + return results; +} +function runDiffSizePolicy(policy, changedFiles) { + const changedLines = changedFiles.reduce((total, file2) => { + return total + (file2.additions ?? 0) + (file2.deletions ?? 0); + }, 0); + if (changedLines <= policy.max_changed_lines) { + return { + name: "policy:diff_size", + status: "passed", + detail: `${String(changedLines)} changed line(s) within max_changed_lines ${String(policy.max_changed_lines)}` + }; + } + return violationResult( + policy.mode, + "policy:diff_size", + [ + `${String(changedLines)} changed line(s) exceed max_changed_lines`, + `${String(policy.max_changed_lines)}; split the push or raise`, + "policies.diff_size.max_changed_lines if this is intentional" + ].join(" ") + ); +} +function runForbiddenPathsPolicy(policy, changedFiles) { + const matches = changedFiles.filter((file2) => file2.status !== "deleted").flatMap((file2) => { + const pattern = firstMatchingPattern(policy.patterns, file2.path); + return pattern ? [{ path: file2.path, pattern }] : []; + }); + if (matches.length === 0) { + return { + name: "policy:forbidden_paths", + status: "passed", + detail: "no changed live paths match forbidden patterns" + }; + } + return violationResult( + policy.mode, + "policy:forbidden_paths", + [ + `${String(matches.length)} changed path(s) match forbidden patterns:`, + `${formatForbiddenPathMatches(matches)}; remove them from the push`, + "or update policies.forbidden_paths.patterns if this is intentional" + ].join(" ") + ); +} +function firstMatchingPattern(patterns, path) { + return patterns.find((pattern) => (0, import_ignore2.default)().add(pattern).ignores(path)); +} +function formatForbiddenPathMatches(matches) { + const formatted = matches.slice(0, FORBIDDEN_PATH_DETAIL_LIMIT).map((match) => `${match.path} (${match.pattern})`); + const remaining = matches.length - formatted.length; + if (remaining > 0) { + formatted.push(`${String(remaining)} more`); + } + return formatted.join(", "); +} +function violationResult(mode, name, detail) { + return { + detail, + name, + status: mode === "warning" ? "warning" : "blocked" + }; +} + +// src/runner/tool-command.ts +var CHANGED_FILES_TOKEN = "{changed_files}"; +async function runToolCommand(tool, changedFilePaths, repoRoot, env) { + const command = expandChangedFilesToken(tool.command, changedFilePaths); + const [executable, ...args] = command; + if (!executable) { + return { + passed: false, + detail: "command was empty" + }; + } + const commandResult = await runProcessOutcome({ + args, + command: executable, + cwd: repoRoot, + env: sanitizeGitLocalEnv(env), + timeoutSeconds: tool.timeout_seconds + }); + if (commandResult.kind === "passed") { + return { passed: true }; + } + return { + passed: false, + detail: formatProcessFailure(commandResult.failure), + outputTail: commandResult.outputTail + }; +} +function expandChangedFilesToken(command, changedFilePaths) { + return command.flatMap( + (token) => token === CHANGED_FILES_TOKEN ? [...changedFilePaths] : [token] + ); +} + +// src/runner/deterministic-plan.ts +function buildDeterministicCheckRunPlan(config2) { + return [ + ...buildBuiltInPolicyEntries(config2.policies), + ...buildPluginEntries(config2), + ...config2.tools.map(buildConfiguredToolEntry) + ]; +} +function buildBuiltInPolicyEntries(policies) { + const entries = []; + if (policies.diff_size) { + entries.push( + buildBuiltInPolicyEntry({ + label: "Diff size", + policies: { + diff_size: policies.diff_size + }, + resultName: "policy:diff_size", + transformDetail: formatDiffSizeDisplayDetail + }) + ); + } + if (policies.forbidden_paths) { + entries.push( + buildBuiltInPolicyEntry({ + label: "Forbidden paths", + policies: { + forbidden_paths: policies.forbidden_paths + }, + resultName: "policy:forbidden_paths" + }) + ); + } + return entries; +} +function buildBuiltInPolicyEntry(options) { + return { + failFast: false, + async run(context) { + const result = runBuiltInPolicies( + options.policies, + context.changedFileResolution.files + )[0]; + if (!result) { + throw new Error( + `Built-In Policy ${options.resultName} did not produce a result.` + ); + } + return { + result, + transcriptResult: { + detail: options.transformDetail ? options.transformDetail(result.detail) : result.detail, + label: options.label, + status: result.status + } + }; + } + }; +} +function buildPluginEntries(config2) { + const entries = []; + if (config2.plugins.gitleaks?.enabled) { + entries.push(buildGitleaksPluginEntry(config2.plugins.gitleaks)); + } + return entries; +} +function buildGitleaksPluginEntry(plugin) { + return { + failFast: plugin.fail_fast, + async run(context) { + const name = "plugin:gitleaks"; + const commandResult = await runGitleaksPlugin( + plugin, + context.changedFileResolution, + context.repoRoot, + context.env + ); + const result = commandResult.passed ? { name, status: "passed" } : { + name, + status: modeToStatus(plugin.mode), + detail: commandResult.detail, + outputTail: commandResult.outputTail + }; + return { + result, + transcriptResult: { + detail: result.detail ? result.detail : "gitleaks", + label: "Secrets scan", + outputTail: result.outputTail, + status: result.status + } + }; + } + }; +} +function buildConfiguredToolEntry(tool) { + return { + failFast: tool.fail_fast, + async run(context) { + const selectedPaths = selectToolChangedFilePaths( + context.changedFileResolution.files, + tool.extensions + ); + if (tool.run === "changed_files" && selectedPaths.length === 0) { + return checkResult({ + label: humanizeIdentifier(tool.name), + result: { + name: tool.name, + status: "skipped", + detail: "no matching changed files" + } + }); + } + const commandResult = await runToolCommand( + tool, + selectedPaths, + context.repoRoot, + context.env + ); + const result = commandResult.passed ? { name: tool.name, status: "passed" } : { + name: tool.name, + status: modeToStatus(tool.mode), + detail: commandResult.detail, + outputTail: commandResult.outputTail + }; + return checkResult({ + label: humanizeIdentifier(tool.name), + result + }); + } + }; +} +function checkResult(options) { + return { + result: options.result, + transcriptResult: { + detail: options.result.detail, + label: options.label, + outputTail: options.result.outputTail, + status: options.result.status + } + }; +} +function modeToStatus(mode) { + return mode === "warning" ? "warning" : "blocked"; +} +function formatDiffSizeDisplayDetail(detail) { + if (!detail) { + return void 0; + } + const passed = detail.match( + /^(\d+) changed line\(s\) within max_changed_lines (\d+)$/ + ); + return passed ? `${passed[1]} / ${passed[2]} changed lines` : detail; +} + // src/runner/summary.ts function summarizeDeterministicResults(results) { const blockedCount = results.filter((result) => result.status === "blocked").length; @@ -27165,11 +27351,8 @@ function createDeterministicTranscript(stdout) { writeResultRow(stdout, "skipped", "No checks configured"); writeLine(stdout); }, - writePolicyResult(result) { - writeCheckResult(result.name, result); - }, - writePluginResult(name, result) { - writeCheckResult(name, result); + writeCheckResult(result) { + writeRenderedCheckResult(result); }, writeStart(checkCount) { writeSection(stdout, "Checks"); @@ -27209,54 +27392,23 @@ function createDeterministicTranscript(stdout) { } writeLine(stdout, "Checks passed."); writeLine(stdout); - }, - writeToolResult(tool, result) { - writeCheckResult(tool.name, result); } }; - function writeCheckResult(name, result) { - const display = displayCheck(name); - const detail = formatDetail(name, result.detail); + function writeRenderedCheckResult(result) { const status = mapStatus(result.status); - writeResultRow(stdout, status, display.label, detail ?? display.detail); + writeResultRow(stdout, status, result.label, result.detail); if (result.outputTail) { writeDetail(stdout, "Command output:"); writeIndentedBlock(stdout, result.outputTail.split("\n")); } if (result.status === "warning") { - warnings.push(display.label); + warnings.push(result.label); } if (result.status === "blocked") { - blockers.push(display.label); + blockers.push(result.label); } } } -function displayCheck(name) { - if (name === "policy:diff_size") { - return { label: "Diff size" }; - } - if (name === "policy:forbidden_paths") { - return { label: "Forbidden paths" }; - } - if (name === "plugin:gitleaks") { - return { detail: "gitleaks", label: "Secrets scan" }; - } - return { label: humanizeIdentifier(name) }; -} -function formatDetail(name, detail) { - if (!detail) { - return void 0; - } - if (name === "policy:diff_size") { - const passed = detail.match( - /^(\d+) changed line\(s\) within max_changed_lines (\d+)$/ - ); - if (passed) { - return `${passed[1]} / ${passed[2]} changed lines`; - } - } - return detail; -} function mapStatus(status) { const statusByResult = { blocked: "blocked", @@ -27267,42 +27419,9 @@ function mapStatus(status) { return statusByResult[status]; } -// src/runner/tool-command.ts -var CHANGED_FILES_TOKEN = "{changed_files}"; -async function runToolCommand(tool, changedFilePaths, repoRoot, env) { - const command = expandChangedFilesToken(tool.command, changedFilePaths); - const [executable, ...args] = command; - if (!executable) { - return { - passed: false, - detail: "command was empty" - }; - } - const commandResult = await runProcessOutcome({ - args, - command: executable, - cwd: repoRoot, - env: sanitizeGitLocalEnv(env), - timeoutSeconds: tool.timeout_seconds - }); - if (commandResult.kind === "passed") { - return { passed: true }; - } - return { - passed: false, - detail: formatProcessFailure(commandResult.failure), - outputTail: commandResult.outputTail - }; -} -function expandChangedFilesToken(command, changedFilePaths) { - return command.flatMap( - (token) => token === CHANGED_FILES_TOKEN ? [...changedFilePaths] : [token] - ); -} - // src/runner/deterministic.ts function buildDeterministicCheckPlan(config2) { - const checkCount = countBuiltInPolicies(config2.policies) + countPluginChecks(config2) + config2.tools.length; + const checkCount = buildDeterministicCheckRunPlan(config2).length; return { checkCount, needsChangedFileResolution: checkCount > 0, @@ -27316,95 +27435,24 @@ async function runDeterministicChecks(request) { const env = request.env ?? process.env; const results = []; const transcript = createDeterministicTranscript(stdout); - const plan = buildDeterministicCheckPlan(config2); - let stopAfterBlockingPlugin = false; - if (!plan.runChecks) { + const runPlan = buildDeterministicCheckRunPlan(config2); + if (runPlan.length === 0) { transcript.writeNoChecks(); return { exitCode: 0, results }; } const changedFileResolution = requireChangedFileResolution( request.changedFileResolution ); - const changedFiles = changedFileResolution.files; - transcript.writeStart(plan.checkCount); - for (const policyResult of runBuiltInPolicies( - config2.policies, - changedFiles - )) { - results.push(policyResult); - transcript.writePolicyResult(policyResult); - } - if (config2.plugins.gitleaks?.enabled) { - const plugin = config2.plugins.gitleaks; - const name = "plugin:gitleaks"; - const commandResult = await runGitleaksPlugin( - plugin, + transcript.writeStart(runPlan.length); + for (const entry of runPlan) { + const entryResult = await entry.run({ changedFileResolution, - repoRoot, - env - ); - if (commandResult.passed) { - const result = { name, status: "passed" }; - results.push(result); - transcript.writePluginResult(name, result); - } else { - const status = plugin.mode === "warning" ? "warning" : "blocked"; - const result = { - name, - status, - detail: commandResult.detail, - outputTail: commandResult.outputTail - }; - results.push(result); - transcript.writePluginResult(name, result); - if (status === "blocked" && plugin.fail_fast) { - transcript.writeFailFast(); - stopAfterBlockingPlugin = true; - } - } - } - if (stopAfterBlockingPlugin) { - const resultSummary2 = summarizeDeterministicResults(results); - transcript.writeSummary(resultSummary2); - return { exitCode: resultSummary2.exitCode, results }; - } - for (const tool of config2.tools) { - const selectedPaths = selectToolChangedFilePaths( - changedFiles, - tool.extensions - ); - if (tool.run === "changed_files" && selectedPaths.length === 0) { - const result2 = { - name: tool.name, - status: "skipped", - detail: "no matching changed files" - }; - results.push(result2); - transcript.writeToolResult(tool, result2); - continue; - } - const commandResult = await runToolCommand( - tool, - selectedPaths, - repoRoot, - env - ); - if (commandResult.passed) { - const result2 = { name: tool.name, status: "passed" }; - results.push(result2); - transcript.writeToolResult(tool, result2); - continue; - } - const status = tool.mode === "warning" ? "warning" : "blocked"; - const result = { - name: tool.name, - status, - detail: commandResult.detail, - outputTail: commandResult.outputTail - }; - results.push(result); - transcript.writeToolResult(tool, result); - if (status === "blocked" && tool.fail_fast) { + env, + repoRoot + }); + results.push(entryResult.result); + transcript.writeCheckResult(entryResult.transcriptResult); + if (entryResult.result.status === "blocked" && entry.failFast) { transcript.writeFailFast(); break; } @@ -27413,9 +27461,6 @@ async function runDeterministicChecks(request) { transcript.writeSummary(resultSummary); return { exitCode: resultSummary.exitCode, results }; } -function countPluginChecks(config2) { - return Number(Boolean(config2.plugins.gitleaks?.enabled)); -} function requireChangedFileResolution(changedFileResolution) { if (changedFileResolution) { return changedFileResolution; diff --git a/src/runner/deterministic-plan.ts b/src/runner/deterministic-plan.ts new file mode 100644 index 0000000..ce496d9 --- /dev/null +++ b/src/runner/deterministic-plan.ts @@ -0,0 +1,232 @@ +import type { + BuiltInPoliciesConfig, + GitleaksPluginConfig, + PushgateConfig, + ToolConfig, +} from "../config/index.js"; +import { + selectToolChangedFilePaths, + type ChangedFileResolution, +} from "../path-policy/index.js"; +import { humanizeIdentifier } from "../terminal/format.js"; +import type { ToolResult, ToolResultStatus } from "./deterministic.js"; +import { runGitleaksPlugin } from "./plugins/gitleaks.js"; +import { runBuiltInPolicies } from "./policies.js"; +import type { DeterministicTranscriptCheckResult } from "./transcript.js"; +import { runToolCommand } from "./tool-command.js"; + +export interface DeterministicCheckExecutionContext { + changedFileResolution: ChangedFileResolution; + env: NodeJS.ProcessEnv; + repoRoot: string; +} + +export interface DeterministicCheckRunResult { + result: ToolResult; + transcriptResult: DeterministicTranscriptCheckResult; +} + +export interface DeterministicCheckPlanEntry { + failFast: boolean; + run( + context: DeterministicCheckExecutionContext, + ): Promise; +} + +export function buildDeterministicCheckRunPlan( + config: PushgateConfig, +): DeterministicCheckPlanEntry[] { + return [ + ...buildBuiltInPolicyEntries(config.policies), + ...buildPluginEntries(config), + ...config.tools.map(buildConfiguredToolEntry), + ]; +} + +function buildBuiltInPolicyEntries( + policies: BuiltInPoliciesConfig, +): DeterministicCheckPlanEntry[] { + const entries: DeterministicCheckPlanEntry[] = []; + + if (policies.diff_size) { + entries.push( + buildBuiltInPolicyEntry({ + label: "Diff size", + policies: { + diff_size: policies.diff_size, + }, + resultName: "policy:diff_size", + transformDetail: formatDiffSizeDisplayDetail, + }), + ); + } + + if (policies.forbidden_paths) { + entries.push( + buildBuiltInPolicyEntry({ + label: "Forbidden paths", + policies: { + forbidden_paths: policies.forbidden_paths, + }, + resultName: "policy:forbidden_paths", + }), + ); + } + + return entries; +} + +function buildBuiltInPolicyEntry(options: { + label: string; + policies: BuiltInPoliciesConfig; + resultName: string; + transformDetail?: (detail: string | undefined) => string | undefined; +}): DeterministicCheckPlanEntry { + return { + failFast: false, + async run(context) { + const result = runBuiltInPolicies( + options.policies, + context.changedFileResolution.files, + )[0]; + + if (!result) { + throw new Error( + `Built-In Policy ${options.resultName} did not produce a result.`, + ); + } + + return { + result, + transcriptResult: { + detail: options.transformDetail + ? options.transformDetail(result.detail) + : result.detail, + label: options.label, + status: result.status, + }, + }; + }, + }; +} + +function buildPluginEntries(config: PushgateConfig): DeterministicCheckPlanEntry[] { + const entries: DeterministicCheckPlanEntry[] = []; + + if (config.plugins.gitleaks?.enabled) { + entries.push(buildGitleaksPluginEntry(config.plugins.gitleaks)); + } + + return entries; +} + +function buildGitleaksPluginEntry( + plugin: GitleaksPluginConfig, +): DeterministicCheckPlanEntry { + return { + failFast: plugin.fail_fast, + async run(context) { + const name = "plugin:gitleaks"; + const commandResult = await runGitleaksPlugin( + plugin, + context.changedFileResolution, + context.repoRoot, + context.env, + ); + const result: ToolResult = commandResult.passed + ? { name, status: "passed" } + : { + name, + status: modeToStatus(plugin.mode), + detail: commandResult.detail, + outputTail: commandResult.outputTail, + }; + + return { + result, + transcriptResult: { + detail: result.detail ? result.detail : "gitleaks", + label: "Secrets scan", + outputTail: result.outputTail, + status: result.status, + }, + }; + }, + }; +} + +function buildConfiguredToolEntry(tool: ToolConfig): DeterministicCheckPlanEntry { + return { + failFast: tool.fail_fast, + async run(context) { + const selectedPaths = selectToolChangedFilePaths( + context.changedFileResolution.files, + tool.extensions, + ); + + if (tool.run === "changed_files" && selectedPaths.length === 0) { + return checkResult({ + label: humanizeIdentifier(tool.name), + result: { + name: tool.name, + status: "skipped", + detail: "no matching changed files", + }, + }); + } + + const commandResult = await runToolCommand( + tool, + selectedPaths, + context.repoRoot, + context.env, + ); + const result: ToolResult = commandResult.passed + ? { name: tool.name, status: "passed" } + : { + name: tool.name, + status: modeToStatus(tool.mode), + detail: commandResult.detail, + outputTail: commandResult.outputTail, + }; + + return checkResult({ + label: humanizeIdentifier(tool.name), + result, + }); + }, + }; +} + +function checkResult(options: { + label: string; + result: ToolResult; +}): DeterministicCheckRunResult { + return { + result: options.result, + transcriptResult: { + detail: options.result.detail, + label: options.label, + outputTail: options.result.outputTail, + status: options.result.status, + }, + }; +} + +function modeToStatus(mode: "blocking" | "warning"): ToolResultStatus { + return mode === "warning" ? "warning" : "blocked"; +} + +function formatDiffSizeDisplayDetail( + detail: string | undefined, +): string | undefined { + if (!detail) { + return undefined; + } + + const passed = detail.match( + /^(\d+) changed line\(s\) within max_changed_lines (\d+)$/, + ); + + return passed ? `${passed[1]} / ${passed[2]} changed lines` : detail; +} diff --git a/src/runner/deterministic.ts b/src/runner/deterministic.ts index 84b620e..22e9496 100644 --- a/src/runner/deterministic.ts +++ b/src/runner/deterministic.ts @@ -1,16 +1,8 @@ import type { PushgateConfig } from "../config/index.js"; -import { - selectToolChangedFilePaths, - type ChangedFileResolution, -} from "../path-policy/index.js"; -import { - countBuiltInPolicies, - runBuiltInPolicies, -} from "./policies.js"; -import { runGitleaksPlugin } from "./plugins/gitleaks.js"; +import type { ChangedFileResolution } from "../path-policy/index.js"; +import { buildDeterministicCheckRunPlan } from "./deterministic-plan.js"; import { summarizeDeterministicResults } from "./summary.js"; import { createDeterministicTranscript } from "./transcript.js"; -import { runToolCommand } from "./tool-command.js"; export { CHANGED_FILES_TOKEN, @@ -48,10 +40,7 @@ export interface DeterministicCheckRequest { export function buildDeterministicCheckPlan( config: PushgateConfig, ): DeterministicCheckPlan { - const checkCount = - countBuiltInPolicies(config.policies) + - countPluginChecks(config) + - config.tools.length; + const checkCount = buildDeterministicCheckRunPlan(config).length; return { checkCount, @@ -69,10 +58,9 @@ export async function runDeterministicChecks( const env = request.env ?? process.env; const results: ToolResult[] = []; const transcript = createDeterministicTranscript(stdout); - const plan = buildDeterministicCheckPlan(config); - let stopAfterBlockingPlugin = false; + const runPlan = buildDeterministicCheckRunPlan(config); - if (!plan.runChecks) { + if (runPlan.length === 0) { transcript.writeNoChecks(); return { exitCode: 0, results }; } @@ -80,106 +68,20 @@ export async function runDeterministicChecks( const changedFileResolution = requireChangedFileResolution( request.changedFileResolution, ); - const changedFiles = changedFileResolution.files; - transcript.writeStart(plan.checkCount); + transcript.writeStart(runPlan.length); - for (const policyResult of runBuiltInPolicies( - config.policies, - changedFiles, - )) { - results.push(policyResult); - transcript.writePolicyResult(policyResult); - } - - if (config.plugins.gitleaks?.enabled) { - const plugin = config.plugins.gitleaks; - const name = "plugin:gitleaks"; - const commandResult = await runGitleaksPlugin( - plugin, + for (const entry of runPlan) { + const entryResult = await entry.run({ changedFileResolution, - repoRoot, env, - ); - - if (commandResult.passed) { - const result: ToolResult = { name, status: "passed" }; - - results.push(result); - transcript.writePluginResult(name, result); - } else { - const status: ToolResultStatus = - plugin.mode === "warning" ? "warning" : "blocked"; - const result: ToolResult = { - name, - status, - detail: commandResult.detail, - outputTail: commandResult.outputTail, - }; - - results.push(result); - transcript.writePluginResult(name, result); - - if (status === "blocked" && plugin.fail_fast) { - transcript.writeFailFast(); - stopAfterBlockingPlugin = true; - } - } - } - - if (stopAfterBlockingPlugin) { - const resultSummary = summarizeDeterministicResults(results); - - transcript.writeSummary(resultSummary); - return { exitCode: resultSummary.exitCode, results }; - } - - for (const tool of config.tools) { - const selectedPaths = selectToolChangedFilePaths( - changedFiles, - tool.extensions, - ); - - if (tool.run === "changed_files" && selectedPaths.length === 0) { - const result: ToolResult = { - name: tool.name, - status: "skipped", - detail: "no matching changed files", - }; - - results.push(result); - transcript.writeToolResult(tool, result); - continue; - } - - const commandResult = await runToolCommand( - tool, - selectedPaths, repoRoot, - env, - ); + }); - if (commandResult.passed) { - const result: ToolResult = { name: tool.name, status: "passed" }; - - results.push(result); - transcript.writeToolResult(tool, result); - continue; - } + results.push(entryResult.result); + transcript.writeCheckResult(entryResult.transcriptResult); - const status: ToolResultStatus = - tool.mode === "warning" ? "warning" : "blocked"; - const result: ToolResult = { - name: tool.name, - status, - detail: commandResult.detail, - outputTail: commandResult.outputTail, - }; - - results.push(result); - transcript.writeToolResult(tool, result); - - if (status === "blocked" && tool.fail_fast) { + if (entryResult.result.status === "blocked" && entry.failFast) { transcript.writeFailFast(); break; } @@ -191,10 +93,6 @@ export async function runDeterministicChecks( return { exitCode: resultSummary.exitCode, results }; } -function countPluginChecks(config: PushgateConfig): number { - return Number(Boolean(config.plugins.gitleaks?.enabled)); -} - function requireChangedFileResolution( changedFileResolution: ChangedFileResolution | null | undefined, ): ChangedFileResolution { diff --git a/src/runner/transcript.ts b/src/runner/transcript.ts index f3c306d..57bd673 100644 --- a/src/runner/transcript.ts +++ b/src/runner/transcript.ts @@ -1,7 +1,5 @@ -import type { ToolConfig } from "../config/index.js"; import { formatCount, - humanizeIdentifier, writeDetail, writeIndentedBlock, writeLine, @@ -10,17 +8,21 @@ import { type TerminalStatus, } from "../terminal/format.js"; import type { ToolResult } from "./deterministic.js"; -import type { BuiltInPolicyResult } from "./policies.js"; import type { DeterministicResultSummary } from "./summary.js"; +export interface DeterministicTranscriptCheckResult { + label: string; + status: ToolResult["status"]; + detail?: string; + outputTail?: string; +} + export interface DeterministicTranscript { writeFailFast(): void; + writeCheckResult(result: DeterministicTranscriptCheckResult): void; writeNoChecks(): void; - writePluginResult(name: string, result: ToolResult): void; - writePolicyResult(result: BuiltInPolicyResult): void; writeStart(checkCount: number): void; writeSummary(summary: DeterministicResultSummary): void; - writeToolResult(tool: ToolConfig, result: ToolResult): void; } export function createDeterministicTranscript( @@ -40,12 +42,8 @@ export function createDeterministicTranscript( writeLine(stdout); }, - writePolicyResult(result) { - writeCheckResult(result.name, result); - }, - - writePluginResult(name, result) { - writeCheckResult(name, result); + writeCheckResult(result) { + writeRenderedCheckResult(result); }, writeStart(checkCount) { @@ -94,21 +92,14 @@ export function createDeterministicTranscript( writeLine(stdout, "Checks passed."); writeLine(stdout); }, - - writeToolResult(tool, result) { - writeCheckResult(tool.name, result); - }, }; - function writeCheckResult( - name: string, - result: Pick, + function writeRenderedCheckResult( + result: DeterministicTranscriptCheckResult, ): void { - const display = displayCheck(name); - const detail = formatDetail(name, result.detail); const status = mapStatus(result.status); - writeResultRow(stdout, status, display.label, detail ?? display.detail); + writeResultRow(stdout, status, result.label, result.detail); if (result.outputTail) { writeDetail(stdout, "Command output:"); @@ -116,47 +107,13 @@ export function createDeterministicTranscript( } if (result.status === "warning") { - warnings.push(display.label); + warnings.push(result.label); } if (result.status === "blocked") { - blockers.push(display.label); - } - } -} - -function displayCheck(name: string): { detail?: string; label: string } { - if (name === "policy:diff_size") { - return { label: "Diff size" }; - } - - if (name === "policy:forbidden_paths") { - return { label: "Forbidden paths" }; - } - - if (name === "plugin:gitleaks") { - return { detail: "gitleaks", label: "Secrets scan" }; - } - - return { label: humanizeIdentifier(name) }; -} - -function formatDetail(name: string, detail: string | undefined): string | undefined { - if (!detail) { - return undefined; - } - - if (name === "policy:diff_size") { - const passed = detail.match( - /^(\d+) changed line\(s\) within max_changed_lines (\d+)$/, - ); - - if (passed) { - return `${passed[1]} / ${passed[2]} changed lines`; + blockers.push(result.label); } } - - return detail; } function mapStatus(status: ToolResult["status"]): TerminalStatus { diff --git a/test/deterministic-runner.test.ts b/test/deterministic-runner.test.ts index 80d7f8f..d202ff4 100644 --- a/test/deterministic-runner.test.ts +++ b/test/deterministic-runner.test.ts @@ -568,18 +568,17 @@ test("renders deterministic transcript without running commands", () => { const transcript = createDeterministicTranscript(output.stream); transcript.writeStart(3); - transcript.writePolicyResult({ - name: "policy:diff_size", + transcript.writeCheckResult({ + label: "Diff size", status: "passed", - detail: "5 changed line(s) within max_changed_lines 10", + detail: "5 / 10 changed lines", }); - transcript.writePolicyResult({ - name: "policy:diff_size", + transcript.writeCheckResult({ + label: "Diff size", status: "passed", - detail: "", }); - transcript.writeToolResult(tool(), { - name: "check", + transcript.writeCheckResult({ + label: "Check", status: "blocked", detail: "exited with code 2", outputTail: "first line\nsecond line",