Files
gstack/lib/ship-audit.ts
T
garrytanandcapy-ai[bot] 530daf02fd fix: pre-landing review fixes
- verify requires each output's brief rendered and sent verbatim, reruns judge
  against the recorded judged.jsonl hash, and flags outputs collected after judge
- judge drops self-folds, fold cycles and chains
- prose findings parse a leading label or a label heading, not any severity word
- releasePaths: manifests count as release metadata only for version-only edits,
  lockfiles never; TODOS.md stays out of lane-evidence exemptions
- push stops when the sealed-output guard did not run
- ship-audit reads the test tree once instead of one git grep per file
- drop the uncalled --delegate measured mode; doc/help step-number fixes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-30 18:49:17 +00:00

105 lines
6.7 KiB
TypeScript

/**
* ship-audit — gstack-ship-preflight --audit (GSTA-167 variant G): the parent's single
* reads and the deterministic part of /ship's non-blocking audits in one call. It writes
* the branch diff once to <out>/diff.patch, the commits to <out>/commits.txt and the
* TODOS.md items that name a touched path to <out>/todos.md, maps each changed runtime
* file to the tests that name it (Step 7), lists the plan candidates Step 8 offers, and
* prints which audit still needs a reader. The parent reads these small files and never
* TODOS.md or the diff whole; the audits' briefs stay their sections' own.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { classifyPath } from './review-depth';
import { resolveStateRoot } from './state-root';
import { bin, gh, git, hostedOrigin, Report, short } from './ship-bin-common';
const DATA_PATH = /\.(json|jsonl|ndjson|ya?ml|toml|csv|tsv|svg|png|jpe?g|gif|webp|ico|pdf)$/i;
const TEST_PATH = /(^|\/)(test|tests|__tests__|spec)\/|\.(test|spec)\.[a-z0-9]+$/i;
const TEST_SPECS = ['test', 'tests', '__tests__', 'spec', '*.test.*', '*.spec.*'];
const TODOS_HITS_MAX = 120;
const stem = (p: string) => path.basename(p).replace(/\.[^.]+$/, '');
const readOr = (file: string) => { try { return fs.readFileSync(file, 'utf8'); } catch { return ''; } };
/** TODOS.md lines naming a touched path or its file stem (a stem under 4 characters only by full path), under their nearest heading. */
export function todosHits(text: string, touched: readonly string[]): string[] {
const keys = [...new Set(touched.flatMap(p => [p, ...(stem(p).length >= 4 ? [stem(p)] : [])]))];
const out: string[] = [];
let heading = '', printed = '';
for (const line of text.split('\n')) {
if (/^#{1,6} /.test(line)) { heading = line; continue; }
if (!keys.some(k => line.includes(k))) continue;
if (heading && heading !== printed) { out.push(heading); printed = heading; }
out.push(line);
if (out.length >= TODOS_HITS_MAX) break;
}
return out;
}
/** Step 8's discovery: the PR body's Plan: binding, docs/designs/ files this branch changed or that name it, then personal plan files naming it. */
function planCandidates(cwd: string, fork: string, branch: string): string[] {
const found: string[] = [];
if (hostedOrigin(cwd)) {
const bound = /^\s*Plan:\s*(\S+)/m.exec(gh(cwd, ['pr', 'view', '--json', 'body', '-q', '.body']).stdout.replace(/[\r`]/g, ''))?.[1];
if (bound) found.push(`PLAN_BINDING: ${bound}`);
}
const designs = new Set([
...git(cwd, ['diff', '--name-only', '--diff-filter=AM', fork, '--', 'docs/designs/*.md']).stdout.split('\n'),
...(branch ? git(cwd, ['grep', '-l', '-F', '-e', branch, '--', 'docs/designs/*.md']).stdout.split('\n') : []),
].filter(Boolean));
for (const d of designs) found.push(`PLAN_CANDIDATE: ${d}`);
if (!branch) return found;
const slug = /^SLUG=(\S+)/m.exec(bin(cwd, 'gstack-slug', [], 30_000).stdout)?.[1] ?? path.basename(cwd).replace(/[^a-zA-Z0-9._-]/g, '');
const home = process.env.HOME ?? '';
for (const dir of [path.join(resolveStateRoot(process.env), 'projects', slug), path.join(home, '.claude/plans'), path.join(home, '.codex/plans'), path.join(cwd, '.gstack/plans')]) {
let names: string[];
try { names = fs.readdirSync(dir).filter(n => n.endsWith('.md')); } catch { continue; }
for (const n of names) if (readOr(path.join(dir, n)).includes(branch)) found.push(`PLAN_CANDIDATE: ${path.join(dir, n)}`);
}
return found;
}
export function audit(cwd: string, base: string, out: string): Report {
const r = new Report();
const fork = git(cwd, ['merge-base', `origin/${base}`, 'HEAD']).stdout.trim() || `origin/${base}`;
fs.mkdirSync(out, { recursive: true, mode: 0o700 });
const diff = git(cwd, ['diff', fork]).stdout;
const files = git(cwd, ['diff', '--name-only', fork]).stdout.split('\n').filter(Boolean);
const commits = git(cwd, ['log', '--oneline', `origin/${base}..HEAD`]).stdout;
fs.writeFileSync(path.join(out, 'diff.patch'), diff, { mode: 0o600 });
fs.writeFileSync(path.join(out, 'commits.txt'), commits, { mode: 0o600 });
r.add(`CONTEXT: diff=${path.join(out, 'diff.patch')} bytes=${Buffer.byteLength(diff)} files=${files.length} commits=${commits.split('\n').filter(Boolean).length} log=${path.join(out, 'commits.txt')}`);
if (!fs.existsSync(path.join(cwd, 'TODOS.md'))) r.add('TODOS: none (no TODOS.md)');
else {
const text = readOr(path.join(cwd, 'TODOS.md'));
const hits = todosHits(text, files);
fs.writeFileSync(path.join(out, 'todos.md'), hits.join('\n') + (hits.length ? '\n' : ''), { mode: 0o600 });
r.add(`TODOS: bytes=${Buffer.byteLength(text)} hits=${hits.filter(l => !/^#{1,6} /.test(l)).length} file=${path.join(out, 'todos.md')} (Steps 8.2 and 14 read this file, never TODOS.md whole)`);
}
const tests = files.filter(p => TEST_PATH.test(p)).map(p => ({ p, text: readOr(path.join(cwd, p)) }));
const runtime = files.filter(p => !TEST_PATH.test(p) && !DATA_PATH.test(p) && fs.existsSync(path.join(cwd, p)) && classifyPath(p, cwd) === 'code');
// One read of the tracked test tree for every changed file, instead of one full-tree git grep per file.
const testTree = runtime.length ? git(cwd, ['ls-files', '-z', '--', ...TEST_SPECS]).stdout.split('\0').filter(Boolean).map(f => readOr(path.join(cwd, f))) : [];
const map = runtime.map(p => ({
p,
changed: tests.filter(t => t.p.includes(stem(p)) || t.text.includes(stem(p))).length,
referencing: testTree.filter(text => text.includes(stem(p))).length,
}));
const mapFile = path.join(out, 'coverage.txt');
fs.writeFileSync(mapFile, map.map(m => `${m.p} changed_tests=${m.changed} referencing_tests=${m.referencing}\n`).join(''), { mode: 0o600 });
for (const m of map.slice(0, 8)) r.add(`COVERAGE_MAP: ${m.p} changed_tests=${m.changed} referencing_tests=${m.referencing}`);
const untested = map.filter(m => m.changed === 0).map(m => m.p);
r.add(!runtime.length ? 'Coverage: skipped (no runtime file changed; no audit reader)'
: untested.length ? `Coverage: ${untested.length} of ${runtime.length} changed runtime files have no changed test naming them (${short(untested, 4)}); map=${mapFile}`
: `Coverage: deterministic (each of ${runtime.length} changed runtime files has a changed test naming it; no audit reader); map=${mapFile}`);
const branch = git(cwd, ['branch', '--show-current']).stdout.trim().replace(/\//g, '-').replace(/[^a-zA-Z0-9._-]/g, '');
const plans = planCandidates(cwd, fork, branch);
for (const line of plans.slice(0, 5)) r.add(line);
if (!plans.length) r.add('Plan completion audit: not run (no plan is bound to this branch and no docs/designs/ file matches). Fix: add "Plan: <path>" to the PR body, or run /autoplan.');
r.add(`READER: coverage=${untested.length ? 'needed' : 'none'} plan=${plans.length ? 'needed' : 'none'}`);
return r;
}