mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 15:57:33 +01:00
* fix(ship): base merges, adjacent base edits, test-only and audit-requested fixes no longer force a second review loop (GST-407, GST-384, GST-391)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* ship driver: trim two phrases for headroom with #3662
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* ship driver: keep the Next steps heading phrase the handoff check reads; trim elsewhere
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: regenerate ship goldens
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes
comparePath returns same only when every hunk sits where the base change puts it (patch-ids ignore positions) and treats a file-mode change as changed; the scoped-review packet says when a fence is truncated; --docs-result must be the posted audit's result; the --context-reviewed record goes beside the packet, outside the worktree. Negative controls for each.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pass-2 review fixes
A test edit routes as tests= only when it adds lines with no gate tag; a removed or changed line or an unreadable test diff goes through the full loop. Diffs skip textconv. A hunkless patch (binary, empty, mode-only) is same when its patch-id matches. Without --reviewed-base test files route as before. finish's packet path and attempt's state directory have fallbacks.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: Step 11 review fixes; a version-only stamp_files edit after review is release metadata (GBRA-251)
gateEditedPaths reads removed lines only inside hunks; carry refuses when a path listing fails; docs freshness stops on a failed listing; attempt counts an unreadable state file. releasePaths includes stamp_files paths whose edit is a version swap under stamp_patterns.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: no test edit skips review; guard packet writes; exclusive attempt state
A test-only edit with no gate tag gets the scoped fix-check of its test hunks instead of a lanes-only rerun. Packet write failures print a STOP or RECEIPT_CARRY_REFUSED. attempt's allowance uses an exclusive create.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: attempt prints gstack's install command; carry checks the product diff outside context paths
The attempt STOP no longer echoes the audit child's fix text. A scoped-review carry now requires the product patch-id without the context paths to match at both heads.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: oversized packets take the full loop; exact --docs-requested match; name-only test globs only in test dirs
A scoped-review packet section over 200 lines sends that path to the full loop instead of a truncated packet. The docs-result path match is exact. test_*.py, *Test.java and similar count as test code only under a test directory.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(unattended): carry's scoped-review refusal and what it keeps
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: carry's scoped-review route in ship-policy, review-policy and troubleshooting (docs-a-1)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(windows): curate ship-rereview-triggers out of the Windows lane like ship-release (run 41193336209)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(review-log): record a notes-only /ship pass clean; name the cause and repair when a pass's tree moved (GST-430)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(receipt): carry and write handle PRs with thousands of files; tree receipt fails closed on a failed diff (GST-448)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(ship): preflight routes RECEIPT_CARRY_SCOPED_REVIEW to the scoped review; carry --dry-run writes no packet (GST-442)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(ship): Step 1.4 and Step 16 route the scoped-review refusal and the --docs-requested fix; trims keep the cap (GST-442, GST-444)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: /ship review pass 1: notes-only floor, scoped carry order, named temp and diff refusals
- A notes-only pass is recorded clean only when every finding is fixed, or noted or deferred below the
blocking floor (never CRITICAL or blocking), by its logger-stamped action, and the findings account
for the claimed issue and critical counts (GST-430).
- The preflight scoped carry route commits and pushes the base-merge fix before writing the packet (GST-442).
- patchId refuses by name on an unwritable TMPDIR; the tree receipt names why its diff failed (GST-448).
- Step 16 stage 3 spells out --docs-requested with its --docs-result (GST-444).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: pin the Step 16 --docs-requested route with its --docs-result
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: a claimed clean review row with a logger-stamped open gate finding reads UNVERIFIED (red-team, pass 1)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: Step 11 findings: gate rows from --findings are stamped, one open rule, stale patch-id dirs swept
- deriveFromFindings stamps gate rows with the session kind before deriving status, so a findings file cannot
dispose of a gate finding the unattended logger forces open (outside-voice#1).
- reviewFreshness reads a claimed row's findings with isOpenByAction, the rule the notes-only rewrite uses;
noted and deferred clear a finding only when it is marked blocking: false and is not CRITICAL.
- patchId sweeps gstack-patch-id-* directories over an hour old that a killed carry left in TMPDIR.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: Step 11 round 2: stamped-open gates win over status and disposition; resolved gate, skipped and below-floor actions stay CURRENT
- isOpenFinding treats action 'open' (the logger's forced-open stamp) as open before a row's status or disposition.
- reviewFreshness flags a claimed clean row only for an 'open' action or a CRITICAL or blocking deferral or note, so
interactive kept and restored gates, skipped advisories and INFORMATIONAL deferrals read CURRENT as before.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: carry dry run offers the scoped route last; patch-id diff under the git dir; changelog matches the review-log floor
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: a claimed clean review row with any unresolved non-gate CRITICAL finding reads UNVERIFIED (pass 2 red-team)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: carry dry-run scoped route, patch-id directory, tree-receipt refusal (docs-d-1 audit)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(ship): Step 3 names merge conflicts in its STOP sentence again (ship-workflow-clarity pin)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(tree-receipt): keep the gated-to-current diff at the file helper timeout (60 s); pregate literals check
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(carry): large-diff fixture sets core.longpaths so Git for Windows can add its long receipt paths (windows-free-shard 1, run 41210080055)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: restore bin/gstack-ship-receipt after the main merge
The merge commit b10e2db wrote a truncated copy of the file. This is the
three-way merge of both sides with the refused() union: main's codes plus
RECEIPT_CARRY_SCOPED_REVIEW.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* ship: trim a Step 3 sentence so the skeleton fits maxSkeletonBytes with train 77
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* ship: keep the no-edit rule in the trimmed Step 3 sentence; regenerate the ship goldens
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>
145 lines
11 KiB
TypeScript
145 lines
11 KiB
TypeScript
/**
|
|
* GST-430 (CAPY-2, qm#2837): a /ship review pass whose only findings were notes or deferrals has no
|
|
* open defect, so the logger records it clean with issues_found 0 and Step 11.5 can bind it. A finding
|
|
* still open (skipped, gate open, no action) keeps the claimed status. A row whose tree moved during
|
|
* the pass names that cause and the cheapest repair.
|
|
*/
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import { spawnSync } from 'node:child_process';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { isOpenFinding } from '../lib/headless-artifacts';
|
|
import { reviewFreshness } from '../lib/review-evidence';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const BIN = path.join(ROOT, 'bin', 'gstack-review-log');
|
|
|
|
let home: string;
|
|
beforeEach(() => { home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-notes-only-')); });
|
|
afterEach(() => { fs.rmSync(home, { recursive: true, force: true }); });
|
|
|
|
function run(args: string[]) {
|
|
const r = spawnSync(BIN, args, { cwd: ROOT, encoding: 'utf8', timeout: 60_000, env: { ...process.env, GSTACK_HOME: home, GSTACK_SESSION_KIND: 'unattended' } });
|
|
return { code: r.status, err: r.stderr, out: r.stdout };
|
|
}
|
|
function lastRow(): any {
|
|
const projects = path.join(home, 'projects');
|
|
const rows = fs.readdirSync(projects).flatMap(slug => fs.readdirSync(path.join(projects, slug)).filter(f => f.endsWith('-reviews.jsonl'))
|
|
.flatMap(f => fs.readFileSync(path.join(projects, slug, f), 'utf8').trim().split('\n').map(l => JSON.parse(l))));
|
|
return rows.at(-1);
|
|
}
|
|
const note = (i: number, action: string, extra: Record<string, unknown> = {}) =>
|
|
({ fingerprint: `lib/a.ts:${i}:testing`, severity: 'INFORMATIONAL', action, locus: 'diff', kind: 'test-gap', blocking: false, raised_by: 'specialist-batched', ...extra });
|
|
const pass = (findings: unknown[], extra: Record<string, unknown> = {}) => JSON.stringify({
|
|
skill: 'review', via: 'ship', timestamp: '2026-10-31T00:00:00Z', status: 'issues_found', issues_found: findings.length, critical: 0,
|
|
informational: findings.length, quality_score: 9, specialists: {}, findings, completed: true, converged: true, cycles: 0, pass: 2,
|
|
review_scope: 'frozen', range_base: null, ...extra,
|
|
});
|
|
|
|
describe('a notes-only /ship review pass (GST-430)', () => {
|
|
// Value: protects=Step 11.5 binding a pass whose 3 notes are not defects; catches=a notes-only pass logged as
|
|
// issues_found and left UNVERIFIED, which cost CAPY-2 an extra rebind pass.
|
|
test('three noted findings are recorded clean with 0 issues and read CURRENT', () => {
|
|
const token = run(['--start', 'review']).out.trim();
|
|
const r = run([pass([note(1, 'noted'), note(2, 'noted'), note(3, 'deferred', { issue: null })]), '--finish', token]);
|
|
expect(r.code).toBe(0);
|
|
const row = lastRow();
|
|
expect([row.status, row.issues_found, row.critical, row.informational, row.review_binding.state]).toEqual(['clean', 0, 0, 0, 'verified']);
|
|
expect(reviewFreshness(row, row.wtree)).toEqual({ status: 'CURRENT', reason: 'completed clean pass on unchanged content' });
|
|
});
|
|
|
|
// Value: protects=open defects still blocking 11.5; catches=a normalization that clears a skipped finding, a
|
|
// gate finding the unattended logger forced open, a finding with no action, or an incomplete pass.
|
|
test('negative controls keep issues_found', () => {
|
|
const cases: Array<[string, string]> = [
|
|
['skipped', pass([note(1, 'noted'), note(2, 'skipped')])],
|
|
['gate open', pass([note(1, 'noted'), note(2, 'kept', { gate: 'RH1', gate_id: 'g', reason: 'r' })])],
|
|
['no action', pass([note(1, 'noted'), { fingerprint: 'lib/a.ts:9:testing', severity: 'CRITICAL' }])],
|
|
['incomplete', pass([note(1, 'noted')], { completed: false })],
|
|
['caller status over a stamped-open gate', pass([note(1, 'noted'), note(2, 'kept', { gate: 'RH1', gate_id: 'g', reason: 'r', severity: 'CRITICAL', status: 'resolved' })])],
|
|
['critical noted', pass([note(1, 'noted'), note(2, 'noted', { severity: 'CRITICAL' })])],
|
|
['blocking deferred', pass([note(1, 'noted'), note(2, 'deferred', { blocking: true, issue: null })])],
|
|
['counts beyond the findings', pass([note(1, 'noted')], { issues_found: 2, critical: 1 })],
|
|
['noted with no blocking field', pass([note(1, 'noted'), note(2, 'noted', { blocking: undefined })])],
|
|
];
|
|
for (const [name, row] of cases) {
|
|
expect(run([row]).code).toBe(0);
|
|
expect([name, lastRow().status]).toEqual([name, 'issues_found']);
|
|
}
|
|
});
|
|
|
|
// Value: protects=a claimed clean row cannot carry a gate finding the unattended logger stamped open past Step 11.5;
|
|
// catches=a reader that trusts the claimed status and counts over the stamped finding (red-team#2, pass 1).
|
|
test('a claimed clean row with a stamped-open gate finding is not CURRENT', () => {
|
|
const token = run(['--start', 'review']).out.trim();
|
|
const gate = note(1, 'kept', { gate: 'RH1', gate_id: 'g', reason: 'r', severity: 'CRITICAL' });
|
|
expect(run([pass([gate], { status: 'clean', issues_found: 0, critical: 0, informational: 0 }), '--finish', token]).code).toBe(0);
|
|
const row = lastRow();
|
|
expect([row.status, row.findings[0].action, row.review_binding.state]).toEqual(['clean', 'open', 'verified']);
|
|
expect(reviewFreshness(row, row.wtree)?.status).toBe('UNVERIFIED');
|
|
});
|
|
|
|
// Value: protects=a claimed clean row reads open findings by the same rule the rewrite uses; catches=a CRITICAL
|
|
// deferral or a skipped finding under a claimed clean status reading CURRENT (adversarial-native#3, Step 11).
|
|
// Kept and restored interactive gates, skipped advisories and an INFORMATIONAL deferral stay CURRENT, as before this PR
|
|
// (fix-check and adversarial-native, Step 11 round 2).
|
|
test('a claimed clean row with a CRITICAL deferral is not CURRENT; resolved gate, skipped and below-floor actions are', () => {
|
|
const w = 'c'.repeat(40);
|
|
const row = (findings: unknown[]) => ({ skill: 'review', status: 'clean', issues_found: 0, critical: 0, completed: true, converged: true, wtree: w,
|
|
review_binding: { state: 'verified', start_wtree: w, end_wtree: w }, findings, status_source: 'claimed' });
|
|
expect(reviewFreshness(row([note(1, 'deferred', { severity: 'CRITICAL', issue: null })]), w)?.status).toBe('UNVERIFIED');
|
|
expect(reviewFreshness(row([note(1, 'deferred', { blocking: true })]), w)?.status).toBe('UNVERIFIED');
|
|
expect(reviewFreshness(row([note(1, 'open', { gate: 'RH1' })]), w)?.status).toBe('UNVERIFIED');
|
|
expect([note(1, 'skipped', { severity: 'CRITICAL' }), { fingerprint: 'lib/a.ts:2:x', severity: 'CRITICAL' }, note(1, 'mystery', { severity: 'CRITICAL' })]
|
|
.map(f => reviewFreshness(row([f]), w)?.status)).toEqual(['UNVERIFIED', 'UNVERIFIED', 'UNVERIFIED']);
|
|
expect(reviewFreshness(row([note(1, 'kept', { gate: 'RH1', reason: 'r', actor: 'interactive', severity: 'CRITICAL' })]), w)?.status).toBe('CURRENT');
|
|
const gate = (action: string) => note(1, action, { gate: 'RH1', reason: 'r', actor: 'interactive' });
|
|
expect([gate('kept'), gate('restored'), note(1, 'skipped'), note(1, 'deferred', { blocking: undefined }), note(1, 'noted')]
|
|
.map(f => reviewFreshness(row([f]), w)?.status)).toEqual(['CURRENT', 'CURRENT', 'CURRENT', 'CURRENT', 'CURRENT']);
|
|
});
|
|
|
|
// Value: protects=a findings file cannot dispose of a gate finding the unattended logger must force open; catches=gate rows
|
|
// from --findings derived as resolved without stamping (outside-voice#1, Step 11).
|
|
test('a --findings file gate row is stamped open before its status is derived', () => {
|
|
const file = path.join(home, 'findings.jsonl');
|
|
fs.writeFileSync(file, JSON.stringify({ schema_version: 1, run: 'r', phase: 'review', voice: 'native', title: 't', fingerprint: 'lib/a.ts:1:x', severity: 'CRITICAL', action: 'auto-fixed', gate: 'RH1', gate_id: 'g' }) + '\n');
|
|
const token = run(['--start', 'review']).out.trim();
|
|
run([JSON.stringify({ skill: 'review', via: 'ship', timestamp: '2026-10-31T00:00:00Z', completed: true, converged: true }), '--findings', file, '--finish', token]);
|
|
const row = lastRow();
|
|
expect([row.findings_open, row.status === 'clean']).toEqual([1, false]);
|
|
expect(reviewFreshness(row, row.wtree)?.status).not.toBe('CURRENT');
|
|
});
|
|
|
|
// Value: protects=the forced-open stamp wins over a findings file's own status or disposition; catches=a gate row marked
|
|
// status resolved deriving clean after it was stamped open (outside-voice#1, Step 11 round 2).
|
|
test('a --findings gate row stamped open stays open whatever its status or disposition says', () => {
|
|
for (const extra of [{ status: 'resolved' }, { disposition: 'fixed' }]) {
|
|
const file = path.join(home, 'findings.jsonl');
|
|
fs.writeFileSync(file, JSON.stringify({ schema_version: 1, run: 'r', phase: 'review', voice: 'native', title: 't', fingerprint: 'lib/a.ts:1:x', severity: 'CRITICAL', action: 'auto-fixed', gate: 'RH1', gate_id: 'g', ...extra }) + '\n');
|
|
const token = run(['--start', 'review']).out.trim();
|
|
run([JSON.stringify({ skill: 'review', via: 'ship', timestamp: '2026-10-31T00:00:00Z', completed: true, converged: true }), '--findings', file, '--finish', token]);
|
|
expect([JSON.stringify(extra), lastRow().findings_open]).toEqual([JSON.stringify(extra), 1]);
|
|
}
|
|
});
|
|
|
|
test('isOpenFinding: fixed and auto-fixed are not open; noted and deferred only below the floor; skipped and missing actions are', () => {
|
|
expect(['fixed', 'auto-fixed', 'deferred', 'noted', 'skipped', undefined].map(action => isOpenFinding({ action, blocking: false })))
|
|
.toEqual([false, false, false, false, true, true]);
|
|
expect(['deferred', 'noted'].map(action => isOpenFinding({ action }))).toEqual([true, true]);
|
|
});
|
|
});
|
|
|
|
describe('a tree that changed during the pass (GST-430)', () => {
|
|
// Value: protects=a refusal that says why the record cannot bind and what the cheapest repair is; catches=the
|
|
// bare 'content changed during review' that sent CAPY-2 through a ~$20 full pass without saying why.
|
|
test('the STALE reason names both trees, the cause and the repair', () => {
|
|
const s = 'a'.repeat(64), e = 'b'.repeat(64);
|
|
const fresh = reviewFreshness({ skill: 'adversarial-review', review_binding: { state: 'changed', start_wtree: s, end_wtree: e } }, e);
|
|
expect(fresh?.status).toBe('STALE');
|
|
expect(fresh?.reason).toContain(`content changed during review: the tree moved from ${s.slice(0, 12)} to ${e.slice(0, 12)} while adversarial-review ran`);
|
|
expect(fresh?.reason).toContain('freeze the checkout until every reviewer returns');
|
|
expect(fresh?.reason).toContain('docs/troubleshooting.md#review-content-changed');
|
|
});
|
|
});
|