Files
gstack/scripts/gate-diff-calibrate.ts
Garry Tanandcapy-ai[bot] 5b9b8372c4 feat: honest work and gate integrity — gate-edit detector, review/ship gate pass, zero-run green, claims-need-evidence kernel (#3114)
* docs(designs): honest work and gate integrity plan (autoplan-reviewed)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(e2e): review-gate-integrity rule case registered in touchfiles and the PR profile

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(honesty): claims-need-evidence kernel, no_claim card field, CEO value test, implementer dispatch rules, ACCEPTANCE_EDITED, Honest Work ethos

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(gate-diff): gate-edit detector lib + CLI, patch fixtures, calibration script, tests_ran in gstack-evidence

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(gate-diff): RH-15? only on CI/config floors after calibration

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(review,ship): Gate Integrity category, GATE_SCAN_BLOCK, one-way gate disposition, actor stamping, ZERO-RUN lanes, draft-while-open

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: re-measure parity and context budgets on the integrated tree

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(touchfiles): gate-diff fixtures and helpers select review-gate-integrity

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: archaeology lint wording, paid-file census, harness-version non-behavioral record

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pin review Step 3.5 rename, plan-path placeholder, mktemp template, per-stream evidence tail

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: ACCEPTANCE_EDITED fence substitutes the <plan-path> placeholder

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(gate-diff): shallow boundary commits report no_base instead of a whole-tree inventory; recalibrate

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-10 22:33:01 +00:00

148 lines
6.9 KiB
TypeScript

/**
* gate-diff calibration — free, unshipped-to-skills tool inventory for the
* gate-edit detector (W1 of docs/designs/HONEST_WORK_GATE_INTEGRITY.md).
*
* bun scripts/gate-diff-calibrate.ts [--ref origin/main] [--count 50] [--repo <path>] [--markdown]
*
* Runs the pure core (lib/gate-diff) over the first-parent delta of the last
* N commits on <ref>'s first-parent line (one per landed PR: a merge commit
* or a squash merge), exactly what `gstack-gate-diff --commit <sha>` scans. Prints one row per
* commit: listed, eligible, read, unread, tags by id, unmatched, unsupported.
*
* What this measures: the TOOL's inventory (how much it lists, how often each
* tag fires, how often the read cap bites). It does NOT measure agent findings,
* kept/restored/open dispositions or reviewer value; those are collected after
* ship from the persisted review records. The hand read of tagged hunks is a
* human step this script only supports (--markdown gives the table to annotate).
* No artifact is written: calibration never touches the state root.
*/
import * as path from 'node:path';
import { EMPTY_PROJECT_CONFIG, parseGateIntegrityBlock, scanCandidate, specDirectoryHoldsSpecs, type ScanResult } from '../lib/gate-diff/index';
import { commitInfo, diffCommit, firstParent, readClaudeMd, repoRoot, runGit, treePaths } from '../lib/gate-diff/git';
import { COVERAGE_TAGS } from '../lib/gate-diff/tags';
interface Args {
ref: string;
count: number;
repo: string;
markdown: boolean;
}
function parseArgs(argv: string[]): Args {
const a: Args = { ref: 'origin/main', count: 50, repo: process.cwd(), markdown: false };
for (let i = 0; i < argv.length; i++) {
const x = argv[i];
if (x === '--ref') a.ref = argv[++i] ?? a.ref;
else if (x === '--count') a.count = Number(argv[++i] ?? a.count);
else if (x === '--repo') a.repo = argv[++i] ?? a.repo;
else if (x === '--markdown') a.markdown = true;
else if (x === '--help' || x === '-h') {
console.log('usage: bun scripts/gate-diff-calibrate.ts [--ref origin/main] [--count 50] [--repo <path>] [--markdown]');
process.exit(0);
} else throw new Error(`unknown argument ${x}`);
}
if (!Number.isFinite(a.count) || a.count <= 0) throw new Error('--count must be a positive integer');
return a;
}
/** The last N commits on <ref>'s first-parent line: merge commits and squash merges alike, one per landed PR. */
function firstParentCommits(ref: string, count: number, cwd: string): string[] {
const r = runGit(['log', '--first-parent', '--format=%H', '-n', String(count), ref, '--'], cwd);
if (r.status !== 0) throw new Error(`git log ${ref} failed: ${r.stderr.trim()}`);
return r.stdout.toString('utf-8').split('\n').filter(Boolean);
}
interface Row {
sha: string;
subject: string;
result?: ScanResult;
error?: string;
}
function scanOne(sha: string, root: string, project: ReturnType<typeof parseGateIntegrityBlock>): Row {
const info = commitInfo(sha, root);
const subject = (info?.message.split('\n')[0] ?? '').slice(0, 72);
if (!firstParent(sha, root)) return { sha, subject, error: 'parent missing (shallow history: git fetch --deepen)' };
try {
const files = diffCommit(sha, root);
const result = scanCandidate({
files, commits: info ? [info] : [], project, specIsTestPath: specDirectoryHoldsSpecs(treePaths(root, sha)),
candidate: sha.slice(0, 12), artifact: '(calibration: not written)',
});
return { sha, subject, result };
} catch (e: any) {
return { sha, subject, error: String(e?.message ?? e) };
}
}
function tagCell(tagged: Record<string, number>): string {
const keys = Object.keys(tagged).sort((a, b) => Number.parseInt(a.slice(3), 10) - Number.parseInt(b.slice(3), 10) || a.localeCompare(b));
return keys.length ? keys.map((k) => `${k}:${tagged[k]}`).join(' ') : '-';
}
function median(xs: number[]): number {
if (xs.length === 0) return 0;
const s = [...xs].sort((a, b) => a - b);
const mid = Math.floor(s.length / 2);
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
}
function percentile(xs: number[], p: number): number {
if (xs.length === 0) return 0;
const s = [...xs].sort((a, b) => a - b);
return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
}
function main(): number {
const args = parseArgs(process.argv.slice(2));
const root = repoRoot(path.resolve(args.repo));
if (!root) throw new Error(`${args.repo} is not inside a git repository`);
const shas = firstParentCommits(args.ref, args.count, root);
const project = parseGateIntegrityBlock(readClaudeMd(root));
const rows = shas.map((sha) => scanOne(sha, root, project));
const ok = rows.filter((r) => r.result).map((r) => r.result as ScanResult);
const totals: Record<string, number> = {};
const commitsWithTag: Record<string, number> = {};
for (const r of ok) {
for (const [k, v] of Object.entries(r.summary.tagged)) {
totals[k] = (totals[k] ?? 0) + v;
commitsWithTag[k] = (commitsWithTag[k] ?? 0) + 1;
}
}
const listed = ok.map((r) => r.summary.listed);
const eligible = ok.map((r) => r.summary.eligible);
const partial = ok.filter((r) => r.summary.unread > 0).length;
const sep = args.markdown ? ' | ' : '\t';
const line = (cells: string[]) => (args.markdown ? `| ${cells.join(' | ')} |` : cells.join(sep));
const out: string[] = [];
out.push(line(['commit', 'subject', 'listed', 'eligible', 'read', 'unread', 'unmatched', 'unsupported', 'tags']));
if (args.markdown) out.push('|---|---|---:|---:|---:|---:|---:|---:|---|');
for (const r of rows) {
if (!r.result) {
out.push(line([r.sha.slice(0, 12), r.subject.replace(/\|/g, '\\|'), '-', '-', '-', '-', '-', '-', `ERROR: ${r.error}`]));
continue;
}
const s = r.result.summary;
out.push(line([
r.sha.slice(0, 12), r.subject.replace(/\|/g, '\\|'), String(s.listed), String(s.eligible), String(s.inspected), String(s.unread),
String(s.unmatched), String(r.result.unsupportedPaths.length), tagCell(s.tagged),
]));
}
out.push('');
out.push(`${args.markdown ? '**Scanned:**' : 'Scanned:'} ${ok.length} of ${rows.length} first-parent commits on ${args.ref}; ${rows.length - ok.length} errored.`);
out.push(`${args.markdown ? '**Listed per commit:**' : 'Listed per commit:'} median ${median(listed)}, p75 ${percentile(listed, 75)}, max ${Math.max(0, ...listed)}; eligible median ${median(eligible)}, p75 ${percentile(eligible, 75)}, max ${Math.max(0, ...eligible)}; partial (read cap hit) in ${partial} commit(s).`);
const tagLines = [...COVERAGE_TAGS, 'RH-3?', 'RH-15?'].filter((t) => totals[t]).map((t) => `${t}: ${totals[t]} hunk(s) in ${commitsWithTag[t]} commit(s)`);
out.push(`${args.markdown ? '**Tags:**' : 'Tags:'} ${tagLines.length ? tagLines.join('; ') : 'none fired'}.`);
console.log(out.join('\n'));
return 0;
}
try {
process.exitCode = main();
} catch (e: any) {
console.error(`gate-diff-calibrate: ${e?.message ?? e}`);
process.exitCode = 1;
}