From 902f547f3cfcb96597fdc581ec13a8ef0c273a07 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:42:10 -0400 Subject: [PATCH 1/2] Add finding previews, fixture capture, comparison and progress tools --- README.md | 81 ++++++++++ docs/agents.md | 21 +++ package-lock.json | 4 +- package.json | 2 +- src/calibrate.js | 3 +- src/capture.js | 114 ++++++++++++++ src/check.js | 75 +++++++-- src/cli.js | 153 +----------------- src/commands.js | 286 +++++++++++++++++++++++++++++++++ src/compare.js | 173 ++++++++++++++++++++ src/index.d.ts | 85 ++++++++++ src/index.js | 4 + src/presentation.js | 96 +++++++++++ test/feedback.test.js | 358 ++++++++++++++++++++++++++++++++++++++++++ 14 files changed, 1285 insertions(+), 170 deletions(-) create mode 100644 src/capture.js create mode 100644 src/commands.js create mode 100644 src/compare.js create mode 100644 src/presentation.js create mode 100644 test/feedback.test.js diff --git a/README.md b/README.md index dab5f21..214ff1e 100644 --- a/README.md +++ b/README.md @@ -452,3 +452,84 @@ Use advisory mode on a labeled sample before enforcing a rule. - [jev-lint](https://github.com/mizchi/jev-lint): caching, calibration, and hook integration. This implementation is independently written. MIT licensed. + +## Inspect a finding + +Text reports show the rule, violation probability, cutoff, and a bounded preview +of changed lines with before/after line numbers. The model judges an evidence +packet; the preview does not claim to identify the exact offending line. JSON +reports include the same preview and indicate omitted or truncated lines. + +Checks show delayed progress on stderr, including checked packets and cache hits. +Use `--no-progress` for quiet output or `--verbose` for individual requests. JSON +stays on stdout. Progress makes no inference calls and does not extend the hook +budget. Cached answers are reused only for matching evidence, rule, model/backend +identity, and protocol. Custom evaluators must supply a stable `cacheIdentity` to +opt into caching; hosts with mutable inference settings may leave it disabled. + +## Turn a mistake into an example + +Preview an incorrectly blocked edit (`pass`) or a missed violation (`violation`): + +```sh +judgement capture --rule wording --path docs/guide.md \ + --name "Quoted UI label" --expected pass +``` + +Capture reads the index against HEAD, including unchanged supporting files named +by the rule's context globs. It does not read unstaged changes or call the model. +Use `--base --head ` for committed changes and repeat +`--context ` to include additional unchanged supporting files. + +Review and redact the preview, then save a new fixture file: + +```sh +judgement capture --rule wording --path docs/guide.md \ + --name "Quoted UI label" --expected pass \ + --output .judgement/examples/quoted-label.json +judgement test --rule wording --examples .judgement/examples/quoted-label.json +``` + +`--output` refuses to overwrite existing files and never stages anything. If the +preview contains sensitive values, save the preview to a local scratch file and +replace them with synthetic values before adding it to the repository. The fixture +can be tested independently with `--examples` or its example merged into the +rule's main fixture file. Multi-file changes need reduction to a single changed +file with unchanged supporting context. Binary files, symlinks, and captures above +256 KB are rejected instead of producing misleading fixtures. + +## Compare calibration runs + +Keep the fixtures fixed while changing a rule, prompt, model, or cutoff: + +```sh +mkdir -p .judgement/results +judgement test --format json > .judgement/results/before.json +# Make the candidate change, then rerun the same examples. +judgement test --format json > .judgement/results/after.json +judgement compare --before .judgement/results/before.json \ + --after .judgement/results/after.json +``` + +Comparison lists improvements and regressions per example, so a net gain does not +hide a new false block. It compares correct-run rates when repetition counts differ +and retains incomplete results, failures, and raw probability ranges in JSON. +A regression exits 1; incompatible input exits 2. Fixture hashes and labels must +match. Reports without fixture provenance must be regenerated. Calibration reports +with several cutoffs use each report's configured cutoff by default; select another +recorded cutoff with `--before-threshold` or `--after-threshold`. Comparison is local +and makes no inference calls. Keep generated reports out of source control. + +## Strict CI checks + +Run `judgement check --base --head --timeout-ms 120000` +without `--hook` or `--advisory`. Violations exit 1, invalid configuration exits 2, +and incomplete coverage or inference failures exit 3. Make that check required +in the repository's merge rules; a local hook may be skipped or time out. + +For public contributions, run a pinned, trusted copy of Judgement with trusted +workflow code and a read-only checkout of the base revision. Fetch the PR head as +Git objects and inspect its diff without checking out or executing PR code. Keep +inference credentials scoped to the checking step, disable package install scripts, +and never restore caches controlled by untrusted PR code into that job. CI uses +its configured inference backend; align its model with the one used for calibration. diff --git a/docs/agents.md b/docs/agents.md index 1550f21..a3ce46d 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -150,3 +150,24 @@ remain incomplete. The default binary evaluator does not request context expansi based on its probability. Use explicit context and compare expanded packets when investigating a missed violation. Recalibrate when changing question types; Choice confidence and Noul probability use different meanings and scales. + +## Collect and compare feedback + +Use `judgement capture --rule --path --name --expected pass|violation` +to preview an index-based example. Review and redact it before saving a new file +with `--output`; capture never overwrites or stages files. Include unchanged +supporting evidence with `--context`. Reduce multi-file changes rather than +silently discarding context that changed. Captured content must satisfy the +repository's privacy requirements before it is committed. + +Save baseline and candidate `test --format json` reports locally, using identical +fixtures. `judgement compare --before --after ` exposes per-example +regressions and exits 1 when any are found. Do not change expected labels to hide +regressions. Compare both valid exceptions and violations; repeated observations +of the same example are not independent coverage. + +Finding snippets are changed-line previews, not model-localized accusations. +Read the supplied evidence before proposing a fix. Progress and cache-hit counts +are diagnostics; only a complete report establishes check completion. In CI, use +strict checks without `--hook` or `--advisory`, and require the resulting status +before merge. Missing inference credentials must not produce a successful check. diff --git a/package-lock.json b/package-lock.json index 45c3a03..01eb98f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@roo-code/judgement", - "version": "0.3.1", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@roo-code/judgement", - "version": "0.3.1", + "version": "0.4.0", "license": "MIT", "dependencies": { "picomatch": "4.0.7" diff --git a/package.json b/package.json index 72e910a..6671c21 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@roo-code/judgement", - "version": "0.3.1", + "version": "0.4.0", "description": "Fast Git checks for plain-language business rules, powered by Jev", "type": "module", "license": "MIT", diff --git a/src/calibrate.js b/src/calibrate.js index 2e5559a..5e8ca95 100644 --- a/src/calibrate.js +++ b/src/calibrate.js @@ -2,7 +2,7 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { check } from './check.js'; -import { git, gitEnv } from './git.js'; +import { git, gitEnv, hash } from './git.js'; import { ConfigurationError, matches, @@ -179,6 +179,7 @@ export async function calibrate(options = {}) { }; const report = { ruleId: criterion.id, + fixtureSha256: hash(JSON.stringify(examples)), configuredThreshold: criterion.threshold, backend: options.evaluate ? 'custom' : (options.model ?? MODEL), protocolVersion: PROTOCOL_VERSION, diff --git a/src/capture.js b/src/capture.js new file mode 100644 index 0000000..bddf037 --- /dev/null +++ b/src/capture.js @@ -0,0 +1,114 @@ +import { writeFile } from 'node:fs/promises'; +import { snapshot, inventory, streamGit, git, policyBlob } from './git.js'; +import { + parsePolicy, + readPolicy, + ConfigurationError, + matches, +} from './policy.js'; +import { examplesFrom, safePath } from './calibrate.js'; + +/** Capture index/commit objects, never unstaged working-tree content. No inference. */ +export async function captureExample(options = {}) { + const { ruleId, path, name, expected } = options; + if ( + !ruleId || + !safePath(path) || + !name?.trim() || + !['pass', 'violation'].includes(expected) + ) + throw new ConfigurationError( + 'Capture requires --rule, a safe --path, --name, and --expected pass|violation.', + ); + const snap = await snapshot(options, options.signal); + const text = await policyBlob(snap, snap.base, options.signal); + const policy = + text === null ? await readPolicy(snap.root) : parsePolicy(text); + const rule = policy.criteria.find((item) => item.id === ruleId); + if (!rule) + throw new ConfigurationError( + 'The selected rule does not exist in the base policy.', + ); + const change = snap.changes.find((item) => item.path === path); + if (!change) + throw new ConfigurationError( + 'The selected path has no change in this snapshot.', + ); + let remaining = 256_000; + const read = async (path, oid, mode) => { + if (mode === '000000') return null; + if (!['100644', '100755'].includes(mode)) + throw new ConfigurationError('Capture supports regular text files only.'); + const size = Number( + await git(snap.root, ['cat-file', '-s', oid], options.signal, snap.env), + ); + if (!Number.isFinite(size) || size > remaining) + throw new ConfigurationError( + 'Capture exceeds 256 KB. Reduce the example before saving it.', + ); + remaining -= size; + const chunks = []; + for await (const chunk of streamGit( + snap.root, + ['cat-file', 'blob', oid], + options.signal, + snap.env, + )) + chunks.push(chunk); + const content = Buffer.concat(chunks); + if (content.includes(0)) + throw new ConfigurationError('Capture does not support binary files.'); + try { + return new TextDecoder('utf-8', { fatal: true }).decode(content); + } catch { + throw new ConfigurationError('Capture supports UTF-8 text only.'); + } + }; + const before = await read(path, change.oldOid, change.oldMode); + const after = await read(path, change.oid, change.mode); + const files = await inventory(snap, options.signal); + const contexts = new Set(options.context ?? []); + for (const pattern of rule.context ?? []) { + const found = [...files.keys()].filter((file) => matches(file, [pattern])); + if (!found.length) + throw new ConfigurationError(`Required context has no match: ${pattern}`); + for (const file of found) contexts.add(file); + } + const context = {}; + for (const file of contexts) { + if (file === path) continue; + if (!safePath(file) || !files.has(file)) + throw new ConfigurationError( + 'Context must name an existing safe repository path.', + ); + if (snap.changes.some((item) => item.path === file)) + throw new ConfigurationError( + 'Supporting context changed too. Reduce this to a single-file example with unchanged context.', + ); + const entry = files.get(file); + context[file] = await read(file, entry.oid, entry.mode); + } + const fixture = { + ruleId, + examples: [ + { + name, + path, + before, + after, + expected, + ...(contexts.size ? { context } : {}), + }, + ], + }; + examplesFrom(fixture, rule); + return fixture; +} + +/** Save only to a new file; callers preview/redact first. Never stage or overwrite. */ +export async function saveCapturedExample(fixture, output) { + await writeFile(output, JSON.stringify(fixture, null, 2) + '\n', { + flag: 'wx', + mode: 0o600, + }); +} diff --git a/src/check.js b/src/check.js index fe464a9..c15f0b9 100644 --- a/src/check.js +++ b/src/check.js @@ -1,3 +1,4 @@ +import { changedLines, terminalText } from './presentation.js'; import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { setImmediate } from 'node:timers/promises'; @@ -77,18 +78,36 @@ export async function check(options = {}) { ? AbortSignal.any([options.signal, controller.signal]) : controller.signal; let stopped = false; - const work = run(options, result, signal, () => stopped).catch((error) => { - if (!stopped) { - result.messages.push( - error instanceof ConfigurationError - ? error.message - : signal.aborted - ? 'Check deadline reached; a full check is still required.' - : 'Check could not finish. Verify Git access and the judgment backend.', - ); - if (error instanceof ConfigurationError) result.status = 'invalid'; + const status = (values = {}) => { + if (stopped) return; + try { + options.onStatus?.({ + files: result.coverage.files, + rulesTotal: 0, + rulesFinished: 0, + judged: result.coverage.judged, + cached: result.coverage.cached, + elapsedMs: performance.now() - started, + ...values, + }); + } catch { + /* Display callbacks cannot affect a check. */ } - }); + }; + const work = run(options, result, signal, () => stopped, status).catch( + (error) => { + if (!stopped) { + result.messages.push( + error instanceof ConfigurationError + ? error.message + : signal.aborted + ? 'Check deadline reached; a full check is still required.' + : 'Check could not finish. Verify Git access and the judgment backend.', + ); + if (error instanceof ConfigurationError) result.status = 'invalid'; + } + }, + ); let timer; const deadline = new Promise((resolve) => { timer = setTimeout(() => { @@ -119,7 +138,7 @@ export async function check(options = {}) { return structuredClone(result); } -async function run(options, result, signal, stopped) { +async function run(options, result, signal, stopped, status) { const trace = (message) => { if (!stopped()) { try { @@ -151,6 +170,10 @@ async function run(options, result, signal, stopped) { return; } const policy = parsePolicy(policyText); + let rulesFinished = 0; + const progress = () => + status({ rulesTotal: policy.criteria.length, rulesFinished }); + progress(); trace(`policy: ${result.policySource}; ${policy.criteria.length} rules`); if (snap.changes.length === 0) return; const files = await inventory(snap, signal); @@ -207,6 +230,7 @@ async function run(options, result, signal, stopped) { result.coverage.cached++; result.coverage.judged++; } + progress(); trace(`cache hit: ${label}; ${formatAnswer(cached)}`); return cached; } catch { @@ -224,6 +248,7 @@ async function run(options, result, signal, stopped) { ); signal.throwIfAborted(); if (!stopped()) result.coverage.judged++; + progress(); trace( `answer: ${label}; ${formatAnswer(answer)} (${Math.round(performance.now() - started)} ms)`, ); @@ -299,6 +324,7 @@ async function run(options, result, signal, stopped) { const record = { id: criterion.id, rule: criterion.rule, + threshold: criterion.threshold, status: 'incomplete', findings: [], unresolved: [], @@ -344,6 +370,7 @@ async function run(options, result, signal, stopped) { if (answer.violationProbability >= criterion.threshold) { record.status = 'violation'; record.findings.push({ + ...changedLines(request), paths: request.focusPaths, confidence: answer.violationProbability, violationProbability: answer.violationProbability, @@ -362,6 +389,7 @@ async function run(options, result, signal, stopped) { ) { record.status = 'violation'; record.findings.push({ + ...changedLines(request), paths: request.focusPaths, confidence: answer.confidence, sources: request.evidence.map(({ path, kind, line }) => ({ @@ -508,6 +536,11 @@ async function run(options, result, signal, stopped) { record.status = record.findings.length ? 'violation' : 'incomplete'; record.unresolved.push('Evidence collection or judgment failed.'); } + } finally { + if (!stopped()) { + rulesFinished++; + progress(); + } } }), ), @@ -547,13 +580,25 @@ export function formatReport(report) { for (const rule of report.rules) { if (rule.status === 'pass' || rule.status === 'not_applicable') continue; lines.push(`${rule.status}: ${rule.id}: ${rule.rule}`); - for (const finding of rule.findings) + for (const finding of rule.findings) { lines.push( - ` ${finding.paths.join(', ')} (${finding.violationProbability === undefined ? 'confidence' : 'violation probability'} ${finding.confidence.toFixed(2)})`, + ` ${finding.paths.join(', ')} (${finding.violationProbability === undefined ? 'confidence' : 'violation probability'} ${finding.confidence.toFixed(2)}; cutoff ${rule.threshold?.toFixed(2) ?? 'unknown'})`, ); + if (finding.changes?.length) { + lines.push( + ' Changed-line preview (the model judged the packet, not individual lines):', + ); + for (const change of finding.changes) + lines.push( + ` ${change.path}:${change.line ?? '?'} [${change.side}] ${change.side === 'after' ? '+' : '-'}${change.text}${change.truncated ? '…' : ''}`, + ); + if (finding.omitted) + lines.push(` … ${finding.omitted} more changed lines`); + } + } for (const unresolved of new Set(rule.unresolved)) lines.push(` ${unresolved}`); } lines.push(...report.messages); - return lines.join('\n'); + return lines.map(terminalText).join('\n'); } diff --git a/src/cli.js b/src/cli.js index ef8c7de..365c639 100755 --- a/src/cli.js +++ b/src/cli.js @@ -1,156 +1,7 @@ #!/usr/bin/env node -import { parseArgs } from 'node:util'; -import { - check, - exitCode, - formatReport, - calibrate, - formatCalibrationReport, - testRules, - calibrateRules, - exampleSuiteExitCode, - formatExampleSuite, -} from './index.js'; - +import { runCli } from './commands.js'; try { - const { values, positionals } = parseArgs({ - allowPositionals: true, - options: { - all: { type: 'boolean' }, - 'examples-dir': { type: 'string' }, - rule: { type: 'string' }, - examples: { type: 'string' }, - repeats: { type: 'string' }, - thresholds: { type: 'string' }, - concurrency: { type: 'string' }, - staged: { type: 'boolean' }, - verbose: { type: 'boolean', short: 'v' }, - hook: { type: 'boolean' }, - advisory: { type: 'boolean' }, - base: { type: 'string' }, - head: { type: 'string' }, - cwd: { type: 'string' }, - 'dry-run': { type: 'boolean' }, - 'no-cache': { type: 'boolean' }, - format: { type: 'string' }, - 'timeout-ms': { type: 'string' }, - model: { type: 'string' }, - help: { type: 'boolean', short: 'h' }, - }, - }); - if (values.help) { - console.log( - 'judgement check [--staged [--hook] | --base --head ] [--dry-run] [--format json] [--timeout-ms ] [--no-cache] [--advisory] [--verbose]\nReads .judgement/rules.json from the base tree. Hooks allow incomplete checks after 3 seconds; strict checks exit 3.\njudgement test [--rule ] [--examples-dir ] [--repeats 3] [--dry-run] [--format json]\njudgement calibrate (--rule | --all) [--examples ] [--repeats 3] [--thresholds 0.8,0.85,0.9,0.95] [--concurrency 2] [--timeout-ms 3000] [--dry-run] [--format json] [--verbose]\nCalibration reads the working-tree policy and .judgement/examples/.json; it never edits your policy or index.', - ); - } else if (['calibrate', 'test'].includes(positionals[0])) { - if ( - positionals.length !== 1 || - values.staged || - values.base || - values.head || - values.hook || - values.advisory || - (values.all && values.rule) || - (values.examples && values['examples-dir']) || - (positionals[0] === 'test' && values.thresholds !== undefined) || - (values.format && !['json', 'text'].includes(values.format)) - ) - throw new Error('Invalid calibration arguments. Use --help.'); - if (values.thresholds?.split(',').some((value) => !value.trim())) - throw new Error('Thresholds must be comma-separated numbers.'); - const controller = new AbortController(); - const interrupt = () => controller.abort(); - process.once('SIGINT', interrupt); - const suite = positionals[0] === 'test' || values.all; - const run = - positionals[0] === 'test' - ? testRules - : values.all - ? calibrateRules - : calibrate; - if (values['examples-dir'] && !suite) - throw new Error('--examples-dir requires test or calibrate --all.'); - let report; - try { - report = await run({ - examplesDirectory: values['examples-dir'], - signal: controller.signal, - cwd: values.cwd, - ruleId: values.rule, - examplesPath: values.examples, - repeats: - values.repeats === undefined ? undefined : Number(values.repeats), - thresholds: values.thresholds?.split(',').map(Number), - concurrency: - values.concurrency === undefined - ? undefined - : Number(values.concurrency), - deadlineMs: - values['timeout-ms'] === undefined - ? undefined - : Number(values['timeout-ms']), - model: values.model, - dryRun: values['dry-run'], - onDiagnostic: values.verbose - ? (message) => console.error(`judgement: ${message}`) - : undefined, - }); - } finally { - process.removeListener('SIGINT', interrupt); - } - console.log( - values.format === 'json' - ? JSON.stringify(report) - : suite - ? formatExampleSuite(report) - : formatCalibrationReport(report), - ); - process.exitCode = suite - ? exampleSuiteExitCode(report) - : report.dryRun || report.recommendation !== null - ? 0 - : 3; - } else { - if ( - values.all || - values['examples-dir'] || - values.rule || - values.examples || - values.repeats || - values.thresholds || - values.concurrency || - positionals.length > 1 || - (positionals[0] && positionals[0] !== 'check') || - (values.staged && values.base) || - (values.head && !values.base) || - (values.hook && values.base) || - (values.format && !['json', 'text'].includes(values.format)) - ) - throw new Error('Invalid arguments. Use --help.'); - const report = await check({ - cwd: values.cwd, - onDiagnostic: values.verbose - ? (message) => console.error(`judgement: ${message}`) - : undefined, - base: values.base, - head: values.head, - hook: values.hook, - dryRun: values['dry-run'], - cache: !values['no-cache'], - model: values.model, - deadlineMs: - values['timeout-ms'] === undefined - ? undefined - : Number(values['timeout-ms']), - }); - console.log( - values.format === 'json' ? JSON.stringify(report) : formatReport(report), - ); - process.exitCode = - values['dry-run'] && report.status !== 'invalid' - ? 0 - : exitCode(report, { hook: values.hook, advisory: values.advisory }); - } + process.exitCode = await runCli(); } catch (error) { console.error(error.message); process.exitCode = error.name === 'AbortError' ? 130 : 2; diff --git a/src/commands.js b/src/commands.js new file mode 100644 index 0000000..b7ec124 --- /dev/null +++ b/src/commands.js @@ -0,0 +1,286 @@ +import { ConfigurationError } from './policy.js'; +import { parseArgs } from 'node:util'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { check, exitCode, formatReport } from './check.js'; +import { calibrate, formatCalibrationReport } from './calibrate.js'; +import { + testRules, + calibrateRules, + exampleSuiteExitCode, + formatExampleSuite, +} from './examples.js'; +import { captureExample, saveCapturedExample } from './capture.js'; +import { compareReports, formatComparison } from './compare.js'; +import { createProgressReporter } from './presentation.js'; + +const HELP = `judgement check [--staged [--hook] | --base --head ] [--verbose] [--no-progress] [--no-cache] [--timeout-ms ] [--format json] [--dry-run] [--advisory] +Hooks allow incomplete checks after 3 seconds; strict checks exit 3. Progress goes to stderr. +judgement test [--rule ] [--examples-dir ] [--repeats 3] [--format json] +judgement calibrate (--rule | --all) [--examples ] [--thresholds 0.75,0.8,0.85] [--format json] +judgement capture --rule --path --name --expected pass|violation [--base --head ] [--context ] [--output ] +Capture previews JSON from the index by default, with no inference. Review/redact before saving with --output. Never overwrites or stages files. +judgement compare --before --after [--before-threshold ] [--after-threshold ] [--format json] +Compare saved reports on identical fixtures; regressions exit 1. No inference calls. +All commands accept --cwd . Example commands read the working-tree policy; checks read the base policy.`; + +/** Shared CLI for standalone use and hosts providing their own inference adapter. */ +export async function runCli(args = process.argv.slice(2), options = {}) { + const output = options.stdout ?? ((text) => process.stdout.write(text)); + const errorOutput = options.stderr ?? ((text) => process.stderr.write(text)); + const { values, positionals } = parseArgs({ + args, + allowPositionals: true, + options: { + ...Object.fromEntries( + [ + 'all', + 'staged', + 'hook', + 'advisory', + 'dry-run', + 'no-cache', + 'no-progress', + ].map((key) => [key, { type: 'boolean' }]), + ), + ...Object.fromEntries( + [ + 'examples-dir', + 'rule', + 'examples', + 'repeats', + 'thresholds', + 'concurrency', + 'base', + 'head', + 'cwd', + 'format', + 'timeout-ms', + 'model', + 'path', + 'name', + 'expected', + 'output', + 'before', + 'after', + 'before-threshold', + 'after-threshold', + ].map((key) => [key, { type: 'string' }]), + ), + context: { type: 'string', multiple: true }, + verbose: { type: 'boolean', short: 'v' }, + help: { type: 'boolean', short: 'h' }, + }, + }); + if (values.help) { + output(HELP + '\n'); + return 0; + } + const command = positionals[0] ?? 'check'; + const allowed = { + check: [ + 'staged', + 'hook', + 'advisory', + 'base', + 'head', + 'dry-run', + 'no-cache', + 'no-progress', + 'timeout-ms', + 'model', + 'verbose', + 'concurrency', + ], + test: [ + 'rule', + 'examples', + 'examples-dir', + 'repeats', + 'concurrency', + 'timeout-ms', + 'model', + 'verbose', + 'dry-run', + ], + calibrate: [ + 'all', + 'rule', + 'examples', + 'examples-dir', + 'repeats', + 'thresholds', + 'concurrency', + 'timeout-ms', + 'model', + 'verbose', + 'dry-run', + ], + capture: [ + 'rule', + 'path', + 'name', + 'expected', + 'context', + 'output', + 'staged', + 'base', + 'head', + ], + compare: ['before', 'after', 'before-threshold', 'after-threshold'], + }; + if ( + positionals.length > 1 || + !allowed[command] || + Object.keys(values).some( + (key) => !['cwd', 'format', ...allowed[command]].includes(key), + ) || + (values.format && !['text', 'json'].includes(values.format)) + ) + throw new ConfigurationError('Invalid command arguments. Use --help.'); + if ( + (values.staged && values.base) || + (values.head && !values.base) || + (values.hook && values.base) + ) + throw new ConfigurationError('Invalid snapshot arguments.'); + if (values.model !== undefined && (options.evaluate || options.check)) + throw new ConfigurationError( + 'The host selects the inference model; --model requires the standalone evaluator.', + ); + const cwd = resolve(values.cwd ?? options.cwd ?? process.cwd()); + if (command === 'compare') { + if (!values.before || !values.after) + throw new ConfigurationError( + 'Compare requires --before and --after reports.', + ); + const before = JSON.parse( + await readFile(resolve(cwd, values.before), 'utf8'), + ); + const after = JSON.parse( + await readFile(resolve(cwd, values.after), 'utf8'), + ); + const report = compareReports(before, after, { + beforeThreshold: + values['before-threshold'] === undefined + ? undefined + : Number(values['before-threshold']), + afterThreshold: + values['after-threshold'] === undefined + ? undefined + : Number(values['after-threshold']), + }); + output( + (values.format === 'json' + ? JSON.stringify(report) + : formatComparison(report)) + '\n', + ); + return report.regressions ? 1 : 0; + } + const controller = new AbortController(); + const interrupt = () => controller.abort(); + process.once('SIGINT', interrupt); + let progress; + try { + const common = { + cwd, + signal: controller.signal, + model: values.model, + dryRun: values['dry-run'], + concurrency: + values.concurrency === undefined + ? undefined + : Number(values.concurrency), + deadlineMs: + values['timeout-ms'] === undefined + ? undefined + : Number(values['timeout-ms']), + onDiagnostic: values.verbose + ? (message) => errorOutput(`judgement: ${message}\n`) + : undefined, + }; + if (command === 'capture') { + const fixture = await captureExample({ + cwd, + signal: controller.signal, + base: values.base, + head: values.head, + ruleId: values.rule, + path: values.path, + name: values.name, + expected: values.expected, + context: values.context, + }); + if (values.output) { + await saveCapturedExample(fixture, resolve(cwd, values.output)); + errorOutput( + `Saved ${values.output}; not staged. Review and redact before committing.\n`, + ); + } + output(JSON.stringify(fixture, null, 2) + '\n'); + return 0; + } + if (command !== 'check') { + if ( + (values.all && values.rule) || + (values.examples && values['examples-dir']) || + values.thresholds?.split(',').some((value) => !value.trim()) + ) + throw new ConfigurationError('Invalid example command arguments.'); + const suite = command === 'test' || values.all; + if (values['examples-dir'] && !suite) + throw new ConfigurationError( + '--examples-dir requires test or calibrate --all.', + ); + const run = + command === 'test' ? testRules : suite ? calibrateRules : calibrate; + const report = await run({ + ...common, + evaluate: options.evaluate, + ruleId: values.rule, + examplesPath: values.examples, + examplesDirectory: values['examples-dir'], + repeats: + values.repeats === undefined ? undefined : Number(values.repeats), + thresholds: values.thresholds?.split(',').map(Number), + }); + controller.signal.throwIfAborted(); + output( + (values.format === 'json' + ? JSON.stringify(report) + : suite + ? formatExampleSuite(report) + : formatCalibrationReport(report)) + '\n', + ); + return suite + ? exampleSuiteExitCode(report) + : report.dryRun || report.recommendation !== null + ? 0 + : 3; + } + if (!values['no-progress'] && !values.verbose) + progress = createProgressReporter({ write: errorOutput }); + const report = await (options.check ?? check)({ + ...common, + evaluate: options.evaluate, + base: values.base, + head: values.head, + hook: values.hook, + cache: !values['no-cache'], + onStatus: progress?.update, + }); + controller.signal.throwIfAborted(); + progress?.stop(); + output( + (values.format === 'json' + ? JSON.stringify(report) + : formatReport(report)) + '\n', + ); + return values['dry-run'] && report.status !== 'invalid' + ? 0 + : exitCode(report, values); + } finally { + progress?.stop(); + process.removeListener('SIGINT', interrupt); + } +} diff --git a/src/compare.js b/src/compare.js new file mode 100644 index 0000000..6af8e03 --- /dev/null +++ b/src/compare.js @@ -0,0 +1,173 @@ +import { ConfigurationError } from './policy.js'; +import { terminalText } from './presentation.js'; + +const fail = (message) => { + throw new ConfigurationError(message); +}; +function index(report, threshold) { + const reports = report?.reports ?? [report]; + if (!Array.isArray(reports) || !reports.length) + fail('Expected a calibration or example-suite JSON report.'); + const rules = new Map(); + for (const item of reports) { + if ( + !item || + typeof item.ruleId !== 'string' || + !Array.isArray(item.results) || + !item.results.length || + item.dryRun + ) + fail('Comparison requires completed, non-dry-run reports.'); + if (rules.has(item.ruleId)) fail('Duplicate rule in comparison input.'); + const selected = threshold ?? item.configuredThreshold; + if (!Number.isFinite(selected) || selected < 0 || selected > 1) + fail('Comparison thresholds must be between 0 and 1.'); + const groups = new Map(); + for (const row of item.results.filter( + (row) => row.threshold === selected, + )) { + if ( + typeof row.example !== 'string' || + !['pass', 'violation'].includes(row.expected) || + !['pass', 'violation', 'incomplete', 'invalid'].includes(row.status) + ) + fail('Malformed calibration result.'); + let group = groups.get(row.example); + if (!group) { + group = { + expected: row.expected, + total: 0, + correct: 0, + violations: 0, + incomplete: 0, + failures: 0, + probabilityMin: null, + probabilityMax: null, + }; + groups.set(row.example, group); + } + if (group.expected !== row.expected) + fail('An example has conflicting expected labels.'); + group.total++; + if (row.status === row.expected && !row.operationalFailure) + group.correct++; + if (row.status === 'violation') group.violations++; + if (row.status === 'incomplete') group.incomplete++; + if (row.operationalFailure || row.status === 'invalid') group.failures++; + for (const answer of row.answers ?? []) { + const probability = answer.violationProbability; + if (probability !== undefined) { + if ( + !Number.isFinite(probability) || + probability < 0 || + probability > 1 + ) + fail('Malformed violation probability.'); + group.probabilityMin = Math.min( + group.probabilityMin ?? probability, + probability, + ); + group.probabilityMax = Math.max( + group.probabilityMax ?? probability, + probability, + ); + } + } + } + if (!groups.size) + fail( + `No runs for ${item.ruleId} at cutoff ${selected}. Choose a recorded threshold.`, + ); + rules.set(item.ruleId, { + threshold: selected, + fixtureSha256: item.fixtureSha256 ?? report.fixtureSha256?.[item.ruleId], + groups, + }); + } + return rules; +} + +/** Compare saved reports without making inference calls or selecting a new policy. */ +export function compareReports(before, after, options = {}) { + const left = index(before, options.beforeThreshold), + right = index(after, options.afterThreshold); + const result = { + status: 'pass', + improvements: 0, + regressions: 0, + unchanged: 0, + examples: [], + }; + if (left.size !== right.size) fail('Reports must contain the same rules.'); + for (const [id, a] of left) { + const b = right.get(id); + if (!b) fail('Reports must contain the same rules.'); + if (!a.fixtureSha256 || !b.fixtureSha256) + fail( + 'Fixture provenance is missing. Regenerate both reports with the current Judgement version.', + ); + if (a.fixtureSha256 !== b.fixtureSha256) + fail( + `Examples changed for ${id}. Compare the same fixtures to measure a rule, prompt, or model change.`, + ); + if (a.groups.size !== b.groups.size) + fail('Reports must contain the same examples.'); + for (const [name, previous] of a.groups) { + const current = b.groups.get(name); + if (!current || previous.expected !== current.expected) + fail('Reports must contain the same example names and labels.'); + const oldCorrect = previous.correct / previous.total, + newCorrect = current.correct / current.total; + // A new service failure is still a regression when both runs were already incorrect. + const regression = + newCorrect < oldCorrect || + current.failures / current.total > previous.failures / previous.total; + const improvement = + !regression && + (newCorrect > oldCorrect || + current.failures / current.total < + previous.failures / previous.total); + const change = regression + ? 'regression' + : improvement + ? 'improvement' + : 'unchanged'; + if (regression) { + result.regressions++; + result.status = 'regression'; + } else if (improvement) result.improvements++; + else result.unchanged++; + result.examples.push({ + ruleId: id, + name, + expected: previous.expected, + change, + beforeThreshold: a.threshold, + afterThreshold: b.threshold, + before: previous, + after: current, + }); + } + } + return result; +} + +export function formatComparison(report) { + const lines = [ + `judgement compare: ${report.improvements} improved, ${report.regressions} regressed, ${report.unchanged} unchanged`, + ]; + for (const item of report.examples.filter( + (row) => row.change !== 'unchanged', + )) { + const details = (run) => + `${run.correct}/${run.total} correct; ${run.incomplete} incomplete; ${run.failures} failures`; + lines.push( + `${item.change}: ${item.ruleId}: ${item.name} (expected ${item.expected})`, + ` ${item.beforeThreshold}: ${details(item.before)} → ${item.afterThreshold}: ${details(item.after)}`, + ); + } + lines.push( + 'Compared observed success rates per example; repeated samples are not independent coverage.', + ); + return lines.map(terminalText).join('\n'); +} diff --git a/src/index.d.ts b/src/index.d.ts index 89c91a5..462344a 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -28,8 +28,17 @@ export type Criterion = { export type RuleResult = { id: string; rule: string; + threshold?: number; status: 'pass' | 'violation' | 'not_applicable' | 'incomplete'; findings: { + changes?: { + path: string; + side: 'before' | 'after'; + line: number | null; + text: string; + truncated: boolean; + }[]; + omitted?: number; paths: string[]; confidence: number; violationProbability?: number; @@ -75,6 +84,7 @@ export type CheckOptions = BackendOptions & { evaluate?: Evaluator; env?: Record; onDiagnostic?: (message: string) => void; + onStatus?: (event: CheckProgress) => void; onProgress?: (event: { id: string; status: RuleResult['status'] }) => void; }; export function check(options?: CheckOptions): Promise; @@ -157,6 +167,7 @@ export type CalibrationSummary = { meanElapsedMs: number; }; export type CalibrationReport = { + fixtureSha256?: string; ruleId: string; configuredThreshold: number; backend: string; @@ -227,3 +238,77 @@ export type PreparedExamples = { export function prepareExamples( options?: PreparedExamplesOptions, ): Promise; + +export type CheckProgress = { + files: number; + rulesTotal: number; + rulesFinished: number; + judged: number; + cached: number; + elapsedMs: number; +}; +export function formatProgress(event: CheckProgress): string; +export function createProgressReporter(options?: { + write?: (text: string) => void; + delayMs?: number; + intervalMs?: number; +}): { update(event: CheckProgress): void; stop(): void }; +export type CaptureOptions = Pick< + CheckOptions, + 'cwd' | 'base' | 'head' | 'env' | 'signal' +> & { + ruleId: string; + path: string; + name: string; + expected: 'pass' | 'violation'; + context?: string[]; +}; +export function captureExample( + options: CaptureOptions, +): Promise; +export function saveCapturedExample( + fixture: CalibrationExamples, + output: string, +): Promise; +export type ComparisonCounts = { + expected: 'pass' | 'violation'; + total: number; + correct: number; + violations: number; + incomplete: number; + failures: number; + probabilityMin: number | null; + probabilityMax: number | null; +}; +export type ComparisonReport = { + status: 'pass' | 'regression'; + improvements: number; + regressions: number; + unchanged: number; + examples: { + ruleId: string; + name: string; + expected: 'pass' | 'violation'; + change: 'improvement' | 'regression' | 'unchanged'; + beforeThreshold: number; + afterThreshold: number; + before: ComparisonCounts; + after: ComparisonCounts; + }[]; +}; +export function compareReports( + before: CalibrationReport | ExampleSuiteReport, + after: CalibrationReport | ExampleSuiteReport, + options?: { beforeThreshold?: number; afterThreshold?: number }, +): ComparisonReport; +export function formatComparison(report: ComparisonReport): string; +export function runCli( + args?: string[], + options?: { + cwd?: string; + stdout?: (text: string) => void; + stderr?: (text: string) => void; + evaluate?: Evaluator; + check?: typeof check; + }, +): Promise; diff --git a/src/index.js b/src/index.js index 12b6740..79415e7 100644 --- a/src/index.js +++ b/src/index.js @@ -20,3 +20,7 @@ export { formatExampleSuite, } from './examples.js'; export { prepareExamples } from './prepare-examples.js'; +export { captureExample, saveCapturedExample } from './capture.js'; +export { compareReports, formatComparison } from './compare.js'; +export { createProgressReporter, formatProgress } from './presentation.js'; +export { runCli } from './commands.js'; diff --git a/src/presentation.js b/src/presentation.js new file mode 100644 index 0000000..deb787f --- /dev/null +++ b/src/presentation.js @@ -0,0 +1,96 @@ +/** Escape control characters in repository-controlled text before terminal output. */ +export const terminalText = (text) => + String(text).replace( + /[\x00-\x08\x0b-\x1f\x7f]/g, + (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`, + ); + +/** A bounded preview of changes, not a claim that the model localized a violation. */ +export function changedLines(request) { + const changes = []; + let omitted = 0; + const patches = new Map(); + for (const item of request.evidence) { + if (item.kind === 'patch' && request.focusPaths.includes(item.path)) + patches.set(item.path, (patches.get(item.path) ?? '') + item.text); + } + for (const [path, patch] of patches) { + let oldLine = null, + newLine = null; + for (const text of patch.split('\n')) { + const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(text); + if (hunk) { + oldLine = Number(hunk[1]); + newLine = Number(hunk[2]); + continue; + } + if ( + oldLine === null && + newLine === null && + (text.startsWith('--- ') || + text.startsWith('+++ ') || + text.startsWith('diff ') || + text.startsWith('index ')) + ) + continue; + const side = + text[0] === '+' ? 'after' : text[0] === '-' ? 'before' : null; + if (side) { + if (changes.length < 12) + changes.push({ + path, + side, + line: side === 'after' ? newLine : oldLine, + text: text.slice(1, 241), + truncated: text.length > 241, + }); + else omitted++; + } + if (text[0] === '+' && newLine !== null) newLine++; + if (text[0] === '-' && oldLine !== null) oldLine++; + if (text[0] === ' ') { + if (oldLine !== null) oldLine++; + if (newLine !== null) newLine++; + } + } + } + return { changes, omitted }; +} + +export function formatProgress(event) { + return `judgement: ${event.files} files; ${event.rulesFinished}/${event.rulesTotal} rules finished; ${event.judged} packets checked (${event.cached} cached); ${Math.round(event.elapsedMs / 1000)}s`; +} + +/** Delayed, rate-limited stderr feedback. No additional inference or deadline changes. */ +export function createProgressReporter({ + write = (text) => process.stderr.write(text), + delayMs = 750, + intervalMs = 2000, +} = {}) { + let event, interval, receivedAt; + const emit = () => { + if (event) + write( + formatProgress({ + ...event, + elapsedMs: event.elapsedMs + performance.now() - receivedAt, + }) + '\n', + ); + }; + const timer = setTimeout(() => { + emit(); + interval = setInterval(emit, intervalMs); + interval.unref?.(); + }, delayMs); + timer.unref?.(); + return { + update: (value) => { + event = value; + receivedAt = performance.now(); + }, + stop: () => { + clearTimeout(timer); + clearInterval(interval); + }, + }; +} diff --git a/test/feedback.test.js b/test/feedback.test.js new file mode 100644 index 0000000..b37d542 --- /dev/null +++ b/test/feedback.test.js @@ -0,0 +1,358 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + mkdtemp, + mkdir, + readFile, + writeFile, + rm, + symlink, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { execFile } from 'node:child_process'; +import { + captureExample, + saveCapturedExample, + compareReports, + formatComparison, + check, + formatReport, + createProgressReporter, + runCli, + testRules, +} from '../src/index.js'; +const exec = promisify(execFile); +async function repo(t) { + const cwd = await mkdtemp(join(tmpdir(), 'judgement-feedback-')); + t.after(() => rm(cwd, { recursive: true, force: true })); + const git = async (...args) => + ( + await exec( + 'git', + [ + '-c', + 'core.hooksPath=/dev/null', + '-c', + 'commit.gpgSign=false', + ...args, + ], + { + cwd, + }, + ) + ).stdout; + await git('init', '-q', '--template='); + await git('config', 'user.name', 'Example'); + await git('config', 'user.email', 'example@example.invalid'); + await mkdir(join(cwd, '.judgement/examples'), { recursive: true }); + await writeFile( + join(cwd, '.judgement/rules.json'), + JSON.stringify({ + criteria: [ + { + id: 'wording', + rule: 'Use lowercase nouns.', + files: ['*.ts'], + context: ['context.ts'], + threshold: 0.75, + }, + ], + }), + ); + await writeFile(join(cwd, 'file.ts'), 'before\n\n'); + await writeFile(join(cwd, 'context.ts'), 'context\n'); + await git('add', '.'); + await git('commit', '-qm', 'baseline'); + return { cwd, git }; +} +const capture = { + ruleId: 'wording', + path: 'file.ts', + name: 'Ordinary noun', + expected: 'pass', +}; + +test('capture preserves staged bytes and context, previews without writing, and refuses overwrite', async (t) => { + const { cwd, git } = await repo(t); + await writeFile(join(cwd, 'file.ts'), 'staged\n\n'); + await git('add', 'file.ts'); + await writeFile(join(cwd, 'file.ts'), 'unstaged\n'); + const index = await git('diff', '--cached'); + const fixture = await captureExample({ cwd, ...capture }); + assert.equal(fixture.examples[0].before, 'before\n\n'); + assert.equal(fixture.examples[0].after, 'staged\n\n'); + assert.deepEqual(fixture.examples[0].context, { 'context.ts': 'context\n' }); + assert.equal(await git('diff', '--cached'), index); + assert.equal(await readFile(join(cwd, 'file.ts'), 'utf8'), 'unstaged\n'); + let stdout = ''; + await runCli( + [ + 'capture', + '--rule', + 'wording', + '--path', + 'file.ts', + '--name', + 'preview', + '--expected', + 'pass', + ], + { cwd, stdout: (s) => (stdout += s) }, + ); + assert.equal(JSON.parse(stdout).examples[0].after, 'staged\n\n'); + const file = join(cwd, '.judgement/examples/wording.json'); + await saveCapturedExample(fixture, file); + await assert.rejects(saveCapturedExample(fixture, file), { code: 'EEXIST' }); + const report = await testRules({ + cwd, + ruleId: 'wording', + repeats: 1, + evaluate: async () => ({ violationProbability: 0.01 }), + }); + assert.equal(report.status, 'pass'); + assert.match(report.reports[0].fixtureSha256, /^[0-9a-f]{64}$/); +}); + +test('capture handles additions and deletions and refuses changed support, binary and symlink evidence', async (t) => { + const { cwd, git } = await repo(t); + await writeFile(join(cwd, 'new.ts'), 'new\n'); + await git('add', 'new.ts'); + let f = await captureExample({ cwd, ...capture, path: 'new.ts' }); + assert.equal(f.examples[0].before, null); + await rm(join(cwd, 'file.ts')); + await git('add', 'file.ts'); + f = await captureExample({ cwd, ...capture }); + assert.equal(f.examples[0].after, null); + await writeFile(join(cwd, 'context.ts'), 'changed\n'); + await git('add', 'context.ts'); + await assert.rejects(captureExample({ cwd, ...capture }), /context changed/); + await git('reset', '-q', 'HEAD', 'context.ts'); + await writeFile(join(cwd, 'new.ts'), Buffer.from([0, 1, 2])); + await git('add', 'new.ts'); + await assert.rejects( + captureExample({ cwd, ...capture, path: 'new.ts' }), + /binary/, + ); + await rm(join(cwd, 'new.ts')); + await symlink('context.ts', join(cwd, 'new.ts')); + await git('add', 'new.ts'); + await assert.rejects( + captureExample({ cwd, ...capture, path: 'new.ts' }), + /regular text/, + ); +}); + +function report(rows, extras = {}) { + return { + ruleId: 'r', + fixtureSha256: 'same', + configuredThreshold: 0.75, + dryRun: false, + results: rows.map( + ([example, expected, status, p, operationalFailure = false]) => ({ + example, + expected, + status, + threshold: 0.75, + repetition: 1, + answers: [{ violationProbability: p }], + operationalFailure, + }), + ), + ...extras, + }; +} +test('comparison exposes regressions despite net improvement and rejects changed fixtures', () => { + const before = report([ + ['a', 'pass', 'violation', 0.8], + ['b', 'violation', 'pass', 0.6], + ['c', 'violation', 'violation', 0.9], + ]); + const after = report([ + ['a', 'pass', 'pass', 0.1], + ['b', 'violation', 'violation', 0.9], + ['c', 'violation', 'pass', 0.6], + ]); + const c = compareReports(before, after); + assert.equal(c.improvements, 2); + assert.equal(c.regressions, 1); + assert.equal(c.status, 'regression'); + assert.match(formatComparison(c), /regression: r: c/); + assert.throws( + () => compareReports(before, { ...after, fixtureSha256: 'different' }), + /Examples changed/, + ); + assert.throws( + () => compareReports({ ...before, fixtureSha256: undefined }, after), + /provenance/, + ); + assert.throws( + () => compareReports({ ...before, dryRun: true }, after), + /non-dry-run/, + ); + assert.throws( + () => compareReports(before, after, { afterThreshold: 0.9 }), + /No runs/, + ); +}); +test('comparison normalizes unequal repetitions and counts failed inference as regression', () => { + const before = report([['a', 'pass', 'pass', 0.1]]); + const after = report([ + ['a', 'pass', 'pass', 0.1], + ['a', 'pass', 'pass', 0.2], + ]); + assert.equal(compareReports(before, after).unchanged, 1); + const failed = report([['a', 'pass', 'incomplete', 0.1, true]]); + assert.equal(compareReports(before, failed).regressions, 1); +}); + +test('findings show cutoff and actual changed-line coordinates, with cached results and safe progress', async (t) => { + const { cwd, git } = await repo(t); + await writeFile(join(cwd, 'file.ts'), 'changed\n\n'); + await git('add', 'file.ts'); + let calls = 0; + const events = []; + const options = { + cwd, + cacheIdentity: 'fixture', + evaluate: async () => { + calls++; + return { violationProbability: 0.9 }; + }, + onStatus: (e) => events.push(e), + }; + const first = await check(options); + const second = await check(options); + assert.equal(calls, 1); + assert.equal(second.coverage.cached, 1); + assert.equal(first.rules[0].threshold, 0.75); + assert.deepEqual( + first.rules[0].findings[0].changes.map((c) => [c.side, c.line, c.text]), + [ + ['before', 1, 'before'], + ['after', 1, 'changed'], + ], + ); + assert.match(formatReport(first), /cutoff 0.75/); + assert.match(formatReport(first), /file.ts:1 \[after\] \+changed/); + assert.match(formatReport(first), /not individual lines/); + assert.ok(events.some((e) => e.cached === 1)); + assert.ok(events.some((e) => e.rulesFinished === 1)); + assert.equal( + ( + await check({ + ...options, + onStatus: () => { + throw Error('display'); + }, + }) + ).status, + 'violation', + ); +}); + +test('delayed progress stays on stderr and stops after completion', async () => { + const messages = []; + const reporter = createProgressReporter({ + write: (s) => messages.push(s), + delayMs: 30, + intervalMs: 30, + }); + reporter.update({ + files: 4, + rulesTotal: 2, + rulesFinished: 0, + judged: 0, + cached: 0, + elapsedMs: 0, + }); + assert.equal(messages.length, 0); + await new Promise((r) => setTimeout(r, 45)); + assert.ok(messages.length > 0); + reporter.stop(); + const count = messages.length; + await new Promise((r) => setTimeout(r, 45)); + assert.equal(messages.length, count); + let out = '', + err = ''; + const code = await runCli(['check', '--format', 'json'], { + stdout: (s) => (out += s), + stderr: (s) => (err += s), + check: async (options) => { + options.onStatus({ + files: 10, + rulesTotal: 2, + rulesFinished: 0, + judged: 0, + cached: 0, + elapsedMs: 0, + }); + await new Promise((r) => setTimeout(r, 800)); + return { + status: 'incomplete', + rules: [], + coverage: { judged: 0, cached: 0 }, + messages: [], + elapsedMs: 800, + }; + }, + }); + assert.equal(code, 3); + assert.equal(JSON.parse(out).status, 'incomplete'); + assert.match(err, /10 files/); +}); + +test('CLI rejects cross-command flags and compare returns regression exit status', async (t) => { + const { cwd } = await repo(t); + await assert.rejects(runCli(['capture', '--hook'], { cwd }), /Invalid/); + await writeFile( + join(cwd, 'before.json'), + JSON.stringify(report([['a', 'pass', 'pass', 0.1]])), + ); + await writeFile( + join(cwd, 'after.json'), + JSON.stringify(report([['a', 'pass', 'violation', 0.9]])), + ); + let out = ''; + assert.equal( + await runCli( + [ + 'compare', + '--before', + 'before.json', + '--after', + 'after.json', + '--format', + 'json', + ], + { cwd, stdout: (s) => (out += s) }, + ), + 1, + ); + assert.equal(JSON.parse(out).regressions, 1); +}); + +test('capture supports committed changes and comparison honors selected recorded cutoffs', async (t) => { + const { cwd, git } = await repo(t); + const base = (await git('rev-parse', 'HEAD')).trim(); + await writeFile(join(cwd, 'file.ts'), 'committed\n'); + await git('add', 'file.ts'); + await git('commit', '-qm', 'change'); + await writeFile(join(cwd, 'file.ts'), 'unstaged\n'); + const fixture = await captureExample({ cwd, ...capture, base, head: 'HEAD' }); + assert.equal(fixture.examples[0].after, 'committed\n'); + const runs = report([['a', 'violation', 'violation', 0.8]]); + runs.results.push({ ...runs.results[0], threshold: 0.9, status: 'pass' }); + assert.equal( + compareReports(runs, runs, { afterThreshold: 0.9 }).regressions, + 1, + ); + await assert.rejects( + runCli(['test', '--model', 'different'], { + evaluate: async () => ({ violationProbability: 0 }), + }), + /host selects/, + ); +}); From e4196715a2c0ce55fc41423704db077adb427215 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:49:29 -0400 Subject: [PATCH 2/2] Preserve UTF-8 byte order marks in captured examples --- src/capture.js | 4 +++- test/feedback.test.js | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/capture.js b/src/capture.js index bddf037..dfa24eb 100644 --- a/src/capture.js +++ b/src/capture.js @@ -59,7 +59,9 @@ export async function captureExample(options = {}) { if (content.includes(0)) throw new ConfigurationError('Capture does not support binary files.'); try { - return new TextDecoder('utf-8', { fatal: true }).decode(content); + return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode( + content, + ); } catch { throw new ConfigurationError('Capture supports UTF-8 text only.'); } diff --git a/test/feedback.test.js b/test/feedback.test.js index b37d542..ea55ec7 100644 --- a/test/feedback.test.js +++ b/test/feedback.test.js @@ -135,6 +135,16 @@ test('capture handles additions and deletions and refuses changed support, binar captureExample({ cwd, ...capture, path: 'new.ts' }), /binary/, ); + await writeFile(join(cwd, 'new.ts'), Buffer.from([0xff, 0xfe])); + await git('add', 'new.ts'); + await assert.rejects( + captureExample({ cwd, ...capture, path: 'new.ts' }), + /UTF-8/, + ); + await writeFile(join(cwd, 'new.ts'), '\ufeffcontent\n'); + await git('add', 'new.ts'); + f = await captureExample({ cwd, ...capture, path: 'new.ts' }); + assert.equal(f.examples[0].after, '\ufeffcontent\n'); await rm(join(cwd, 'new.ts')); await symlink('context.ts', join(cwd, 'new.ts')); await git('add', 'new.ts');