diff --git a/bin/pushgate.mjs b/bin/pushgate.mjs index 0c72d70..4d56a57 100755 --- a/bin/pushgate.mjs +++ b/bin/pushgate.mjs @@ -10205,6 +10205,316 @@ function withStream(stream, options) { // src/version.ts var PUSHGATE_VERSION = "3.5.0"; +// src/transcript/pushgate-transcript.ts +function createPushgateTranscript(stdout) { + return { + deterministic: createDeterministicTranscript(stdout), + localAi: createLocalAiTranscript(stdout), + push: createPushTranscript(stdout), + warningConfirmation: createWarningConfirmationTranscript(stdout) + }; +} +function createDeterministicTranscript(stdout) { + const warnings = []; + const blockers = []; + const plannedChecks = []; + const liveUpdates = supportsLiveUpdates(stdout); + let completedCheckCount = 0; + let linesBelowCheckRows = 0; + return { + writeFailFast() { + writeSkippedRemainingChecks("not run after fail_fast"); + writeDetail( + stdout, + "Stopped after a blocking failure because fail_fast is true." + ); + }, + writeNoChecks() { + writeSection(stdout, "Checks"); + writeResultRow(stdout, "skipped", "No checks configured"); + writeLine(stdout); + }, + writeCheckResult(result) { + writeRenderedCheckResult(result); + }, + writeStart(checks) { + plannedChecks.push(...checks); + writeSection(stdout, "Checks"); + writeDetail(stdout, `Running ${formatCount(checks.length, "check")}.`); + for (const check2 of checks) { + writeResultRow(stdout, "running", check2.label, check2.detail); + } + }, + writeSummary(summary) { + writeLine(stdout); + if (summary.blockedCount > 0) { + writeLine( + stdout, + `Checks completed with ${formatCount(summary.blockedCount, "blocking failure")} and ${formatCount(summary.warningCount, "warning")}.` + ); + writeLine(stdout); + writeSection( + stdout, + summary.blockedCount === 1 ? "Blocked" : "Blocked checks" + ); + for (const blocker of blockers) { + writeDetail( + stdout, + `${blocker} failed and is configured as a blocking check.` + ); + } + writeLine(stdout); + writeLine( + stdout, + "Fix the blocking failures above, or use `git push --no-verify` only when you intend to bypass the Local Push Gate." + ); + return; + } + if (summary.warningCount > 0) { + writeLine( + stdout, + `Checks completed with ${formatCount(summary.warningCount, "non-blocking warning")}.` + ); + writeLine(stdout); + writeSection( + stdout, + summary.warningCount === 1 ? "Warning" : "Warnings" + ); + for (const warning of warnings) { + writeDetail( + stdout, + `${warning} failed, but this check does not block the push.` + ); + } + writeLine(stdout); + return; + } + writeLine(stdout, "Checks passed."); + writeLine(stdout); + } + }; + function writeRenderedCheckResult(result) { + const status = mapDeterministicStatus(result.status); + writeCompletedCheckResult(status, result); + if (result.outputTail) { + writeCommandOutputTail(result.outputTail); + } + if (result.status === "warning") { + warnings.push(result.label); + } + if (result.status === "blocked") { + blockers.push(result.label); + } + } + function writeCompletedCheckResult(status, result) { + const plannedCheck = plannedChecks[completedCheckCount]; + const detail = result.detail ?? plannedCheck?.detail; + writeCompletedCheckRow(status, result.label, detail); + } + function writeSkippedRemainingChecks(detail) { + while (completedCheckCount < plannedChecks.length) { + const plannedCheck = plannedChecks[completedCheckCount]; + if (!plannedCheck) { + return; + } + writeCompletedCheckRow("skipped", plannedCheck.label, detail); + } + } + function writeCompletedCheckRow(status, label, detail) { + if (liveUpdates && plannedChecks[completedCheckCount]) { + replacePlannedCheckRow(completedCheckCount, status, label, detail); + } else { + writeResultRow(stdout, status, label, detail); + } + completedCheckCount += 1; + } + function replacePlannedCheckRow(index, status, label, detail) { + const distanceFromCursor = linesBelowCheckRows + plannedChecks.length - index; + stdout.write( + `\x1B[${distanceFromCursor}A\r\x1B[2K${formatResultRow( + status, + label, + detail, + { stream: stdout } + )}\x1B[${distanceFromCursor}B\r` + ); + } + function writeCommandOutputTail(outputTail) { + const lines = outputTail.split("\n"); + writeDetail(stdout, "Command output:"); + writeIndentedBlock(stdout, lines); + linesBelowCheckRows += 1 + lines.length; + } +} +function createLocalAiTranscript(stdout) { + return { + writeEvents(events) { + for (const event of events) { + renderLocalAiTranscriptEvent(event, stdout); + } + }, + writeSection() { + writeSection(stdout, "AI review"); + }, + writeSkipped(options) { + if (options.reason === "local-ai-mode-off") { + return; + } + writeSection(stdout, "AI review"); + writeResultRow( + stdout, + "skipped", + "Local AI Review", + `skipped because ${SKIP_AI_CHECK_CONFIG_KEY}=true` + ); + } + }; +} +function createWarningConfirmationTranscript(stdout) { + return { + writeConfirmed(options) { + writeLine( + stdout, + `Warning Confirmation accepted: continuing with ${String(options.warningCount)} warning(s) from ${options.phase}.` + ); + }, + writeDeclined(options) { + writeLine( + stdout, + `Push blocked because Warning Confirmation was declined for ${String(options.warningCount)} warning(s) from ${options.phase}.` + ); + }, + writeUnavailable(options) { + writeLine(stdout, options.message); + writeLine( + stdout, + "Push blocked because Warning Confirmation could not be collected." + ); + } + }; +} +function createPushTranscript(stdout) { + return { + writePassed() { + writeLine(stdout); + writeLine(stdout, "Local Push Gate passed. Push allowed."); + } + }; +} +function renderLocalAiTranscriptEvent(event, stdout) { + switch (event.kind) { + case "skip-no-files": + writeResultRow(stdout, "skipped", "No changed files to review"); + return; + case "block-changed-lines": + writeResultRow( + stdout, + "blocked", + "Changed lines", + `${String(event.changedLineCount)} changed lines exceed ai.max_changed_lines ${String(event.maxChangedLines)}` + ); + return; + case "skip-prompt-tokens": + writeResultRow( + stdout, + "skipped", + "Prompt budget", + `approximately ${String(event.estimatedPromptTokens)} tokens exceeds ai.max_prompt_tokens ${String(event.maxPromptTokens)}` + ); + return; + case "review-start": + writeDetail(stdout, `Provider: ${capitalize(event.providerId)}`); + writeDetail(stdout, `Files reviewed: ${String(event.changedFileCount)}`); + return; + case "full-file-context": + writeDetail( + stdout, + `Context: ${formatCount(event.diffLineCount, "diff line")} plus ${formatCount(event.fullFileCount, "full file")} for extra context` + ); + return; + case "provider-failure": { + const status = event.aiMode === "advisory" ? "warning" : "blocked"; + writeResultRow( + stdout, + status, + `${capitalize(event.result.provider)} provider`, + event.result.message + ); + if (event.result.detail) { + writeDetail(stdout, "Detail:"); + writeIndentedBlock(stdout, event.result.detail.split("\n")); + } + if (event.result.output) { + writeDetail(stdout, "Provider output:"); + writeIndentedBlock(stdout, event.result.output.split("\n")); + } + return; + } + case "normalization-note": + writeDetail(stdout, `Note: ${event.note}`); + return; + case "review-passed": + writeResultRow(stdout, "passed", "No findings"); + return; + case "finding": { + const status = event.finding.severity === "blocking" ? "blocked" : "warning"; + const location = event.finding.line === "N/A" ? event.finding.file : `${event.finding.file}:${event.finding.line}`; + writeResultRow( + stdout, + status, + `AI ${humanizeCategory(event.finding.category)}`, + location + ); + writeDetail(stdout, `Message: ${event.finding.message}`); + writeDetail(stdout, `Suggestion: ${event.finding.suggestion}`); + return; + } + case "review-summary": + if (event.summary.blockingCount > 0 || event.summary.warningCount > 0) { + writeDetail( + stdout, + `Finished with ${formatCount(event.summary.blockingCount, "blocking finding")} and ${formatCount(event.summary.warningCount, "warning")}.` + ); + } + return; + case "advisory-continue": + writeDetail(stdout, "Continuing because ai.mode is advisory."); + return; + case "provider-blocked": + writeLine(stdout); + writeLine(stdout, "Local AI Review is blocking in this repository."); + writeLine( + stdout, + `Fix the provider issue, or use \`git -c ${SKIP_AI_CHECK_CONFIG_KEY}=true push\` to bypass only Local AI Review for this push.` + ); + return; + case "review-blocked": + writeLine(stdout); + writeLine(stdout, "Local AI Review blocked the push."); + writeLine( + stdout, + `Fix the findings above, or use \`git -c ${SKIP_AI_CHECK_CONFIG_KEY}=true push\` to bypass only Local AI Review for this push.` + ); + return; + } +} +function mapDeterministicStatus(status) { + const statusByResult = { + blocked: "blocked", + passed: "passed", + skipped: "skipped", + warning: "warning" + }; + return statusByResult[status]; +} +function supportsLiveUpdates(stream) { + const output = stream; + return output.isTTY === true && process.env.TERM !== "dumb"; +} +function humanizeCategory(category) { + return category.replace(/_/g, " "); +} + // src/ai/guardrails.ts function evaluateChangedFileGuardrails(options) { if (options.changedFiles.length === 0) { @@ -26645,107 +26955,6 @@ function countTextLines(text) { return text.endsWith("\n") ? newlineCount : newlineCount + 1; } -// src/ai/transcript.ts -function renderLocalAiTranscript(events, stdout) { - for (const event of events) { - renderLocalAiTranscriptEvent(event, stdout); - } -} -function renderLocalAiTranscriptEvent(event, stdout) { - switch (event.kind) { - case "skip-no-files": - writeResultRow(stdout, "skipped", "No changed files to review"); - return; - case "block-changed-lines": - writeResultRow( - stdout, - "blocked", - "Changed lines", - `${String(event.changedLineCount)} changed lines exceed ai.max_changed_lines ${String(event.maxChangedLines)}` - ); - return; - case "skip-prompt-tokens": - writeResultRow( - stdout, - "skipped", - "Prompt budget", - `approximately ${String(event.estimatedPromptTokens)} tokens exceeds ai.max_prompt_tokens ${String(event.maxPromptTokens)}` - ); - return; - case "review-start": - writeDetail(stdout, `Provider: ${capitalize(event.providerId)}`); - writeDetail(stdout, `Files reviewed: ${String(event.changedFileCount)}`); - return; - case "full-file-context": - writeDetail( - stdout, - `Context: ${formatCount(event.diffLineCount, "diff line")} plus ${formatCount(event.fullFileCount, "full file")} for extra context` - ); - return; - case "provider-failure": { - const status = event.aiMode === "advisory" ? "warning" : "blocked"; - writeResultRow( - stdout, - status, - `${capitalize(event.result.provider)} provider`, - event.result.message - ); - if (event.result.detail) { - writeDetail(stdout, "Detail:"); - writeIndentedBlock(stdout, event.result.detail.split("\n")); - } - if (event.result.output) { - writeDetail(stdout, "Provider output:"); - writeIndentedBlock(stdout, event.result.output.split("\n")); - } - return; - } - case "normalization-note": - writeDetail(stdout, `Note: ${event.note}`); - return; - case "review-passed": - writeResultRow(stdout, "passed", "No findings"); - return; - case "finding": { - const status = event.finding.severity === "blocking" ? "blocked" : "warning"; - const location = event.finding.line === "N/A" ? event.finding.file : `${event.finding.file}:${event.finding.line}`; - writeResultRow( - stdout, - status, - `AI ${humanizeCategory(event.finding.category)}`, - location - ); - writeDetail(stdout, `Message: ${event.finding.message}`); - writeDetail(stdout, `Suggestion: ${event.finding.suggestion}`); - return; - } - case "review-summary": - if (event.summary.blockingCount > 0 || event.summary.warningCount > 0) { - writeDetail( - stdout, - `Finished with ${formatCount(event.summary.blockingCount, "blocking finding")} and ${formatCount(event.summary.warningCount, "warning")}.` - ); - } - return; - case "advisory-continue": - writeDetail(stdout, "Continuing because ai.mode is advisory."); - return; - case "provider-blocked": - writeLine(stdout); - writeLine(stdout, "Local AI is blocking in this repository."); - writeLine(stdout, "Fix the provider issue, or use `git -c pushgate.skip-ai-check=true push` to bypass only the AI phase for one push."); - return; - case "review-blocked": - writeLine(stdout); - writeLine(stdout, "Local AI review blocked the push."); - writeLine(stdout, "Fix the findings above, or use `git -c pushgate.skip-ai-check=true push` to bypass only the AI phase for one push."); - return; - } -} -function humanizeCategory(category) { - return category.replace(/_/g, " "); -} - // src/ai/verdict.ts function buildLocalAiVerdict(aiMode, result) { if (result.kind === "provider-error") { @@ -26817,19 +27026,22 @@ function buildLocalAiVerdict(aiMode, result) { // src/ai/local-ai-gate.ts async function runLocalAiReview(options) { - const stdout = options.stdout ?? process.stdout; + const transcript = options.transcript ?? createLocalAiTranscript(process.stdout); const providerRuntime = resolveLocalAiProviderRuntime(options.aiConfig); if (providerRuntime.kind === "provider-error") { - return renderVerdict(options.aiConfig.mode, providerRuntime.result, stdout); + return renderVerdict( + options.aiConfig.mode, + providerRuntime.result, + transcript + ); } const changedFileGuardrail = evaluateChangedFileGuardrails({ changedFiles: options.changedFileResolution.files, maxChangedLines: options.aiConfig.max_changed_lines }); if (changedFileGuardrail.kind !== "run") { - renderLocalAiTranscript( - transcriptEventsForChangedFileGuardrail(changedFileGuardrail), - stdout + transcript.writeEvents( + transcriptEventsForChangedFileGuardrail(changedFileGuardrail) ); return { exitCode: changedFileGuardrail.kind === "block-changed-lines" ? 1 : 0, @@ -26847,38 +27059,35 @@ async function runLocalAiReview(options) { prompt: payload.prompt }); if (promptGuardrail.kind !== "run") { - renderLocalAiTranscript( + transcript.writeEvents( [ { kind: "skip-prompt-tokens", estimatedPromptTokens: promptGuardrail.estimatedPromptTokens, maxPromptTokens: promptGuardrail.maxPromptTokens } - ], - stdout + ] ); return { exitCode: 0, warningCount: 0 }; } - renderLocalAiTranscript( + transcript.writeEvents( [ { kind: "review-start", providerId: providerRuntime.providerId, changedFileCount: payload.changedFiles.length } - ], - stdout + ] ); if (payload.fullFiles.length > 0) { - renderLocalAiTranscript( + transcript.writeEvents( [ { kind: "full-file-context", diffLineCount: payload.diffLineCount, fullFileCount: payload.fullFiles.length } - ], - stdout + ] ); } return renderVerdict( @@ -26889,12 +27098,12 @@ async function runLocalAiReview(options) { repoRoot: options.repoRoot, timeoutSeconds: options.aiConfig.timeout_seconds }), - stdout + transcript ); } -function renderVerdict(aiMode, result, stdout) { +function renderVerdict(aiMode, result, transcript) { const verdict = buildLocalAiVerdict(aiMode, result); - renderLocalAiTranscript(verdict.transcriptEvents, stdout); + transcript.writeEvents(verdict.transcriptEvents); return { exitCode: verdict.exitCode, warningCount: verdict.warningCount @@ -27353,138 +27562,6 @@ function summarizeDeterministicResults(results) { }; } -// src/runner/transcript.ts -function createDeterministicTranscript(stdout) { - const warnings = []; - const blockers = []; - const plannedChecks = []; - const liveUpdates = supportsLiveUpdates(stdout); - let completedCheckCount = 0; - let linesBelowCheckRows = 0; - return { - writeFailFast() { - writeSkippedRemainingChecks("not run after fail_fast"); - writeDetail(stdout, "Stopped after a blocking failure because fail_fast is true."); - }, - writeNoChecks() { - writeSection(stdout, "Checks"); - writeResultRow(stdout, "skipped", "No checks configured"); - writeLine(stdout); - }, - writeCheckResult(result) { - writeRenderedCheckResult(result); - }, - writeStart(checks) { - plannedChecks.push(...checks); - writeSection(stdout, "Checks"); - writeDetail(stdout, `Running ${formatCount(checks.length, "check")}.`); - for (const check2 of checks) { - writeResultRow(stdout, "running", check2.label, check2.detail); - } - }, - writeSummary(summary) { - writeLine(stdout); - if (summary.blockedCount > 0) { - writeLine( - stdout, - `Checks completed with ${formatCount(summary.blockedCount, "blocking failure")} and ${formatCount(summary.warningCount, "warning")}.` - ); - writeLine(stdout); - writeSection(stdout, summary.blockedCount === 1 ? "Blocked" : "Blocked checks"); - for (const blocker of blockers) { - writeDetail(stdout, `${blocker} failed and is configured as a blocking check.`); - } - writeLine(stdout); - writeLine( - stdout, - "Fix the blocking failures above, or use `git push --no-verify` only when you intend to bypass local hooks." - ); - return; - } - if (summary.warningCount > 0) { - writeLine( - stdout, - `Checks completed with ${formatCount(summary.warningCount, "non-blocking warning")}.` - ); - writeLine(stdout); - writeSection(stdout, summary.warningCount === 1 ? "Warning" : "Warnings"); - for (const warning of warnings) { - writeDetail(stdout, `${warning} failed, but this check does not block the push.`); - } - writeLine(stdout); - return; - } - writeLine(stdout, "Checks passed."); - writeLine(stdout); - } - }; - function writeRenderedCheckResult(result) { - const status = mapStatus(result.status); - writeCompletedCheckResult(status, result); - if (result.outputTail) { - writeCommandOutputTail(result.outputTail); - } - if (result.status === "warning") { - warnings.push(result.label); - } - if (result.status === "blocked") { - blockers.push(result.label); - } - } - function writeCompletedCheckResult(status, result) { - const plannedCheck = plannedChecks[completedCheckCount]; - const detail = result.detail ?? plannedCheck?.detail; - writeCompletedCheckRow(status, result.label, detail); - } - function writeSkippedRemainingChecks(detail) { - while (completedCheckCount < plannedChecks.length) { - const plannedCheck = plannedChecks[completedCheckCount]; - if (!plannedCheck) { - return; - } - writeCompletedCheckRow("skipped", plannedCheck.label, detail); - } - } - function writeCompletedCheckRow(status, label, detail) { - if (liveUpdates && plannedChecks[completedCheckCount]) { - replacePlannedCheckRow(completedCheckCount, status, label, detail); - } else { - writeResultRow(stdout, status, label, detail); - } - completedCheckCount += 1; - } - function replacePlannedCheckRow(index, status, label, detail) { - const distanceFromCursor = linesBelowCheckRows + plannedChecks.length - index; - stdout.write( - `\x1B[${distanceFromCursor}A\r\x1B[2K${formatResultRow( - status, - label, - detail, - { stream: stdout } - )}\x1B[${distanceFromCursor}B\r` - ); - } - function writeCommandOutputTail(outputTail) { - const lines = outputTail.split("\n"); - writeDetail(stdout, "Command output:"); - writeIndentedBlock(stdout, lines); - linesBelowCheckRows += 1 + lines.length; - } -} -function mapStatus(status) { - const statusByResult = { - blocked: "blocked", - passed: "passed", - skipped: "skipped", - warning: "warning" - }; - return statusByResult[status]; -} -function supportsLiveUpdates(stream) { - const output = stream; - return output.isTTY === true && process.env.TERM !== "dumb"; -} - // src/runner/deterministic.ts function buildDeterministicCheckPlan(config2) { const checkCount = buildDeterministicCheckRunPlan(config2).length; @@ -27496,11 +27573,10 @@ function buildDeterministicCheckPlan(config2) { } async function runDeterministicChecks(request) { const { config: config2 } = request; - const stdout = request.stdout ?? process.stdout; const repoRoot = request.repoRoot ?? process.cwd(); const env = request.env ?? process.env; const results = []; - const transcript = createDeterministicTranscript(stdout); + const transcript = request.transcript ?? createDeterministicTranscript(process.stdout); const runPlan = buildDeterministicCheckRunPlan(config2); if (runPlan.length === 0) { transcript.writeNoChecks(); @@ -27718,6 +27794,7 @@ function createTerminalWarningConfirmer(options = {}) { // src/workflows/local-push-gate-run.ts async function runLocalPushGate(options) { + const transcript = createPushgateTranscript(options.stdout); const localAi = getLocalAiPhaseDecision(options.config, options.skipControls); const changedFileResolution = await resolveChangedFilesIfRequired({ config: options.config, @@ -27729,7 +27806,7 @@ async function runLocalPushGate(options) { config: options.config, env: options.env, repoRoot: options.repoRoot, - stdout: options.stdout + transcript: transcript.deterministic }); if (deterministicSummary.exitCode !== 0) { return deterministicSummary.exitCode; @@ -27737,7 +27814,7 @@ async function runLocalPushGate(options) { if (!await confirmWarningsBeforeContinuing({ confirmer: options.warningConfirmer, phase: "deterministic checks", - stdout: options.stdout, + transcript: transcript.warningConfirmation, warningCount: deterministicSummary.results.filter( (result) => result.status === "warning" ).length @@ -27750,7 +27827,7 @@ async function runLocalPushGate(options) { decision: localAi, env: options.env, repoRoot: options.repoRoot, - stdout: options.stdout + transcript: transcript.localAi }); if (localAiSummary.exitCode !== 0) { return localAiSummary.exitCode; @@ -27758,13 +27835,12 @@ async function runLocalPushGate(options) { if (!await confirmWarningsBeforeContinuing({ confirmer: options.warningConfirmer, phase: "local AI review", - stdout: options.stdout, + transcript: transcript.warningConfirmation, warningCount: localAiSummary.warningCount })) { return 1; } - writeLine(options.stdout); - writeLine(options.stdout, "Pushgate passed. Changes allowed..."); + transcript.push.writePassed(); return 0; } async function resolveChangedFilesIfRequired(options) { @@ -27780,14 +27856,12 @@ async function resolveChangedFilesIfRequired(options) { } async function runLocalAiPhase(options) { if (options.decision.kind === "skip") { - const message = formatLocalAiSkipReason(options.decision.reason); - if (message !== null) { - writeSection(options.stdout, "AI review"); - writeResultRow(options.stdout, "skipped", message); - } + options.transcript.writeSkipped({ + reason: options.decision.reason + }); return { exitCode: 0, warningCount: 0 }; } - writeSection(options.stdout, "AI review"); + options.transcript.writeSection(); return await runLocalAiReview({ aiConfig: options.config.ai, changedFileResolution: requireChangedFileResolution2( @@ -27797,7 +27871,7 @@ async function runLocalAiPhase(options) { env: options.env, repoRoot: options.repoRoot, reviewConfig: options.config.review, - stdout: options.stdout + transcript: options.transcript }); } async function confirmWarningsBeforeContinuing(options) { @@ -27811,24 +27885,20 @@ async function confirmWarningsBeforeContinuing(options) { warningCount: options.warningCount }); if (confirmed) { - options.stdout.write( - `Continuing with ${String(options.warningCount)} warning(s) from ${options.phase} after confirmation. -` - ); + options.transcript.writeConfirmed({ + phase: options.phase, + warningCount: options.warningCount + }); return true; } - options.stdout.write( - `Push blocked because ${options.phase} produced ${String(options.warningCount)} warning(s) and continuation was not confirmed. -` - ); + options.transcript.writeDeclined({ + phase: options.phase, + warningCount: options.warningCount + }); return false; } catch (error51) { if (error51 instanceof WarningConfirmationError) { - options.stdout.write(`${error51.message} -`); - options.stdout.write( - "Push blocked because warning confirmation could not be collected.\n" - ); + options.transcript.writeUnavailable({ message: error51.message }); return false; } throw error51; @@ -27849,12 +27919,6 @@ function getLocalAiPhaseDecision(config2, skipControls) { } return { kind: "run" }; } -function formatLocalAiSkipReason(reason) { - if (reason === "local-ai-mode-off") { - return null; - } - return `Skipping local AI because ${SKIP_AI_CHECK_CONFIG_KEY}=true.`; -} function requireChangedFileResolution2(changedFileResolution, phaseName) { if (changedFileResolution !== null) { return changedFileResolution; diff --git a/docs/architecture/modules.md b/docs/architecture/modules.md index 48b7d0c..c5cbc2d 100644 --- a/docs/architecture/modules.md +++ b/docs/architecture/modules.md @@ -17,6 +17,7 @@ know. | Process execution | `runCommand`, `runTimedCommand`, `runProcessOutcome`, `runInheritedCommand` | `src/process/*` | Shared child-process mechanics and outcome formatting. | | Deterministic runner | `runDeterministicChecks` | `src/runner/*` | Runs built-in policies, plugin checks, configured tools, transcript, and summary. | | Local AI review | `runLocalAiReview` | `src/ai/*` | Applies guardrails, builds payload, calls provider runtime, builds verdict. | +| Transcript | `createPushgateTranscript(stdout)` | `src/transcript/*`, `src/terminal/format.ts` | Owns language-agnostic developer-facing Local Push Gate output. | | Provider adapters | `LocalAiProviderAdapter.runReview` | `src/ai/providers/*` | Isolates Claude and Copilot command and transport details. | | Generated runner | Executable `bin/pushgate.mjs` | `bin/pushgate.mjs`, `scripts/build-runner.mjs` | Installer-facing artifact; source of truth remains `src/`. | @@ -31,7 +32,7 @@ know. | `LocalAiReviewPayload` | AI review context | Provider adapters | Contains changed files, rendered diff, optional full-file context, and final prompt. | | `RawAiReviewOutput` | Provider output parser | AI verdict and transcript | Must match schema version `1` and strict finding fields. | | `AiFinding` | Review-output normalization | Verdict and transcript | Adds provider/model source metadata and normalized severity/category. | -| `LocalAiVerdict` | Verdict module | AI gate | Contains final exit code plus transcript events. | +| `LocalAiVerdict` | Verdict module | AI gate and Transcript | Contains final exit code plus Local AI transcript events. | ## Dependency Shape @@ -43,6 +44,7 @@ The high fan-in files are useful because they hide internal layout: | `src/path-policy/index.ts` | Public changed-file policy barrel for resolver, filters, errors, and types. | | `src/ai/types.ts` | Shared local AI review types and provider contracts. | | `src/runner/deterministic.ts` | Main deterministic-check interface and changed-file token helpers. | +| `src/transcript/index.ts` | Public Transcript interface for phase-specific rendering adapters. | | `src/git/command.ts` | Shared Git command execution and checked-error behavior. | Keep barrels deep. They should expose module interfaces, not every internal @@ -59,3 +61,4 @@ helper. | Installer behavior and installed hook assets | `test/install.test.ts` | | Process outcome behavior | `test/process.test.ts` | | Local AI prompt context, guardrails, provider adapters, output repair, verdicts | `test/ai.test.ts` | +| Transcript rendering for Local Push Gate output | `test/transcript.test.ts` | diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index c340523..779fcac 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -34,6 +34,9 @@ flowchart TD Deterministic --> Process["src/process/*"] AI --> Process AI --> Providers["Claude / Copilot adapters"] + Workflow --> Transcript["src/transcript/*"] + Deterministic --> Transcript + AI --> Transcript ``` ## Architectural Layers @@ -48,6 +51,7 @@ flowchart TD | Process execution | Shared child-process mechanics and outcome policy | `src/process/*` | | Deterministic runner | Built-in policies, plugin checks, configured tools, fail-fast behavior | `src/runner/*` | | Local AI review | Guardrails, prompt context, provider adapters, output parsing, verdict rendering | `src/ai/*`, `schemas/ai-review-output-v1.schema.json` | +| Transcript | Language-agnostic developer-facing Local Push Gate output | `src/transcript/*`, `src/terminal/format.ts` | | Distribution | Generated runner and generated validators | `bin/pushgate.mjs`, `src/generated/*`, `scripts/*` | | Tests | Behavior-level coverage for hook, install, runner, config, path policy, process, and AI | `test/*.test.ts`, `test/support/*` | @@ -65,6 +69,7 @@ TypeScript `interface` declarations. | Deterministic check summary | `src/runner/deterministic.ts` | Exit code plus per-check results after policies, plugins, and tools run. | | Local AI provider adapter | `src/ai/types.ts`, `src/ai/provider-runtime.ts` | Provider-specific execution returns one provider-neutral result. | | AI review output contract | `src/ai/review-contract.ts` | Every provider response validates against the same strict finding schema. | +| Transcript rendering | `src/transcript/*` | Phase modules pass language-agnostic facts; Transcript owns terminal copy and bypass guidance. | ## Read First diff --git a/docs/architecture/runtime-flow.md b/docs/architecture/runtime-flow.md index 23a8707..42a5f1f 100644 --- a/docs/architecture/runtime-flow.md +++ b/docs/architecture/runtime-flow.md @@ -91,7 +91,7 @@ resolution. 5. Run configured tools in order. 6. Expand `{changed_files}` into argv entries without shell interpolation. 7. Apply timeout, output-tail, mode, and fail-fast behavior. -8. Return a deterministic check summary and transcript output. +8. Return a deterministic check summary while streaming Transcript output. ## Local AI Phase @@ -108,7 +108,7 @@ flowchart TD Guard2 --> Adapter["provider.runReview"] Adapter --> Contract["validate AI review contract"] Contract --> Verdict["buildLocalAiVerdict"] - Verdict --> Transcript["renderLocalAiTranscript"] + Verdict --> Transcript["transcript.localAi.writeEvents"] ``` Provider adapters currently exist for Claude and Copilot. Both receive the same diff --git a/src/ai/local-ai-gate.ts b/src/ai/local-ai-gate.ts index b00cf51..87d88d7 100644 --- a/src/ai/local-ai-gate.ts +++ b/src/ai/local-ai-gate.ts @@ -1,16 +1,17 @@ import type { AiConfig, ReviewConfig } from "../config/index.js"; import type { ChangedFileResolution } from "../path-policy/index.js"; +import { + createLocalAiTranscript, + type LocalAiTranscript, + type LocalAiTranscriptEvent, +} from "../transcript/index.js"; import { evaluateChangedFileGuardrails, evaluatePromptGuardrail, } from "./guardrails.js"; import { resolveLocalAiProviderRuntime } from "./provider-runtime.js"; import { buildLocalAiReviewPayload } from "./review-context.js"; -import { renderLocalAiTranscript } from "./transcript.js"; -import type { - LocalAiProviderResult, - LocalAiTranscriptEvent, -} from "./types.js"; +import type { LocalAiProviderResult } from "./types.js"; import { buildLocalAiVerdict } from "./verdict.js"; export interface LocalAiRunSummary { @@ -24,13 +25,18 @@ export async function runLocalAiReview(options: { env?: NodeJS.ProcessEnv; repoRoot: string; reviewConfig: ReviewConfig; - stdout?: NodeJS.WritableStream; + transcript?: LocalAiTranscript; }): Promise { - const stdout = options.stdout ?? process.stdout; + const transcript = + options.transcript ?? createLocalAiTranscript(process.stdout); const providerRuntime = resolveLocalAiProviderRuntime(options.aiConfig); if (providerRuntime.kind === "provider-error") { - return renderVerdict(options.aiConfig.mode, providerRuntime.result, stdout); + return renderVerdict( + options.aiConfig.mode, + providerRuntime.result, + transcript, + ); } const changedFileGuardrail = evaluateChangedFileGuardrails({ @@ -39,9 +45,8 @@ export async function runLocalAiReview(options: { }); if (changedFileGuardrail.kind !== "run") { - renderLocalAiTranscript( + transcript.writeEvents( transcriptEventsForChangedFileGuardrail(changedFileGuardrail), - stdout, ); return { exitCode: changedFileGuardrail.kind === "block-changed-lines" ? 1 : 0, @@ -61,7 +66,7 @@ export async function runLocalAiReview(options: { }); if (promptGuardrail.kind !== "run") { - renderLocalAiTranscript( + transcript.writeEvents( [ { kind: "skip-prompt-tokens", @@ -69,12 +74,11 @@ export async function runLocalAiReview(options: { maxPromptTokens: promptGuardrail.maxPromptTokens, }, ], - stdout, ); return { exitCode: 0, warningCount: 0 }; } - renderLocalAiTranscript( + transcript.writeEvents( [ { kind: "review-start", @@ -82,11 +86,10 @@ export async function runLocalAiReview(options: { changedFileCount: payload.changedFiles.length, }, ], - stdout, ); if (payload.fullFiles.length > 0) { - renderLocalAiTranscript( + transcript.writeEvents( [ { kind: "full-file-context", @@ -94,7 +97,6 @@ export async function runLocalAiReview(options: { fullFileCount: payload.fullFiles.length, }, ], - stdout, ); } @@ -106,17 +108,17 @@ export async function runLocalAiReview(options: { repoRoot: options.repoRoot, timeoutSeconds: options.aiConfig.timeout_seconds, }), - stdout, + transcript, ); } function renderVerdict( aiMode: AiConfig["mode"], result: LocalAiProviderResult, - stdout: NodeJS.WritableStream, + transcript: LocalAiTranscript, ): LocalAiRunSummary { const verdict = buildLocalAiVerdict(aiMode, result); - renderLocalAiTranscript(verdict.transcriptEvents, stdout); + transcript.writeEvents(verdict.transcriptEvents); return { exitCode: verdict.exitCode, warningCount: verdict.warningCount, diff --git a/src/ai/transcript.ts b/src/ai/transcript.ts deleted file mode 100644 index 4f89dfa..0000000 --- a/src/ai/transcript.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { - capitalize, - formatCount, - writeDetail, - writeIndentedBlock, - writeLine, - writeResultRow, -} from "../terminal/format.js"; -import type { LocalAiTranscriptEvent } from "./types.js"; - -export function renderLocalAiTranscript( - events: readonly LocalAiTranscriptEvent[], - stdout: NodeJS.WritableStream, -): void { - for (const event of events) { - renderLocalAiTranscriptEvent(event, stdout); - } -} - -function renderLocalAiTranscriptEvent( - event: LocalAiTranscriptEvent, - stdout: NodeJS.WritableStream, -): void { - switch (event.kind) { - case "skip-no-files": - writeResultRow(stdout, "skipped", "No changed files to review"); - return; - case "block-changed-lines": - writeResultRow( - stdout, - "blocked", - "Changed lines", - `${String(event.changedLineCount)} changed lines exceed ai.max_changed_lines ${String(event.maxChangedLines)}`, - ); - return; - case "skip-prompt-tokens": - writeResultRow( - stdout, - "skipped", - "Prompt budget", - `approximately ${String(event.estimatedPromptTokens)} tokens exceeds ai.max_prompt_tokens ${String(event.maxPromptTokens)}`, - ); - return; - case "review-start": - writeDetail(stdout, `Provider: ${capitalize(event.providerId)}`); - writeDetail(stdout, `Files reviewed: ${String(event.changedFileCount)}`); - return; - case "full-file-context": - writeDetail( - stdout, - `Context: ${formatCount(event.diffLineCount, "diff line")} plus ${formatCount(event.fullFileCount, "full file")} for extra context`, - ); - return; - case "provider-failure": { - const status = event.aiMode === "advisory" ? "warning" : "blocked"; - - writeResultRow( - stdout, - status, - `${capitalize(event.result.provider)} provider`, - event.result.message, - ); - - if (event.result.detail) { - writeDetail(stdout, "Detail:"); - writeIndentedBlock(stdout, event.result.detail.split("\n")); - } - - if (event.result.output) { - writeDetail(stdout, "Provider output:"); - writeIndentedBlock(stdout, event.result.output.split("\n")); - } - - return; - } - case "normalization-note": - writeDetail(stdout, `Note: ${event.note}`); - return; - case "review-passed": - writeResultRow(stdout, "passed", "No findings"); - return; - case "finding": { - const status = - event.finding.severity === "blocking" ? "blocked" : "warning"; - const location = - event.finding.line === "N/A" - ? event.finding.file - : `${event.finding.file}:${event.finding.line}`; - - writeResultRow( - stdout, - status, - `AI ${humanizeCategory(event.finding.category)}`, - location, - ); - writeDetail(stdout, `Message: ${event.finding.message}`); - writeDetail(stdout, `Suggestion: ${event.finding.suggestion}`); - return; - } - case "review-summary": - if ( - event.summary.blockingCount > 0 || - event.summary.warningCount > 0 - ) { - writeDetail( - stdout, - `Finished with ${formatCount(event.summary.blockingCount, "blocking finding")} and ${formatCount(event.summary.warningCount, "warning")}.`, - ); - } - return; - case "advisory-continue": - writeDetail(stdout, "Continuing because ai.mode is advisory."); - return; - case "provider-blocked": - writeLine(stdout); - writeLine(stdout, "Local AI is blocking in this repository."); - writeLine(stdout, "Fix the provider issue, or use `git -c pushgate.skip-ai-check=true push` to bypass only the AI phase for one push."); - return; - case "review-blocked": - writeLine(stdout); - writeLine(stdout, "Local AI review blocked the push."); - writeLine(stdout, "Fix the findings above, or use `git -c pushgate.skip-ai-check=true push` to bypass only the AI phase for one push."); - return; - } -} - -function humanizeCategory(category: string): string { - return category.replace(/_/g, " "); -} diff --git a/src/ai/types.ts b/src/ai/types.ts index bd59638..4f800b4 100644 --- a/src/ai/types.ts +++ b/src/ai/types.ts @@ -1,4 +1,4 @@ -import type { AiMode, ProviderConfig } from "../config/index.js"; +import type { ProviderConfig } from "../config/index.js"; import type { ChangedFile } from "../path-policy/index.js"; import type { AiFindingCategory, @@ -106,66 +106,6 @@ export type LocalAiProviderResult = | LocalAiProviderFailure | LocalAiProviderReview; -export type LocalAiTranscriptEvent = - | { - kind: "skip-no-files"; - } - | { - kind: "block-changed-lines"; - changedLineCount: number; - maxChangedLines: number; - } - | { - kind: "skip-prompt-tokens"; - estimatedPromptTokens: number; - maxPromptTokens: number; - } - | { - kind: "review-start"; - providerId: string; - changedFileCount: number; - } - | { - kind: "full-file-context"; - diffLineCount: number; - fullFileCount: number; - } - | { - kind: "provider-failure"; - aiMode: AiMode; - result: LocalAiProviderFailure; - } - | { - kind: "normalization-note"; - note: string; - } - | { - kind: "review-passed"; - } - | { - kind: "finding"; - finding: AiFinding; - } - | { - kind: "review-summary"; - summary: AiReviewSummary; - } - | { - kind: "advisory-continue"; - } - | { - kind: "provider-blocked"; - } - | { - kind: "review-blocked"; - }; - -export interface LocalAiVerdict { - exitCode: number; - transcriptEvents: readonly LocalAiTranscriptEvent[]; - warningCount: number; -} - export interface LocalAiProviderRunOptions { env: NodeJS.ProcessEnv; payload: LocalAiReviewPayload; diff --git a/src/ai/verdict.ts b/src/ai/verdict.ts index 6ab886c..487fbc1 100644 --- a/src/ai/verdict.ts +++ b/src/ai/verdict.ts @@ -1,10 +1,15 @@ import type { AiConfig } from "../config/index.js"; +import type { LocalAiTranscriptEvent } from "../transcript/index.js"; import type { LocalAiProviderResult, - LocalAiTranscriptEvent, - LocalAiVerdict, } from "./types.js"; +export interface LocalAiVerdict { + exitCode: number; + transcriptEvents: readonly LocalAiTranscriptEvent[]; + warningCount: number; +} + export function buildLocalAiVerdict( aiMode: AiConfig["mode"], result: LocalAiProviderResult, diff --git a/src/runner/deterministic-plan.ts b/src/runner/deterministic-plan.ts index c0f02df..a3207f1 100644 --- a/src/runner/deterministic-plan.ts +++ b/src/runner/deterministic-plan.ts @@ -9,10 +9,10 @@ import { type ChangedFileResolution, } from "../path-policy/index.js"; import { humanizeIdentifier } from "../terminal/format.js"; +import type { DeterministicTranscriptCheckResult } from "../transcript/index.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 { diff --git a/src/runner/deterministic.ts b/src/runner/deterministic.ts index f2cdf35..6d497ef 100644 --- a/src/runner/deterministic.ts +++ b/src/runner/deterministic.ts @@ -1,8 +1,11 @@ import type { PushgateConfig } from "../config/index.js"; import type { ChangedFileResolution } from "../path-policy/index.js"; +import { + createDeterministicTranscript, + type DeterministicTranscript, +} from "../transcript/index.js"; import { buildDeterministicCheckRunPlan } from "./deterministic-plan.js"; import { summarizeDeterministicResults } from "./summary.js"; -import { createDeterministicTranscript } from "./transcript.js"; export { CHANGED_FILES_TOKEN, @@ -34,7 +37,7 @@ export interface DeterministicCheckRequest { config: PushgateConfig; env?: NodeJS.ProcessEnv; repoRoot?: string; - stdout?: NodeJS.WritableStream; + transcript?: DeterministicTranscript; } export function buildDeterministicCheckPlan( @@ -53,11 +56,11 @@ export async function runDeterministicChecks( request: DeterministicCheckRequest, ): Promise { const { config } = request; - const stdout = request.stdout ?? process.stdout; const repoRoot = request.repoRoot ?? process.cwd(); const env = request.env ?? process.env; const results: ToolResult[] = []; - const transcript = createDeterministicTranscript(stdout); + const transcript = + request.transcript ?? createDeterministicTranscript(process.stdout); const runPlan = buildDeterministicCheckRunPlan(config); if (runPlan.length === 0) { diff --git a/src/runner/transcript.ts b/src/runner/transcript.ts deleted file mode 100644 index 4d7ea5d..0000000 --- a/src/runner/transcript.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { - formatResultRow, - formatCount, - writeDetail, - writeIndentedBlock, - writeLine, - writeResultRow, - writeSection, - type TerminalStatus, -} from "../terminal/format.js"; -import type { ToolResult } from "./deterministic.js"; -import type { DeterministicResultSummary } from "./summary.js"; - -export interface DeterministicTranscriptCheckResult { - label: string; - status: ToolResult["status"]; - detail?: string; - outputTail?: string; -} - -export interface DeterministicTranscriptPlannedCheck { - label: string; - detail?: string; -} - -export interface DeterministicTranscript { - writeFailFast(): void; - writeCheckResult(result: DeterministicTranscriptCheckResult): void; - writeNoChecks(): void; - writeStart(checks: readonly DeterministicTranscriptPlannedCheck[]): void; - writeSummary(summary: DeterministicResultSummary): void; -} - -export function createDeterministicTranscript( - stdout: NodeJS.WritableStream, -): DeterministicTranscript { - const warnings: string[] = []; - const blockers: string[] = []; - const plannedChecks: DeterministicTranscriptPlannedCheck[] = []; - const liveUpdates = supportsLiveUpdates(stdout); - let completedCheckCount = 0; - let linesBelowCheckRows = 0; - - return { - writeFailFast() { - writeSkippedRemainingChecks("not run after fail_fast"); - writeDetail(stdout, "Stopped after a blocking failure because fail_fast is true."); - }, - - writeNoChecks() { - writeSection(stdout, "Checks"); - writeResultRow(stdout, "skipped", "No checks configured"); - writeLine(stdout); - }, - - writeCheckResult(result) { - writeRenderedCheckResult(result); - }, - - writeStart(checks) { - plannedChecks.push(...checks); - - writeSection(stdout, "Checks"); - writeDetail(stdout, `Running ${formatCount(checks.length, "check")}.`); - - for (const check of checks) { - writeResultRow(stdout, "running", check.label, check.detail); - } - }, - - writeSummary(summary) { - writeLine(stdout); - - if (summary.blockedCount > 0) { - writeLine( - stdout, - `Checks completed with ${formatCount(summary.blockedCount, "blocking failure")} and ${formatCount(summary.warningCount, "warning")}.`, - ); - writeLine(stdout); - writeSection(stdout, summary.blockedCount === 1 ? "Blocked" : "Blocked checks"); - - for (const blocker of blockers) { - writeDetail(stdout, `${blocker} failed and is configured as a blocking check.`); - } - - writeLine(stdout); - writeLine( - stdout, - "Fix the blocking failures above, or use `git push --no-verify` only when you intend to bypass local hooks.", - ); - return; - } - - if (summary.warningCount > 0) { - writeLine( - stdout, - `Checks completed with ${formatCount(summary.warningCount, "non-blocking warning")}.`, - ); - writeLine(stdout); - writeSection(stdout, summary.warningCount === 1 ? "Warning" : "Warnings"); - - for (const warning of warnings) { - writeDetail(stdout, `${warning} failed, but this check does not block the push.`); - } - writeLine(stdout); - return; - } - - writeLine(stdout, "Checks passed."); - writeLine(stdout); - }, - }; - - function writeRenderedCheckResult( - result: DeterministicTranscriptCheckResult, - ): void { - const status = mapStatus(result.status); - - writeCompletedCheckResult(status, result); - - if (result.outputTail) { - writeCommandOutputTail(result.outputTail); - } - - if (result.status === "warning") { - warnings.push(result.label); - } - - if (result.status === "blocked") { - blockers.push(result.label); - } - } - - function writeCompletedCheckResult( - status: TerminalStatus, - result: DeterministicTranscriptCheckResult, - ): void { - const plannedCheck = plannedChecks[completedCheckCount]; - const detail = result.detail ?? plannedCheck?.detail; - - writeCompletedCheckRow(status, result.label, detail); - } - - function writeSkippedRemainingChecks(detail: string): void { - while (completedCheckCount < plannedChecks.length) { - const plannedCheck = plannedChecks[completedCheckCount]; - - if (!plannedCheck) { - return; - } - - writeCompletedCheckRow("skipped", plannedCheck.label, detail); - } - } - - function writeCompletedCheckRow( - status: TerminalStatus, - label: string, - detail: string | undefined, - ): void { - if (liveUpdates && plannedChecks[completedCheckCount]) { - replacePlannedCheckRow(completedCheckCount, status, label, detail); - } else { - writeResultRow(stdout, status, label, detail); - } - - completedCheckCount += 1; - } - - function replacePlannedCheckRow( - index: number, - status: TerminalStatus, - label: string, - detail: string | undefined, - ): void { - const distanceFromCursor = - linesBelowCheckRows + plannedChecks.length - index; - - stdout.write( - `\u001B[${distanceFromCursor}A\r\u001B[2K${formatResultRow( - status, - label, - detail, - { stream: stdout }, - )}\u001B[${distanceFromCursor}B\r`, - ); - } - - function writeCommandOutputTail(outputTail: string): void { - const lines = outputTail.split("\n"); - - writeDetail(stdout, "Command output:"); - writeIndentedBlock(stdout, lines); - linesBelowCheckRows += 1 + lines.length; - } -} - -function mapStatus(status: ToolResult["status"]): TerminalStatus { - const statusByResult = { - blocked: "blocked", - passed: "passed", - skipped: "skipped", - warning: "warning", - } as const satisfies Record; - - return statusByResult[status]; -} - -function supportsLiveUpdates(stream: NodeJS.WritableStream): boolean { - const output = stream as NodeJS.WritableStream & { - isTTY?: boolean; - }; - - return output.isTTY === true && process.env.TERM !== "dumb"; -} diff --git a/src/transcript/events.ts b/src/transcript/events.ts new file mode 100644 index 0000000..d5c0733 --- /dev/null +++ b/src/transcript/events.ts @@ -0,0 +1,90 @@ +import type { AiConfig } from "../config/index.js"; +import type { + AiFinding, + AiReviewSummary, + LocalAiProviderFailure, +} from "../ai/types.js"; + +export type DeterministicTranscriptCheckStatus = + | "passed" + | "skipped" + | "warning" + | "blocked"; + +export interface DeterministicTranscriptCheckResult { + label: string; + status: DeterministicTranscriptCheckStatus; + detail?: string; + outputTail?: string; +} + +export interface DeterministicTranscriptPlannedCheck { + label: string; + detail?: string; +} + +export interface DeterministicTranscriptSummary { + blockedCount: number; + exitCode: number; + warningCount: number; +} + +export type LocalAiSkipReason = "local-ai-mode-off" | "skip-ai-check"; + +export type LocalAiTranscriptEvent = + | { + kind: "skip-no-files"; + } + | { + kind: "block-changed-lines"; + changedLineCount: number; + maxChangedLines: number; + } + | { + kind: "skip-prompt-tokens"; + estimatedPromptTokens: number; + maxPromptTokens: number; + } + | { + kind: "review-start"; + providerId: string; + changedFileCount: number; + } + | { + kind: "full-file-context"; + diffLineCount: number; + fullFileCount: number; + } + | { + kind: "provider-failure"; + aiMode: AiConfig["mode"]; + result: LocalAiProviderFailure; + } + | { + kind: "normalization-note"; + note: string; + } + | { + kind: "review-passed"; + } + | { + kind: "finding"; + finding: AiFinding; + } + | { + kind: "review-summary"; + summary: AiReviewSummary; + } + | { + kind: "advisory-continue"; + } + | { + kind: "provider-blocked"; + } + | { + kind: "review-blocked"; + }; + +export type WarningConfirmationPhase = + | "deterministic checks" + | "local AI review"; diff --git a/src/transcript/index.ts b/src/transcript/index.ts new file mode 100644 index 0000000..0fcbb23 --- /dev/null +++ b/src/transcript/index.ts @@ -0,0 +1,20 @@ +export { + createDeterministicTranscript, + createLocalAiTranscript, + createPushgateTranscript, + type DeterministicTranscript, + type LocalAiTranscript, + type PushgateTranscript, + type PushTranscript, + type WarningConfirmationTranscript, +} from "./pushgate-transcript.js"; + +export type { + DeterministicTranscriptCheckResult, + DeterministicTranscriptCheckStatus, + DeterministicTranscriptPlannedCheck, + DeterministicTranscriptSummary, + LocalAiSkipReason, + LocalAiTranscriptEvent, + WarningConfirmationPhase, +} from "./events.js"; diff --git a/src/transcript/pushgate-transcript.ts b/src/transcript/pushgate-transcript.ts new file mode 100644 index 0000000..12a080e --- /dev/null +++ b/src/transcript/pushgate-transcript.ts @@ -0,0 +1,455 @@ +import { SKIP_AI_CHECK_CONFIG_KEY } from "../skip-controls.js"; +import { + capitalize, + formatCount, + formatResultRow, + writeDetail, + writeIndentedBlock, + writeLine, + writeResultRow, + writeSection, + type TerminalStatus, +} from "../terminal/format.js"; +import type { + DeterministicTranscriptCheckResult, + DeterministicTranscriptCheckStatus, + DeterministicTranscriptPlannedCheck, + DeterministicTranscriptSummary, + LocalAiSkipReason, + LocalAiTranscriptEvent, + WarningConfirmationPhase, +} from "./events.js"; + +export interface DeterministicTranscript { + writeFailFast(): void; + writeCheckResult(result: DeterministicTranscriptCheckResult): void; + writeNoChecks(): void; + writeStart(checks: readonly DeterministicTranscriptPlannedCheck[]): void; + writeSummary(summary: DeterministicTranscriptSummary): void; +} + +export interface LocalAiTranscript { + writeEvents(events: readonly LocalAiTranscriptEvent[]): void; + writeSection(): void; + writeSkipped(options: { reason: LocalAiSkipReason }): void; +} + +export interface WarningConfirmationTranscript { + writeConfirmed(options: { + phase: WarningConfirmationPhase; + warningCount: number; + }): void; + writeDeclined(options: { + phase: WarningConfirmationPhase; + warningCount: number; + }): void; + writeUnavailable(options: { message: string }): void; +} + +export interface PushTranscript { + writePassed(): void; +} + +export interface PushgateTranscript { + deterministic: DeterministicTranscript; + localAi: LocalAiTranscript; + push: PushTranscript; + warningConfirmation: WarningConfirmationTranscript; +} + +export function createPushgateTranscript( + stdout: NodeJS.WritableStream, +): PushgateTranscript { + return { + deterministic: createDeterministicTranscript(stdout), + localAi: createLocalAiTranscript(stdout), + push: createPushTranscript(stdout), + warningConfirmation: createWarningConfirmationTranscript(stdout), + }; +} + +export function createDeterministicTranscript( + stdout: NodeJS.WritableStream, +): DeterministicTranscript { + const warnings: string[] = []; + const blockers: string[] = []; + const plannedChecks: DeterministicTranscriptPlannedCheck[] = []; + const liveUpdates = supportsLiveUpdates(stdout); + let completedCheckCount = 0; + let linesBelowCheckRows = 0; + + return { + writeFailFast() { + writeSkippedRemainingChecks("not run after fail_fast"); + writeDetail( + stdout, + "Stopped after a blocking failure because fail_fast is true.", + ); + }, + + writeNoChecks() { + writeSection(stdout, "Checks"); + writeResultRow(stdout, "skipped", "No checks configured"); + writeLine(stdout); + }, + + writeCheckResult(result) { + writeRenderedCheckResult(result); + }, + + writeStart(checks) { + plannedChecks.push(...checks); + + writeSection(stdout, "Checks"); + writeDetail(stdout, `Running ${formatCount(checks.length, "check")}.`); + + for (const check of checks) { + writeResultRow(stdout, "running", check.label, check.detail); + } + }, + + writeSummary(summary) { + writeLine(stdout); + + if (summary.blockedCount > 0) { + writeLine( + stdout, + `Checks completed with ${formatCount(summary.blockedCount, "blocking failure")} and ${formatCount(summary.warningCount, "warning")}.`, + ); + writeLine(stdout); + writeSection( + stdout, + summary.blockedCount === 1 ? "Blocked" : "Blocked checks", + ); + + for (const blocker of blockers) { + writeDetail( + stdout, + `${blocker} failed and is configured as a blocking check.`, + ); + } + + writeLine(stdout); + writeLine( + stdout, + "Fix the blocking failures above, or use `git push --no-verify` only when you intend to bypass the Local Push Gate.", + ); + return; + } + + if (summary.warningCount > 0) { + writeLine( + stdout, + `Checks completed with ${formatCount(summary.warningCount, "non-blocking warning")}.`, + ); + writeLine(stdout); + writeSection( + stdout, + summary.warningCount === 1 ? "Warning" : "Warnings", + ); + + for (const warning of warnings) { + writeDetail( + stdout, + `${warning} failed, but this check does not block the push.`, + ); + } + writeLine(stdout); + return; + } + + writeLine(stdout, "Checks passed."); + writeLine(stdout); + }, + }; + + function writeRenderedCheckResult( + result: DeterministicTranscriptCheckResult, + ): void { + const status = mapDeterministicStatus(result.status); + + writeCompletedCheckResult(status, result); + + if (result.outputTail) { + writeCommandOutputTail(result.outputTail); + } + + if (result.status === "warning") { + warnings.push(result.label); + } + + if (result.status === "blocked") { + blockers.push(result.label); + } + } + + function writeCompletedCheckResult( + status: TerminalStatus, + result: DeterministicTranscriptCheckResult, + ): void { + const plannedCheck = plannedChecks[completedCheckCount]; + const detail = result.detail ?? plannedCheck?.detail; + + writeCompletedCheckRow(status, result.label, detail); + } + + function writeSkippedRemainingChecks(detail: string): void { + while (completedCheckCount < plannedChecks.length) { + const plannedCheck = plannedChecks[completedCheckCount]; + + if (!plannedCheck) { + return; + } + + writeCompletedCheckRow("skipped", plannedCheck.label, detail); + } + } + + function writeCompletedCheckRow( + status: TerminalStatus, + label: string, + detail: string | undefined, + ): void { + if (liveUpdates && plannedChecks[completedCheckCount]) { + replacePlannedCheckRow(completedCheckCount, status, label, detail); + } else { + writeResultRow(stdout, status, label, detail); + } + + completedCheckCount += 1; + } + + function replacePlannedCheckRow( + index: number, + status: TerminalStatus, + label: string, + detail: string | undefined, + ): void { + const distanceFromCursor = + linesBelowCheckRows + plannedChecks.length - index; + + stdout.write( + `\u001B[${distanceFromCursor}A\r\u001B[2K${formatResultRow( + status, + label, + detail, + { stream: stdout }, + )}\u001B[${distanceFromCursor}B\r`, + ); + } + + function writeCommandOutputTail(outputTail: string): void { + const lines = outputTail.split("\n"); + + writeDetail(stdout, "Command output:"); + writeIndentedBlock(stdout, lines); + linesBelowCheckRows += 1 + lines.length; + } +} + +export function createLocalAiTranscript( + stdout: NodeJS.WritableStream, +): LocalAiTranscript { + return { + writeEvents(events) { + for (const event of events) { + renderLocalAiTranscriptEvent(event, stdout); + } + }, + + writeSection() { + writeSection(stdout, "AI review"); + }, + + writeSkipped(options) { + if (options.reason === "local-ai-mode-off") { + return; + } + + writeSection(stdout, "AI review"); + writeResultRow( + stdout, + "skipped", + "Local AI Review", + `skipped because ${SKIP_AI_CHECK_CONFIG_KEY}=true`, + ); + }, + }; +} + +function createWarningConfirmationTranscript( + stdout: NodeJS.WritableStream, +): WarningConfirmationTranscript { + return { + writeConfirmed(options) { + writeLine( + stdout, + `Warning Confirmation accepted: continuing with ${String(options.warningCount)} warning(s) from ${options.phase}.`, + ); + }, + + writeDeclined(options) { + writeLine( + stdout, + `Push blocked because Warning Confirmation was declined for ${String(options.warningCount)} warning(s) from ${options.phase}.`, + ); + }, + + writeUnavailable(options) { + writeLine(stdout, options.message); + writeLine( + stdout, + "Push blocked because Warning Confirmation could not be collected.", + ); + }, + }; +} + +function createPushTranscript( + stdout: NodeJS.WritableStream, +): PushTranscript { + return { + writePassed() { + writeLine(stdout); + writeLine(stdout, "Local Push Gate passed. Push allowed."); + }, + }; +} + +function renderLocalAiTranscriptEvent( + event: LocalAiTranscriptEvent, + stdout: NodeJS.WritableStream, +): void { + switch (event.kind) { + case "skip-no-files": + writeResultRow(stdout, "skipped", "No changed files to review"); + return; + case "block-changed-lines": + writeResultRow( + stdout, + "blocked", + "Changed lines", + `${String(event.changedLineCount)} changed lines exceed ai.max_changed_lines ${String(event.maxChangedLines)}`, + ); + return; + case "skip-prompt-tokens": + writeResultRow( + stdout, + "skipped", + "Prompt budget", + `approximately ${String(event.estimatedPromptTokens)} tokens exceeds ai.max_prompt_tokens ${String(event.maxPromptTokens)}`, + ); + return; + case "review-start": + writeDetail(stdout, `Provider: ${capitalize(event.providerId)}`); + writeDetail(stdout, `Files reviewed: ${String(event.changedFileCount)}`); + return; + case "full-file-context": + writeDetail( + stdout, + `Context: ${formatCount(event.diffLineCount, "diff line")} plus ${formatCount(event.fullFileCount, "full file")} for extra context`, + ); + return; + case "provider-failure": { + const status = event.aiMode === "advisory" ? "warning" : "blocked"; + + writeResultRow( + stdout, + status, + `${capitalize(event.result.provider)} provider`, + event.result.message, + ); + + if (event.result.detail) { + writeDetail(stdout, "Detail:"); + writeIndentedBlock(stdout, event.result.detail.split("\n")); + } + + if (event.result.output) { + writeDetail(stdout, "Provider output:"); + writeIndentedBlock(stdout, event.result.output.split("\n")); + } + + return; + } + case "normalization-note": + writeDetail(stdout, `Note: ${event.note}`); + return; + case "review-passed": + writeResultRow(stdout, "passed", "No findings"); + return; + case "finding": { + const status = + event.finding.severity === "blocking" ? "blocked" : "warning"; + const location = + event.finding.line === "N/A" + ? event.finding.file + : `${event.finding.file}:${event.finding.line}`; + + writeResultRow( + stdout, + status, + `AI ${humanizeCategory(event.finding.category)}`, + location, + ); + writeDetail(stdout, `Message: ${event.finding.message}`); + writeDetail(stdout, `Suggestion: ${event.finding.suggestion}`); + return; + } + case "review-summary": + if ( + event.summary.blockingCount > 0 || + event.summary.warningCount > 0 + ) { + writeDetail( + stdout, + `Finished with ${formatCount(event.summary.blockingCount, "blocking finding")} and ${formatCount(event.summary.warningCount, "warning")}.`, + ); + } + return; + case "advisory-continue": + writeDetail(stdout, "Continuing because ai.mode is advisory."); + return; + case "provider-blocked": + writeLine(stdout); + writeLine(stdout, "Local AI Review is blocking in this repository."); + writeLine( + stdout, + `Fix the provider issue, or use \`git -c ${SKIP_AI_CHECK_CONFIG_KEY}=true push\` to bypass only Local AI Review for this push.`, + ); + return; + case "review-blocked": + writeLine(stdout); + writeLine(stdout, "Local AI Review blocked the push."); + writeLine( + stdout, + `Fix the findings above, or use \`git -c ${SKIP_AI_CHECK_CONFIG_KEY}=true push\` to bypass only Local AI Review for this push.`, + ); + return; + } +} + +function mapDeterministicStatus( + status: DeterministicTranscriptCheckStatus, +): TerminalStatus { + const statusByResult = { + blocked: "blocked", + passed: "passed", + skipped: "skipped", + warning: "warning", + } as const satisfies Record; + + return statusByResult[status]; +} + +function supportsLiveUpdates(stream: NodeJS.WritableStream): boolean { + const output = stream as NodeJS.WritableStream & { + isTTY?: boolean; + }; + + return output.isTTY === true && process.env.TERM !== "dumb"; +} + +function humanizeCategory(category: string): string { + return category.replace(/_/g, " "); +} diff --git a/src/workflows/local-push-gate-run.ts b/src/workflows/local-push-gate-run.ts index 254d9a3..6fc9e70 100644 --- a/src/workflows/local-push-gate-run.ts +++ b/src/workflows/local-push-gate-run.ts @@ -9,18 +9,15 @@ import { runDeterministicChecks, } from "../runner/deterministic.js"; import { - SKIP_AI_CHECK_CONFIG_KEY, - type SkipControlState, -} from "../skip-controls.js"; -import { - writeLine, - writeResultRow, - writeSection, -} from "../terminal/format.js"; + createPushgateTranscript, + type LocalAiTranscript, + type WarningConfirmationPhase, + type WarningConfirmationTranscript, +} from "../transcript/index.js"; +import type { SkipControlState } from "../skip-controls.js"; import { createTerminalWarningConfirmer, WarningConfirmationError, - type WarningConfirmationPhase, type WarningConfirmer, } from "./warning-confirmation.js"; @@ -51,6 +48,7 @@ type LocalAiPhaseDecision = export async function runLocalPushGate( options: LocalPushGateRunOptions, ): Promise { + const transcript = createPushgateTranscript(options.stdout); const localAi = getLocalAiPhaseDecision(options.config, options.skipControls); const changedFileResolution = await resolveChangedFilesIfRequired({ config: options.config, @@ -63,7 +61,7 @@ export async function runLocalPushGate( config: options.config, env: options.env, repoRoot: options.repoRoot, - stdout: options.stdout, + transcript: transcript.deterministic, }); if (deterministicSummary.exitCode !== 0) { @@ -74,7 +72,7 @@ export async function runLocalPushGate( !(await confirmWarningsBeforeContinuing({ confirmer: options.warningConfirmer, phase: "deterministic checks", - stdout: options.stdout, + transcript: transcript.warningConfirmation, warningCount: deterministicSummary.results.filter( (result) => result.status === "warning", ).length, @@ -89,7 +87,7 @@ export async function runLocalPushGate( decision: localAi, env: options.env, repoRoot: options.repoRoot, - stdout: options.stdout, + transcript: transcript.localAi, }); if (localAiSummary.exitCode !== 0) { @@ -100,15 +98,14 @@ export async function runLocalPushGate( !(await confirmWarningsBeforeContinuing({ confirmer: options.warningConfirmer, phase: "local AI review", - stdout: options.stdout, + transcript: transcript.warningConfirmation, warningCount: localAiSummary.warningCount, })) ) { return 1; } - writeLine(options.stdout); - writeLine(options.stdout, "Pushgate passed. Changes allowed..."); + transcript.push.writePassed(); return 0; } @@ -139,20 +136,17 @@ async function runLocalAiPhase(options: { decision: LocalAiPhaseDecision; env: NodeJS.ProcessEnv; repoRoot: string; - stdout: NodeJS.WritableStream; + transcript: LocalAiTranscript; }): Promise<{ exitCode: number; warningCount: number }> { if (options.decision.kind === "skip") { - const message = formatLocalAiSkipReason(options.decision.reason); - - if (message !== null) { - writeSection(options.stdout, "AI review"); - writeResultRow(options.stdout, "skipped", message); - } + options.transcript.writeSkipped({ + reason: options.decision.reason, + }); return { exitCode: 0, warningCount: 0 }; } - writeSection(options.stdout, "AI review"); + options.transcript.writeSection(); return await runLocalAiReview({ aiConfig: options.config.ai, @@ -163,14 +157,14 @@ async function runLocalAiPhase(options: { env: options.env, repoRoot: options.repoRoot, reviewConfig: options.config.review, - stdout: options.stdout, + transcript: options.transcript, }); } async function confirmWarningsBeforeContinuing(options: { confirmer: WarningConfirmer | undefined; phase: WarningConfirmationPhase; - stdout: NodeJS.WritableStream; + transcript: WarningConfirmationTranscript; warningCount: number; }): Promise { if (options.warningCount === 0) { @@ -186,22 +180,21 @@ async function confirmWarningsBeforeContinuing(options: { }); if (confirmed) { - options.stdout.write( - `Continuing with ${String(options.warningCount)} warning(s) from ${options.phase} after confirmation.\n`, - ); + options.transcript.writeConfirmed({ + phase: options.phase, + warningCount: options.warningCount, + }); return true; } - options.stdout.write( - `Push blocked because ${options.phase} produced ${String(options.warningCount)} warning(s) and continuation was not confirmed.\n`, - ); + options.transcript.writeDeclined({ + phase: options.phase, + warningCount: options.warningCount, + }); return false; } catch (error) { if (error instanceof WarningConfirmationError) { - options.stdout.write(`${error.message}\n`); - options.stdout.write( - "Push blocked because warning confirmation could not be collected.\n", - ); + options.transcript.writeUnavailable({ message: error.message }); return false; } @@ -230,16 +223,6 @@ function getLocalAiPhaseDecision( return { kind: "run" }; } -function formatLocalAiSkipReason( - reason: Extract["reason"], -): string | null { - if (reason === "local-ai-mode-off") { - return null; - } - - return `Skipping local AI because ${SKIP_AI_CHECK_CONFIG_KEY}=true.`; -} - function requireChangedFileResolution( changedFileResolution: ChangedFileResolution | null, phaseName: string, diff --git a/src/workflows/warning-confirmation.ts b/src/workflows/warning-confirmation.ts index b1b17b6..2a658e5 100644 --- a/src/workflows/warning-confirmation.ts +++ b/src/workflows/warning-confirmation.ts @@ -3,10 +3,7 @@ import { InteractiveTerminalError, type InteractiveTerminal, } from "./terminal.js"; - -export type WarningConfirmationPhase = - | "deterministic checks" - | "local AI review"; +import type { WarningConfirmationPhase } from "../transcript/index.js"; export interface WarningConfirmationRequest { phase: WarningConfirmationPhase; diff --git a/test/ai.test.ts b/test/ai.test.ts index c4cb7df..ac341ce 100644 --- a/test/ai.test.ts +++ b/test/ai.test.ts @@ -31,7 +31,6 @@ import { createCommandProviderAdapter } from "../src/ai/providers/command-provid import { claudeProvider } from "../src/ai/providers/claude.js"; import { copilotProvider } from "../src/ai/providers/copilot.js"; import type { ProviderCommandResult } from "../src/ai/providers/run-provider-command.js"; -import { renderLocalAiTranscript } from "../src/ai/transcript.js"; import { buildLocalAiVerdict } from "../src/ai/verdict.js"; import type { LocalAiProviderAdapter } from "../src/ai/types.js"; import { @@ -39,6 +38,7 @@ import { sanitizeGitLocalEnv, } from "../src/git/environment.js"; import { resolveChangedFiles } from "../src/path-policy/index.js"; +import { createLocalAiTranscript } from "../src/transcript/index.js"; test("validates the canonical AI review contract with Zod", () => { const result = AiReviewOutputSchema.safeParse(canonicalAiReviewOutput()); @@ -804,7 +804,7 @@ test("builds and renders local AI verdict output without provider execution", () }); assert.equal(verdict.exitCode, 0); - renderLocalAiTranscript(verdict.transcriptEvents, output.stream); + createLocalAiTranscript(output.stream).writeEvents(verdict.transcriptEvents); assert.match( output.text(), @@ -1054,7 +1054,7 @@ test("runs the Claude adapter through the provider interface with model selectio max_lines_for_full_file: 300, target_branch: "main", }, - stdout: output.stream, + transcript: createLocalAiTranscript(output.stream), }); assert.equal(result.exitCode, 0, output.text()); @@ -1861,7 +1861,7 @@ test("maps Copilot auth-like failures through advisory mode", async () => { max_lines_for_full_file: 300, target_branch: "main", }, - stdout: output.stream, + transcript: createLocalAiTranscript(output.stream), }); assert.equal(result.exitCode, 0, output.text()); @@ -2085,12 +2085,12 @@ test("blocks local AI before provider invocation when changed-line guardrail is max_lines_for_full_file: 300, target_branch: "main", }, - stdout: output.stream, + transcript: createLocalAiTranscript(output.stream), }); assert.equal(result.exitCode, 1, output.text()); assert.match(output.text(), /\[block\] Changed lines\s+\d+ changed lines exceed ai\.max_changed_lines 1/); - assert.match(output.text(), /Local AI review blocked the push/); + assert.match(output.text(), /Local AI Review blocked the push/); assert.doesNotMatch(output.text(), /provider claude failed/); }); }); @@ -2130,7 +2130,7 @@ test("reports unsupported local AI providers through the public gate", async () max_lines_for_full_file: 300, target_branch: "main", }, - stdout: output.stream, + transcript: createLocalAiTranscript(output.stream), }); assert.equal(result.exitCode, 1, output.text()); @@ -2139,7 +2139,7 @@ test("reports unsupported local AI providers through the public gate", async () output.text(), /does not implement the configured AI provider "openai" yet/, ); - assert.match(output.text(), /Local AI is blocking in this repository/); + assert.match(output.text(), /Local AI Review is blocking in this repository/); assert.doesNotMatch(output.text(), /Provider: Openai/); }); @@ -2169,7 +2169,7 @@ test("skips local AI after prompt rendering when prompt token guardrail is excee max_lines_for_full_file: 300, target_branch: "main", }, - stdout: output.stream, + transcript: createLocalAiTranscript(output.stream), }); assert.equal(result.exitCode, 0, output.text()); @@ -2222,7 +2222,7 @@ test("passes configured timeout seconds to the Claude adapter", async () => { max_lines_for_full_file: 300, target_branch: "main", }, - stdout: output.stream, + transcript: createLocalAiTranscript(output.stream), }); assert.equal(result.exitCode, 1, output.text()); diff --git a/test/deterministic-runner.test.ts b/test/deterministic-runner.test.ts index 4392182..2da8f1f 100644 --- a/test/deterministic-runner.test.ts +++ b/test/deterministic-runner.test.ts @@ -25,7 +25,10 @@ import { runDeterministicChecks, } from "../src/runner/deterministic.js"; import { summarizeDeterministicResults } from "../src/runner/summary.js"; -import { createDeterministicTranscript } from "../src/runner/transcript.js"; +import { + createDeterministicTranscript, + type DeterministicTranscript, +} from "../src/transcript/index.js"; const changedFiles: ChangedFile[] = [ { @@ -615,7 +618,7 @@ test("renders deterministic transcript without running commands", () => { "Blocked", " Check failed and is configured as a blocking check.", "", - "Fix the blocking failures above, or use `git push --no-verify` only when you intend to bypass local hooks.", + "Fix the blocking failures above, or use `git push --no-verify` only when you intend to bypass the Local Push Gate.", "", ].join("\n"), ); @@ -682,13 +685,20 @@ async function runChecks( config: PushgateConfig, options: Omit< Parameters[0], - "config" - > = {}, + "config" | "transcript" + > & { + stdout?: NodeJS.WritableStream; + transcript?: DeterministicTranscript; + } = {}, ) { + const { stdout, transcript, ...requestOptions } = options; + return await runDeterministicChecks({ changedFileResolution, - ...options, + ...requestOptions, config, + transcript: + transcript ?? (stdout ? createDeterministicTranscript(stdout) : undefined), }); } diff --git a/test/hook.test.ts b/test/hook.test.ts index 19e7e79..be275ad 100644 --- a/test/hook.test.ts +++ b/test/hook.test.ts @@ -337,7 +337,7 @@ test("skip-ai-check keeps deterministic checks running on a real installed-hook assert.match(output, /\[ok\] Record tool/); assert.match( output, - /Skipping local AI because pushgate\.skip-ai-check=true/, + /Local AI Review\s+skipped because pushgate\.skip-ai-check=true/, ); }); }); diff --git a/test/runner.test.ts b/test/runner.test.ts index 07c1a36..fde7d12 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -112,7 +112,7 @@ test("deterministic warnings prompt before local AI runs", async () => { assert.match(result.stdout, /\[warn\] Warn tool\s+exited with code 7/); assert.match( result.stdout, - /Continuing with 1 warning\(s\) from deterministic checks after confirmation/, + /Warning Confirmation accepted: continuing with 1 warning\(s\) from deterministic checks/, ); assert.deepEqual(prompts, [ { phase: "deterministic checks", warningCount: 1 }, @@ -136,7 +136,7 @@ test("declining deterministic warnings blocks the pre-push runner", async () => assert.match(result.stdout, /\[warn\] Warn tool\s+exited with code 7/); assert.match( result.stdout, - /Push blocked because deterministic checks produced 1 warning\(s\) and continuation was not confirmed/, + /Push blocked because Warning Confirmation was declined for 1 warning\(s\) from deterministic checks/, ); assert.deepEqual(prompts, [ { phase: "deterministic checks", warningCount: 1 }, @@ -195,7 +195,7 @@ test("skip-ai-check keeps deterministic work and prints visible AI skip output", assert.match(result.stdout, /\[skip\] No checks configured/); assert.match( result.stdout, - /Skipping local AI because pushgate\.skip-ai-check=true/, + /Local AI Review\s+skipped because pushgate\.skip-ai-check=true/, ); assert.equal(result.stderr, ""); }); @@ -227,7 +227,7 @@ test("blocking local AI findings block the pre-push runner", async () => { assert.equal(result.code, 1, formatResult(result)); assert.match(result.stdout, /Provider: Claude/); assert.match(result.stdout, /\[block\] AI logic errors\s+src\/changed\.ts:2-3/); - assert.match(result.stdout, /Local AI review blocked the push/); + assert.match(result.stdout, /Local AI Review blocked the push/); assert.equal(result.stderr, ""); }); }); @@ -268,7 +268,7 @@ test("Copilot local AI warnings continue after confirmation", async () => { ); assert.match( result.stdout, - /Continuing with 1 warning\(s\) from local AI review after confirmation/, + /Warning Confirmation accepted: continuing with 1 warning\(s\) from local AI review/, ); assert.deepEqual(prompts, [ { phase: "local AI review", warningCount: 1 }, @@ -308,7 +308,7 @@ test("declining local AI warnings blocks the pre-push runner", async () => { assert.match(result.stdout, /\[warn\] AI performance\s+src\/changed\.ts:2/); assert.match( result.stdout, - /Push blocked because local AI review produced 1 warning\(s\) and continuation was not confirmed/, + /Push blocked because Warning Confirmation was declined for 1 warning\(s\) from local AI review/, ); assert.deepEqual(prompts, [ { phase: "local AI review", warningCount: 1 }, @@ -344,7 +344,7 @@ test("blocking local AI provider failures block the pre-push runner", async () = result.stdout, /\[block\] Claude provider\s+Claude Code CLI was not found on PATH/, ); - assert.match(result.stdout, /Local AI is blocking in this repository/); + assert.match(result.stdout, /Local AI Review is blocking in this repository/); assert.equal(result.stderr, ""); }); }); @@ -411,7 +411,7 @@ test("advisory local AI provider failures continue after confirmation", async () assert.match(result.stdout, /Continuing because ai.mode is advisory/); assert.match( result.stdout, - /Continuing with 1 warning\(s\) from local AI review after confirmation/, + /Warning Confirmation accepted: continuing with 1 warning\(s\) from local AI review/, ); assert.deepEqual(prompts, [ { phase: "local AI review", warningCount: 1 }, @@ -448,7 +448,7 @@ test("AI changed-line guardrail blocks provider invocation visibly", async () => result.stdout, /\[block\] Changed lines\s+\d+ changed lines exceed ai\.max_changed_lines 1/, ); - assert.match(result.stdout, /Local AI review blocked the push/); + assert.match(result.stdout, /Local AI Review blocked the push/); assert.doesNotMatch(result.stdout, /Running local AI review with claude/); assert.equal(result.stderr, ""); }); diff --git a/test/transcript.test.ts b/test/transcript.test.ts new file mode 100644 index 0000000..548d508 --- /dev/null +++ b/test/transcript.test.ts @@ -0,0 +1,156 @@ +import assert from "node:assert/strict"; +import { Writable } from "node:stream"; +import test from "node:test"; + +import { createPushgateTranscript } from "../src/transcript/index.js"; + +test("renders Local AI Review Skip Control and final pass copy", () => { + const output = captureOutput(); + const transcript = createPushgateTranscript(output.stream); + + transcript.localAi.writeSkipped({ reason: "skip-ai-check" }); + transcript.push.writePassed(); + + assert.equal( + output.text(), + [ + "AI review", + " [skip] Local AI Review skipped because pushgate.skip-ai-check=true", + "", + "Local Push Gate passed. Push allowed.", + "", + ].join("\n"), + ); +}); + +test("keeps local AI mode off silent", () => { + const output = captureOutput(); + const transcript = createPushgateTranscript(output.stream); + + transcript.localAi.writeSkipped({ reason: "local-ai-mode-off" }); + + assert.equal(output.text(), ""); +}); + +test("renders Warning Confirmation outcomes", () => { + const output = captureOutput(); + const transcript = createPushgateTranscript(output.stream); + + transcript.warningConfirmation.writeConfirmed({ + phase: "deterministic checks", + warningCount: 1, + }); + transcript.warningConfirmation.writeDeclined({ + phase: "local AI review", + warningCount: 2, + }); + transcript.warningConfirmation.writeUnavailable({ + message: + "Warning confirmation required for local AI review, but no interactive terminal is available.", + }); + + assert.equal( + output.text(), + [ + "Warning Confirmation accepted: continuing with 1 warning(s) from deterministic checks.", + "Push blocked because Warning Confirmation was declined for 2 warning(s) from local AI review.", + "Warning confirmation required for local AI review, but no interactive terminal is available.", + "Push blocked because Warning Confirmation could not be collected.", + "", + ].join("\n"), + ); +}); + +test("renders Local AI Review blocking copy with AI Skip Control guidance", () => { + const output = captureOutput(); + const transcript = createPushgateTranscript(output.stream); + + transcript.localAi.writeEvents([ + { + kind: "provider-failure", + aiMode: "blocking", + result: { + kind: "provider-error", + code: "missing_binary", + provider: "claude", + message: "Claude Code CLI was not found on PATH.", + }, + }, + { kind: "provider-blocked" }, + { + kind: "finding", + finding: { + category: "logic_errors", + confidence: "high", + file: "src/changed.ts", + line: "7", + message: "The changed branch returns the wrong value.", + severity: "blocking", + source: { + provider: "claude", + }, + suggestion: "Return the intended value.", + }, + }, + { + kind: "review-summary", + summary: { + blockingCount: 1, + verdict: "BLOCK", + warningCount: 0, + }, + }, + { kind: "review-blocked" }, + ]); + + assert.match(output.text(), /\[block\] Claude provider\s+Claude Code CLI was not found on PATH/); + assert.match(output.text(), /Local AI Review is blocking in this repository/); + assert.match(output.text(), /git -c pushgate\.skip-ai-check=true push/); + assert.match(output.text(), /\[block\] AI logic errors\s+src\/changed\.ts:7/); + assert.match(output.text(), /Local AI Review blocked the push/); +}); + +test("renders Deterministic Check blocking copy with Local Push Gate guidance", () => { + const output = captureOutput(); + const transcript = createPushgateTranscript(output.stream); + + transcript.deterministic.writeStart([ + { + label: "Lint", + }, + ]); + transcript.deterministic.writeCheckResult({ + detail: "exited with code 1", + label: "Lint", + status: "blocked", + }); + transcript.deterministic.writeSummary({ + blockedCount: 1, + exitCode: 1, + warningCount: 0, + }); + + assert.match(output.text(), /Checks completed with 1 blocking failure and 0 warnings/); + assert.match(output.text(), /Lint failed and is configured as a blocking check/); + assert.match(output.text(), /git push --no-verify/); + assert.match(output.text(), /bypass the Local Push Gate/); +}); + +function captureOutput(): { + stream: Writable; + text(): string; +} { + let output = ""; + + return { + stream: new Writable({ + write(chunk, _encoding, callback) { + output += String(chunk); + callback(); + }, + }), + text() { + return output; + }, + }; +} diff --git a/test/workflow-run-plan.test.ts b/test/workflow-run-plan.test.ts index 2934883..ed9eddb 100644 --- a/test/workflow-run-plan.test.ts +++ b/test/workflow-run-plan.test.ts @@ -61,7 +61,7 @@ test("inactive deterministic checks and local AI do not resolve changed files", assert.equal(result.code, 0, formatResult(result)); assert.match(result.stdout, /\[skip\] No checks configured/); - assert.match(result.stdout, /Pushgate passed\. Changes allowed\.\.\./); + assert.match(result.stdout, /Local Push Gate passed\. Push allowed\./); assert.equal(result.stderr, ""); }); }); @@ -96,7 +96,7 @@ test("skip-ai-check keeps deterministic changed-file work", async () => { assert.match(result.stdout, /\[ok\] Changed files tool/); assert.match( result.stdout, - /Skipping local AI because pushgate\.skip-ai-check=true/, + /Local AI Review\s+skipped because pushgate\.skip-ai-check=true/, ); assert.doesNotMatch(result.stdout, /Claude Code CLI was not found on PATH/); assert.equal(result.stderr, "");