mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 07:18:03 +01:00
v1.92.81.0 feat(ship-release): finish accepts the owner's edit after a fix-check (GST-965) (#3861)
* feat(ship-release): finish accepts the owner's edit after a fix-check (GST-965) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship-release): fix_check.files narrows the accepted scope; gate-edited and over-cap paths stay behavior (GST-965) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(review-policy): the gate-edited and over-cap exclusions hold with --reviewed-base (GST-965) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship-release): a gate-edited test is never owner-accepted; an empty fix_check.files stops (GST-965) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(review-policy): the over-cap exclusion holds with --reviewed-base (GST-965 fix-check note) 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>
This commit is contained in:
1 parent
0542230e63
commit
1022f4fc51
7 files changed
+140
-18
No files matched your search
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## [1.92.81.0] - 2026-11-02
|
||||
|
||||
**`gstack-ship-release finish` now accepts the owner's edit after a fix-check instead of asking for another review round (GST-965).** On gbrain #8425, the fix-check found a blocker, the owner's pre-agreed fallback was applied and "then ship" was granted. Finish still classed the edit as behavior and stopped with `insert 5–11.5 → 12–14 → 16`, a third round the R51 cap forbids, so /ship could not get through Step 16 without running its stages by hand.
|
||||
|
||||
### Added
|
||||
- **`finish --review-loop-exception <json|@file>`** (`lib/ship-release.ts`) takes the same record `gstack-ship-receipt write --review-loop-exception` takes. Paths changed since `fix_check.head` that the branch already touched at that commit (narrowed to the record's `fix_check.files` when it lists them) route as `accepted=` on the `ROUTE:` line, a `REVIEW_LOOP_EXCEPTION:` line names them with the owner, date and channel, and the docs stage carries them (`accepted=` in the DOCS line). A path outside that scope and a gate-edited test (with or without `--reviewed-base`) still route as behavior, and with `--reviewed-base` so does a file too large for the scoped review. A malformed record, or a `fix_check.head` that is not HEAD or an ancestor of it, stops before the release commit. Without the flag nothing changes.
|
||||
|
||||
### Tests
|
||||
- `test/ship-rereview-triggers.test.ts`: an owner fallback edit to a source file and a doc after the fix-check stops without the record and passes Step 16 with it; a new file outside the scope (even when `fix_check.files` lists it), a fix-check head off the branch, a malformed record, an unreadable record file and an empty `fix_check.files` stop; a `fix_check.files` list narrows what is accepted; a test weakened after the fix-check stops with and without `--reviewed-base`.
|
||||
|
||||
## [1.92.80.0] - 2026-11-02
|
||||
|
||||
**On a Capy machine, the git fixture tests no longer fail locally because of Capy's git shim log.** Capy's `/usr/bin/git` shim appends to `$HOME/.capy/git-shim.log`, so each fixture home that ran git during its template build ended up with a log naming the template. The leak scan in `test/helpers/git-fixture-template.ts` flagged it, and 34 tests in `test/train-backstop.test.ts`, `test/restamp-mentions.test.ts` and `test/pregate-user-policy.test.ts` failed on every Capy lane, though they passed on the remote gate.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.92.80.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.92.81.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
@@ -429,7 +429,14 @@ rules hash, base and registry commits, the gate scan bound to the head and the r
|
||||
the queue refuses a receipt below the depth its head needs
|
||||
([ship-policy.md#ship-receipt](ship-policy.md#ship-receipt)). A stop the owner
|
||||
accepted after a fix-check edit goes in `review_loop_exception`, never in prose
|
||||
under `REVIEW_LOOP:`.
|
||||
under `REVIEW_LOOP:`. Step 16 takes the same record:
|
||||
`gstack-ship-release finish --review-loop-exception <json|@file>` routes the
|
||||
paths the branch touched at `fix_check.head` (narrowed to the record's
|
||||
`fix_check.files` when it lists them) and changed since as `accepted=` in
|
||||
`ROUTE:`, prints them on a `REVIEW_LOOP_EXCEPTION:` line, and carries them
|
||||
through the docs stage. A path outside that scope and a gate-edited test still
|
||||
route as behavior, with `--reviewed-base` (as /ship passes it) so does a file
|
||||
too large for the scoped review, and without the record nothing changes.
|
||||
|
||||
The receipt's summary line prints `passes=<n> blocking_found=<k> deferred=<m>`,
|
||||
which is what a scorecard tracks (target: a median of 1 pass per PR). A
|
||||
|
||||
+43
-14
@@ -15,6 +15,7 @@ import { mergeChangelogs, normalizeChangelog } from './changelog-check';
|
||||
import { versionAtRef } from './restamp';
|
||||
import { classifyPath } from './review-depth';
|
||||
import { comparePath, contextPacket, gateEditedPaths, isTestCode, PACKET_SECTION_CAP, readScopedReview, touchedPaths, type PatchSides } from './ship-patch';
|
||||
import { parseReviewLoopException, type ReviewLoopException } from './ship-receipt-depth';
|
||||
import { parseReceiptDocs, type ReceiptDocs } from './ship-receipt-docs';
|
||||
import { resolveStateRoot } from './state-root';
|
||||
import { bumpVersion, cmpVersion, fmtVersion, parseVersion, versionWidth, type Bump } from './version-source';
|
||||
@@ -27,10 +28,11 @@ export const RELEASE_HELP = `gstack-ship-release — /ship Steps 12 and 15-17 (s
|
||||
--keep-current (or --version <current>) keeps VERSION; under bump ask a session no person answers needs --answer before a bump is written
|
||||
finish --base <base> --reviewed-tree <tree> [--reviewed-base <sha>] --docs-post <post.json> [--lane <label>=<command>]... [--build <command>]...
|
||||
[--trailer <text>] [--docs-rechecked] [--no-lanes-approved] [--context-reviewed <record.json>]
|
||||
[--docs-requested <path>... --docs-result <the audit's result JSON>]
|
||||
[--docs-requested <path>... --docs-result <the audit's result JSON>] [--review-loop-exception <json|@file>]
|
||||
Step 15's release commit, Step 16 stages 1-4 (your builds, the reviewed-tree route, docs freshness,
|
||||
each lane's receipt run once if stale) and the pregate on the stamped tree; --reviewed-base (the merge base
|
||||
saved with the reviewed tree) routes paths only the base moved as base=, and an adjacent base edit as context=
|
||||
saved with the reviewed tree) routes paths only the base moved as base=, and an adjacent base edit as context=;
|
||||
--review-loop-exception (the record write takes) routes paths the branch touched at fix_check.head (narrowed to fix_check.files when listed) and changed since as accepted=
|
||||
push --branch <branch-name> [--gate-open <n>] [--gate-open-approved]
|
||||
Step 17 after its guard block: the saved-branch check, the open-gate check, the sealed guard, the idempotency check, git push -u
|
||||
|
||||
@@ -159,8 +161,8 @@ function commitRelease(cwd: string, release: string[], trailer: string | undefin
|
||||
}
|
||||
|
||||
/** Step 16 stage 2's route. With --reviewed-base, a path whose branch patch is the reviewed one counts as base (lib/ship-patch.ts). */
|
||||
type Routed = Record<'behavior' | 'docs' | 'metadata' | 'tests' | 'base' | 'context', string[]>;
|
||||
function route(cwd: string, base: string, reviewed: string, reviewedBase: string | undefined, release: string[], opts: { docsRechecked: boolean; contextReviewed?: string }, r: Report): Routed | null {
|
||||
type Routed = Record<'behavior' | 'docs' | 'metadata' | 'tests' | 'base' | 'context' | 'accepted', string[]>;
|
||||
function route(cwd: string, base: string, reviewed: string, reviewedBase: string | undefined, release: string[], opts: { docsRechecked: boolean; contextReviewed?: string; loop?: ReviewLoopException }, r: Report): Routed | null {
|
||||
const now = bin(cwd, 'gstack-wtree', [], 60_000).stdout.trim();
|
||||
if (!/^[0-9a-f]{40,64}$/.test(now)) { r.stop('gstack-wtree printed no tree; the stage 2 comparison is blocked'); return null; }
|
||||
const changedSet = touchedPaths(cwd, reviewed, now);
|
||||
@@ -179,7 +181,7 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string
|
||||
sides = { reviewedBase: rb, reviewed, currentBase: cb, current: now };
|
||||
touched = new Set([...a, ...b]);
|
||||
}
|
||||
const by: Routed = { behavior: [], docs: [], metadata: [], tests: [], base: [], context: [] };
|
||||
const by: Routed = { behavior: [], docs: [], metadata: [], tests: [], base: [], context: [], accepted: [] };
|
||||
for (const p of [...changedSet].sort()) {
|
||||
const verdict = sides ? (touched.has(p) ? comparePath(cwd, sides, p) : 'same') : 'changed';
|
||||
by[verdict === 'same' ? 'base' : release.includes(p) ? 'metadata' : sides && isTestCode(p) ? 'tests' : classifyPath(p, cwd) === 'docs' ? 'docs' : verdict === 'context' ? 'context' : 'behavior'].push(p);
|
||||
@@ -194,7 +196,22 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string
|
||||
by.context = by.context.filter(p => !packetBody.over.includes(p));
|
||||
by.tests = by.tests.filter(p => !packetBody.over.includes(p));
|
||||
}
|
||||
r.add(`ROUTE: reviewed=${reviewed.slice(0, 12)} current=${now.slice(0, 12)} behavior=${by.behavior.length} docs=${by.docs.length} metadata=${by.metadata.length} tests=${by.tests.length} base=${sides ? by.base.length : 'unknown'} context=${by.context.length}`);
|
||||
const loop = opts.loop;
|
||||
if (loop) {
|
||||
const head = loop.fix_check.head;
|
||||
const since = touchedPaths(cwd, head, now);
|
||||
const fork = git(cwd, ['merge-base', baseRef, head]).stdout.trim();
|
||||
const atHead = fork ? touchedPaths(cwd, fork, head) : null;
|
||||
if (!since || !atHead) { r.stop(`git could not list the paths changed since fix-check ${head.slice(0, 12)} or the paths the branch touched at it against ${baseRef}: stage 2 is blocked`); return null; }
|
||||
const listed = loop.fix_check.files;
|
||||
const unscanned = [...by.behavior, ...by.docs].filter(isTestCode);
|
||||
const forced = new Set([...gated, ...(packetBody?.over ?? []), ...(gateEditedPaths(cwd, reviewed, now, unscanned) ?? unscanned)]);
|
||||
by.accepted = [...by.behavior, ...by.docs].filter(p => since.has(p) && atHead.has(p) && (!listed || listed.includes(p)) && !forced.has(p)).sort();
|
||||
by.behavior = by.behavior.filter(p => !by.accepted.includes(p));
|
||||
by.docs = by.docs.filter(p => !by.accepted.includes(p));
|
||||
}
|
||||
r.add(`ROUTE: reviewed=${reviewed.slice(0, 12)} current=${now.slice(0, 12)} behavior=${by.behavior.length} docs=${by.docs.length} metadata=${by.metadata.length} tests=${by.tests.length} base=${sides ? by.base.length : 'unknown'} context=${by.context.length}${loop ? ` accepted=${by.accepted.length}` : ''}`);
|
||||
if (loop) r.add(`REVIEW_LOOP_EXCEPTION: accepted ${by.accepted.length ? short(by.accepted) : 'none'} (changed since fix-check ${loop.fix_check.head.slice(0, 12)}; accepted by ${loop.accepted_by} ${loop.date} via ${loop.via})`);
|
||||
if (by.behavior.length) { r.stop(`behavior, tests or build inputs changed since review (${short(by.behavior.sort())}): insert 5–11.5 → 12–14 → 16 before Step 17 (stage 2.1)`); return null; }
|
||||
const scoped = [...by.context, ...by.tests].sort();
|
||||
if (scoped.length && sides) {
|
||||
@@ -225,7 +242,7 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string
|
||||
* version, each against the audited bytes; GST-906), and --docs-requested fixes the audit's own result asked for do not
|
||||
* stale the audit (GST-391).
|
||||
*/
|
||||
function docsFresh(cwd: string, base: string, postFile: string, moved: readonly string[], requested: string[], resultFile: string | undefined, r: Report): boolean {
|
||||
function docsFresh(cwd: string, base: string, postFile: string, moved: readonly string[], requested: string[], resultFile: string | undefined, loopAccepted: readonly string[] | undefined, r: Report): boolean {
|
||||
const cmp = bin(cwd, 'gstack-docs-candidate', ['compare', postFile], 120_000);
|
||||
let stale: string[] | null = null;
|
||||
try { stale = JSON.parse(cmp.stdout).content_changed; } catch { stale = null; }
|
||||
@@ -277,10 +294,11 @@ function docsFresh(cwd: string, base: string, postFile: string, moved: readonly
|
||||
const merged = mergeChangelogs(audited.stdout, atNowBase.stdout, atAuditBase.stdout);
|
||||
return kept !== null && merged !== null && normalizeChangelog(kept) === normalizeChangelog(audited.stdout) && normalizeChangelog(merged) === normalizeChangelog(now);
|
||||
});
|
||||
const carried = { tests: stale.filter(isTestCode), base: stale.filter(p => !isTestCode(p) && (moved.includes(p) || restamped.includes(p) || (!!auditBase && !!nowBase && !branchAtAudit.has(p) && !branchNow.has(p)))), requested: [] as string[] };
|
||||
const carried = { tests: stale.filter(isTestCode), base: stale.filter(p => !isTestCode(p) && (moved.includes(p) || restamped.includes(p) || (!!auditBase && !!nowBase && !branchAtAudit.has(p) && !branchNow.has(p)))), requested: [] as string[], accepted: [] as string[] };
|
||||
carried.accepted = stale.filter(p => loopAccepted?.includes(p) && !carried.tests.includes(p) && !carried.base.includes(p));
|
||||
let audit: any = null;
|
||||
try { audit = resultFile ? JSON.parse(fs.readFileSync(resultFile, 'utf8')) : null; } catch { audit = null; }
|
||||
for (const p of requested.filter(q => stale!.includes(q) && !carried.tests.includes(q) && !carried.base.includes(q))) {
|
||||
for (const p of requested.filter(q => stale!.includes(q) && !carried.tests.includes(q) && !carried.base.includes(q) && !carried.accepted.includes(q))) {
|
||||
const asked = [...(Array.isArray(audit?.blockers) ? audit.blockers : []), ...(Array.isArray(audit?.decisions) ? audit.decisions : [])].filter((s: unknown) => typeof s === 'string');
|
||||
const token = new RegExp(`(^|[\\s\`'"(\\[])${p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=$|[\\s\`'")\\]]|[,.:;](?:$|\\s))`);
|
||||
const stat = git(cwd, ['diff', '--numstat', '--no-renames', record.head, '--', `:(literal)${p}`]).stdout.trim().split(/\s+/);
|
||||
@@ -295,8 +313,8 @@ function docsFresh(cwd: string, base: string, postFile: string, moved: readonly
|
||||
carried.requested.push(p);
|
||||
}
|
||||
if (carried.requested.length && bin(cwd, 'gstack-ship-receipt', ['docs-caps', '--base', base], 60_000).status !== 0) { r.stop('a --docs-requested fix breaks a doc cap (gstack-ship-receipt docs-caps): trim the file, then rerun finish'); return false; }
|
||||
const left = stale.filter(p => !carried.tests.includes(p) && !carried.base.includes(p) && !carried.requested.includes(p));
|
||||
const note = left.length || stale.length === 0 ? '' : ` (carried: tests=${carried.tests.length} base=${carried.base.length} requested=${carried.requested.length})`;
|
||||
const left = stale.filter(p => !carried.tests.includes(p) && !carried.base.includes(p) && !carried.requested.includes(p) && !carried.accepted.includes(p));
|
||||
const note = left.length || stale.length === 0 ? '' : ` (carried: tests=${carried.tests.length} base=${carried.base.length} requested=${carried.requested.length}${loopAccepted ? ` accepted=${carried.accepted.length}` : ''})`;
|
||||
const exception = record.exception ? ` (blocked; documentation exception accepted by ${record.exception.accepted_by} ${record.exception.date} via ${record.exception.via})` : '';
|
||||
r.add(`DOCS: ${left.length ? `stale (${short(left)})` : `fresh against the Step 14.5 post-audit snapshot${note}${exception}`}`);
|
||||
if (left.length) { r.stop('documentation inputs changed since the accepted audit: use stage 3 recovery (the remaining re-audit, a fix the audit\'s result asked for via --docs-requested <path> --docs-result <audit-id>-result.json, or a named risk); never refresh hashes'); return false; }
|
||||
@@ -308,6 +326,17 @@ export async function finish(argv: readonly string[], cwd: string): Promise<Repo
|
||||
const base = flag('--base')!;
|
||||
const r = new Report();
|
||||
const list = lanes(argv) as { label: string; command: string }[];
|
||||
let loop: ReviewLoopException | undefined;
|
||||
const loopArg = flag('--review-loop-exception');
|
||||
if (loopArg !== undefined) {
|
||||
let raw: unknown;
|
||||
try { raw = JSON.parse(loopArg.startsWith('@') ? fs.readFileSync(path.resolve(cwd, loopArg.slice(1)), 'utf8') : loopArg); } catch (e) { return r.stop(`--review-loop-exception: ${firstLine((e as Error).message)}`); }
|
||||
const parsed = parseReviewLoopException(raw);
|
||||
if (!parsed.ok) return r.stop(`--review-loop-exception: ${parsed.message}`);
|
||||
if (Array.isArray((raw as { fix_check?: { files?: unknown } }).fix_check?.files) && !parsed.exception.fix_check.files) return r.stop('--review-loop-exception: fix_check.files lists no paths; omit it to accept the paths the branch touched at fix_check.head');
|
||||
if (git(cwd, ['merge-base', '--is-ancestor', parsed.exception.fix_check.head, 'HEAD']).status !== 0) return r.stop(`--review-loop-exception: fix_check.head ${parsed.exception.fix_check.head.slice(0, 12)} is not HEAD or an ancestor of it`);
|
||||
loop = parsed.exception;
|
||||
}
|
||||
const policy = shipPolicy(cwd, base);
|
||||
if (!commitRelease(cwd, releasePaths(policy, cwd, 'HEAD', base), flag('--trailer'), r)) return r;
|
||||
for (const cmd of all('--build')) {
|
||||
@@ -317,9 +346,9 @@ export async function finish(argv: readonly string[], cwd: string): Promise<Repo
|
||||
}
|
||||
if (!all('--build').length) r.add('BUILD: none passed (record the inspected sources, or pass --build per declared command)');
|
||||
const release = releasePaths(policy, cwd, flag('--reviewed-tree')!, base);
|
||||
const routed = route(cwd, base, flag('--reviewed-tree')!, flag('--reviewed-base'), release, { docsRechecked: has('--docs-rechecked'), contextReviewed: flag('--context-reviewed') }, r);
|
||||
const routed = route(cwd, base, flag('--reviewed-tree')!, flag('--reviewed-base'), release, { docsRechecked: has('--docs-rechecked'), contextReviewed: flag('--context-reviewed'), loop }, r);
|
||||
if (!routed) return r;
|
||||
if (!docsFresh(cwd, base, flag('--docs-post')!, [...routed.base, ...routed.context, ...stampedFiles(policy, cwd, flag('--reviewed-tree')!, base)], all('--docs-requested'), flag('--docs-result'), r)) return r;
|
||||
if (!docsFresh(cwd, base, flag('--docs-post')!, [...routed.base, ...routed.context, ...stampedFiles(policy, cwd, flag('--reviewed-tree')!, base)], all('--docs-requested'), flag('--docs-result'), loop ? routed.accepted : undefined, r)) return r;
|
||||
if (!list.length && !has('--no-lanes-approved')) return r.stop('no test lanes: require Step 5\'s explicit untested-scope approval (rerun with --no-lanes-approved) or run Steps 5-15');
|
||||
const allow = evidenceExempt(release).join(',');
|
||||
const check = (l: { label: string; command: string }) => bin(cwd, 'gstack-evidence', ['check', '--label', l.label, '--expect-cmd', l.command, '--max-age', '24', '--allow-paths', allow], 60_000).stdout;
|
||||
@@ -343,7 +372,7 @@ export async function finish(argv: readonly string[], cwd: string): Promise<Repo
|
||||
r.add(`PREGATE: ${verdict} (rerun on the stamped tree)`);
|
||||
if (!/^(pass|warn|requires-remote cleared)/.test(verdict)) return r.stop(`pregate ${verdict} on the stamped tree: fix it${/uncleared/.test(verdict) ? ', or gstack-pregate clear with the remote run URL' : ''}, then rerun finish`);
|
||||
}
|
||||
return r.next('Step 16 stage 5: paste these results and the two-line honesty inventory, then run Step 17\'s guard block and gstack-ship-release push');
|
||||
return r.next(`Step 16 stage 5: paste these results and the two-line honesty inventory, then run Step 17's guard block and gstack-ship-release push${loop ? '; Step 18 records the same exception with gstack-ship-receipt write --review-loop-exception' : ''}`);
|
||||
}
|
||||
|
||||
export function push(argv: readonly string[], cwd: string): Report {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.92.80",
|
||||
"version": "1.92.81",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -662,3 +662,79 @@ describe('a CHANGELOG re-heading after the docs audit (GST-906, GBRA-258, gbrain
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('finish with an owner-accepted edit after a fix-check (GST-965, GBRA-277, gbrain #8425)', () => {
|
||||
const exceptionFile = (f: ShipFx, head: string, extra: Record<string, unknown> = {}) => {
|
||||
const file = path.join(f.root, 'loop-exception.json');
|
||||
fs.writeFileSync(file, JSON.stringify({ accepted_by: 'GStack Minder (rule 23 grant)', date: '2026-11-02', via: 'GSTA-36', reason: 'the pre-agreed fallback after the fix-check blocker, then ship', fix_check: { head, findings: 1 }, ...extra }));
|
||||
return file;
|
||||
};
|
||||
const afterFixCheck = () => {
|
||||
const f = fx({ 'src/x.ts': 'export const x = 1;\n' }, { 'src/x.ts': 'export const x = 2;\n', 'README.md': '# Fx, with the branch feature\n' });
|
||||
const fixHead = f.git('rev-parse', 'HEAD').stdout.trim();
|
||||
const reviewed = wtree(f);
|
||||
const post = snapshot(f);
|
||||
f.write({ 'src/x.ts': 'export const x = 3;\n', 'README.md': '# Fx, with the fallback\n' }); f.commit('owner fallback after the fix-check');
|
||||
return { f, fixHead, reviewed, post };
|
||||
};
|
||||
|
||||
// Value: protects=an edit the owner accepted after a fix-check ships through Step 16 without another review round (R51 cap);
|
||||
// fails_when=finish routes the accepted src edit as behavior or stales the docs stage on the accepted doc, or accepts it
|
||||
// without the exception file; seam=finish's route and docsFresh; no_claim=the receipt and queue checks (exceptionProblems)
|
||||
test('paths changed since fix_check.head route as accepted and the docs stage carries them; without the file it still stops', () => {
|
||||
const { f, fixHead, reviewed, post } = afterFixCheck();
|
||||
const plain = finish(f, reviewed, post);
|
||||
expect([plain.status, plain.stdout.includes('STOP: behavior, tests or build inputs changed since review (src/x.ts)')]).toEqual([3, true]);
|
||||
expect(lineOf(plain.stdout, 'ROUTE')).toMatch(/ behavior=1 docs=1 metadata=0 tests=0 base=unknown context=0$/);
|
||||
const r = finish(f, reviewed, post, ['--review-loop-exception', `@${exceptionFile(f, fixHead)}`]);
|
||||
expect(lineOf(r.stdout, 'ROUTE')).toMatch(/ behavior=0 docs=0 metadata=0 tests=0 base=unknown context=0 accepted=2$/);
|
||||
expect(lineOf(r.stdout, 'REVIEW_LOOP_EXCEPTION')).toBe(`REVIEW_LOOP_EXCEPTION: accepted README.md,src/x.ts (changed since fix-check ${fixHead.slice(0, 12)}; accepted by GStack Minder (rule 23 grant) 2026-11-02 via GSTA-36)`);
|
||||
expect(lineOf(r.stdout, 'DOCS')).toBe('DOCS: fresh against the Step 14.5 post-audit snapshot (carried: tests=0 base=0 requested=0 accepted=2)');
|
||||
expect([r.status, lineOf(r.stdout, 'EVIDENCE')]).toEqual([0, expect.stringMatching(/^EVIDENCE: FRESH label=tests /)]);
|
||||
});
|
||||
|
||||
// Value: protects=the exception covers only the frozen scope and only a real fix-check; fails_when=a path the branch never
|
||||
// touched at the fix-check, or an exception naming a commit outside the branch or a malformed record, is accepted; seam=finish
|
||||
test('a path outside the frozen scope still routes as behavior; a fix-check head off the branch or a malformed record stops', () => {
|
||||
const { f, fixHead, reviewed, post } = afterFixCheck();
|
||||
f.write({ 'src/new.ts': 'export const n = 1;\n' }); f.commit('a new file after the fix-check');
|
||||
const outside = finish(f, reviewed, post, ['--review-loop-exception', `@${exceptionFile(f, fixHead)}`]);
|
||||
expect(lineOf(outside.stdout, 'ROUTE')).toMatch(/ behavior=1 .* accepted=2$/);
|
||||
expect([outside.status, outside.stdout.includes('STOP: behavior, tests or build inputs changed since review (src/new.ts)')]).toEqual([3, true]);
|
||||
const off = finish(f, reviewed, post, ['--review-loop-exception', `@${exceptionFile(f, 'e'.repeat(40))}`]);
|
||||
expect([off.status, off.stdout]).toEqual([3, expect.stringContaining(`STOP: --review-loop-exception: fix_check.head eeeeeeeeeeee is not HEAD or an ancestor of it`)]);
|
||||
const bad = finish(f, reviewed, post, ['--review-loop-exception', `@${exceptionFile(f, fixHead, { reason: '' })}`]);
|
||||
expect([bad.status, bad.stdout]).toEqual([3, expect.stringContaining('STOP: --review-loop-exception: review_loop_exception.reason must be a non-empty one-line string')]);
|
||||
const missing = finish(f, reviewed, post, ['--review-loop-exception', `@${path.join(f.root, 'no-such-record.json')}`]);
|
||||
expect([missing.status, missing.stdout]).toEqual([3, expect.stringContaining('STOP: --review-loop-exception: ')]);
|
||||
});
|
||||
|
||||
// Value: protects=fix_check.files only narrows the frozen scope; fails_when=a listed path the branch never touched at the
|
||||
// fix-check routes as accepted, or a touched path the list leaves out is accepted anyway; seam=finish's route
|
||||
test('fix_check.files narrows the accepted scope and cannot add a path the branch had not touched at the fix-check', () => {
|
||||
const { f, fixHead, reviewed, post } = afterFixCheck();
|
||||
f.write({ 'src/new.ts': 'export const n = 1;\n' }); f.commit('a new file after the fix-check');
|
||||
const r = finish(f, reviewed, post, ['--review-loop-exception', `@${exceptionFile(f, fixHead, { fix_check: { head: fixHead, findings: 1, files: ['src/x.ts', 'src/new.ts'] } })}`]);
|
||||
expect(lineOf(r.stdout, 'ROUTE')).toMatch(/ behavior=1 docs=1 metadata=0 tests=0 base=unknown context=0 accepted=1$/);
|
||||
expect(lineOf(r.stdout, 'REVIEW_LOOP_EXCEPTION')).toStartWith('REVIEW_LOOP_EXCEPTION: accepted src/x.ts (');
|
||||
expect([r.status, r.stdout.includes('STOP: behavior, tests or build inputs changed since review (src/new.ts)')]).toEqual([3, true]);
|
||||
const none = finish(f, reviewed, post, ['--review-loop-exception', `@${exceptionFile(f, fixHead, { fix_check: { head: fixHead, findings: 1, files: [] } })}`]);
|
||||
expect([none.status, none.stdout]).toEqual([3, expect.stringContaining('STOP: --review-loop-exception: fix_check.files lists no paths')]);
|
||||
});
|
||||
|
||||
// Value: protects=a test weakened after the fix-check is never owner-accepted, with or without --reviewed-base;
|
||||
// fails_when=finish accepts a gate-edited test (a .skip, a removed assertion) because no base sides were given; seam=finish's route
|
||||
test('a gate-edited test after the fix-check still routes as behavior under the exception', () => {
|
||||
const f = fx({ 'src/x.ts': 'export const x = 1;\n' }, { 'src/x.ts': 'export const x = 2;\n', 'test/x.test.ts': "import { expect, test } from 'bun:test';\ntest('a', () => { expect(2).toBe(2); });\n" });
|
||||
const fixHead = f.git('rev-parse', 'HEAD').stdout.trim();
|
||||
const reviewed = wtree(f);
|
||||
const reviewedBase = mergeBase(f);
|
||||
const post = snapshot(f);
|
||||
f.write({ 'test/x.test.ts': "import { test } from 'bun:test';\ntest.skip('a', () => {});\n" }); f.commit('weaken the test after the fix-check');
|
||||
for (const extra of [[], ['--reviewed-base', reviewedBase]]) {
|
||||
const r = finish(f, reviewed, post, ['--review-loop-exception', `@${exceptionFile(f, fixHead)}`, ...extra]);
|
||||
expect([r.status, lineOf(r.stdout, 'ROUTE'), r.stdout.includes('STOP: behavior, tests or build inputs changed since review (test/x.test.ts)')]).toEqual([3, expect.stringMatching(/ behavior=1 .* accepted=0$/), true]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user