mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-09 10:18:32 +01:00
* feat(ship): review loop converges (frozen finding scope, 3-pass cap, NO FINDINGS first-class) and receipts carry across conflict-only merges
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(ship): keep the Step 9 first-entry and Fix-First phrasing the skip/requeue contract asserts; fill the review record's pass, review_scope and range_base placeholders in the caller template test; narrow a test type
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes (pass 1) and the review-loop budget wording
Two blocking findings from /ship's own pass 1, both reproduced in the QA smoke before the fix:
gstack-ship-receipt carry hashed the product diff with git patch-id --stable, which strips
whitespace, so a space moved in a reviewed shell line carried as "no product change"; and it
classed package.json and every lockfile as release metadata, so a dependency swap carried too.
The patch-id is now --verbatim, package.json and VERSION are metadata only while the change is a
version stamp (lib/pregate/stamp-only.ts), lockfiles are product unless the policy names them,
the test globs name code extensions, a dirty working tree refuses before anything is compared,
and a receipt carried twice keeps the reviewed head as carry.from.
gstack-review-scope: an unresolvable base is POLICY_SOURCE_UNAVAILABLE instead of a silent
working-tree read; --range-base is validated; git runs with core.quotePath=false; range_base is
null on pass 1; a blocking finding with no path is fix; category "testing" is a test finding
below the floor; defer searches once, files nothing on a failed search and exits 0 unfiled;
summary refuses zero matching passes and the receipt write is chained to it with &&; a finding
the diff causes elsewhere names the changed line in `cause` and classify follows it.
Review-loop budget wording (ideas from tstack's sol-loop and pstack's interrogate, rewritten;
NOTICE.md): the blocker / should / nit glossary and two stop rules in Step 9.4, the checklist
and docs/review-policy.md; a lead-judgment stage in the finding merge both /review and /ship
render. The core read diffs $RANGE_BASE from pass 2 on. Caps and pins re-measured with reasons.
Step 7 coverage audit: four tests added in existing files, the --review-loop case moved to its
own test (the shared test tripped bun's 5 s default).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pin the renumbered review-army stages and point the wording test at product code
CI at c3e27fc failed three free-tier tests, all this branch's own: the
workflow-required-steps fixture and shared-libs-rendering still pinned the
pre-Lead-judgment stage numbers, and test-of-test-ratchet counted
review-loop-budget-wording.test.ts as test-only code because it read only
generated markdown. The fixture and rendering test now pin stages 1-8, and the
wording test renders both armies through generateReviewArmy and reads the
authored ship template as well as the generated sections.
Pass-2 maintainability notes folded in: docs/troubleshooting.md names the two
new RECEIPT_CARRY_REFUSED causes (dirty tree, unreadable status) and the two new
REVIEW_SCOPE_INPUT causes (non-hex --range-base, summary with no matching
pass); gstack-review-scope's help states that classify reads the base branch's
policy and refuses an unresolvable base unless --policy is passed.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: date a review-log row from its binding start when it has no timestamp
The Step 9.4 template asks the writer to fill `timestamp`; the logger stamps
`review_binding.started_at` but never `timestamp`. `gstack-review-scope
summary` dropped such a row silently, so this PR's own pass 2 was missing from
REVIEW_LOOP (passes=1 for a two-pass loop). A row now dates from `timestamp`,
else from its binding start; a row with neither is still not counted.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pass-4 review fixes (carry stamp rule, deleted-file locus, scanned defer, diff prefixes, row time)
Five findings at the blocking floor from the Step 11 outside voice (gpt-6.1-sol)
and the native adversarial pass, fixed as a fourth pass approved by GStack
Minder under the three-pass policy:
- carry: a path the policy names under stamp_files, stamp_paths or
release_outputs is metadata only while its change is a stamp (versionStampOnly
for a manifest kind, stampOnlyText otherwise), the rule the tree receipt
applies; a logic change in a stamped source file is product. Every
metadata-listed path is read at both heads and a non-stamp change drops its
globs from the product exclusion.
- review-scope: parseHunks keeps a deleted file as a whole changed location, so
a cause anchored in a removed export is the diff's to fix, not deferred.
- defer: issues are filed through lib/gstack-post's publication scan; a HIGH
finding in the title or body refuses and a MEDIUM one needs a human, both
recorded as unfiled with the reason; --repo names the destination.
- classify: both diffs pin --src-prefix=a/ --dst-prefix=b/ --no-textconv, so
diff.mnemonicPrefix cannot send every finding outside.
- summary: a row dates from review_binding.started_at before the writer-typed
timestamp, so a date-only value cannot drop a real pass.
Docs: ship-policy.md#receipt-carry, review-policy.md, the Step 9.4 template
and the CHANGELOG gate-integrity note record the rule and the pass.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(review-army): assert the Red Team branch the run took in the N+1 case
Under the blocking floor the Performance specialist rates a 12-line N+1
INFORMATIONAL, so the conditional Red Team does not activate; the case
encoded the old severity (N+1 is CRITICAL, a Red Team always follows). It
now asserts the branch the run took: a Red Team that ran must follow the
Performance specialist; no Red Team is correct only when the report's own
count says 0 critical and its Red Team line names that reason. The N+1
detection assertion is unchanged. The free contract test gains the
informational-not-activated and informational-claimed controls and the
budget test's synthetic report carries the count line.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* v1.91.256.0 stamp
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>
278 lines
14 KiB
TypeScript
278 lines
14 KiB
TypeScript
/**
|
|
* review-scope — the finding scope of /ship's pre-landing review loop
|
|
* (ship/sections/review-army.md, Step 9.4; docs/ship-policy.md#review-scope).
|
|
*
|
|
* Five Captains hit the same loop: every pass re-read the whole diff, found
|
|
* new neighbours in pre-existing code, fixed them, and the fixes drew another
|
|
* pass until the cycle cap. Under `review_scope: frozen` (the default) the
|
|
* scope is fixed after pass 1: findings in the diff are fixed, findings in
|
|
* code the PR did not change are filed as issues (deferred), and later passes
|
|
* read only the range since the previous pass and fix only blocking findings.
|
|
* Everything below the blocking floor is a named note in the PR body and never
|
|
* triggers a pass. `review_scope: all` keeps the old behaviour.
|
|
*
|
|
* Pure functions here; bin/gstack-review-scope runs git and gh.
|
|
*/
|
|
|
|
export type Locus = 'diff' | 'fix-range' | 'outside';
|
|
export type Kind = 'defect' | 'test-gap' | 'advisory';
|
|
export type ReviewScopePolicy = 'frozen' | 'diff-plus-blocking' | 'all';
|
|
export const REVIEW_SCOPE_POLICIES: readonly ReviewScopePolicy[] = ['frozen', 'diff-plus-blocking', 'all'];
|
|
export type ScopeAction = 'fix' | 'defer' | 'note';
|
|
|
|
/** Lines around a changed hunk that still count as the diff (a missing check right after an added line is the diff's fault). */
|
|
export const HUNK_CONTEXT_LINES = 3;
|
|
|
|
export interface ScopeFinding {
|
|
fingerprint?: string; path?: string; line?: number; severity?: string; category?: string; summary?: string;
|
|
problem?: string; fix?: string; specialist?: string; advisory?: boolean; test_stub?: string;
|
|
/** The changed line that causes a defect reported elsewhere (a broken caller, a consumer of a removed export); the locus follows it. */
|
|
cause?: { path?: string; line?: number };
|
|
[key: string]: unknown;
|
|
}
|
|
export interface ScopedFinding extends ScopeFinding { locus: Locus; kind: Kind; blocking: boolean; scope: ScopeAction }
|
|
|
|
/** Per path, the new-side line ranges a diff touched; `'whole'` for an added or untracked file. */
|
|
export type HunkMap = Record<string, Array<[number, number]> | 'whole'>;
|
|
|
|
/**
|
|
* Parse `git diff -U0` output (plus untracked paths as whole files) into a hunk
|
|
* map. The diff must be produced with `--src-prefix=a/ --dst-prefix=b/` (the
|
|
* `diff.mnemonicPrefix` setting prints `c/` and `w/` otherwise, and no path
|
|
* would match a finding). A deleted file is a whole changed location too: a
|
|
* finding whose `cause` is a removed export anchors there.
|
|
*/
|
|
export function parseHunks(diff: string, untracked: readonly string[] = []): HunkMap {
|
|
const map: HunkMap = {};
|
|
let current: string | null = null;
|
|
let added = false;
|
|
let removed: string | null = null;
|
|
for (const raw of diff.split('\n')) {
|
|
const line = raw.replace(/\r$/, '');
|
|
if (line.startsWith('diff --git ')) { current = null; added = false; removed = null; continue; }
|
|
if (line.startsWith('--- ')) {
|
|
added = line === '--- /dev/null';
|
|
removed = added ? null : line.replace(/^--- (?:a\/)?/, '').replace(/\t.*$/, '');
|
|
continue;
|
|
}
|
|
if (line.startsWith('+++ ')) {
|
|
if (line === '+++ /dev/null') { if (removed) map[removed] = 'whole'; current = null; continue; }
|
|
current = line.replace(/^\+\+\+ (?:b\/)?/, '').replace(/\t.*$/, '');
|
|
if (added) map[current] = 'whole';
|
|
else if (!(current in map)) map[current] = [];
|
|
continue;
|
|
}
|
|
if (!current || map[current] === 'whole') continue;
|
|
const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
|
if (!m) continue;
|
|
const start = Number(m[1]);
|
|
const count = m[2] === undefined ? 1 : Number(m[2]);
|
|
// A pure deletion (+N,0) still marks the line where the code used to be.
|
|
(map[current] as Array<[number, number]>).push([start, count === 0 ? start : start + count - 1]);
|
|
}
|
|
for (const p of untracked) if (p) map[normalizePath(p)] = 'whole';
|
|
return map;
|
|
}
|
|
|
|
function normalizePath(p: string): string {
|
|
return p.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
}
|
|
|
|
function inHunks(hunks: HunkMap, path: string, line: number | undefined, context: number): boolean {
|
|
const ranges = hunks[normalizePath(path)];
|
|
if (ranges === undefined) return false;
|
|
if (ranges === 'whole' || line === undefined || !Number.isFinite(line)) return true;
|
|
return ranges.some(([a, b]) => line >= a - context && line <= b + context);
|
|
}
|
|
|
|
/**
|
|
* Where a finding sits: in the range since the previous pass, in the branch
|
|
* diff, or in code the PR did not change. A `cause` anchor wins over the
|
|
* reported path, so a defect the diff causes in an unchanged caller is the
|
|
* diff's.
|
|
*/
|
|
export function locusOf(finding: ScopeFinding, diffHunks: HunkMap, rangeHunks: HunkMap | null, context = HUNK_CONTEXT_LINES): Locus {
|
|
const anchor = finding.cause && typeof finding.cause === 'object' && typeof finding.cause.path === 'string' ? finding.cause : finding;
|
|
const path = typeof anchor.path === 'string' ? anchor.path : fingerprintPath(finding.fingerprint);
|
|
if (!path) return 'outside';
|
|
const line = typeof anchor.line === 'number' ? anchor.line : anchor === finding ? fingerprintLine(finding.fingerprint) : undefined;
|
|
if (rangeHunks && inHunks(rangeHunks, path, line, context)) return 'fix-range';
|
|
if (inHunks(diffHunks, path, line, context)) return 'diff';
|
|
return 'outside';
|
|
}
|
|
|
|
function fingerprintPath(fp: unknown): string | undefined {
|
|
if (typeof fp !== 'string' || fp.startsWith('shared-libs:')) return undefined;
|
|
const [path] = fp.split(':');
|
|
return path || undefined;
|
|
}
|
|
function fingerprintLine(fp: unknown): number | undefined {
|
|
if (typeof fp !== 'string') return undefined;
|
|
const n = Number(fp.split(':')[1]);
|
|
return Number.isInteger(n) && n > 0 ? n : undefined;
|
|
}
|
|
|
|
/** The categories a reviewer uses for a test finding; `testing` is what the Testing specialist's schema emits. */
|
|
export const TEST_GAP_CATEGORIES: readonly string[] = ['testing', 'tests', 'test-gap', 'test-coverage', 'coverage', 'missing-test', 'untested'];
|
|
|
|
/** A finding's kind: optional advice, a test finding (a gap, a weak or flaky test), or a product defect. */
|
|
export function kindOf(finding: ScopeFinding): Kind {
|
|
if (finding.advisory === true) return 'advisory';
|
|
if (TEST_GAP_CATEGORIES.includes(String(finding.category ?? '').toLowerCase())) return 'test-gap';
|
|
return 'defect';
|
|
}
|
|
|
|
/**
|
|
* The blocking floor: a CRITICAL defect (a test that would fail, a production
|
|
* break, a security or data-loss defect). Advisories, test gaps, wording and
|
|
* INFORMATIONAL items are below it and never trigger another pass.
|
|
*/
|
|
export function isBlocking(finding: ScopeFinding, kind = kindOf(finding)): boolean {
|
|
return kind === 'defect' && String(finding.severity ?? '').toUpperCase() === 'CRITICAL';
|
|
}
|
|
|
|
export interface ScopeInput { policy: ReviewScopePolicy; pass: number; diffHunks: HunkMap; rangeHunks: HunkMap | null; context?: number }
|
|
|
|
/**
|
|
* The scope decision per finding. `all`: fix everything (today's loop).
|
|
* `frozen` pass 1: fix the diff, defer blocking findings in unchanged code,
|
|
* note the rest. `frozen` pass 2+: fix blocking findings in the range since
|
|
* the previous pass or in the diff; defer blocking findings outside; note
|
|
* everything else.
|
|
*/
|
|
export function scopeFindings(findings: readonly ScopeFinding[], input: ScopeInput): ScopedFinding[] {
|
|
return findings.map(finding => {
|
|
const kind = kindOf(finding);
|
|
const blocking = isBlocking(finding, kind);
|
|
const locus = locusOf(finding, input.diffHunks, input.pass > 1 ? input.rangeHunks : null, input.context);
|
|
// A blocking finding nobody could place cannot be filed against a path; the diff's author resolves it now.
|
|
const placed = typeof finding.path === 'string' || fingerprintPath(finding.fingerprint) !== undefined;
|
|
let scope: ScopeAction;
|
|
if (input.policy === 'all') scope = 'fix';
|
|
else if (locus === 'outside') scope = !blocking ? 'note' : input.policy === 'diff-plus-blocking' || !placed ? 'fix' : 'defer';
|
|
else if (input.pass <= 1) scope = 'fix';
|
|
else scope = blocking ? 'fix' : 'note';
|
|
return { ...finding, locus, kind, blocking, scope };
|
|
});
|
|
}
|
|
|
|
export function scopeCounts(scoped: readonly ScopedFinding[]): { fix: number; defer: number; note: number; blocking: number } {
|
|
return {
|
|
fix: scoped.filter(f => f.scope === 'fix').length, defer: scoped.filter(f => f.scope === 'defer').length,
|
|
note: scoped.filter(f => f.scope === 'note').length, blocking: scoped.filter(f => f.blocking).length,
|
|
};
|
|
}
|
|
|
|
export function scopeLine(policy: ReviewScopePolicy, pass: number, scoped: readonly ScopedFinding[]): string {
|
|
const c = scopeCounts(scoped);
|
|
return `REVIEW_SCOPE: policy=${policy} pass=${pass} findings=${scoped.length} fix=${c.fix} defer=${c.defer} note=${c.note} blocking=${c.blocking}`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// deferral: one issue per blocking finding in code the PR did not change
|
|
// ---------------------------------------------------------------------------
|
|
export const DEFERRED_MARKER = 'gstack-deferred-finding';
|
|
const oneLine = (s: unknown) => String(s ?? '').replace(/[\x00-\x1f\x7f\u2028\u2029]+/g, ' ').trim();
|
|
|
|
export function findingIdentity(f: ScopeFinding): string {
|
|
if (typeof f.fingerprint === 'string' && f.fingerprint) return f.fingerprint;
|
|
return [f.path ?? '?', f.line ?? '', f.category ?? ''].join(':');
|
|
}
|
|
export function findingWhere(f: ScopeFinding): string {
|
|
return f.path ? `${f.path}${typeof f.line === 'number' ? `:${f.line}` : ''}` : fingerprintPath(f.fingerprint) ?? '(no path)';
|
|
}
|
|
function findingText(f: ScopeFinding): string {
|
|
return oneLine(f.summary ?? f.problem ?? f.category ?? 'review finding');
|
|
}
|
|
|
|
export interface DeferContext { repo?: string; pr?: number; branch?: string; head?: string }
|
|
|
|
/** Issue title and body for a deferred finding; the marker keeps a rerun from filing it twice. */
|
|
export function renderDeferredIssue(f: ScopedFinding, ctx: DeferContext): { title: string; body: string; marker: string } {
|
|
const text = findingText(f);
|
|
const title = `Deferred review finding: ${findingWhere(f)} — ${text}`.slice(0, 120);
|
|
const origin = ctx.pr ? `${ctx.repo ? `${ctx.repo}#${ctx.pr}` : `PR #${ctx.pr}`}` : ctx.branch ? `branch \`${ctx.branch}\`` : 'a /ship run';
|
|
const marker = `<!-- ${DEFERRED_MARKER}: ${oneLine(findingIdentity(f))} -->`;
|
|
const body = [
|
|
`Filed by /ship's pre-landing review of ${origin}${ctx.head ? ` (head ${ctx.head.slice(0, 12)})` : ''}. The finding is in code that PR did not change, so it was deferred here instead of being fixed there (review scope frozen after pass 1).`,
|
|
'',
|
|
`- **Severity:** ${oneLine(f.severity ?? 'unrated')}`,
|
|
`- **Where:** \`${findingWhere(f)}\``,
|
|
`- **Finding:** ${text}`,
|
|
...(f.fix ? [`- **Suggested fix:** ${oneLine(f.fix)}`] : []),
|
|
...(f.specialist ? [`- **Found by:** ${oneLine(f.specialist)}`] : []),
|
|
'',
|
|
marker,
|
|
'',
|
|
].join('\n');
|
|
return { title, body, marker };
|
|
}
|
|
|
|
export interface DeferredRecord { fingerprint: string; path: string; severity: string; summary: string; issue: number | null; url?: string; reason?: string }
|
|
|
|
/** The PR-body section for deferred findings: references, never closing keywords, so a merge closes nothing. Only `frozen` defers. */
|
|
export function renderDeferredSection(deferred: readonly DeferredRecord[], noted: readonly ScopeFinding[] = []): string {
|
|
const out: string[] = [];
|
|
if (deferred.length) {
|
|
out.push('## Deferred findings', '', 'Blocking findings in code this PR did not change (review scope: frozen); filed as issues and not closed by this PR.', '');
|
|
for (const d of deferred) {
|
|
const ref = d.issue ? `#${d.issue}` : `unfiled${d.reason ? ` (${oneLine(d.reason)})` : ''}`;
|
|
out.push(`- Deferred: ${ref} — ${oneLine(d.severity)} \`${oneLine(d.path)}\` ${oneLine(d.summary)}`);
|
|
}
|
|
out.push('');
|
|
}
|
|
if (noted.length) {
|
|
out.push('## Review notes', '', 'Below the blocking floor (advisory, informational, test-gap or wording items); recorded here and not fixed in this PR.', '');
|
|
for (const n of noted) out.push(`- ${oneLine(n.severity ?? 'INFORMATIONAL')}${n.advisory ? ' [ADVISORY]' : ''} \`${findingWhere(n)}\` ${findingText(n)}`);
|
|
out.push('');
|
|
}
|
|
return out.join('\n');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// loop summary: what the ship receipt carries for the factory scorecard
|
|
// ---------------------------------------------------------------------------
|
|
export interface ReviewLoopSummary { passes: number; blocking_found: number; deferred: number; noted: number; fixed: number; scope: ReviewScopePolicy | 'unknown' }
|
|
|
|
/** Review-log rows of one /ship invocation (skill review, via ship, at or after `since`) → the loop summary. */
|
|
/**
|
|
* When a review-log row's pass started: the logger's own `review_binding.started_at`
|
|
* first, and the writer-typed `timestamp` only when the logger stamped nothing. The
|
|
* writer fills `timestamp` by hand, so a date-only or guessed value could otherwise
|
|
* sort a real pass before `--since` and leave the receipt without a loop to record.
|
|
*/
|
|
function rowTime(row: Record<string, any>): number {
|
|
for (const value of [row.review_binding?.started_at, row.timestamp]) {
|
|
const ms = typeof value === 'string' ? Date.parse(value) : NaN;
|
|
if (Number.isFinite(ms)) return ms;
|
|
}
|
|
return NaN;
|
|
}
|
|
|
|
export function summarizeReviewLoop(rows: readonly Record<string, any>[], since: string): ReviewLoopSummary {
|
|
const sinceMs = Date.parse(since);
|
|
const passes = rows.filter(r => r && r.skill === 'review' && r.via === 'ship' && Number.isFinite(rowTime(r))
|
|
&& (!Number.isFinite(sinceMs) || rowTime(r) >= sinceMs));
|
|
const blocking = new Set<string>();
|
|
const deferred = new Set<string>();
|
|
const noted = new Set<string>();
|
|
const fixed = new Set<string>();
|
|
let scope: ReviewLoopSummary['scope'] = 'unknown';
|
|
for (const row of passes) {
|
|
if (REVIEW_SCOPE_POLICIES.includes(row.review_scope)) scope = row.review_scope;
|
|
for (const f of Array.isArray(row.findings) ? row.findings : []) {
|
|
if (!f || typeof f !== 'object') continue;
|
|
const id = findingIdentity(f);
|
|
if (f.blocking === true) blocking.add(id);
|
|
if (f.action === 'deferred') deferred.add(id);
|
|
if (f.action === 'noted') noted.add(id);
|
|
if (f.action === 'fixed' || f.action === 'auto-fixed') fixed.add(id);
|
|
}
|
|
}
|
|
return { passes: passes.length, blocking_found: blocking.size, deferred: deferred.size, noted: noted.size, fixed: fixed.size, scope };
|
|
}
|
|
|
|
export function reviewLoopLine(s: ReviewLoopSummary): string {
|
|
return `REVIEW_LOOP: passes=${s.passes} blocking_found=${s.blocking_found} deferred=${s.deferred} noted=${s.noted} fixed=${s.fixed} scope=${s.scope}`;
|
|
}
|