mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-09 02:08:19 +01:00
ship finish chains granted fix-check rows; a pregate rerun at the same tree keeps its clears (GST-1627) (#4077)
* ship finish chains granted fix-check rows; a pregate rerun at the same tree keeps its clears (GST-1627) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (one grant per fix-check link; same-tree clears keep only at the same head) Review-Finding: s11/outside-voice#1 reader=outside-voice Review-Finding: s11/outside-voice#2 reader=outside-voice Review-Finding: s11/adversarial-native#2 reader=adversarial-native Review-Finding: s11/adversarial-native#3 reader=adversarial-native Review-Finding: s11/adversarial-native#6 reader=adversarial-native Review-Finding: p1/red-team#1 reader=red-team Review-Finding: p1/red-team#2 reader=red-team Review-Finding: p1/specialist-batched#1 reader=security 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
b10cc47efe
commit
603d6bb22a
10 files changed
+364
-40
No files matched your search
+3
-1
@@ -33,7 +33,7 @@ import { PREGATE_CONFIG_PATH, explainConfig, loadConfigFromHead } from '../lib/p
|
||||
import { describeDraft, draftConfig } from '../lib/pregate/init-draft';
|
||||
import { buildRunCheck } from '../lib/pregate/run-check';
|
||||
import { buildContext, defaultUserConfigPath, describeRefusal, loadedDotenvFiles } from '../lib/pregate/context';
|
||||
import { CHECKS, CHECK_IDS, PREGATE_GUARDS_OUT_REL, PREGATE_OUT_REL, PREGATE_TIER2, PREPUSH_CHECK_IDS, clearDecision, clearObligation, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile } from '../lib/pregate/index';
|
||||
import { CHECKS, CHECK_IDS, PREGATE_GUARDS_OUT_REL, PREGATE_OUT_REL, PREGATE_TIER2, PREPUSH_CHECK_IDS, clearDecision, clearObligation, keepSameTreeClears, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile } from '../lib/pregate/index';
|
||||
import { describeLane, discoverLanes } from '../lib/pregate/workflows';
|
||||
import { REMOTE_GATE_LANE } from '../lib/pregate/checks/lanes';
|
||||
import { describeSubstitution } from '../lib/pregate/remote-receipt';
|
||||
@@ -200,9 +200,11 @@ async function cmdRun(root: string, rest: string[], json: boolean): Promise<numb
|
||||
}
|
||||
const file = await runPregate(ctx, { stage, only, run: flag(rest, '--run') });
|
||||
const out = stage === 'guards' && !flag(rest, '--out') ? path.join(root, PREGATE_GUARDS_OUT_REL) : outPath(root, rest);
|
||||
const kept = keepSameTreeClears(fs.existsSync(out) ? readPregateFile(out).file : null, file);
|
||||
let written: string;
|
||||
try { written = writePregateFile(root, out, file); }
|
||||
catch (e: any) { process.stderr.write(resultLine('gstack-pregate', 'ARTIFACT_SCHEMA', e.message) + '\n'); return EXIT.fail; }
|
||||
for (const line of kept) (json ? process.stderr : process.stdout).write(`${line}\n`);
|
||||
const blocking = file.checks.some(c => c.status === 'fail' || c.status === 'incomplete' || c.status === 'not_installed');
|
||||
const status = file.checks.some(c => c.status === 'incomplete' || c.status === 'not_installed') && !file.checks.some(c => c.status === 'fail') ? 'incomplete' : 'complete';
|
||||
if (json) {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- **finish counts every granted fix-check in a chain, and accept keeps clears made at the same tree.** A second granted fix-check (a merge resolution, say) logs its range from the first fix-check's commit. `gstack-ship-release finish` read only the newest `review-fix-check` row and refused any range that didn't start at the reviewed tree, so the later fix-check unbound the earlier one and lanes logged a synthetic combined row (#4063). finish now chains each row to the earlier row whose commit is its `range_base`, back to the reviewed tree, and counts the chain when every link is clean and has its own owner grant (one grant counts for one link); a gap or an ungranted link still refuses. Separately, accept's final `gstack-pregate` rewrote pregate.json and dropped the `macos-named-regressions` and `windows-smoke` clears the lane had made at the same tree, so accept never exited 0. A rerun at the same head, tree, policy and base branch now keeps each earlier receipt for the same obligation and prints `PREGATE_CLEAR_KEPT:`; another head or tree, or a rerun with a failed or unfinished check, keeps nothing. (GST-1627)
|
||||
@@ -1233,6 +1233,25 @@ tests <verdict> in <n>s`. The run is correct, only slower. Adding `"os":
|
||||
next accept carry the tests stage too, when the run's `tree.json` covers the
|
||||
selection.
|
||||
|
||||
<a id="same-tree-clears"></a>**A rerun at the same tree keeps its clears (GST-1627).** accept's final
|
||||
pregate reran `gstack-pregate` on the tree the lane had already cleared
|
||||
`macos-named-regressions` and `windows-smoke` on from CI runs, and the fresh
|
||||
record dropped both receipts, so accept stopped on `requires-remote (2
|
||||
uncleared)` and never exited 0 (#4063, GST-1595). Now, when `gstack-pregate`
|
||||
writes over a record for the same head, tree, policy and base branch, each uncleared
|
||||
obligation takes the earlier receipt for the same lane, platform, workflow and
|
||||
job, with its `cleared_by`, stamp-only substitution and flaky exception, as long
|
||||
as the earlier obligation listed every test the new one needs. Each kept
|
||||
receipt prints `PREGATE_CLEAR_KEPT: <lane> receipt=<receipt> (recorded at this
|
||||
tree <tree12>; …)` (on stderr under `--json`), and accept relays the lines
|
||||
before its `PREGATE:` line. A record for another head or tree keeps nothing,
|
||||
so any commit after the clear, even an amend that keeps the tree, needs a new
|
||||
clear at the new head: a receipt and its flaky exception were vetted against
|
||||
the head they name. A rerun with a
|
||||
failed, incomplete or not-installed check also keeps nothing, so a remote gate
|
||||
that ran again and timed out stays uncleared. `gstack-pregate verify` checks a
|
||||
kept receipt as it checks any other one.
|
||||
|
||||
<a id="carry-imported-tests"></a>**An inventory file CI's JUnit tree never lists (GST-1595).** bun registers
|
||||
the tests of a test file another test file imports under the importer, so the
|
||||
imported file's own JUnit suite never appears in `tree.json` and the carry
|
||||
|
||||
@@ -686,11 +686,13 @@ 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.
|
||||
|
||||
A completed fix-check is a review, not an owner acceptance (GST-1130). Finish
|
||||
reads the newest `review-fix-check` row from `gstack-review-read`. When it is a
|
||||
completed clean read on committed content (freshness `CURRENT` against its own
|
||||
tree) whose commit is on the branch, whose `range_base` is a branch commit
|
||||
holding the reviewed tree, and for which an approved `review_loop_grant` in
|
||||
`gstack-approvals` names a stop head inside `range_base..commit`, each code or test path inside `range_base..commit`
|
||||
reads the newest `review-fix-check` row from `gstack-review-read`, chained back
|
||||
through earlier rows whose commit is the next row's `range_base`
|
||||
([chained fix-checks](ship-policy.md#fix-check-chain), GST-1627). When every link
|
||||
is a completed clean read on committed content (freshness `CURRENT` against its own
|
||||
tree) whose commit is on the branch, the oldest link's `range_base` is a branch commit
|
||||
holding the reviewed tree, and for each link an approved `review_loop_grant` in
|
||||
`gstack-approvals` names a stop head inside its `range_base..commit`, each code or test path inside a link's `range_base..commit`
|
||||
that is unchanged since, or whose change against the base is still the one the
|
||||
fix-check read after a base merge also edited it (GST-1195), routes as
|
||||
`fix_checked=` in `ROUTE:` and is listed on a
|
||||
|
||||
+24
-1
@@ -1635,7 +1635,8 @@ ruling: `ruled`, `action: "ruled"` or any other claim the lane writes on its own
|
||||
row is ignored. Step 16's `finish` reads the branch's rulings
|
||||
(`lib/ruled-notes.ts`) and prints each as `RULED_NOTE: <fingerprint> at <head12>
|
||||
ruled by <who> at <time>: <reason> (approval <id>)`. A ruling closes a finding on
|
||||
the newest `review-fix-check` row (action `skipped`, `noted` or `deferred`, not a
|
||||
a `review-fix-check` row finish counts (the newest, or an earlier link of its
|
||||
[chain](#fix-check-chain); action `skipped`, `noted` or `deferred`, not a
|
||||
gate finding) when its head lies inside that row's `range_base..commit`, the same
|
||||
test a `review_loop_grant` passes. So the order of ruling and fix-check doesn't
|
||||
matter, and a ruling made at an earlier /ship's head closes nothing: its
|
||||
@@ -1814,6 +1815,28 @@ the lean panel). At the cap it stops for the owner, whose recorded grant
|
||||
(`--grant`) adds one scoped fix-check, never `done`. So `finish` never asks for
|
||||
a step the planner refuses.
|
||||
|
||||
<a id="fix-check-chain"></a>**Chained fix-checks (GST-1627).** A second granted
|
||||
fix-check after the first one (a merge resolution, say) logs its row with
|
||||
`range_base` at the first fix-check's commit. finish used to read only the newest
|
||||
row and refuse any range that didn't start at the reviewed tree, so the later
|
||||
fix-check unbound the earlier one and lanes logged a synthetic combined row
|
||||
(#4063: fc3 at 265d868, then fc4 over the merge). finish now walks back from the
|
||||
newest row: while a row's `range_base` doesn't hold the reviewed tree, the link
|
||||
before it is the newest earlier row whose commit is that `range_base`. The chain
|
||||
counts only when it reaches a row whose `range_base` holds the reviewed tree and
|
||||
every link is a clean, committed read with its own approved
|
||||
`review_loop_grant`. An earlier link's grant must name a stop head before its
|
||||
commit, since that commit is where the next link starts, and a grant counts for
|
||||
one link only (the newest link that names it), so one grant never covers two
|
||||
fix-checks. A path counts as `fix_checked` when some link's range
|
||||
touched it and it is unchanged since that link (or its patch against the base is
|
||||
still the one that link read). The line reads `FIX_CHECK: reviewed <paths>
|
||||
(fix-check chain of <n> from <range_base12>: at <commit12> grant <id>; …)`. A
|
||||
`range_base` no earlier row ends at is a gap and refuses with the single-row
|
||||
reason (`range_base <sha12> does not hold the reviewed tree`); a link that is
|
||||
stale or ungranted refuses with its reason and `; fix-check at <sha12>, link <k>
|
||||
of <n>`. Either way nothing in the chain counts.
|
||||
|
||||
<a id="test-code"></a>
|
||||
<a id="uid-sidecar"></a>
|
||||
## `test_code` and Godot UID sidecars after review
|
||||
|
||||
@@ -484,3 +484,26 @@ export function clearObligation(file: PregateFile, lane: string, receipt: string
|
||||
file.verdict = verdictOf(file);
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* A rerun at the same tree keeps the clears its predecessor recorded (GST-1627): `gstack-ship-release accept` reruns the
|
||||
* pregate on the final tree after the lane cleared macos-named-regressions and windows-smoke from CI runs at that tree, and
|
||||
* a fresh record dropped them, so accept never passed. A receipt carries only into an uncleared obligation for the same
|
||||
* lane, platform, workflow and job whose tests the earlier one covered, at the same head, under the same policy and base
|
||||
* branch, with its flaky exception when it had one. A record for another head or tree (a same-tree amend gets new clears,
|
||||
* since a receipt and its exception were vetted against the head they name), or a rerun with a failed or unfinished check (a remote gate
|
||||
* that timed out this time), keeps nothing. Returns one PREGATE_CLEAR_KEPT line per lane.
|
||||
*/
|
||||
export function keepSameTreeClears(prev: PregateFile | null, file: PregateFile): string[] {
|
||||
if (!prev || prev.head !== file.head || prev.tree !== file.tree || prev.policy !== file.policy || prev.policy_digest !== file.policy_digest || baseBranchOf(prev.base) !== baseBranchOf(file.base)) return [];
|
||||
if (file.checks.some(c => c.status === 'fail' || c.status === 'incomplete' || c.status === 'not_installed')) return [];
|
||||
const kept: string[] = [];
|
||||
for (const r of file.requires_remote ?? []) {
|
||||
if (r.receipt) continue;
|
||||
const old = prev.requires_remote?.find(o => o.receipt && o.lane === r.lane && o.platform === r.platform && o.workflow === r.workflow && o.job === r.job && r.tests.every(t => o.tests.includes(t)));
|
||||
if (!old?.receipt) continue;
|
||||
clearObligation(file, r.lane, old.receipt, old.cleared_by ?? 'an earlier run at this tree', old.substitution, prev.exceptions?.find(e => e.lane === r.lane));
|
||||
kept.push(`PREGATE_CLEAR_KEPT: ${r.lane} receipt=${old.receipt} (recorded at this tree ${file.tree.slice(0, 12)}; docs/pregate.md#same-tree-clears)`);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
+79
-33
@@ -201,10 +201,10 @@ function harnessRepin(cwd: string, from: string, to: string, p: string): boolean
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* A code or test path the newest completed clean review-fix-check row read (inside its range_base..commit range, where
|
||||
* range_base holds the reviewed tree, and unchanged since) counts as fix_checked when an approved review_loop_grant names a stop
|
||||
* head inside range_base..commit: the owner's granted fix-check after Step 11 is a review (GST-1130). A finding on that row the
|
||||
* owner ruled a disclosed note (an approved gstack-approvals rule row whose stop head lies inside the row's range_base..commit, never the row's own claim) is closed, and every ruling
|
||||
* A code or test path a granted fix-check read counts as fix_checked (fixChecked: the newest completed clean review-fix-check
|
||||
* row, chained through earlier rows back to the reviewed tree, each under its own approved review_loop_grant): the owner's
|
||||
* granted fix-check after Step 11 is a review (GST-1130, GST-1627). A finding on a link the owner ruled a disclosed note (an
|
||||
* approved gstack-approvals rule row whose stop head lies inside that link's range_base..commit, never the row's own claim) is closed, and every ruling
|
||||
* prints as RULED_NOTE (GST-1537). Authored docs keep the stage 2.2 recheck.
|
||||
*/
|
||||
type FixCheckException = ReviewLoopException & { fix_check: NonNullable<ReviewLoopException['fix_check']> };
|
||||
@@ -243,6 +243,75 @@ function patchBase(cwd: string, baseRef: string, commit: string, parent: string
|
||||
return parent && fork && holds(parent, commit) && holds(fork, parent) ? parent : fork;
|
||||
}
|
||||
|
||||
type FixLink = { row: Record<string, any>; commit: string; from: string };
|
||||
|
||||
/**
|
||||
* The granted fix-checks finish counts (GST-1130, GST-1627): the newest review-fix-check row and, while its range_base does
|
||||
* not hold the reviewed tree, the newest earlier row whose commit is that range_base, back to a row whose range_base holds
|
||||
* it. Every link must be a completed clean read on committed branch content with its own approved review_loop_grant, a stop
|
||||
* head inside its range_base..commit (before the commit for an earlier link, since that commit is the next link's start);
|
||||
* a grant counts for one link only, the newest link that names it, so one grant naming two links' commits covers one.
|
||||
* A range_base no earlier row ends at is a gap, and the chain does not count. A path counts as fix_checked when a link's
|
||||
* range touched it and it is unchanged since that link, or its patch against the base is still the one the link read.
|
||||
*/
|
||||
function fixChecked(cwd: string, o: { reviewed: string; now: string; baseRef: string; parent?: string; currentBase?: string; candidates: string[] }, r: Report): { counted: boolean; paths: string[] } | null {
|
||||
const rows: Record<string, any>[] = bin(cwd, 'gstack-review-read', [], 60_000).stdout.split('\n---CONFIG---')[0].split('\n')
|
||||
.flatMap(l => { try { const v = JSON.parse(l); return v?.skill === 'review-fix-check' ? [v] : []; } catch { return []; } });
|
||||
const commitOf = (c: unknown) => typeof c === 'string' && /^[0-9a-f]{7,64}$/.test(c) ? git(cwd, ['rev-parse', '--verify', '--quiet', `${c}^{commit}`]).stdout.trim() : '';
|
||||
const ancestor = (a: string, d: string) => git(cwd, ['merge-base', '--is-ancestor', a, d]).status === 0;
|
||||
const onBranch = (c: string) => !!c && ancestor(c, 'HEAD');
|
||||
const commits = rows.map(row => commitOf(row.commit_full));
|
||||
const chain: FixLink[] = [];
|
||||
let gap = '';
|
||||
for (let i = rows.length - 1; i >= 0;) {
|
||||
const link = { row: rows[i]!, commit: commits[i]!, from: commitOf(rows[i]!.range_base) };
|
||||
chain.unshift(link);
|
||||
if (!onBranch(link.from) || git(cwd, ['rev-parse', `${link.from}^{tree}`]).stdout.trim() === o.reviewed) break;
|
||||
i = commits.slice(0, i).lastIndexOf(link.from);
|
||||
if (i < 0) gap = `range_base ${link.from.slice(0, 12)} does not hold the reviewed tree`;
|
||||
}
|
||||
const inRange = (l: FixLink, c: string) => !!l.from && !!l.commit && ancestor(l.from, c) && ancestor(c, l.commit);
|
||||
const rulings = readRuledNotes(cwd).map(n => ({ ...n, links: chain.filter(l => inRange(l, n.head)) }));
|
||||
for (const n of rulings) r.add(`RULED_NOTE: ${ruledNoteText(n)}${chain.length && !n.links.length ? ' (outside this fix-check\'s range; not applied)' : ''}`);
|
||||
const grants = chain.length ? (loc => 'file' in loc ? readRecord(loc).rows : [])(locate({ cwd, env: process.env })).filter(g => g.outcome === 'approved' && `${g.ask} ${g.scope}`.includes('review_loop_grant')) : [];
|
||||
const granted: (typeof grants[number] | undefined)[] = [];
|
||||
const used = new Set<string>();
|
||||
for (let k = chain.length - 1; k >= 0; k--) {
|
||||
const l = chain[k]!;
|
||||
const g = grants.filter(g => !used.has(g.id))
|
||||
.flatMap(g => (`${g.ask} ${g.scope}`.match(/\b[0-9a-f]{7,40}\b/g) ?? []).map(commitOf).filter(c => !!c && inRange(l, c) && (k === chain.length - 1 || c !== l.commit)).map(stop => ({ g, stop })))
|
||||
.reduce<{ g: typeof grants[number]; stop: string } | undefined>((best, c) => (!best || ancestor(best.stop, c.stop) ? c : best), undefined)?.g;
|
||||
if (g) used.add(g.id);
|
||||
granted[k] = g;
|
||||
}
|
||||
const why = (l: FixLink) => {
|
||||
const fresh = reviewFreshness(l.row, l.row.wtree, new Set(rulings.filter(n => n.links.includes(l)).map(n => n.finding)));
|
||||
return fresh?.status !== 'CURRENT' ? fresh?.reason ?? 'not a review row'
|
||||
: l.row.tree !== l.row.wtree || !onBranch(l.commit) ? 'the row was logged on content no branch commit holds'
|
||||
: !onBranch(l.from) ? 'the row names no range_base commit on this branch' : '';
|
||||
};
|
||||
const at = (l: FixLink) => (chain.length > 1 ? `; fix-check at ${l.commit.slice(0, 12)}, link ${chain.indexOf(l) + 1} of ${chain.length}` : '');
|
||||
const newestFirst = [...chain].reverse();
|
||||
const stale = newestFirst.map(l => ({ l, w: why(l) })).find(x => x.w);
|
||||
const ungranted = newestFirst.find(l => !granted[chain.indexOf(l)]);
|
||||
const notCounted = !chain.length ? '' : stale ? `${stale.w}${at(stale.l)}` : gap ? `${gap}${at(chain[0]!)}`
|
||||
: ungranted ? `no approved review_loop_grant names a stop head inside its range_base..commit${at(ungranted)}` : '';
|
||||
if (notCounted) r.add(`FIX_CHECK: not counted (${notCounted})`);
|
||||
if (!chain.length || notCounted) return { counted: false, paths: [] };
|
||||
const read: ((p: string) => boolean)[] = [];
|
||||
for (const l of chain) {
|
||||
const range = touchedPaths(cwd, l.from, l.commit), after = touchedPaths(cwd, l.commit, o.now);
|
||||
if (!range || !after) { r.stop(`git could not diff the fix-check range ${l.from.slice(0, 12)}..${l.commit.slice(0, 12)} against the reviewed and current trees: stage 2 is blocked`); return null; }
|
||||
const sides = { reviewedBase: patchBase(cwd, o.baseRef, l.commit, o.parent), reviewed: l.commit, currentBase: o.currentBase ?? git(cwd, ['merge-base', o.baseRef, 'HEAD']).stdout.trim(), current: o.now };
|
||||
read.push(p => range.has(p) && (!after.has(p) || (!!sides.reviewedBase && !!sides.currentBase && comparePath(cwd, sides, p) === 'same')));
|
||||
}
|
||||
const paths = o.candidates.filter(p => read.some(f => f(p))).sort();
|
||||
const list = paths.length ? short(paths) : 'none';
|
||||
r.add(chain.length === 1 ? `FIX_CHECK: reviewed ${list} (fix-check at ${chain[0]!.commit.slice(0, 12)}, range from ${chain[0]!.from.slice(0, 12)}, grant ${granted[0]!.id})`
|
||||
: `FIX_CHECK: reviewed ${list} (fix-check chain of ${chain.length} from ${chain[0]!.from.slice(0, 12)}: ${chain.map((l, k) => `at ${l.commit.slice(0, 12)} grant ${granted[k]!.id}`).join('; ')})`);
|
||||
return { counted: true, paths };
|
||||
}
|
||||
|
||||
function route(cwd: string, base: string, reviewed: string, reviewedBase: ReviewedBase | undefined, release: string[], opts: RouteOpts, 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; }
|
||||
@@ -286,36 +355,12 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: Review
|
||||
by.behavior.push(...packetBody.over);
|
||||
for (const k of ['context', 'tests', 'bounded'] as const) by[k] = by[k].filter(p => !packetBody.over.includes(p));
|
||||
}
|
||||
const fixRow = bin(cwd, 'gstack-review-read', [], 60_000).stdout.split('\n---CONFIG---')[0].split('\n')
|
||||
.flatMap(l => { try { const v = JSON.parse(l); return v?.skill === 'review-fix-check' ? [v] : []; } catch { return []; } }).at(-1);
|
||||
const commitOf = (c: unknown) => typeof c === 'string' && /^[0-9a-f]{7,64}$/.test(c) ? git(cwd, ['rev-parse', '--verify', '--quiet', `${c}^{commit}`]).stdout.trim() : '';
|
||||
const onBranch = (c: string) => !!c && git(cwd, ['merge-base', '--is-ancestor', c, 'HEAD']).status === 0;
|
||||
const fixCommit = commitOf(fixRow?.commit_full), fixFrom = commitOf(fixRow?.range_base);
|
||||
const fix = fixChecked(cwd, { reviewed, now, baseRef, parent, currentBase: sides?.currentBase, candidates: [...by.behavior, ...by.tests, ...by.context, ...by.bounded] }, r);
|
||||
if (!fix) return null;
|
||||
const counted = fix.counted;
|
||||
by.fix_checked = fix.paths;
|
||||
for (const k of ['behavior', 'tests', 'context', 'bounded'] as const) by[k] = by[k].filter(p => !by.fix_checked.includes(p));
|
||||
const ancestor = (a: string, d: string) => git(cwd, ['merge-base', '--is-ancestor', a, d]).status === 0;
|
||||
const inFixRange = (c: string) => !!fixFrom && !!fixCommit && ancestor(fixFrom, c) && ancestor(c, fixCommit);
|
||||
const rulings = readRuledNotes(cwd).map(n => ({ ...n, applied: !!fixRow && inFixRange(n.head) }));
|
||||
for (const n of rulings) r.add(`RULED_NOTE: ${ruledNoteText(n)}${fixRow && !n.applied ? ' (outside this fix-check\'s range; not applied)' : ''}`);
|
||||
const fresh = fixRow && reviewFreshness(fixRow, fixRow.wtree, new Set(rulings.filter(n => n.applied).map(n => n.finding)));
|
||||
const grants = fixRow ? (loc => 'file' in loc ? readRecord(loc).rows : [])(locate({ cwd, env: process.env })) : [];
|
||||
const covering = grants.filter(g => g.outcome === 'approved' && `${g.ask} ${g.scope}`.includes('review_loop_grant'))
|
||||
.flatMap(g => (`${g.ask} ${g.scope}`.match(/\b[0-9a-f]{7,40}\b/g) ?? []).map(commitOf).filter(c => !!c && inFixRange(c)).map(stop => ({ g, stop })));
|
||||
const grant = covering.reduce<typeof covering[number] | undefined>((best, c) => (!best || ancestor(best.stop, c.stop) ? c : best), undefined)?.g;
|
||||
const notCounted = !fixRow ? '' : fresh?.status !== 'CURRENT' ? fresh?.reason ?? 'not a review row'
|
||||
: fixRow.tree !== fixRow.wtree || !onBranch(fixCommit) ? 'the row was logged on content no branch commit holds'
|
||||
: !onBranch(fixFrom) ? 'the row names no range_base commit on this branch'
|
||||
: git(cwd, ['rev-parse', `${fixFrom}^{tree}`]).stdout.trim() !== reviewed ? `range_base ${fixFrom.slice(0, 12)} does not hold the reviewed tree`
|
||||
: !grant ? 'no approved review_loop_grant names a stop head inside its range_base..commit' : '';
|
||||
if (notCounted) r.add(`FIX_CHECK: not counted (${notCounted})`);
|
||||
const counted = !!fixRow && !notCounted;
|
||||
if (counted) {
|
||||
const range = touchedPaths(cwd, fixFrom, fixCommit), after = touchedPaths(cwd, fixCommit, now);
|
||||
if (!range || !after) { r.stop(`git could not diff the fix-check range ${fixFrom.slice(0, 12)}..${fixCommit.slice(0, 12)} against the reviewed and current trees: stage 2 is blocked`); return null; }
|
||||
const fixSides = { reviewedBase: patchBase(cwd, baseRef, fixCommit, parent), reviewed: fixCommit, currentBase: sides?.currentBase ?? git(cwd, ['merge-base', baseRef, 'HEAD']).stdout.trim(), current: now };
|
||||
const unmoved = (p: string) => !after.has(p) || (!!fixSides.reviewedBase && !!fixSides.currentBase && comparePath(cwd, fixSides, p) === 'same');
|
||||
by.fix_checked = [...by.behavior, ...by.tests, ...by.context, ...by.bounded].filter(p => range.has(p) && unmoved(p)).sort();
|
||||
for (const k of ['behavior', 'tests', 'context', 'bounded'] as const) by[k] = by[k].filter(p => !by.fix_checked.includes(p));
|
||||
r.add(`FIX_CHECK: reviewed ${by.fix_checked.length ? short(by.fix_checked) : 'none'} (fix-check at ${fixCommit.slice(0, 12)}, range from ${fixFrom.slice(0, 12)}, grant ${grant!.id})`);
|
||||
}
|
||||
const loop = opts.loop;
|
||||
if (loop) {
|
||||
const head = loop.fix_check.head;
|
||||
@@ -609,6 +654,7 @@ export async function accept(argv: readonly string[], cwd: string): Promise<Repo
|
||||
const tests = /^PREGATE: tests (.+?) in ([\d.]+)s/m.exec(out);
|
||||
if (carried && (carried.startsWith('CI_CARRY: accepted') || !tests)) r.add(clip(carried, 240));
|
||||
else if (tests) r.add(`${carried ? clip(carried, 200) : 'CI_CARRY: none (the pregate policy declares no run_profiles)'}; the final pregate's tests stage ran here instead: tests ${tests[1]} in ${tests[2]}s (docs/pregate.md#accept-local-tests)`);
|
||||
for (const line of out.match(/^PREGATE_CLEAR_KEPT: .*$/gm) ?? []) r.add(clip(line, 240));
|
||||
r.add(`PREGATE: ${verdict} (final tree)`);
|
||||
if (!/^(pass|warn|requires-remote cleared)/.test(verdict)) return r.stop(`pregate ${verdict} on the final tree: fix it, then rerun accept`);
|
||||
return r.next(`Step 18: the receipt records ci_carried from the ledger${exceptions.length ? `; the PR body carries ${exceptions.map(e => JSON.stringify(`Test-lane exception: ${e}`)).join(' and ')} verbatim, one line each, or the receipt write refuses` : ''}`);
|
||||
|
||||
@@ -431,6 +431,10 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }>
|
||||
file: 'test/ship-uid-sidecar.test.ts',
|
||||
reason: 'drives bin/gstack-ship-release, gstack-ship-receipt, gstack-docs-candidate and gstack-wtree (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-mechanical-retake.test.ts; windows-latest fails every case at the first bin spawn (windows-scoped-shard, run 42205201312)',
|
||||
},
|
||||
{
|
||||
file: 'test/ship-accept-same-tree-clears.test.ts',
|
||||
reason: 'drives bin/gstack-ship-release, gstack-evidence and gstack-pregate (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-accept-record-timeout.test.ts, which windows-latest fails at the first bin spawn (windows-scoped-shard, run 41607840098)',
|
||||
},
|
||||
];
|
||||
|
||||
// Force-include overrides: files a WINDOWS_FRAGILE_PATTERNS regex excludes for
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* GST-1627: accept's final pregate keeps the remote-lane clears recorded at the same tree. #4063 (GSTA-343) cleared
|
||||
* macos-named-regressions and windows-smoke from CI runs, then accept's final `gstack-pregate` wrote a fresh record that
|
||||
* dropped both, so accept never exited 0 and the lane re-cleared by hand. A rerun at the same tree now keeps each receipt;
|
||||
* a record for another tree keeps nothing.
|
||||
*/
|
||||
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { PregateFile, PregateRemote } from '../lib/headless-artifacts';
|
||||
import { keepSameTreeClears } from '../lib/pregate/index';
|
||||
import { fakeGhScript, type FakeWorld } from './helpers/fake-gh-runs';
|
||||
import { lineOf, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
|
||||
|
||||
setDefaultTimeout(120_000);
|
||||
const fixtures: ShipFx[] = [];
|
||||
afterEach(() => { while (fixtures.length) fixtures.pop()!.cleanup(); });
|
||||
|
||||
const BIN = path.dirname(RELEASE);
|
||||
const FREE = "printf ' 3 pass\\n 0 fail\\nRan 3 tests across 1 file. [1.00ms]\\n' >&2";
|
||||
const HOST_OS = ({ darwin: 'macOS', win32: 'Windows' } as Record<string, string>)[process.platform] ?? 'Linux';
|
||||
const RUNNER = ({ darwin: 'macos-latest', win32: 'windows-latest' } as Record<string, string>)[process.platform] ?? 'ubuntu-latest';
|
||||
const OTHER = process.platform === 'darwin' ? 'windows-latest' : 'macos-latest';
|
||||
const LANE = 'other.yml/named-regressions';
|
||||
const RUN_URL = 'https://github.com/fx/fx/actions/runs/9/attempts/1';
|
||||
const TESTS = ['test/a.test.ts'];
|
||||
const BASE = {
|
||||
'.gstack/pregate.json': JSON.stringify({
|
||||
run_profiles: { 'ci/free': { workflow: 'free.yml', events: ['pull_request', 'workflow_dispatch'], jobs: ['suite'], artifact: 'tested-tree', os: HOST_OS } },
|
||||
ship_lanes_from: { [FREE]: { profiles: ['ci/free'], inventory: `printf '${TESTS.join('\\n')}\\n'` } },
|
||||
dependencies: { 'src/a.ts': ['test/a.test.ts'], 'src/b.ts': ['test/a.test.ts'], 'test/**': [], '.gstack/ship-policy.json': [] },
|
||||
}, null, 2) + '\n',
|
||||
'.github/workflows/free.yml': `name: Free\non: [pull_request, workflow_dispatch]\njobs:\n suite:\n runs-on: ${RUNNER}\n steps:\n - run: bun test\n`,
|
||||
'.github/workflows/other.yml': `name: Other\non: [pull_request, workflow_dispatch]\njobs:\n named-regressions:\n runs-on: ${OTHER}\n steps:\n - run: bun test test/a.test.ts\n`,
|
||||
'test/a.test.ts': "import { expect, test } from 'bun:test';\ntest('ok', () => expect(1).toBe(1));\n",
|
||||
};
|
||||
|
||||
/** A pushed branch with a green CI run at HEAD, and the lane's own pregate run, whose other-platform lane it then clears. */
|
||||
function cleared(): ShipFx {
|
||||
const f = shipFixture(BASE, { 'src/b.ts': 'export const b = 2;\n' });
|
||||
fixtures.push(f);
|
||||
const sha = f.git('rev-parse', 'HEAD').stdout.trim();
|
||||
const tree = f.git('rev-parse', 'HEAD^{tree}').stdout.trim();
|
||||
const at = (m: number) => new Date(Date.now() - m * 60_000).toISOString();
|
||||
const run = { id: 7, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'workflow_dispatch', head_sha: sha, path: '.github/workflows/free.yml', created_at: at(20), run_started_at: at(20), updated_at: at(5), repository: { full_name: 'fx/fx' }, head_repository: { full_name: 'fx/fx' } };
|
||||
const other = { ...run, id: 9, path: '.github/workflows/other.yml' };
|
||||
const w: FakeWorld = { runs: [run, other] as FakeWorld['runs'], jobs: { 7: [{ name: 'suite', conclusion: 'success', completed_at: at(5) }], 9: [{ name: 'named-regressions', conclusion: 'success', completed_at: at(5) }] }, trees: { 7: { run_id: 7, run_attempt: 1, event: 'workflow_dispatch', sha, tree, files: TESTS, tests: 1, os: HOST_OS } } };
|
||||
fs.writeFileSync(path.join(f.root, 'world.json'), JSON.stringify(w));
|
||||
f.gh(fakeGhScript(path.join(f.root, 'world.json')));
|
||||
expect(f.run(RELEASE, ['push', '--branch', 'feat', '--early', '--base', 'main'], { GSTACK_SESSION_KIND: 'unattended' }).status).toBe(0);
|
||||
const first = pregate(f);
|
||||
expect(first.stdout, first.stdout + first.stderr).toContain(`requires-remote: ${LANE} `);
|
||||
expect(lineOf(first.stdout, 'PREGATE')).toStartWith('PREGATE: requires-remote (1 uncleared) ');
|
||||
const clear = f.run(path.join(BIN, 'gstack-pregate'), ['clear', '--remote', LANE, '--run-url', RUN_URL]);
|
||||
expect(clear.status, clear.stdout + clear.stderr).toBe(0);
|
||||
return f;
|
||||
}
|
||||
const pregate = (f: ShipFx) => f.run(path.join(BIN, 'gstack-pregate'), ['--allow-repo-commands', '--base', 'main']);
|
||||
const accept = (f: ShipFx) => f.run(RELEASE, ['accept', '--base', 'main', '--lane', `free=${FREE}`, '--wait', '0']);
|
||||
const record = (f: ShipFx): PregateFile => JSON.parse(fs.readFileSync(path.join(f.work, '.gstack/tmp/pregate.json'), 'utf8'));
|
||||
|
||||
describe('accept keeps the remote-lane clears made at its tree (GST-1627)', () => {
|
||||
// Value: protects=accept exits 0 on a tree whose other-platform lane the lane already cleared from CI (#4063's macOS and Windows
|
||||
// clears); fails_when=accept's final gstack-pregate writes a fresh record that drops the receipt and stops on requires-remote;
|
||||
// seam=bin/gstack-pregate's run writer under gstack-ship-release accept
|
||||
test('a clear at the same tree survives accept\'s final pregate, and accept exits 0', () => {
|
||||
const f = cleared();
|
||||
const out = accept(f);
|
||||
expect([out.status, lineOf(out.stdout, 'PREGATE_CLEAR_KEPT'), lineOf(out.stdout, 'PREGATE')], out.stdout).toEqual([0,
|
||||
expect.stringMatching(new RegExp(`^PREGATE_CLEAR_KEPT: ${LANE} receipt=${RUN_URL} \\(recorded at this tree [0-9a-f]{12}; docs/pregate\\.md#same-tree-clears\\)$`)),
|
||||
'PREGATE: requires-remote cleared (final tree)']);
|
||||
expect(record(f).requires_remote?.find(r => r.lane === LANE)?.receipt).toBe(RUN_URL);
|
||||
const verify = f.run(path.join(BIN, 'gstack-pregate'), ['verify']);
|
||||
expect(verify.status, verify.stdout + verify.stderr).toBe(0);
|
||||
});
|
||||
|
||||
// Value: protects=a clear binds one tree: a commit after it leaves the lane uncleared; fails_when=the writer carries a receipt
|
||||
// onto another tree; seam=bin/gstack-pregate's run writer
|
||||
test('a different tree drops the clear', () => {
|
||||
const f = cleared();
|
||||
f.write({ 'src/b.ts': 'export const b = 3;\n' }); f.commit('a change after the clear');
|
||||
const out = pregate(f);
|
||||
expect([lineOf(out.stdout, 'PREGATE_CLEAR_KEPT'), lineOf(out.stdout, 'PREGATE')], out.stdout).toEqual(['', expect.stringMatching(/^PREGATE: requires-remote \(1 uncleared\) /)]);
|
||||
expect(record(f).requires_remote?.find(r => r.lane === LANE)?.receipt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('keepSameTreeClears (GST-1627)', () => {
|
||||
const obligation = (o: Partial<PregateRemote> = {}): PregateRemote => ({ lane: LANE, platform: 'macos', workflow: 'other.yml', job: 'named-regressions', tests: ['test/a.test.ts'], receipt: null, ...o });
|
||||
const file = (remote: PregateRemote[], o: Partial<PregateFile> = {}): PregateFile => ({ schema_version: 1, tree: 'a'.repeat(40), head: 'b'.repeat(40), base: 'origin/main@cccccccccccc', policy: 'base origin/main', checks: [{ id: 'lanes', status: 'requires-remote' }], requires_remote: remote, ...o });
|
||||
const prev = () => file([obligation({ receipt: RUN_URL, cleared_by: 'gstack-pregate clear 2026-11-06T00:00:00Z' })]);
|
||||
|
||||
// Value: protects=a kept receipt is the same evidence for the same obligation; fails_when=it carries across a head, policy, base
|
||||
// branch or tree change (a same-tree amend whose receipt and flaky exception were vetted against the old head), onto an obligation that now needs tests the earlier run never covered, or into a rerun whose own check failed
|
||||
// or did not finish; seam=keepSameTreeClears
|
||||
test('keeps a receipt only for the same head, tree, policy, base branch and lane with covered tests, in a rerun that finished', () => {
|
||||
const now = file([obligation()]);
|
||||
expect(keepSameTreeClears(prev(), now)).toEqual([expect.stringMatching(/^PREGATE_CLEAR_KEPT: /)]);
|
||||
expect([now.requires_remote![0]!.receipt, now.requires_remote![0]!.cleared_by, now.verdict]).toEqual([RUN_URL, 'gstack-pregate clear 2026-11-06T00:00:00Z', 'requires-remote cleared']);
|
||||
const kept = (next: PregateFile) => { keepSameTreeClears(prev(), next); return next.requires_remote![0]!.receipt; };
|
||||
expect(kept(file([obligation()], { base: 'origin/main@dddddddddddd' }))).toBe(RUN_URL);
|
||||
for (const next of [file([obligation()], { head: 'f'.repeat(40) }), file([obligation()], { tree: 'e'.repeat(40) }), file([obligation()], { policy: 'head' }), file([obligation()], { base: 'origin/release@cccccccccccc' }),
|
||||
file([obligation({ tests: ['test/a.test.ts', 'test/b.test.ts'] })]), file([obligation({ job: 'smoke', lane: LANE })]), file([obligation({ platform: 'windows' })])]) {
|
||||
expect(kept(next)).toBeNull();
|
||||
}
|
||||
expect(kept(file([obligation()], { checks: [{ id: 'lanes', status: 'incomplete' }] }))).toBeNull();
|
||||
expect(keepSameTreeClears(null, file([obligation()]))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -207,6 +207,101 @@ describe('finish names the grant that covers the counted fix-check (GST-1610)',
|
||||
});
|
||||
});
|
||||
|
||||
// GST-1627: finish chains contiguous review-fix-check rows back to the reviewed tree. #4063 (GSTA-343): fc3 read the fix
|
||||
// after round 2 (range from the stop head to 265d868), then the owner granted fc4 on the base-merge resolution (range from
|
||||
// 265d868 to the merge). finish read only the newest row and required its range to start at the reviewed tree, so fc4
|
||||
// unbound fc3 and the lane logged a synthetic combined row.
|
||||
const TEST_V3 = "import { expect, test } from 'bun:test';\ntest('a', () => { expect(2).toBe(2); }, 2_000);\n";
|
||||
const CLEAN_ROW = { completed: true, converged: true, status: 'clean', issues_found: 0, critical: 0, informational: 0, findings: [] };
|
||||
const headOf = (f: ShipFx) => f.git('rev-parse', 'HEAD').stdout.trim();
|
||||
const logLink = (f: ShipFx, rangeBase: string) => logFixCheck(f, { range_base: rangeBase, ...CLEAN_ROW });
|
||||
const grantLink = (f: ShipFx, stop: string, n: string) => {
|
||||
const g = run(f, 'gstack-approvals', ['add', '--ask', `Owner grant: scoped fix-check ${n} (review_loop_grant ${stop.slice(0, 12)})`, '--words', `fix-check ${n}`, '--by', 'gstack minder (GSTA-36)', '--scope', 'this /ship invocation', '--json']);
|
||||
expect(g.status, g.stderr).toBe(0);
|
||||
return JSON.parse(g.stdout).added[0] as string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Round 2 stopped at passHead (the reviewed tree), the owner granted fc3, and fc3 read the fix to src/x.ts and its test.
|
||||
* Then the base moved src/x.ts, the merge conflicted, and the resolution is the merge commit fc4 reads.
|
||||
*/
|
||||
function mergeShape(o: { grantFc3?: boolean } = {}) {
|
||||
const f = shipFixture({ 'src/x.ts': 'export const x = 1;\n' }, { 'src/x.ts': 'export const x = 2;\n', 'test/x.test.ts': TEST_V1 });
|
||||
ships.push(f);
|
||||
const passHead = headOf(f);
|
||||
const reviewed = f.run(path.join(BIN, 'gstack-wtree'), []).stdout.trim();
|
||||
const reviewedBase = f.git('merge-base', 'origin/main', 'HEAD').stdout.trim();
|
||||
const g3 = o.grantFc3 === false ? null : grantLink(f, passHead, 'fc3');
|
||||
f.write({ 'src/x.ts': 'export const x = 3;\n', 'test/x.test.ts': TEST_V2 }); f.commit('fix the round-2 blocker');
|
||||
const fc3 = headOf(f);
|
||||
logLink(f, passHead);
|
||||
f.advanceBase({ 'src/x.ts': 'export const x = 10;\n' });
|
||||
expect(f.git('fetch', '-q', 'origin').status).toBe(0);
|
||||
expect(f.git('merge', '-q', '--no-edit', 'origin/main').status).not.toBe(0);
|
||||
f.write({ 'src/x.ts': 'export const x = 13;\n' }); f.commit('merge origin/main: resolve src/x.ts');
|
||||
const fc4 = headOf(f);
|
||||
return { f, passHead, reviewed, reviewedBase, g3, fc3, fc4 };
|
||||
}
|
||||
/** Step 14.5's docs audit runs on the final head, then finish. */
|
||||
function finishAfterAudit(f: ShipFx, reviewed: string, reviewedBase: string) {
|
||||
const post = path.join(f.root, 'post.json');
|
||||
expect(f.run(path.join(BIN, 'gstack-docs-candidate'), ['snapshot', '--out', post, '--audit-id', 'a1', '--mode', 'edit', '--base', 'main', '--docs', 'README.md']).status).toBe(0);
|
||||
return f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--reviewed-base', reviewedBase, '--lane', LANE]);
|
||||
}
|
||||
|
||||
describe('finish chains contiguous fix-check rows (GST-1627)', () => {
|
||||
// Value: protects=the #4063 shape binds without a synthetic row: fc3 (range from the reviewed tree) and fc4 (range from fc3's
|
||||
// commit over the merge resolution) both count, each under its own grant; fails_when=finish reads only the newest row and refuses
|
||||
// fc4's range_base as not the reviewed tree, so src/x.ts and the test route as behavior; seam=finish's route reading the review log
|
||||
test('#4063: fc3 at its fix, then fc4 over the merge resolution, and finish counts both', () => {
|
||||
const { f, passHead, reviewed, reviewedBase, g3, fc3, fc4 } = mergeShape();
|
||||
const g4 = grantLink(f, fc3, 'fc4');
|
||||
logLink(f, fc3);
|
||||
const r = finishAfterAudit(f, reviewed, reviewedBase);
|
||||
expect(lineOf(r.stdout, 'FIX_CHECK'), r.stdout).toBe(`FIX_CHECK: reviewed src/x.ts,test/x.test.ts (fix-check chain of 2 from ${passHead.slice(0, 12)}: at ${fc3.slice(0, 12)} grant ${g3}; at ${fc4.slice(0, 12)} grant ${g4})`);
|
||||
expect([r.status, lineOf(r.stdout, 'ROUTE')], r.stdout).toEqual([0, expect.stringMatching(/ behavior=0 .* fix_checked=2$/)]);
|
||||
});
|
||||
|
||||
// Value: protects=a chain links only through each earlier fix-check's own commit: a commit between fc3 and fc4 that no fix-check
|
||||
// read breaks it and finish refuses; fails_when=finish links rows by order alone or counts fc4 from any earlier branch commit;
|
||||
// seam=finish's chain walk
|
||||
test('a gap in the chain refuses: fc4\'s range starts at a commit no fix-check ends at', () => {
|
||||
const { f, reviewed, reviewedBase, fc4 } = mergeShape();
|
||||
f.write({ 'src/x.ts': 'export const x = 14;\n' }); f.commit('an unread edit');
|
||||
const gap = headOf(f);
|
||||
f.write({ 'test/x.test.ts': TEST_V3 }); f.commit('the fc4 fix');
|
||||
grantLink(f, gap, 'fc4');
|
||||
logLink(f, gap);
|
||||
const r = finishAfterAudit(f, reviewed, reviewedBase);
|
||||
expect(fc4).not.toBe(gap);
|
||||
expect(lineOf(r.stdout, 'FIX_CHECK')).toBe(`FIX_CHECK: not counted (range_base ${gap.slice(0, 12)} does not hold the reviewed tree)`);
|
||||
expect([r.status, r.stdout.includes('STOP: behavior, tests or build inputs changed since review (src/x.ts,test/x.test.ts)')], r.stdout).toEqual([3, true]);
|
||||
});
|
||||
|
||||
// Value: protects=every link is a granted fix-check: an earlier row the owner never granted does not ride on the later grant whose
|
||||
// stop head is its commit; fails_when=a grant at fc3's commit counts for both fc3 and fc4; seam=finish's per-row grant match
|
||||
test('a link without its own grant refuses the chain', () => {
|
||||
const { f, reviewed, reviewedBase, fc3 } = mergeShape({ grantFc3: false });
|
||||
grantLink(f, fc3, 'fc4');
|
||||
logLink(f, fc3);
|
||||
const r = finishAfterAudit(f, reviewed, reviewedBase);
|
||||
expect(lineOf(r.stdout, 'FIX_CHECK')).toBe(`FIX_CHECK: not counted (no approved review_loop_grant names a stop head inside its range_base..commit; fix-check at ${fc3.slice(0, 12)}, link 1 of 2)`);
|
||||
expect(r.status).toBe(3);
|
||||
});
|
||||
|
||||
// Value: protects=one owner grant covers one fix-check: a grant whose text names a stop head in each link's range counts for the
|
||||
// newest link only; fails_when=finish matches every sha in a grant row against every link and counts one grant twice;
|
||||
// seam=finish's per-row grant match
|
||||
test('one grant naming both links\' stop heads counts for one link, and the chain refuses', () => {
|
||||
const { f, passHead, reviewed, reviewedBase, fc3 } = mergeShape({ grantFc3: false });
|
||||
const g = run(f, 'gstack-approvals', ['add', '--ask', `Owner grant: one scoped fix-check after ${passHead.slice(0, 12)} (review_loop_grant ${fc3.slice(0, 12)})`, '--words', 'one scoped fix-check', '--by', 'gstack minder (GSTA-36)', '--scope', 'this /ship invocation', '--json']);
|
||||
expect(g.status, g.stderr).toBe(0);
|
||||
logLink(f, fc3);
|
||||
const r = finishAfterAudit(f, reviewed, reviewedBase);
|
||||
expect([r.status, lineOf(r.stdout, 'FIX_CHECK')], r.stdout).toEqual([3, `FIX_CHECK: not counted (no approved review_loop_grant names a stop head inside its range_base..commit; fix-check at ${fc3.slice(0, 12)}, link 1 of 2)`]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ruledNotes reads only approved owner rulings (GST-1537)', () => {
|
||||
const HEAD = '0123456789abcdef0123456789abcdef01234567';
|
||||
const row = (finding: string, o: Partial<{ ask: string; scope: string; by: string; outcome: string }> = {}) => {
|
||||
|
||||
Reference in new issue
Block a user