Files
gstack/lib/ship-audit.ts
T
Garry Tanandcapy-ai[bot] 901f246d74 feat: crew PRs carry their brief's Plan line; ship-queue ready and land check it (GST-1751) (#4118)
* feat: crew PRs carry their brief's Plan line; ship-queue ready and land check it (GST-1751)

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

* feat: /ship finish and train ready/land check the Plan line (GST-1751)

finish --plan resolves the brief's Plan line before stage 1; train ready,
base-red ready, train land and stamp land read and write the READY note's
plan record like direct ready and land. Review fixes: grant and exception
rows never pass as plan approvals, render --plan stays short when redacted,
no explicit changelog-only exemption, off mode still records a resolved line.

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

* fix: pre-landing review fixes

Review-Finding: p1/red-team#2 reader=red-team
Review-Finding: p1/red-team#3 reader=red-team
Review-Finding: p1/specialist-batched#1 reader=testing
Review-Finding: bin/gstack-approvals:56:functional-contract reader=qa
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: cover the plan check at ready, train ready, land-direct, finish and the policy key (GST-1751)

Review-Finding: p1/specialist-batched#3 reader=testing
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: the Plan line stands for the gate's real state, and a broken store never throws (GST-1751)

A row id from an autoplan run resolves to that run's newest gate revision; a
run id needs the plan approval answered and no decision left unanswered.
readStore skips malformed rows and unreadable files; with the key off the
check never throws or prints. Train land rechecks the Plan line before its
merge on every path. /ship's Step 19-C renders Approvals with --plan;
DRIVER.md back under its size limit.

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

* test: train land refuses a slot with no plan record under require (GST-1751)

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

* fix: pre-landing review fixes

Step 8 plan discovery (lib/ship-audit.ts and the plan-completion sections) skips the Plan record line, so a later Plan: <path> binding is still found; render --plan refuses a line it cannot parse. The review skill's pins drop by 4 bytes.

Review-Finding: p1/red-team#1 reader=red-team
Review-Finding: scripts/resolvers/plan-gates.ts:206:functional-contract reader=qa
Review-Finding: p1/red-team#3 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: changelog fragment passes the prose lint (GST-1751)

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

* docs: captain.md describes the READY PLAN: line (GST-1751)

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

* fix: plan discovery splits the Plan line on whitespace and skips record kinds in any case (GST-1751)

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

* test: lower the gstack-review size pins after the Step 8 binding shrank 4 bytes (GST-1751)

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-11-07 09:53:38 +00:00

109 lines
7.0 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 { parsePlanLine } from './plan-record';
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;
}
/** The PR body's plan-file binding: the first `Plan:` line that is not a plan record line (lib/plan-record.ts parsePlanLine). */
export const planBinding = (body: string) => [...body.replace(/[\r`]/g, '').matchAll(/^[ \t]*Plan:[ \t]*(\S+).*$/gm)].find(m => !parsePlanLine(m[0]))?.[1];
/** 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 = planBinding(gh(cwd, ['pr', 'view', '--json', 'body', '-q', '.body']).stdout);
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;
}