Files
gstack/lib/ship-audit.ts
Garry Tanandcapy-ai[bot] 21277291c8 v1.91.345.0 feat(ship): compact driver plus preflight/release bins over the restored reviewers (GSTA-167) (#3648)
* feat(ship): GSTA-167 turn levers on the restored reviewers: ship preflight/release bins, own-jam meter, dispatch-pair metering, ship/DRIVER.md

Ports the non-reviewer parts of #3633 (gsta-167-pr34) and #3637 (gsta-167-pr5 @2914400)
onto main ae3d58c (v1.91.331.0). Reviewer briefs, checklists, the fix-check prompt and
the depth rules are byte-identical to main and now pinned in
test/review-packets-removed.test.ts.

Left out: gstack-review-packet and its libs, parallel/batched fanout, --like, collect,
the audit prompt split, the docs-audit packet, review-army/adversarial/outside-voice
edits, the gate-scan summary-only block and the review-depth scope-error change.
Packet-bytes reader pricing is dropped from gstack-ship-budget; handoff READ ranges
send each section whole; the driver runs the audit, review, adversarial and
documentation sections as written.

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

* test(context-budget): raise the capy/skill/gstack eager ceiling to its captured value after the main merge (own-jam meter, driver and reviewer-batch prose, GSTA-167)

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

* test(ship-driver): name ship/DRIVER.md.tmpl so the pregate maps the template to its test lane (GSTA-167)

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

* fix(ship-driver): keep the skill generator off lib/ship-budget so a minimal setup checkout still renders (GSTA-167)

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

* test(windows): keep test/ship-release.test.ts off the Windows subset; it launches the shebang bin and sh stubs directly (GSTA-167)

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

* feat(ship): variant E orchestration: one program per review pass and Step 14.5 audit, the review child starts at Step 9, a metered dispatch:docs role, and the driver's record headings (GSTA-167)

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

* feat(ship): variant G orchestration: one call per driver phase (9 for a clean pass, at most 12), gstack-ship-preflight --audit for single reads and deterministic audits, an observe-mode ledger with note and credits, 240 s wait pings, and small-model audit readers (GSTA-167)

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

* fix(ship-budget): a Capy handoff child needs no jam id: verify-handoff with no reading makes it parent-metered (METER: parent), it never delegates on an unknown charge, and reconcile charges the parent's counter delta (GSTA-167 replay 7e)

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

* fix(ship): a poll settles a reader only at done, failed or a final summary, never a fresh idle; return settles the child's open step reservation (GSTA-167 replay 7e)

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

* fix(ship-budget): return releases an open step reservation under the estimate meter instead of booking its projection; measured meters still settle it (GSTA-167 replay 7e)

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

* fix(review): a clean fix-check after below-floor fixes is done; Step 11 and fix-check below-floor findings are notes at pass 1

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

* feat(ship): verbatim reviewer briefs and loss-free collection (gstack-review-dispatch render/sent/collect/judge/verify)

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

* 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>

* fix: pre-landing review fixes (pass 2)

- collect keeps a finding wherever its label stands (headings, table rows,
  mid-line, Informational and upper-case labels); only top-level list items
  inherit a label heading, and any heading or bold-only line ends it
- judge treats a merged file missing any collected id as a drop; verify flags
  merged_ids_missing and a sent reader never collected (NOT_COLLECTED)
- finish computes release paths after --build, so a build that rewrites
  package.json is a build-input change

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

* fix(review-dispatch): verify per render, voided superseded mismatches

- verify treats each render as one dispatch of its reader, so a two-pass
  ledger compares pass-1 sends with pass-1 briefs (later renders print as
  <reader>@2); outputs bind to the latest send of their own render
- a mismatched send re-sent byte-exact before anything was collected is void
  and counted (voided=N on the verdict line); one that produced an output
  stays MISMATCH
- an indented bold sub-line no longer ends a label section (fix-check)
- docs: a failed reader is collected from its failure text

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

* fix(review-dispatch): only the newest render of a path checks its file; absolute ledger paths

- a later render into the same briefs directory overwrites the earlier brief by
  design, so verify checks a brief file only for the newest render of that path
- collect and judge record absolute output, merged and judged paths, so verify
  reads them from any directory
- docs: a failed reader's failure text is parsed like any output

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

* chore: changelog and TODOS

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

* fix(ship-release): the changelog_fragments fragment is release metadata

Fix-check 4 (CRITICAL) after the main merge: releasePaths omitted
<changelog_fragments.dir>/<branch-slug>.md, so finish stopped on the
fragment Step 13 writes under gstack's policy, or routed it to re-review.
version now names the fragment in its Step 13 NEXT line instead of
[Unreleased], DRIVER.md Step 13 writes and lints where NEXT says, and the
preflight POLICY line says Steps 12-16.

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

* chore: changelog and TODOS

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-31 01:09:31 +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;
}