mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-07 09:23:50 +01:00
Merge main 1ccab0d8 into gsta258-l4-ready-receipts (rule 19 grant)
test/review-packets-removed.test.ts takes main's version (#3903 moved the review pins to test/fixtures/review-pins/). This branch's two pin entries become pin files with GSTA-202's recipe: a new replays/GST-508+GST-464+GST-967+GSTA-258-L4.json and the files/ship/sections/review-army.md.json repin to 22c37a64. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
commit
04a02bce2b
180 files changed
+3123
-433
No files matched your search
@@ -40,9 +40,11 @@ describe('findCarry', () => {
|
||||
const v = findCarry(source([run(4)], { 4: record(4) }), { profiles: ['ci-reuse/free-tests'], head: HEAD, tree: TREE, wtree: clean, inventory: () => ['test/a.test.ts', 'test/b.test.ts'] });
|
||||
expect(v.ok).toBe(true);
|
||||
if (!v.ok) return;
|
||||
expect(v.tests).toBe(40);
|
||||
expect([v.tests, v.unit]).toEqual([2, 'files']);
|
||||
expect(v.runs.map(e => e.run_id)).toEqual([4]);
|
||||
expect(v.receipt).toMatch(/^ci-carry tree=bbbbbbbbbbbb tests=40 runs=4\/1 profiles=ci-reuse\/free-tests heads=aaaaaaaaaaaa verified=/);
|
||||
expect(v.receipt).toMatch(/^ci-carry tree=bbbbbbbbbbbb files=2 runs=4\/1 profiles=ci-reuse\/free-tests heads=aaaaaaaaaaaa verified=/);
|
||||
const whole = findCarry(source([run(4)], { 4: record(4) }), { profiles: ['ci-reuse/free-tests'], head: HEAD, tree: TREE, wtree: clean });
|
||||
expect(whole.ok && [whole.tests, whole.unit, whole.receipt.split(' ')[2]]).toEqual([40, undefined, 'tests=40']);
|
||||
});
|
||||
|
||||
// Value: protects=A2, a carry binds to the working-tree fingerprint before and after; fails_when=a dirty tree or a tree edited during the lookup carries
|
||||
|
||||
@@ -109,7 +109,7 @@ describe('gstack-evidence run --reuse (A5)', () => {
|
||||
describe('gstack-evidence record-ci and carried (A4)', () => {
|
||||
const ciRow = () => {
|
||||
const tree = wtree();
|
||||
return { ts: new Date(Date.now() - 600_000).toISOString(), label: 'free', command: 'bun run test', cmd_sha256: createHash('sha256').update('bun run test').digest('hex'), exit: 0, duration_s: 0, commit: gitIn(repo, 'rev-parse HEAD').trim(), tree, dirty: false, wtree: tree, tests_ran: 900, source: 'ci', runs: ['41/2'], profiles: ['ci-reuse/free-tests'] };
|
||||
return { ts: new Date(Date.now() - 600_000).toISOString(), label: 'free', command: 'bun run test', cmd_sha256: createHash('sha256').update('bun run test').digest('hex'), exit: 0, duration_s: 0, commit: gitIn(repo, 'rev-parse HEAD').trim(), tree, dirty: false, wtree: tree, tests_ran: 900, source: 'ci', runs: ['41/2'], profiles: ['ci-reuse/free-tests'], policy: 'origin/main@000000000000:.gstack/pregate.json' };
|
||||
};
|
||||
|
||||
// Value: protects=a carried lane is an explicit CI row the FRESH check accepts by source, and the receipt lists it only at its own tree;
|
||||
@@ -121,7 +121,8 @@ describe('gstack-evidence record-ci and carried (A4)', () => {
|
||||
expect(chk.status).toBe(0);
|
||||
expect(chk.stdout).toContain('EVIDENCE: FRESH label=free exit=0 tests_ran=900');
|
||||
expect(chk.stdout).toContain('source=ci runs=41/2');
|
||||
expect(JSON.parse(run(['carried', '--json']).stdout)).toEqual([{ lane: 'free', run: 41, attempt: 2, tree: wtree() }]);
|
||||
expect(JSON.parse(run(['carried', '--base', 'main', '--json']).stdout)).toEqual([{ lane: 'free', run: 41, attempt: 2, tree: wtree() }]);
|
||||
expect(JSON.parse(run(['carried', '--base', 'release', '--json']).stdout)).toEqual([]);
|
||||
fs.writeFileSync(path.join(repo, 'src.txt'), 'v2\n');
|
||||
gitIn(repo, 'commit -qam two');
|
||||
expect(JSON.parse(run(['carried', '--json']).stdout)).toEqual([]);
|
||||
|
||||
+1
-1
@@ -872,7 +872,7 @@ A new PR or an existing draft needs no question: Step 19 creates or keeps the dr
|
||||
```
|
||||
|
||||
It checks the branch, the offer, the gate above (`--gate-open <n>`; `--gate-open-approved` is B)
|
||||
and `gstack-pregate sealed`, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
|
||||
`gstack-pregate sealed` and push guards, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
|
||||
`PUSH: ALREADY_PUSHED` (go to Step 18), else it runs `git push -u origin <branch-name>`.
|
||||
|
||||
**If the push fails, STOP.** No Step 19 or publication claim. Report the error:
|
||||
|
||||
+1
-1
@@ -871,7 +871,7 @@ A new PR or an existing draft needs no question: Step 19 creates or keeps the dr
|
||||
```
|
||||
|
||||
It checks the branch, the offer, the gate above (`--gate-open <n>`; `--gate-open-approved` is B)
|
||||
and `gstack-pregate sealed`, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
|
||||
`gstack-pregate sealed` and push guards, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
|
||||
`PUSH: ALREADY_PUSHED` (go to Step 18), else it runs `git push -u origin <branch-name>`.
|
||||
|
||||
**If the push fails, STOP.** No Step 19 or publication claim. Report the error:
|
||||
|
||||
+1
-1
@@ -878,7 +878,7 @@ $GSTACK_ROOT/bin/gstack-ship-release push --branch <branch-name>
|
||||
```
|
||||
|
||||
It checks the branch, the offer, the gate above (`--gate-open <n>`; `--gate-open-approved` is B)
|
||||
and `gstack-pregate sealed`, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
|
||||
`gstack-pregate sealed` and push guards, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
|
||||
`PUSH: ALREADY_PUSHED` (go to Step 18), else it runs `git push -u origin <branch-name>`.
|
||||
|
||||
**If the push fails, STOP.** No Step 19 or publication claim. Report the error:
|
||||
|
||||
+1
-1
@@ -880,7 +880,7 @@ $GSTACK_ROOT/bin/gstack-ship-release push --branch <branch-name>
|
||||
```
|
||||
|
||||
It checks the branch, the offer, the gate above (`--gate-open <n>`; `--gate-open-approved` is B)
|
||||
and `gstack-pregate sealed`, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
|
||||
`gstack-pregate sealed` and push guards, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
|
||||
`PUSH: ALREADY_PUSHED` (go to Step 18), else it runs `git push -u origin <branch-name>`.
|
||||
|
||||
**If the push fails, STOP.** No Step 19 or publication claim. Report the error:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"fbe52f3a9e0f4442eb755f1747a95944b373b439bd05c61f3eb68345d1061a5b"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"aad667b5ea4c6d23d930dafffa38178c04be388cd4364fb73759fc92af17bb63"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"a7cc08fb31b6ca65932a316d589f0b09677426d193d63f88ec74fee6d93ce053"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"bd96406da28b03742384560425a333b6d6d7ed2be95bd2026c90e0039632cee2"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"6dd670ea040535ca6cceab81c29a395bb67ce5a2ab8fa2b81e51bc4711e4aada"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"1ebe47ddb3291a9e4fe90b5a260eb57e328144959549bc95c119b68d23652cb5"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"25050a7a61f81df649d1df0d0237dbf961671356395317c6a527741f5bb56877","replay":"GST-940"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"08464b78a723ee463ca3a28d0abbc4eb8f4f512192061c703743707b66b5a311","replay":"GST-942"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"a32963ef38d9d3613c58c805e8517cd2e593c63769b30dd7d40e2592fa48fc78","replay":"GSTA-224 H1"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"b9205f88313cc64902c0b13e9685722ab8138d3dcc58916154f47274abda7d83","replay":"#3638"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"f570db344c9a01df633bcaa281d6017f4f12420fd3e0aa3078c1ce06e0df8332","replay":"#3638"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"e6962fa25b78f362654f2954f79683e524d2ebd452d1494f8a2b099888e8e255","replay":"G1"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"78329b64640260cd8831ca81a9189f3f8dcade9c406431631d3f2cd798cd3967","replay":"#3638"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"263d23ac119dd601d315c191dbdbd503d47c00264c9c0ca81959559ca11d4e95","replay":"#3638"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"b6fd9eb229002ea598f8fe9ff53b1cd8821e3bd37a7aa7b07b5526556c71ebca","replay":"#3638"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"f8015d549e70297f782f0aabf6fe3a9fe31ec5ed61c311ab854e4a858282ed9b","replay":"#3638"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"545c294ae53638b4c8524e8cde08246a4ce3b5c287ec7c44e27f5556a3b0e8cc","replay":"#3638"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"9ea05149f5b13d6a19ecec26285142d09d259cf80a515af828abdd1f43320427","replay":"#3638"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"17bfe813eb3de22835af048ec359770720f1d7f371a26460169f7e32b1bcac9f","replay":"#3638"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"f33846b1dcf2fdeef82d0744ab2ef48fb4a33b34a737dafc33dd0e891756689f","replay":"#3638"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"597215e4438d85d9cc71b48097c61eef7cba4f4b09dc14c92b4b94893b3d3ea2","replay":"#3638"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"7f92a0d0a8319c3c0e076fb3b1a4689c45120b2be2c14dfc7d4cb03aafa9d6a5","replay":"GST-257"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"d6cc2bef7be562387d0c0a22fefe646dff9c295f1d34d8453a6cb496584150e4","replay":"#3638"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"712bf5b0c439e96bac32f19cbe25394dabb4c94aa66600100bfde9b100e15a08","replay":"#3638"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"0142d6bc3f2a5bceb9f8b2adef1d3a206e5836c99a0667fe995568da2e80a5a6","replay":"GST-508+GST-464+GST-967"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sha":"22c37a64fd937f94ce231f8d815c401f232e0b06da8a1e57d3d15a18d3c9f2c0","replay":"GST-508+GST-464+GST-967+GSTA-258-L4"}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"replay":"#3638","justification":"no-loss round 3: main restored the pre-#3621 reviewers (v1.91.331.0)"}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"replay":"G1","justification":"GSTA-167 variant G Arm G1: live no-loss replay of both prose voices with part 1's prompts, 2 passes, normalizer v2, paired rule against pooled C' (parts 1 and 3; specialists at medium as in C and C2)"}
|
||||
@@ -0,0 +1 @@
|
||||
{"replay":"GST-257","justification":"GST-257 PR 1 dispatch-superset no-loss check (test/review-targeted.test.ts): on every fixture diff the branch dispatches main's reviewers at 8be7f21 unchanged plus only the targeted reader, and over the last 100 non-release commits on main it lost none; adds the targeted role and brief to the G1 and L bytes, whose existing reviewer text is unchanged"}
|
||||
@@ -0,0 +1 @@
|
||||
{"replay":"GST-464","justification":"GST-464 brief-identity no-loss check (the brief-body test below): every reviewer brief lib/review-brief.ts cuts from ship/sections/review-army.md and adversarial.md is byte-identical to the bodies at the L and GST-257 pins; only lead instructions outside the cut spans changed"}
|
||||
@@ -0,0 +1 @@
|
||||
{"replay":"GST-508+GST-464+GST-967+GSTA-258-L4","justification":"GSTA-258 L4 (GST-1168) brief-identity no-loss check (the brief-body test below): ship/sections/review-army.md is the GST-508+GST-464+GST-967 bytes plus Step 9.4 lead instructions outside the cut spans (a finding's ledger_id, the Review-Finding fix-commit trailer, logging a late defer when it is decided); every reviewer brief lib/review-brief.ts cuts from it still hashes to BRIEF_BODIES, so no reviewer sees a changed byte"}
|
||||
@@ -0,0 +1 @@
|
||||
{"replay":"GST-508+GST-464+GST-967","justification":"wave review-53cfb5bfe011 merge of main 68410828f into #3852: ship/sections/review-army.md and adversarial.md are main's GST-967 bytes plus exactly the GST-508+GST-464 lines (the merged file minus main equals the wave minus fc292e22f, line for line), so GST-967's dispatch-superset no-loss check and GST-508's no-loss replay both hold; merged-head checks in GStack Minder drive handoffs/GSTA-202/merge-6841-checks.md"}
|
||||
@@ -0,0 +1 @@
|
||||
{"replay":"GST-508+GST-464","justification":"wave review-53cfb5bfe011 merge of #3744 into main: ship/sections/review-army.md and adversarial.md carry the GST-508 no-loss-replayed exclude pathspec placeholders inside the brief bodies (handoffs/GSTA-202/noloss-results.md) and the GST-464 lead-instruction edits outside the cut spans, which its brief-identity no-loss check admitted"}
|
||||
@@ -0,0 +1 @@
|
||||
{"replay":"GST-508+R51+GST-911+GST-967","justification":"wave review-53cfb5bfe011 merge of main 68410828f into #3852: lib/review-depth.ts is main's GST-967 bytes plus exactly the GST-508+R51+GST-911 lines (the merged file minus main equals the wave minus fc292e22f, line for line); GST-967's dispatch-superset no-loss check (test/review-depth.test.ts) passes on the merged file, and the GST-508+R51+GST-911 checks are rerun at the merged head (GStack Minder drive handoffs/GSTA-202/merge-6841-checks.md)"}
|
||||
@@ -0,0 +1 @@
|
||||
{"replay":"GST-508+R51+GST-911","justification":"wave review-53cfb5bfe011 merge of main 3d99c2249 (#3848) into #3852: lib/review-depth.ts keeps GST-508's review_exclude_paths (no-loss replay, GStack Minder drive handoffs/GSTA-202/noloss-results.md), R51 with GST-850's dispatch-superset no-loss check, and GST-911's SVG drawings with GST-911-redos (no-loss replay: 0 of 100 main commits changed, reports/GSTA-249-911-decisions.tsv; test/review-planner-misreads.test.ts, test/review-risk-redos.test.ts); lib/review-risk.ts is main's GST-911 bytes. Merged-head checks (handoffs/GSTA-202/merge-3d99c22-checks.md): Arm P on unit 3593 differs only in the exclude pathspec and the disclosure line, the same brief diff as before the merge, and GST-911 panel unit f5cb6ec1e plans, dispatches and renders byte-identical briefs under 3d99c2249 and the merged head"}
|
||||
@@ -0,0 +1 @@
|
||||
{"replay":"GST-508+R51","justification":"wave review-53cfb5bfe011 merge of #3744 into main: lib/review-depth.ts keeps both admitted changes, the GST-508 no-loss replay of review_exclude_paths (Arm V 33 of 41 blockers vs R 33 of 41, p_NI 0.0279; GStack Minder drive handoffs/GSTA-202/noloss-results.md) and the R51 risk-tier re-score with GST-850's dispatch-superset no-loss check: excluded paths sit beside the added and removed line maps, and the exclusion filter keeps the publishes-docs rule"}
|
||||
@@ -0,0 +1 @@
|
||||
{"replay":"GST-508","justification":"GST-508 (#3744, GSTA-202) preregistered no-loss replay of review_exclude_paths at ef2760f: Arm V found 33 of 41 blockers vs R 33 of 41, exact sign flip with delta 4, p_NI 0.0279 (pass); Arm P identical depth, dispatch and reader sets on 26 of 26 units (GStack Minder drive handoffs/GSTA-202/noloss-results.md)"}
|
||||
@@ -0,0 +1 @@
|
||||
{"replay":"GST-850","justification":"GST-850 dispatch-superset no-loss check: on the 26 GSTA-167 no-loss units the branch plan (wider security and money classes, write-up text in the batched diff) drops no reviewer role main dispatches, so no saved blocker can be lost, and over the last 100 main commits no commit with a gate or risk class drops depth (test/review-risk-routing.test.ts, test/review-risk-linear.test.ts); reviewer text is unchanged"}
|
||||
@@ -0,0 +1 @@
|
||||
{"replay":"GST-898","justification":"GST-898 verdict-identity no-loss check: the linear rm rule, and the rmSync rule (which also raises security on any call with more than 500 characters before its closing parenthesis), give the same verdict as R51's on every changed line of the last 100 main commits (69,023 lines, a563f47 back 100 first-parent commits) and on 60,000 seeded lines per rule (test/review-risk-redos.test.ts), and no commit changes depth, class or risk class, so every diff dispatches main's reviewers unchanged; reviewer text is unchanged"}
|
||||
@@ -0,0 +1 @@
|
||||
{"replay":"GST-911-redos","justification":"GST-911 ReDoS fix verdict-identity no-loss check (wave review-e7b551ebd865): SVG_REFUSED's url() entry, rewritten to run in linear time, refuses exactly the strings #3838's pattern refused (test/review-risk-redos.test.ts: 60,000 seeded strings and the hand-picked edges, and every SVG blob in the repository's history, with zero differences); SVG_MARKUP now refuses a DOCTYPE naming any document but W3C's SVG DTD (test/review-planner-misreads.test.ts), which only keeps more SVGs code and so drops no reviewer; GST-911's no-loss replay holds unchanged; reviewer text is unchanged"}
|
||||
@@ -0,0 +1 @@
|
||||
{"replay":"GST-911","justification":"GST-911 no-loss replay (GSTA-249/13): the planner over the last 100 first-parent main commits to f5cb6ec1e, origin/main's code against this branch, gate scan fixed at 0, changes no commit's depth, class, risk classes, targeted classes or SCOPE_AUTH; the three misreads (a drawing-only SVG, a session-named data file, a bare base) are proven on fixtures (test/review-planner-misreads.test.ts), where every SVG that can run script stays code with the security class; reviewer text is unchanged"}
|
||||
@@ -0,0 +1 @@
|
||||
{"replay":"GST-940","justification":"GST-940 verdict no-loss check: svgDrawing reads a style element's opening tag as quoted pairs with ASCII whitespace, walks its body over the raw text, refuses any & in it, reads comments as CSS and refuses a non-ASCII element or attribute name; on 62,805 unique SVG files (21,658 with a style element) it gives the same verdict as main at 4bbad2422 on every file, and the planner over the last 100 first-parent main commits to 4bbad2422 changes no commit's depth, class or risk classes; the only verdicts it changes are the GST-940 cases in test/review-planner-misreads.test.ts; rerun on the merge with main at 8f08cb76a (GST-967 merged), with the same results; rerun on the merge with main at 5f705f367 (#3852 and #3864 merged), with the same results"}
|
||||
@@ -0,0 +1 @@
|
||||
{"replay":"GST-942","justification":"GST-942 dispatch-superset no-loss check: the rmSync rule no longer stops at the first ), a matching rm( rule is added, and both read a file's changed lines joined for a delete split across lines, so a recursive delete raises security on more diffs and never on fewer; over the last 100 first-parent main commits to 3d99c2249 (62,531 changed lines) the line rules match all 170 lines the old rule matched plus 19 more, and no commit changes depth, class or risk class; reviewer text is unchanged"}
|
||||
@@ -0,0 +1 @@
|
||||
{"replay":"GST-967","justification":"GST-967 dispatch-superset no-loss check (test/review-depth.test.ts, 'GST-967 dispatch-superset no-loss check'): over 1,440 planReview cases the branch plans exactly what main planned at 1022f4f except the 71 gate-class stops before Step 11, which add only Step 11's adversarial, outside and targeted voices, so no reviewer main dispatches is dropped or re-efforted; the brief bodies lib/review-brief.ts cuts from the ship sections hash to main's; only the lead's repeat-decision and Step 11 instructions changed; reviewer text is unchanged"}
|
||||
@@ -0,0 +1 @@
|
||||
{"replay":"GST-986","justification":"GST-986 brief-identity no-loss check (the brief-body test below): every reviewer brief lib/review-brief.ts cuts from ship/sections/adversarial.md is byte-identical to the bodies at the GST-257 pin; only the lead's Step 11 instruction naming the targeted record's path changed, outside the cut spans"}
|
||||
@@ -0,0 +1 @@
|
||||
{"replay":"GSTA-224 H1","justification":"GSTA-224 H1 preregistered no-loss run: the PR-type checklist against main's checklist and a no-gstack arm (Opus 5.5, 81 calls), 5 behavior cases, 10 Round 7 application blockers and 2 clean controls; every bar passed (handoffs/GSTA-224/h1-noloss-results.md)"}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"replay":"L","justification":"GSTA-167 variant G Arm L: live no-loss replay of one batched reader (testing and security plus scoped checklists) and the Red Team on its findings, 2 passes, scored L∘G1 against G1 on the paired rule (part 2)"}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"replay":"PR-a","justification":"GSTA-167 variant G Arm 4: structural plan identity at the 14 cohort heads and the free exact re-score of saved C, D and A' findings (parts 4a and 4b)"}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"replay":"R51","justification":"R51 risk-tier routing no-loss re-score (GSTA-167): the saved panel findings (349 records over 24 of the 26 no-loss units) re-scored against the branch's plan lose no blocker whose only finders are roles it drops, and every gate, security, user-data and money fixture keeps main's reviewers plus the full panel (test/review-targeted.test.ts, test/review-risk-routing.test.ts); reviewer text is unchanged"}
|
||||
+9
-9
@@ -6,52 +6,52 @@
|
||||
"agy/gstack-plan-design-review": 109107,
|
||||
"agy/gstack-plan-devex-review": 114948,
|
||||
"agy/gstack-plan-eng-review": 143581,
|
||||
"agy/gstack-review": 104182,
|
||||
"agy/gstack-review": 104728,
|
||||
"codex/gstack-autoplan": 108400,
|
||||
"codex/gstack-plan-design-review": 110464,
|
||||
"codex/gstack-plan-devex-review": 116456,
|
||||
"codex/gstack-plan-eng-review": 144746,
|
||||
"codex/gstack-review": 103705,
|
||||
"codex/gstack-review": 104251,
|
||||
"copilot/gstack-autoplan": 109100,
|
||||
"copilot/gstack-plan-design-review": 109373,
|
||||
"copilot/gstack-plan-devex-review": 114410,
|
||||
"copilot/gstack-plan-eng-review": 143746,
|
||||
"copilot/gstack-review": 103763,
|
||||
"copilot/gstack-review": 104309,
|
||||
"cursor/gstack-autoplan": 108332,
|
||||
"cursor/gstack-plan-design-review": 107915,
|
||||
"cursor/gstack-plan-devex-review": 113680,
|
||||
"cursor/gstack-plan-eng-review": 142336,
|
||||
"cursor/gstack-review": 122053,
|
||||
"cursor/gstack-review": 122781,
|
||||
"factory/gstack-autoplan": 110610,
|
||||
"factory/gstack-plan-design-review": 111013,
|
||||
"factory/gstack-plan-devex-review": 117043,
|
||||
"factory/gstack-plan-eng-review": 145444,
|
||||
"factory/gstack-review": 125826,
|
||||
"factory/gstack-review": 126554,
|
||||
"gbrain/gstack-autoplan": 108739,
|
||||
"gbrain/gstack-plan-eng-review": 124963,
|
||||
"gemini/gstack-autoplan": 108890,
|
||||
"gemini/gstack-plan-design-review": 109201,
|
||||
"gemini/gstack-plan-devex-review": 114238,
|
||||
"gemini/gstack-plan-eng-review": 143579,
|
||||
"gemini/gstack-review": 103585,
|
||||
"gemini/gstack-review": 104131,
|
||||
"hermes/gstack-autoplan": 108742,
|
||||
"hermes/gstack-plan-eng-review": 124999,
|
||||
"kiro/gstack-autoplan": 108106,
|
||||
"kiro/gstack-plan-design-review": 107761,
|
||||
"kiro/gstack-plan-devex-review": 113532,
|
||||
"kiro/gstack-plan-eng-review": 142198,
|
||||
"kiro/gstack-review": 121877,
|
||||
"kiro/gstack-review": 122605,
|
||||
"openclaw/gstack-autoplan": 108562,
|
||||
"openclaw/gstack-plan-eng-review": 123478,
|
||||
"opencode/gstack-autoplan": 109081,
|
||||
"opencode/gstack-plan-design-review": 109229,
|
||||
"opencode/gstack-plan-devex-review": 114288,
|
||||
"opencode/gstack-plan-eng-review": 143605,
|
||||
"opencode/gstack-review": 122675,
|
||||
"opencode/gstack-review": 123403,
|
||||
"slate/gstack-autoplan": 108219,
|
||||
"slate/gstack-plan-design-review": 107838,
|
||||
"slate/gstack-plan-devex-review": 113606,
|
||||
"slate/gstack-plan-eng-review": 142267,
|
||||
"slate/gstack-review": 121965
|
||||
"slate/gstack-review": 122693
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ describe('gstack-lease', () => {
|
||||
const r = spawnSync('bash', ['-n', LEASE], { encoding: 'utf8', timeout: 30_000 });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
const help = spawnSync('bash', [LEASE, '--help'], { encoding: 'utf8', timeout: 30_000 });
|
||||
expect(help.stdout).toContain('print `<pid> <age>m alive|dead|stale` for every lease');
|
||||
expect(help.stdout).toContain('print `<pid> <age>m alive|idle|dead|stale` for every lease');
|
||||
});
|
||||
|
||||
test('touch writes <git dir>/gstack-leases/<pid> for --pid and prunes leases older than 120 minutes', () => {
|
||||
@@ -716,6 +716,58 @@ describe('gstack-lease', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// GST-1205: gstack-skill-end releases the run gstack-skill-start opened, so a finished run stops holding the lease.
|
||||
test('gstack-skill-start opens a run on the lease and gstack-skill-end with its SESSION_ID releases it; another holder still counts', () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-home-'));
|
||||
const state = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-state-'));
|
||||
dirs.push(home, state);
|
||||
fs.writeFileSync(path.join(state, 'config.yaml'), 'update_check: false\n');
|
||||
const leaseDir = path.join(state, 'leases');
|
||||
const env = { PATH: process.env.PATH!, HOME: home, GSTACK_HOME: state, GSTACK_LEASE_DIR: leaseDir };
|
||||
const holder = sleeper();
|
||||
const start = spawnSync(START, ['--skill', 'ship', '--parent-pid', String(holder)], { encoding: 'utf8', cwd: home, env, timeout: 30_000 });
|
||||
expect(start.status, start.stdout + start.stderr).toBe(0);
|
||||
const sid = start.stdout.match(/^SESSION_ID: (\S+)$/m)?.[1] ?? '';
|
||||
expect(sid.startsWith(`${holder}-`), sid).toBe(true);
|
||||
expect(run(['live'], env).status).toBe(0);
|
||||
const end = spawnSync(path.join(REPO, 'bin/gstack-skill-end'), ['--skill', 'ship', '--outcome', 'success', '--session-id', sid, '--tel-start', '0'], { encoding: 'utf8', cwd: home, env, timeout: 30_000 });
|
||||
expect(end.status, end.stdout + end.stderr).toBe(0);
|
||||
const after = run(['live'], env);
|
||||
expect(after.status, after.stdout + after.stderr).toBe(1);
|
||||
expect(run(['list'], env).stdout).toMatch(new RegExp(`^${holder} \\d+m idle$`, 'm'));
|
||||
const other = sleeper();
|
||||
expect(run(['touch', '--pid', String(other)], env).status).toBe(0);
|
||||
expect(run(['live'], env).stdout).toMatch(new RegExp(`^${other} \\d+m sleep$`, 'm'));
|
||||
});
|
||||
|
||||
test('GST-1205 review: a hold without --run survives a sibling run\'s release in either order; an unrecorded release changes nothing; an orphaned run ages out by itself', () => {
|
||||
const { dir, leases } = checkout();
|
||||
const live = (pid: number) => run(['live', '--root', dir]).stdout.includes(`${pid} `);
|
||||
for (const order of [['hold', 'run'], ['run', 'hold']]) {
|
||||
const p = sleeper();
|
||||
for (const step of order) expect(run(['touch', '--root', dir, '--pid', String(p), ...(step === 'run' ? ['--run', `${p}-1-a`] : [])]).status).toBe(0);
|
||||
expect(run(['release', '--root', dir, '--pid', String(p), '--run', `${p}-1-a`]).status).toBe(0);
|
||||
expect(live(p), order.join('>')).toBe(true);
|
||||
}
|
||||
const legacy = sleeper();
|
||||
expect(run(['touch', '--root', dir, '--pid', String(legacy)]).status).toBe(0);
|
||||
expect(run(['release', '--root', dir, '--pid', String(legacy), '--run', `${legacy}-9-z`]).status).toBe(0);
|
||||
expect(live(legacy)).toBe(true);
|
||||
expect(run(['release', '--root', dir, '--pid', String(legacy)]).status).toBe(2);
|
||||
expect(run(['release', '--root', dir, '--pid', 'x', '--run', 'y']).status).toBe(2);
|
||||
const shared = sleeper();
|
||||
expect(run(['touch', '--root', dir, '--pid', String(shared), '--run', `${shared}-1-old`]).status).toBe(0);
|
||||
const file = path.join(leases, String(shared));
|
||||
const old = Math.floor(Date.now() / 1000) - 121 * 60;
|
||||
fs.writeFileSync(file, fs.readFileSync(file, 'utf8').replace(/^run (\S+) \d+$/m, `run $1 ${old}`));
|
||||
expect(live(shared)).toBe(false);
|
||||
expect(run(['touch', '--root', dir, '--pid', String(shared), '--run', `${shared}-2-new`]).status).toBe(0);
|
||||
expect(live(shared)).toBe(true);
|
||||
expect(run(['release', '--root', dir, '--pid', String(shared), '--run', `${shared}-2-new`]).status).toBe(0);
|
||||
expect(live(shared)).toBe(false);
|
||||
expect(fs.readFileSync(file, 'utf8')).not.toContain('-1-old');
|
||||
});
|
||||
|
||||
test('gstack-skill-start reports a lease the held lock kept from being written, and still starts', () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-home-'));
|
||||
const state = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-state-'));
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { BUMP_NEVER_KEY_VERSION, CHANGELOG_FRAGMENTS_KEY_VERSION, DEFAULT_POLICY, POLICY_KEYS, POLICY_KEY_INTRODUCED, REVIEW_SCOPE_KEY_VERSION, globToRegExp, matchesAny, minimumReader, shipConsequences, validatePolicy } from '../lib/ship-policy';
|
||||
import { BUMP_NEVER_KEY_VERSION, CHANGELOG_FRAGMENTS_KEY_VERSION, DEFAULT_POLICY, POLICY_KEYS, POLICY_KEY_INTRODUCED, REVIEW_SCOPE_KEY_VERSION, STAMP_PLACEHOLDERS_KEY_VERSION, globToRegExp, matchesAny, minimumReader, shipConsequences, validatePolicy } from '../lib/ship-policy';
|
||||
import { fillDue } from '../lib/ship-queue-train';
|
||||
import { cmpVersion, parseVersion } from '../lib/version-source';
|
||||
import { BASE_POLICY, cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
@@ -63,6 +63,32 @@ describe('validatePolicy (schema + containment, pure)', () => {
|
||||
expect(codes({ preregistration_shas: ['not-a-sha'] })).toEqual(['POLICY_CONTAINMENT:preregistration_shas[0]']);
|
||||
});
|
||||
|
||||
// Value: protects=stamp_placeholders defaults to none, needs its reader floor, and admits only short one-line literals
|
||||
// that are neither the {version} slot nor a version (GST-1026); fails_when=a non-default value validates without
|
||||
// min_gstack_version, or a version, a slot, a multi-line or an oversized entry is accepted; seam=validatePolicy
|
||||
test('stamp_placeholders: empty by default, needs its floor, and admits only short literal placeholders (GST-1026)', () => {
|
||||
const codes = (p: unknown) => validatePolicy(p).map(e => `${e.code}:${e.path}`);
|
||||
const at = (stamp_placeholders: unknown) => codes({ stamp_placeholders, min_gstack_version: STAMP_PLACEHOLDERS_KEY_VERSION });
|
||||
expect(DEFAULT_POLICY.stamp_placeholders).toEqual([]);
|
||||
expect(POLICY_KEY_INTRODUCED.stamp_placeholders).toBe(STAMP_PLACEHOLDERS_KEY_VERSION);
|
||||
expect(codes({ stamp_placeholders: [] })).toEqual([]);
|
||||
expect(codes({ stamp_placeholders: ['TBD'] })).toEqual(['POLICY_INVALID:stamp_placeholders']);
|
||||
expect(minimumReader({ stamp_placeholders: ['TBD'] })).toBe(STAMP_PLACEHOLDERS_KEY_VERSION);
|
||||
expect(at(['TBD', 'UNRELEASED'])).toEqual([]);
|
||||
expect(at('TBD')).toEqual(['POLICY_INVALID:stamp_placeholders']);
|
||||
expect(at([7])).toEqual(['POLICY_INVALID:stamp_placeholders[0]']);
|
||||
expect(at(Array.from({ length: 17 }, (_, i) => `P${i}`))).toEqual(['POLICY_CONTAINMENT:stamp_placeholders']);
|
||||
expect(at(['x'.repeat(33)])).toEqual(['POLICY_CONTAINMENT:stamp_placeholders[0]']);
|
||||
expect(at(['x'.repeat(32)])).toEqual([]);
|
||||
expect(at(['TB\nD'])).toEqual(['POLICY_CONTAINMENT:stamp_placeholders[0]']);
|
||||
expect(at([''])).toEqual(['POLICY_CONTAINMENT:stamp_placeholders[0]']);
|
||||
expect(at([' '])).toEqual(['POLICY_CONTAINMENT:stamp_placeholders[0]']);
|
||||
expect(at(['{version}'])).toEqual(['POLICY_INVALID:stamp_placeholders[0]']);
|
||||
expect(at(['next-{version}'])).toEqual(['POLICY_INVALID:stamp_placeholders[0]']);
|
||||
expect(at(['1.2.3.0'])).toEqual(['POLICY_INVALID:stamp_placeholders[0]']);
|
||||
expect(at(['0.60.553'])).toEqual(['POLICY_INVALID:stamp_placeholders[0]']);
|
||||
});
|
||||
|
||||
// Value: protects=bump never is refused without its floor (an older gstack names the cause) and next to any policy that stamps a version at every merge, reports its reader floor, and prints its Step 12 consequence; fails_when=never validates bare, or beside stamp-at-merge, train or changelog_fragments;
|
||||
// why_new=GST-380 added the value to an older key, which the per-key floor check skips; seam=validatePolicy, minimumReader, shipConsequences; no_claim=Step 12's behavior (test/ship-release.test.ts)
|
||||
test('bump never needs its floor and rules out every policy that stamps at merge', () => {
|
||||
|
||||
@@ -53,6 +53,25 @@ describe('E18: a repository without a version source or CHANGELOG', () => {
|
||||
expect(JSON.parse(fs.readFileSync(path.join(out, 'ship-run.json'), 'utf8')).version).toBe('none');
|
||||
});
|
||||
|
||||
// Value: protects=a receipt write --version named in a repository with no version source keeps that version through a
|
||||
// carry (GST-1030 review); fails_when=the carry refuses because HEAD has no readable version, or drops the version;
|
||||
// seam=bin/gstack-ship-receipt cmdCarry
|
||||
test('a carry keeps the version write --version named when HEAD has no version source (GST-1030)', () => {
|
||||
const repo = fixture();
|
||||
for (const f of ['VERSION', 'package.json', 'package-lock.json']) fs.rmSync(path.join(repo.work, f));
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'changelog only'); repo.git('push', '-q', 'origin', 'main');
|
||||
makePr(repo, 'pr-unversioned');
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
fs.writeFileSync(body, '## Summary\n');
|
||||
expect(runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '3', '--base', 'main', '--session-kind', 'x', '--version', '2.0.0', '--body', body]).status).toBe(EXIT.ok);
|
||||
fs.mkdirSync(path.join(repo.work, 'test'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo.work, 'test', 'a.test.ts'), 'test a\n');
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'a test-only change');
|
||||
const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
|
||||
expect(carried.out, carried.err).toContain('CARRY: carried');
|
||||
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt?.version).toBe('2.0.0');
|
||||
});
|
||||
|
||||
test('a repository with a version source keeps its version, and one with only a CHANGELOG records no version rather than "none"', () => {
|
||||
const repo = fixture();
|
||||
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--base', 'main', '--session-kind', 'x', '--json']);
|
||||
@@ -248,6 +267,25 @@ describe('carry follow-ups: git floor, named refusal, branch-touched walk, digit
|
||||
expect(stampOnlyText({ before, after: lines(key('1.2.3.0'), key('1.2.4.0')), oldVersion: '1.2.4.0', newVersion: '1.2.4.0', templates })).toBe(false);
|
||||
});
|
||||
|
||||
// Value: protects=the carry classifier reads a declared placeholder moved to the new version on the branch's own row
|
||||
// as a stamp, through the same predicate as finish and the tree receipt (GST-1026); fails_when=stampOnlyText ignores
|
||||
// the policy's placeholders, or accepts an undeclared one or a base row; seam=lib/ship-receipt.ts stampOnlyText
|
||||
test('stampOnlyText reads a declared placeholder moved on the branch\'s own row as a stamp, and nothing else (GST-1026)', () => {
|
||||
const templates = ["since: '{version}'"];
|
||||
const row = (v: string, id: string) => ` { since: '${v}', id: '${id}' },`;
|
||||
const text = (...rows: string[]) => ['export const ROWS = [', ...rows, '];', ''].join('\n');
|
||||
const before = text(row('TBD', 'base'), row('TBD', 'mine'));
|
||||
const side = { fork: '0.60.552.0', added: new Set([row('TBD', 'mine')]), at: new Set([3]) };
|
||||
const change = (after: string, extra: Record<string, unknown> = {}) => stampOnlyText({ before, after, oldVersion: '0.60.552.0', newVersion: '0.60.553.0', templates, side, placeholders: ['TBD'], ...extra });
|
||||
expect(change(text(row('TBD', 'base'), row('0.60.553.0', 'mine')))).toBe(true);
|
||||
expect(change(text(row('TBD', 'base'), row('0.60.553.0', 'mine')), { oldVersion: '0.60.553.0' })).toBe(true);
|
||||
expect(change(text(row('TBD', 'base'), row('0.60.553.0', 'mine')), { placeholders: [] })).toBe(false);
|
||||
expect(change(text(row('TBD', 'base'), row('0.60.553.0', 'mine')), { placeholders: ['WIP'] })).toBe(false);
|
||||
expect(change(text(row('TBD', 'base'), row('0.60.553.0', 'mine')), { templates: [] })).toBe(false);
|
||||
expect(change(text(row('TBD', 'base'), row('0.60.553.0', 'mine')), { side: undefined })).toBe(false);
|
||||
expect(change(text(row('0.60.553.0', 'base'), row('TBD', 'mine')))).toBe(false);
|
||||
});
|
||||
|
||||
test('planCarry names at most CARRY_NAMED_PATHS paths', () => {
|
||||
const many = Array.from({ length: CARRY_NAMED_PATHS + 3 }, (_, i) => `src/f${i}.ts`);
|
||||
const plan = planCarry({ from: 'a'.repeat(40), head: 'b'.repeat(40), changed: many.map(p => ({ path: p, class: 'product' as const })), productPatchId: { from: 'x', head: 'y' }, testPatchId: { from: '', head: '' }, changedProduct: many, mergeCommits: 0, rangeDiff: '' });
|
||||
|
||||
@@ -216,14 +216,14 @@ describe('gstack-upgrade', () => {
|
||||
// every session's lease names capy-server and the caller's own-ancestry
|
||||
// exclusion used to drop a sibling's fresh lease: a hand-run upgrade moved the checkout.
|
||||
describe('a lease held by a shared harness (capy-server) ancestor', () => {
|
||||
const underHarness = (f: ReturnType<typeof fixture>, harness: string, ageMinutes: number, extra: Record<string, string> = {}) => {
|
||||
const underHarness = (f: ReturnType<typeof fixture>, harness: string, ageMinutes: number, extra: Record<string, string> = {}, lease = 'bash "$1" touch --root "$2" --pid $$ || exit 9') => {
|
||||
const bin = path.join(f.host.base, 'harness');
|
||||
fs.mkdirSync(bin, { recursive: true });
|
||||
const exe = path.join(bin, harness);
|
||||
if (!fs.existsSync(exe)) fs.copyFileSync(fs.realpathSync(spawnSync('bash', ['-c', 'command -v bash'], { encoding: 'utf8', timeout: 30_000 }).stdout.trim()), exe);
|
||||
fs.chmodSync(exe, 0o755);
|
||||
const script = [
|
||||
'bash "$1" touch --root "$2" --pid $$ || exit 9',
|
||||
lease,
|
||||
'[ "$4" -gt 0 ] && "$5" -e "const t = Date.now() / 1000 - Number(process.argv[2]) * 60; require(\'fs\').utimesSync(process.argv[1], t, t)" "${GSTACK_LEASE_DIR:-$(git -C "$2" rev-parse --absolute-git-dir)/gstack-leases}/$$" "$4"',
|
||||
'bash "$3"',
|
||||
'echo "exit=$?"',
|
||||
@@ -242,6 +242,21 @@ describe('gstack-upgrade', () => {
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
||||
});
|
||||
|
||||
// GST-1205: a finished /ship run's lease used to block the upgrade for 120 minutes.
|
||||
test('a lease whose runs all finished (gstack-lease release) no longer blocks; a sibling run still open on the same harness does', () => {
|
||||
const touch = (id: string) => `bash "$1" touch --root "$2" --pid $$ --run "$$-${id}"`;
|
||||
const release = (id: string) => `bash "$1" release --root "$2" --pid $$ --run "$$-${id}"`;
|
||||
const f = fixture();
|
||||
const open = underHarness(f, 'capy-server', 0, {}, `${touch('1-a')} && ${touch('2-b')} && ${release('1-a')} || exit 9`);
|
||||
expect(open.stdout, open.stdout + open.stderr).toContain('exit=3');
|
||||
expect(open.stderr).toContain('(UPGRADE_BUSY)');
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
|
||||
const finished = underHarness(f, 'capy-server', 0, {}, `${touch('1-a')} && ${release('1-a')} || exit 9`);
|
||||
expect(finished.stdout, finished.stdout + finished.stderr).toContain('exit=0');
|
||||
expect(finished.stderr).not.toContain('UPGRADE_BUSY');
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
||||
});
|
||||
|
||||
test('once that lease is 120 minutes old the upgrade proceeds', () => {
|
||||
const f = fixture();
|
||||
const r = underHarness(f, 'capy-server', 121);
|
||||
|
||||
@@ -248,7 +248,7 @@ describe('GSTA-135: registered skills get used', () => {
|
||||
}
|
||||
const oc = register(opts(d, 'openclaw', { mode: 'all', skillsDir: path.join(d, 'oc') }));
|
||||
const review = fs.readFileSync(path.join(oc.dest, 'gstack-review', 'SKILL.md'), 'utf8');
|
||||
expect(descriptionOf(review)[0]).not.toMatch(/^Use /);
|
||||
expect(descriptionOf(review)[0]).not.toMatch(/^Use for: /);
|
||||
}, 120_000);
|
||||
|
||||
test('OpenClaw hybrid is the default: the task skills listed directly, the router for the rest; mode switches refresh and uninstall cleanly', () => {
|
||||
|
||||
@@ -38,6 +38,13 @@ case "$1 $2" in
|
||||
hooks=""; [ -d "$d/extensions/gstack-start" ] && [ "$acc" = true ] && [ "$en" != false ] && hooks='{"name":"before_prompt_build"}'
|
||||
printf '{"plugin":{"status":"loaded","enabled":%s},"typedHooks":[%s]}\n' "$([ "$en" = false ] && echo false || echo true)" "$hooks" ;;
|
||||
esac
|
||||
case "$1 $2" in "config set"|"config unset")
|
||||
j="$d/openclaw.json"; b="$d/fake-base.json"
|
||||
[ -f "$b" ] || { if [ -f "$j" ]; then cp "$j" "$b"; else printf '{"meta":{"lastTouchedVersion":"fake"}}' > "$b"; fi; }
|
||||
acc=false; grep -q '^plugins.entries.gstack-start.hooks.allowConversationAccess=true$' "$c" && acc=true
|
||||
en=$(grep '^plugins.entries.gstack-start.enabled=' "$c" | tail -1 | cut -d= -f2); enj=null; [ -n "$en" ] && enj=$en
|
||||
if grep -q '^plugins.entries.gstack-start' "$c"; then printf '%s,"plugins":{"entries":{"gstack-start":{"enabled":%s,"hooks":{"allowConversationAccess":%s}}}}}' "$(sed 's/}$//' "$b")" "$enj" "$acc" > "$j"; else cp "$b" "$j"; fi ;;
|
||||
esac
|
||||
`;
|
||||
const FAKE_HERMES = String.raw`#!/bin/sh
|
||||
d="$HERMES_HOME"
|
||||
@@ -48,10 +55,19 @@ case "$1 $2" in
|
||||
"plugins remove") rm -rf "$d/plugins/$3" "$d/fake-state" ;;
|
||||
"plugins list") if [ -d "$d/plugins/gstack-start" ]; then printf '[{"name":"gstack-start","status":"%s"}]\n' "$(cat "$d/fake-state" 2>/dev/null || echo 'not enabled')"; else echo '[]'; fi ;;
|
||||
esac
|
||||
case "$1 $2" in "plugins enable"|"plugins disable"|"plugins remove")
|
||||
y="$d/config.yaml"; b="$d/fake-base.yaml"
|
||||
[ -f "$b" ] || { if [ -f "$y" ]; then cp "$y" "$b"; else : > "$b"; fi; }
|
||||
s=$(cat "$d/fake-state" 2>/dev/null)
|
||||
{ cat "$b"; [ -z "$s" ] || printf 'plugins:\n %s:\n - gstack-start\n' "$s"; } > "$y.t"; mv "$y.t" "$y" ;;
|
||||
esac
|
||||
`;
|
||||
/** A configured harness: what each harness's own setup leaves (OpenClaw onboarding, hermes setup) before gstack runs. */
|
||||
const HEALTHY = { openclaw: '{"agents":{"defaults":{"model":{"primary":"anthropic/claude-opus-5-5"}}},"gateway":{"mode":"local"}}', hermes: 'model:\n default: claude-opus-5-5\n provider: anthropic\nterminal:\n backend: local\n' };
|
||||
const CONFIG = { openclaw: 'openclaw.json', hermes: 'config.yaml' };
|
||||
|
||||
/** A temp home, state root and profile, with fake openclaw and hermes CLIs first on PATH. */
|
||||
function rig(target: 'openclaw' | 'hermes', extra: { noCli?: boolean; env?: Record<string, string> } = {}) {
|
||||
function rig(target: 'openclaw' | 'hermes', extra: { noCli?: boolean; fresh?: boolean; env?: Record<string, string> } = {}) {
|
||||
const d = tmp();
|
||||
const bin = path.join(d, 'bin');
|
||||
fs.mkdirSync(bin);
|
||||
@@ -59,11 +75,13 @@ function rig(target: 'openclaw' | 'hermes', extra: { noCli?: boolean; env?: Reco
|
||||
fs.writeFileSync(path.join(bin, 'hermes'), FAKE_HERMES, { mode: 0o755 });
|
||||
const profile = path.join(d, target === 'openclaw' ? 'oc' : 'he');
|
||||
fs.mkdirSync(profile, { recursive: true });
|
||||
const config = path.join(profile, CONFIG[target]);
|
||||
if (!extra.fresh) fs.writeFileSync(config, HEALTHY[target]);
|
||||
const env = { PATH: (extra.noCli ? '' : bin + path.delimiter) + '/usr/bin:/bin', ANTHROPIC_API_KEY: 'sekret', [target === 'openclaw' ? 'OPENCLAW_STATE_DIR' : 'HERMES_HOME']: profile, ...extra.env };
|
||||
const o = (more: Partial<RegisterOptions> = {}): RegisterOptions => ({ target: TARGETS[target], checkout: ROOT, stateRoot: path.join(d, 'state'), env, home: path.join(d, 'home'), version: VERSION, ...more });
|
||||
const root = path.join(profile, target === 'openclaw' ? 'extensions' : 'plugins', HOOK_ID);
|
||||
const calls = () => fs.existsSync(path.join(profile, 'fake-calls')) ? fs.readFileSync(path.join(profile, 'fake-calls'), 'utf8').trim().split('\n') : [];
|
||||
return { d, bin, profile, env, o, root, calls };
|
||||
return { d, bin, profile, config, env, o, root, calls };
|
||||
}
|
||||
|
||||
describe('start hook: the note and the plugin files', () => {
|
||||
@@ -486,7 +504,140 @@ describe('start hook: teardown keeps what gstack did not write (fake CLI)', () =
|
||||
}, 300_000);
|
||||
});
|
||||
|
||||
describe('start hook: HARN-18 smoke defects (GST-778)', () => {
|
||||
const offPath = (r: { env: Record<string, string> }) => ({ ...r.env, PATH: '/usr/bin:/bin' });
|
||||
const cli = (r: { bin: string; env: Record<string, string> }, t: 'openclaw' | 'hermes', ...args: string[]) => spawnSync(path.join(r.bin, t), args, { env: r.env, encoding: 'utf8', timeout: 30_000 });
|
||||
|
||||
test('OPENCLAW_CONFIG_PATH naming the profile own openclaw.json, even through a symlink, keeps the hook', () => {
|
||||
const r = rig('openclaw');
|
||||
const link = path.join(tmp(), 'ln');
|
||||
fs.symlinkSync(r.profile, link);
|
||||
const viaEnv = register({ ...r.o(), env: { ...r.env, GSTACK_HARNESS_START_HOOK: '1', OPENCLAW_CONFIG_PATH: path.join(link, 'openclaw.json') } });
|
||||
expect(viaEnv.hook).toMatchObject({ state: 'active', required: true });
|
||||
const s = rig('openclaw');
|
||||
expect(register({ ...s.o({ startHook: true }), env: { ...s.env, OPENCLAW_CONFIG_PATH: s.config } }).hook?.state).toBe('active');
|
||||
}, 120_000);
|
||||
|
||||
test('a foreign OPENCLAW_CONFIG_PATH still turns an env opt-in off, and says so as a failure with the fix', () => {
|
||||
const r = rig('openclaw');
|
||||
const res = register({ ...r.o(), env: { ...r.env, GSTACK_HARNESS_START_HOOK: '1', OPENCLAW_CONFIG_PATH: path.join(r.d, 'elsewhere', 'openclaw.json') } });
|
||||
expect(res.hook).toMatchObject({ state: 'off', required: true });
|
||||
expect(res.hook?.detail).toContain('OPENCLAW_CONFIG_PATH points OpenClaw at ' + path.join(r.d, 'elsewhere', 'openclaw.json'));
|
||||
expect(res.hook?.fix).toContain('unset OPENCLAW_CONFIG_PATH or set it to ' + r.config);
|
||||
expect(r.calls()).toEqual([]);
|
||||
expect(fs.existsSync(path.join(r.profile, 'extensions'))).toBe(false);
|
||||
expect(readLedger(res.ledger)!.meta.start_hook).toBe('off');
|
||||
expect(fs.readFileSync(r.config, 'utf8')).toBe(HEALTHY.openclaw);
|
||||
const custom = register({ ...r.o({ skillsDir: path.join(r.d, 'custom', 'skills') }), env: { ...r.env, GSTACK_HARNESS_START_HOOK: '1' } });
|
||||
expect(custom.hook).toMatchObject({ state: 'off', required: true });
|
||||
expect(custom.hook?.fix).toContain('--no-start-hook');
|
||||
expect(register({ ...r.o({ skillsDir: path.join(r.d, 'custom', 'skills'), startHook: false }), env: { ...r.env, GSTACK_HARNESS_START_HOOK: '1' } }).hook).toEqual({ state: 'off' });
|
||||
}, 120_000);
|
||||
|
||||
test('a fresh harness with no config: turning the hook on creates no config, runs no harness command, and fails with the setup to run first', () => {
|
||||
for (const t of ['openclaw', 'hermes'] as const) {
|
||||
const r = rig(t, { fresh: true });
|
||||
const res = register(r.o({ startHook: true }));
|
||||
expect(res.hook).toMatchObject({ state: 'written', required: true });
|
||||
expect(res.hook?.detail).toContain('no ' + (t === 'openclaw' ? 'OpenClaw' : 'Hermes') + ' config at ' + r.config);
|
||||
expect(res.hook?.fix).toContain(t === 'openclaw' ? 'openclaw onboard' : 'hermes setup');
|
||||
expect(fs.existsSync(r.config)).toBe(false);
|
||||
expect(r.calls()).toEqual([]);
|
||||
expect(fs.existsSync(path.join(r.profile, 'skills'))).toBe(true);
|
||||
expect(readLedger(res.ledger)!.meta.hook_active).toBe('no');
|
||||
const bare = rig(t, { fresh: true, noCli: true });
|
||||
const noCli = register(bare.o({ startHook: true })).hook;
|
||||
expect(noCli).toMatchObject({ state: 'written', required: true });
|
||||
expect(noCli?.detail).toContain(t + ' is not on PATH');
|
||||
expect(noCli?.fix).toContain(t === 'openclaw' ? 'openclaw onboard' : 'hermes setup');
|
||||
expect(noCli?.fix).not.toContain(t === 'openclaw' ? 'config set' : 'plugins enable');
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
test('once the harness setup has written its config, the next refresh turns the hook on and changes only the plugin entry', () => {
|
||||
const o = rig('openclaw', { fresh: true });
|
||||
register(o.o({ startHook: true }));
|
||||
fs.writeFileSync(o.config, HEALTHY.openclaw);
|
||||
expect(register(o.o()).hook).toMatchObject({ state: 'active', retried: true });
|
||||
const json = JSON.parse(fs.readFileSync(o.config, 'utf8'));
|
||||
expect(json.plugins).toEqual({ entries: { [HOOK_ID]: { enabled: true, hooks: { allowConversationAccess: true } } } });
|
||||
delete json.plugins;
|
||||
expect(json).toEqual(JSON.parse(HEALTHY.openclaw));
|
||||
expect(o.calls().filter(l => l.startsWith('config set')).map(l => l.split('|')[0])).toEqual(['config set plugins.entries.gstack-start.hooks.allowConversationAccess true', 'config set plugins.entries.gstack-start.enabled true']);
|
||||
|
||||
const h = rig('hermes', { fresh: true });
|
||||
register(h.o({ startHook: true }));
|
||||
fs.writeFileSync(h.config, HEALTHY.hermes);
|
||||
expect(register(h.o()).hook).toMatchObject({ state: 'active', retried: true });
|
||||
expect(fs.readFileSync(h.config, 'utf8')).toBe(HEALTHY.hermes + 'plugins:\n enabled:\n - gstack-start\n');
|
||||
}, 300_000);
|
||||
|
||||
test('turning off or unregistering a hook that never turned on, in a harness with no config, creates no config', () => {
|
||||
for (const t of ['openclaw', 'hermes'] as const) {
|
||||
const r = rig(t, { fresh: true });
|
||||
register(r.o({ startHook: true }));
|
||||
if (t === 'openclaw') expect(register(r.o({ startHook: false })).hook?.state).toBe('off');
|
||||
else unregister(r.o().stateRoot, ROOT, 'hermes', r.env);
|
||||
expect(fs.existsSync(r.config)).toBe(false);
|
||||
expect(r.calls()).toEqual([]);
|
||||
expect(fs.existsSync(r.root)).toBe(false);
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
test('Hermes status without hermes on PATH (its own terminal tool) reads the hook from config.yaml', () => {
|
||||
const r = rig('hermes');
|
||||
expect(register(r.o({ startHook: true })).hook?.state).toBe('active');
|
||||
const row = () => status(r.o().stateRoot, VERSION, offPath(r)).find(s => s.target === 'hermes')!.hook!;
|
||||
expect(row()).toMatchObject({ state: 'active' });
|
||||
expect(row().detail).toContain('hermes is not on PATH');
|
||||
cli(r, 'hermes', 'plugins', 'disable', HOOK_ID);
|
||||
expect(row().state).toBe('disabled');
|
||||
fs.writeFileSync(r.config, HEALTHY.hermes);
|
||||
expect(row().state).toBe('written');
|
||||
fs.writeFileSync(r.config, 'plugins: [unclosed\n');
|
||||
expect(row()).toMatchObject({ state: 'unknown' });
|
||||
expect(row().detail).toContain('hermes is not on PATH');
|
||||
}, 120_000);
|
||||
|
||||
test('OpenClaw status without openclaw on PATH is unknown when openclaw.json turns the hook on, disabled when the user turned it off', () => {
|
||||
const r = rig('openclaw');
|
||||
expect(register(r.o({ startHook: true })).hook?.state).toBe('active');
|
||||
const row = () => status(r.o().stateRoot, VERSION, offPath(r)).find(s => s.target === 'openclaw')!.hook!;
|
||||
expect(row()).toMatchObject({ state: 'unknown' });
|
||||
expect(row().detail).toContain('openclaw is not on PATH');
|
||||
cli(r, 'openclaw', 'config', 'set', 'plugins.entries.gstack-start.enabled', 'false');
|
||||
expect(row().state).toBe('disabled');
|
||||
expect(status(r.o().stateRoot, VERSION, r.env).find(s => s.target === 'openclaw')!.hook!.state).toBe('disabled');
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('start hook through ./setup --host', () => {
|
||||
test('OPENCLAW_CONFIG_PATH naming the profile own config keeps an env opt-in on; a foreign one exits 4 with the hook off', () => {
|
||||
const f = makeFixture();
|
||||
const src = makeSource(f, path.join(f.dir, 'gstack'));
|
||||
const bin = path.join(f.dir, 'fakebin');
|
||||
fs.mkdirSync(bin);
|
||||
fs.writeFileSync(path.join(bin, 'openclaw'), FAKE_OPENCLAW, { mode: 0o755 });
|
||||
const profile = path.join(f.home, '.openclaw');
|
||||
fs.mkdirSync(profile, { recursive: true });
|
||||
fs.writeFileSync(path.join(profile, 'openclaw.json'), HEALTHY.openclaw);
|
||||
const PATH = bin + path.delimiter + f.env.PATH;
|
||||
const own = runSetup(f, path.join(src, 'setup'), ['--host', 'openclaw'], { env: { PATH, GSTACK_HARNESS_START_HOOK: '1', OPENCLAW_CONFIG_PATH: path.join(profile, 'openclaw.json') } });
|
||||
expect(own.status, own.stdout + own.stderr).toBe(0);
|
||||
expect(own.stdout).toContain('start hook: active');
|
||||
const g = makeFixture();
|
||||
const gsrc = makeSource(g, path.join(g.dir, 'gstack'));
|
||||
fs.mkdirSync(path.join(g.home, '.openclaw'), { recursive: true });
|
||||
fs.writeFileSync(path.join(g.home, '.openclaw', 'openclaw.json'), HEALTHY.openclaw);
|
||||
const foreign = runSetup(g, path.join(gsrc, 'setup'), ['--host', 'openclaw'], { env: { PATH: bin + path.delimiter + g.env.PATH, GSTACK_HARNESS_START_HOOK: '1', OPENCLAW_CONFIG_PATH: path.join(g.dir, 'other', 'openclaw.json') } });
|
||||
expect(foreign.status, foreign.stdout + foreign.stderr).toBe(4);
|
||||
expect(foreign.stdout).toContain('start hook: off');
|
||||
expect(foreign.stderr).toContain('(HARNESS_HOOK_INACTIVE; docs/troubleshooting.md#harness-hook-inactive)');
|
||||
expect(foreign.stderr).toContain('OPENCLAW_CONFIG_PATH');
|
||||
expect(fs.existsSync(path.join(g.home, '.openclaw', 'skills', 'gstack', 'SKILL.md'))).toBe(true);
|
||||
}, 300_000);
|
||||
|
||||
test('exits 4 with the skills registered when the hook does not turn on, and the next run retries it', () => {
|
||||
const f = makeFixture();
|
||||
const src = makeSource(f, path.join(f.dir, 'gstack'));
|
||||
@@ -495,6 +646,7 @@ describe.skipIf(process.platform === 'win32')('start hook through ./setup --host
|
||||
fs.writeFileSync(path.join(bin, 'openclaw'), FAKE_OPENCLAW, { mode: 0o755 });
|
||||
const profile = path.join(f.home, '.openclaw');
|
||||
fs.mkdirSync(profile, { recursive: true });
|
||||
fs.writeFileSync(path.join(profile, 'openclaw.json'), HEALTHY.openclaw);
|
||||
fs.writeFileSync(path.join(profile, 'fake-fail'), '');
|
||||
const PATH = bin + path.delimiter + f.env.PATH;
|
||||
const first = runSetup(f, path.join(src, 'setup'), ['--host', 'openclaw'], { env: { PATH, GSTACK_HARNESS_START_HOOK: '1' } });
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as path from 'node:path';
|
||||
import { decide, formatDrift, harnessDrift, hasDrift, MANIFEST, readManifest, serializeManifest, workingHarnessFiles,
|
||||
import { decide, formatDrift, harnessDrift, hasDrift, MANIFEST, PINS_DIR, readManifest, workingHarnessFiles, writeManifest,
|
||||
type HarnessManifest } from '../scripts/bump-harness-version';
|
||||
import { caseSeriesIdentitiesV2, globRegex, HARNESS_PATTERNS, HARNESS_VERSION, isCaseOwnedFile, skillDirs, stampTrialSeries, treeEntries,
|
||||
type TreeEntry } from '../scripts/eval-trial-series';
|
||||
@@ -18,12 +18,14 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import { LIVE_REGISTRY, type Registry } from '../scripts/eval-flake-rank';
|
||||
import { matchGlob } from './helpers/test-selection';
|
||||
import { mergePinEdits, type PinMerge } from './helpers/pin-merge-fixture';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
|
||||
describe('HARNESS_VERSION guard', () => {
|
||||
test('every pinned shared-harness file matches scripts/harness-version.json', () => {
|
||||
expect(MANIFEST).toBe('scripts/harness-version.json');
|
||||
expect(PINS_DIR).toBe('scripts/harness-pins');
|
||||
const manifest = readManifest(ROOT);
|
||||
expect(manifest.version).toBe(HARNESS_VERSION);
|
||||
expect(manifest.patterns).toEqual([...HARNESS_PATTERNS]);
|
||||
@@ -54,10 +56,107 @@ describe('HARNESS_VERSION guard', () => {
|
||||
expect(() => decide(base, now, 'bump', 'short')).toThrow('reason');
|
||||
});
|
||||
|
||||
test('the manifest serializes one entry per line, sorted, as valid JSON', () => {
|
||||
const text = serializeManifest({ ...base, files: { 'z.ts': { blob: 'z', since: 1 }, 'a.ts': { blob: 'a', since: 1 } } });
|
||||
expect(JSON.parse(text).files).toEqual({ 'a.ts': { blob: 'a', since: 1 }, 'z.ts': { blob: 'z', since: 1 } });
|
||||
expect(text.split('\n').filter(line => line.includes('"blob"'))).toEqual([' "a.ts": {"blob":"a","since":1},', ' "z.ts": {"blob":"z","since":1}']);
|
||||
const copyManifest = (into: string) => {
|
||||
fs.cpSync(path.join(ROOT, MANIFEST), path.join(into, MANIFEST));
|
||||
fs.cpSync(path.join(ROOT, PINS_DIR), path.join(into, PINS_DIR), { recursive: true });
|
||||
};
|
||||
|
||||
test('the manifest writes one pin file per harness file under scripts/harness-pins and reads back unchanged (GST-1084)', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-pins-'));
|
||||
try {
|
||||
copyManifest(dir);
|
||||
expect(readManifest(dir)).toEqual(readManifest(ROOT));
|
||||
const next: HarnessManifest = { version: 3, patterns: ['test/helpers/x/**'], files: { 'z.ts': { blob: 'z', since: 1 }, 'test/a.ts': { blob: 'a', since: 1, nonBehavioral: 'why' } } };
|
||||
writeManifest(dir, next);
|
||||
expect(readManifest(dir)).toEqual(next);
|
||||
expect(fs.readFileSync(path.join(dir, MANIFEST), 'utf8')).toBe('{\n "version": 3,\n "patterns": [\n "test/helpers/x/**"\n ]\n}\n');
|
||||
expect(fs.readFileSync(path.join(dir, PINS_DIR, 'test/a.ts.json'), 'utf8')).toBe('{"blob":"a","since":1,"nonBehavioral":"why"}\n');
|
||||
expect(fs.existsSync(path.join(dir, PINS_DIR, 'test/helpers/eval-store.ts.json'))).toBe(false);
|
||||
fs.writeFileSync(path.join(dir, PINS_DIR, 'z.ts.txt'), 'stray\n');
|
||||
expect(() => readManifest(dir)).toThrow('every harness pin is <pinned path>.json');
|
||||
fs.rmSync(path.join(dir, PINS_DIR, 'z.ts.txt'));
|
||||
fs.writeFileSync(path.join(dir, PINS_DIR, 'z.ts.json'), '<<<<<<< ours\n');
|
||||
expect(() => readManifest(dir)).toThrow(`${PINS_DIR}/z.ts.json: `);
|
||||
fs.writeFileSync(path.join(dir, PINS_DIR, 'z.ts.json'), '{"blob":"z","since":1}\n');
|
||||
fs.writeFileSync(path.join(dir, MANIFEST), JSON.stringify({ version: 3, patterns: [], files: {} }));
|
||||
expect(() => readManifest(dir)).toThrow('holds only version and patterns');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Value: protects=the one-file-per-pin layout still fails on every stale pin; fails_when=a pin with an old blob, a deleted pin
|
||||
// or a pin for a file that is gone passes the guard; seam=readManifest and harnessDrift on the checkout's harness files
|
||||
test('a stale, missing or orphaned harness pin fails the guard (GST-1084)', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-stale-'));
|
||||
try {
|
||||
copyManifest(dir);
|
||||
const live = readManifest(ROOT);
|
||||
const current = workingHarnessFiles(ROOT, live.patterns);
|
||||
const pin = (file: string) => path.join(dir, PINS_DIR, `${file}.json`);
|
||||
fs.writeFileSync(pin('test/helpers/eval-store.ts'), `${JSON.stringify({ ...live.files['test/helpers/eval-store.ts'], blob: '0'.repeat(40) })}\n`);
|
||||
fs.rmSync(pin('test/helpers/e2e-gate.ts'));
|
||||
fs.writeFileSync(pin('test/helpers/gone.ts'), `${JSON.stringify({ blob: '1'.repeat(40), since: live.version })}\n`);
|
||||
const drift = harnessDrift(readManifest(dir), current);
|
||||
expect(drift).toEqual({ changed: ['test/helpers/eval-store.ts'], added: ['test/helpers/e2e-gate.ts'], removed: ['test/helpers/gone.ts'] });
|
||||
expect(formatDrift(drift, live.version)).toContain(`Merge conflict in ${MANIFEST} or ${PINS_DIR}/: take the higher version, then rerun the script.`);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Value: protects=a --bump that fails partway (pins written, manifest version not) cannot pass the guard with behavior pooled into the
|
||||
// old series; fails_when=a pin ahead of the manifest version reads as no drift; seam=writeManifest failing on a real write, then harnessDrift
|
||||
test('a bump that fails partway leaves a pin ahead of the manifest version, and the guard reports it (GST-1084)', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-partial-'));
|
||||
try {
|
||||
copyManifest(dir);
|
||||
const live = readManifest(dir);
|
||||
const current = workingHarnessFiles(ROOT, live.patterns);
|
||||
const [first, second] = current.map(entry => entry.file);
|
||||
const blocked = path.join(dir, PINS_DIR, `${second}.json`);
|
||||
const original = fs.readFileSync(blocked, 'utf8');
|
||||
fs.rmSync(blocked);
|
||||
fs.mkdirSync(blocked);
|
||||
expect(() => writeManifest(dir, decide(live, current, 'bump', 'behavior changed in the runner'))).toThrow();
|
||||
fs.rmdirSync(blocked);
|
||||
fs.writeFileSync(blocked, original);
|
||||
const after = readManifest(dir);
|
||||
expect(after.version).toBe(live.version);
|
||||
expect(after.files[first!]!.since).toBe(live.version + 1);
|
||||
expect(harnessDrift(after, current).changed).toEqual([first]);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Value: protects=PRs that record decisions for different harness files merge without a conflict (the one-line-per-file manifest
|
||||
// conflicted on adjacent lines and on the trailing comma); fails_when=two non-behavioral decisions on adjacent pins conflict in a
|
||||
// real merge, or two decisions on one pin merge silently; seam=writeManifest and git merge-tree --write-tree on two branches
|
||||
test('non-behavioral decisions on two different harness files merge cleanly in a real git merge (GST-1084)', () => {
|
||||
const live = readManifest(ROOT);
|
||||
const record = (file: string, blob: string, reason: string) => (root: string) => {
|
||||
const manifest = readManifest(root);
|
||||
const files = workingHarnessFiles(ROOT, live.patterns).map(entry => entry.file === file ? { file, blob } : entry);
|
||||
writeManifest(root, decide(manifest, files, 'non-behavioral', reason));
|
||||
};
|
||||
const floor = 'test/helpers/pty/runners/floor.ts', observation = 'test/helpers/pty/runners/observation.ts';
|
||||
const merges: PinMerge[] = [];
|
||||
try {
|
||||
const clean = mergePinEdits(copyManifest, record(floor, 'a'.repeat(40), 'floor runner logging only'), record(observation, 'b'.repeat(40), 'observation runner comment only'));
|
||||
merges.push(clean);
|
||||
const conflict = mergePinEdits(copyManifest, record(floor, 'a'.repeat(40), 'floor runner logging only'), record(floor, 'c'.repeat(40), 'floor runner rename only'));
|
||||
merges.push(conflict);
|
||||
expect(clean.output).not.toContain('CONFLICT');
|
||||
expect(clean.clean).toBe(true);
|
||||
expect(JSON.parse(clean.read(`${PINS_DIR}/${floor}.json`))).toEqual({ blob: 'a'.repeat(40), since: live.version, nonBehavioral: 'floor runner logging only' });
|
||||
expect(JSON.parse(clean.read(`${PINS_DIR}/${observation}.json`))).toEqual({ blob: 'b'.repeat(40), since: live.version, nonBehavioral: 'observation runner comment only' });
|
||||
expect(clean.read(MANIFEST)).toBe(fs.readFileSync(path.join(ROOT, MANIFEST), 'utf8'));
|
||||
expect(conflict.clean).toBe(false);
|
||||
expect(conflict.output).toContain(`CONFLICT (content): Merge conflict in ${PINS_DIR}/${floor}.json`);
|
||||
} finally {
|
||||
for (const merge of merges) fs.rmSync(merge.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -490,10 +490,10 @@ do not launch the downstream skill or open a browser.`,
|
||||
gateAfterStop: undefined, // operational multi-STOP skill, like ship
|
||||
},
|
||||
behavioral: 'plan',
|
||||
maxSkeletonBytes: 59_073, // GSTA-96 hoist (2026-10-26): lowered from 80_900 by the shared preamble that moved to sections/preamble.md, keeping the pre-hoist headroom of 319 bytes; measured 58,754. Before: Shared-code identity/skip/action rules + critical-severity validation; measured 74,493 (2026-09-17). + v1.91.12.0 merge of #2999 (review clarity repairs: await reads, research alongside dispatch, /review deadline and setup authority, findings sources) with #3002 (guarded state-root lines, plan-check checkpoints); each fit alone; measured 74,881 (2026-10-01). + Oct 8 wave: shared voice directive bans "load-bearing" and adds the reply-language rule, offset by tightening the bounded closer (net +17 bytes per tier-2+ skill); measured 74,884 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W2): Step 3.5 Diff scans with the shared GATE_SCAN_BLOCK, the Step 4 carry, the four-option `review-gate-disposition` question in 5c, gate record fields in 5.8 and the Gate edits report line; measured 80,054 (2026-10-10). + Oct 10 multi-agent wave PR D2 (sideways review): Step 4's enum-only paragraph becomes the sideways sweep with the forced-CRITICAL list, plus the flake-fix number sentence in the Gate Integrity paragraph; measured skeleton 80,812 (2026-10-10).
|
||||
maxSkeletonBytes: 60_014, // GST-1088 lane B (#3909, raised from 59,774 to measured, no headroom; owner ruling via GStack Minder 2026-11-03): CONTRACT_CHECKER_SHIPPED=true now that bin/gstack-skill-contract ships (#3912) renders the gstack-skill-contract check/waive sentence under the Done means list (renders/GSTA-256/plan-v3.md line 74, r2-d6 "Done-means list"), +240 bytes; lane C's diet is expected to bring /review back under 59,073; measured 60,014. Before: GST-1088 lane B (raised from 59,073 to measured, no headroom): the contract: frontmatter block and the F1 "Done means" list rendered from it at the top of the body, plus widened triggers; F1 is a gate-rev-2 recommendation on the plan Garry approved with "do it" on gate rev 1 (2026-11-03), contingent on his gate-rev-2 answer, and lane C's diet is expected to bring /review back under 59,073; measured 59,774. Before: GSTA-96 hoist (2026-10-26): lowered from 80_900 by the shared preamble that moved to sections/preamble.md, keeping the pre-hoist headroom of 319 bytes; measured 58,754. Before: Shared-code identity/skip/action rules + critical-severity validation; measured 74,493 (2026-09-17). + v1.91.12.0 merge of #2999 (review clarity repairs: await reads, research alongside dispatch, /review deadline and setup authority, findings sources) with #3002 (guarded state-root lines, plan-check checkpoints); each fit alone; measured 74,881 (2026-10-01). + Oct 8 wave: shared voice directive bans "load-bearing" and adds the reply-language rule, offset by tightening the bounded closer (net +17 bytes per tier-2+ skill); measured 74,884 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W2): Step 3.5 Diff scans with the shared GATE_SCAN_BLOCK, the Step 4 carry, the four-option `review-gate-disposition` question in 5c, gate record fields in 5.8 and the Gate edits report line; measured 80,054 (2026-10-10). + Oct 10 multi-agent wave PR D2 (sideways review): Step 4's enum-only paragraph becomes the sideways sweep with the forced-CRITICAL list, plus the flake-fix number sentence in the Gate Integrity paragraph; measured skeleton 80,812 (2026-10-10).
|
||||
minUnionBytes: 89_000, // Phase 4 wave 1; measured union 93,357
|
||||
mustContain: ['confidence', 'P1', 'P2', 'Review Army', 'adversarial'],
|
||||
maxSizeRatio: 1.3180, // + GSTA-167 variant G part 1 (plan approved by Garry 2026-10-30, autoplan-20261030161754 "Do it", which names about +0.3k prompt tokens per voice): the native adversarial and outside-voice prompts carry the blocking floor and the Severity and Blocks lines, +537 bytes; measured 1.3180 (2026-10-30). Before: 1.3136. GSTA-96 hoist merged with GSTA-92 phase 2 (2026-10-26): both owner-approved raises together, measured 142,550 / 108,523 = 1.3135. Before: Shared-code feature + critical-severity validation: 128,042 union bytes / 108,523 baseline = 1.1799; preserves content floors. + v1.91.12.0 merge of #2999 (above, plus plan-completion fallback intent and specialist checklist-by-path) with #3002; measured 1.1843 (2026-10-01). + v1.91.19.0 wave: verdict-form Codex callers with sandbox preflight (INV-1/B1), P0/P1 structured gate (B1b), bound-plan discovery and data-not-instructions guard (F1/D3), visible LEARNINGS failures (B5); measured 129,941 / 108,523 = 1.1974 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 130,025 / 108,523 = 1.1981 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051); measured 130,359 / 108,523 = 1.2012 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 131,314 / 108,523 = 1.2100 (2026-10-06). Combined Oct 6 wave head (A and B): measured 131,966 / 108,523 = 1.2160 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 132,199 / 108,523 = 1.2182 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 132,258 / 108,523 = 1.2187 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 132,233 / 108,523 = 1.2185 (2026-10-07). Merged with the guard area: measured 132,522 / 108,523 = 1.2211 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 133,902 / 108,523 = 1.2339 (2026-10-08). + gate integrity W2 (above); measured 1.282 (2026-10-10). + Oct 10 multi-agent wave PR D2 (sideways review): sideways sweep and flake-fix number in Step 4; `Diff read` and `Undeclared behavior changes` in the review-mode Scope Check (plan-completion section); measured 140,497 / 108,523 = 1.2946 (2026-10-10). + harness lane E (2026-10-26): the lead-judgment stage in the finding merge and the `cause` anchor in the specialist prompts, shared with /ship through scripts/resolvers/review-army.ts; measured 141,949 / 108,523 = 1.3080.
|
||||
maxSizeRatio: 1.3283, // + GST-1088 lane B (#3909, raised from 1.3261 to measured, no headroom; owner ruling via GStack Minder 2026-11-03): the gstack-skill-contract check/waive sentence that CONTRACT_CHECKER_SHIPPED=true renders (renders/GSTA-256/plan-v3.md line 74, r2-d6 "Done-means list"), +240 bytes; lane C's diet is expected to bring /review back under 1.3180; measured 144,147 / 108,523 = 1.32826, rounded up. Before: + GST-1088 lane B (raised from 1.3180 to measured, no headroom): the contract: frontmatter block and the F1 "Done means" list, contingent on Garry's gate-rev-2 answer; lane C's diet is expected to bring /review back under 1.3180; measured 143,907 / 108,523 = 1.3261. Before: + GSTA-167 variant G part 1 (plan approved by Garry 2026-10-30, autoplan-20261030161754 "Do it", which names about +0.3k prompt tokens per voice): the native adversarial and outside-voice prompts carry the blocking floor and the Severity and Blocks lines, +537 bytes; measured 1.3180 (2026-10-30). Before: 1.3136. GSTA-96 hoist merged with GSTA-92 phase 2 (2026-10-26): both owner-approved raises together, measured 142,550 / 108,523 = 1.3135. Before: Shared-code feature + critical-severity validation: 128,042 union bytes / 108,523 baseline = 1.1799; preserves content floors. + v1.91.12.0 merge of #2999 (above, plus plan-completion fallback intent and specialist checklist-by-path) with #3002; measured 1.1843 (2026-10-01). + v1.91.19.0 wave: verdict-form Codex callers with sandbox preflight (INV-1/B1), P0/P1 structured gate (B1b), bound-plan discovery and data-not-instructions guard (F1/D3), visible LEARNINGS failures (B5); measured 129,941 / 108,523 = 1.1974 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 130,025 / 108,523 = 1.1981 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051); measured 130,359 / 108,523 = 1.2012 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 131,314 / 108,523 = 1.2100 (2026-10-06). Combined Oct 6 wave head (A and B): measured 131,966 / 108,523 = 1.2160 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 132,199 / 108,523 = 1.2182 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 132,258 / 108,523 = 1.2187 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 132,233 / 108,523 = 1.2185 (2026-10-07). Merged with the guard area: measured 132,522 / 108,523 = 1.2211 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 133,902 / 108,523 = 1.2339 (2026-10-08). + gate integrity W2 (above); measured 1.282 (2026-10-10). + Oct 10 multi-agent wave PR D2 (sideways review): sideways sweep and flake-fix number in Step 4; `Diff read` and `Undeclared behavior changes` in the review-mode Scope Check (plan-completion section); measured 140,497 / 108,523 = 1.2946 (2026-10-10). + harness lane E (2026-10-26): the lead-judgment stage in the finding merge and the `cause` anchor in the specialist prompts, shared with /ship through scripts/resolvers/review-army.ts; measured 141,949 / 108,523 = 1.3080.
|
||||
},
|
||||
codex: {
|
||||
skill: 'codex',
|
||||
|
||||
@@ -7,8 +7,11 @@ import * as path from 'node:path';
|
||||
|
||||
export interface FakeWorld {
|
||||
runs: Array<Record<string, unknown> & { id: number; run_attempt: number; head_sha: string; path: string }>;
|
||||
/** Keyed `<run id>/<attempt>` for one attempt's jobs, else `<run id>` for every attempt. */
|
||||
jobs: Record<string, Array<{ name: string; conclusion: string | null; completed_at?: string }>>;
|
||||
trees: Record<string, Record<string, unknown>>;
|
||||
/** Once `marker` exists, the next run read writes `content` to `file` (a working-tree edit inside a run recheck). */
|
||||
editWhenArmed?: { marker: string; file: string; content: string };
|
||||
}
|
||||
|
||||
/** The stub body for shipFixture's gh(): run this file with the world beside it. */
|
||||
@@ -27,11 +30,13 @@ if (import.meta.main) {
|
||||
if ((m = /\/actions\/runs\/(\d+)$/.exec(route!))) {
|
||||
const run = world.runs.find(r => r.id === Number(m![1]));
|
||||
if (!run) { process.stderr.write('HTTP 404'); process.exit(1); }
|
||||
const e = world.editWhenArmed;
|
||||
if (e && fs.existsSync(e.marker)) fs.writeFileSync(e.file, e.content);
|
||||
process.stdout.write(JSON.stringify(run));
|
||||
} else if ((m = /\/actions\/workflows\/([^/]+)\/runs$/.exec(route!))) {
|
||||
lines(world.runs.filter(r => r.head_sha === q.get('head_sha') && r.path === `.github/workflows/${m![1]}`));
|
||||
} else if ((m = /\/actions\/runs\/(\d+)\/attempts\/\d+\/jobs$/.exec(route!))) {
|
||||
lines(world.jobs[m[1]!] ?? []);
|
||||
} else if ((m = /\/actions\/runs\/(\d+)\/attempts\/(\d+)\/jobs$/.exec(route!))) {
|
||||
lines(world.jobs[`${m[1]}/${m[2]}`] ?? world.jobs[m[1]!] ?? []);
|
||||
} else if ((m = /\/actions\/runs\/(\d+)\/artifacts$/.exec(route!))) {
|
||||
lines(world.trees[m[1]!] ? [{ name: q.get('name'), size_in_bytes: 200, expired: false }] : []);
|
||||
} else { process.stderr.write(`fake gh: no route for ${p}`); process.exit(1); }
|
||||
|
||||
@@ -209,6 +209,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
|
||||
'test/fixtures/remote-identity-vectors.json': ['test/bin-context-windows-slug.test.ts', 'test/gstack-slug-parity.test.ts', 'test/remote-identity-vectors.test.ts'],
|
||||
'test/fixtures/review-depth/**': ['test/review-dispatch-compare.test.ts'],
|
||||
'test/fixtures/review-ledger/**': ['test/review-dispatch.test.ts'],
|
||||
'test/fixtures/review-pins/**': ['test/review-packets-removed.test.ts'],
|
||||
'test/fixtures/review-risk/gbrain-evals-289.json': ['test/review-risk-routing.test.ts'],
|
||||
'test/fixtures/review-design-lite-reports-ci-36633323521.json': ['test/review-finalization-budget.test.ts'],
|
||||
'test/fixtures/review-gate-integrity-opus-reports.json': ['test/gate-integrity-templates.test.ts'],
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Two independent edits to a pin store, merged for real (GST-1084). seed(root) writes the base tree, edits a and b
|
||||
* each change it on their own branch, and `git merge-tree --write-tree` says whether the two merge without a
|
||||
* textual conflict. The repository is fresh under os.tmpdir(), outside any checkout, and git runs under gitEnv() so
|
||||
* no global hook or config reaches it; the caller removes root.
|
||||
*/
|
||||
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 { gitEnv } from './git-env';
|
||||
|
||||
export interface PinMerge { root: string; clean: boolean; output: string; read: (rel: string) => string }
|
||||
|
||||
export function mergePinEdits(seed: (root: string) => void, a: (root: string) => void, b: (root: string) => void): PinMerge {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pin-merge-'));
|
||||
const git = (...args: string[]) => spawnSync('git', ['-c', 'user.name=Pin fixture', '-c', 'user.email=pins@gstack.test', '-c', 'commit.gpgsign=false',
|
||||
'-c', 'core.autocrlf=false', ...args], { cwd: root, encoding: 'utf8', timeout: 30_000, env: gitEnv() });
|
||||
const must = (...args: string[]) => {
|
||||
const result = git(...args);
|
||||
if (result.status !== 0) throw new Error(`git ${args.join(' ')} failed (${result.status}): ${result.stderr}`);
|
||||
return result.stdout;
|
||||
};
|
||||
const commit = (edit: (root: string) => void, message: string) => { edit(root); must('add', '-A'); must('commit', '-q', '--no-verify', '-m', message); };
|
||||
let merged: ReturnType<typeof git>;
|
||||
try {
|
||||
must('init', '-q', '-b', 'base');
|
||||
commit(seed, 'seed');
|
||||
for (const [branch, edit] of [['a', a], ['b', b]] as const) {
|
||||
must('checkout', '-q', '-b', branch, 'base');
|
||||
commit(edit, branch);
|
||||
}
|
||||
merged = git('merge-tree', '--write-tree', 'a', 'b');
|
||||
if (merged.status !== 0 && merged.status !== 1) throw new Error(`git merge-tree failed (${merged.status}): ${merged.stderr}`);
|
||||
} catch (e) {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
throw e;
|
||||
}
|
||||
const tree = merged.stdout.split('\n')[0]!;
|
||||
return { root, clean: merged.status === 0, output: merged.stdout, read: rel => must('show', `${tree}:${rel}`) };
|
||||
}
|
||||
@@ -201,6 +201,16 @@ describe('literals (pure)', () => {
|
||||
{ name: 'MAX', old: '3', new: '4', file: 'f.ts' }, { name: 'timeout', old: '5000', new: '9000', file: 'f.ts' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('GST-1211: a key inside a newly added object literal is not a changed literal; an edited call-site value and an exported constant still are', () => {
|
||||
const before = 'export const MAX = 3;\nconst page = { limit: 10 };\n';
|
||||
expect(changedLiterals('f.ts', before, `${before}const query = { selection: { order: 'asc', limit: 300 } };\n`)).toEqual([]);
|
||||
expect(changedLiterals('f.ts', before, 'export const MAX = 3;\nconst page = { limit: 20 };\n')).toEqual([{ name: 'limit', old: '10', new: '20', file: 'f.ts' }]);
|
||||
expect(changedLiterals('f.ts', before, 'export const MAX = 4;\nconst page = { limit: 10 };\nconst query = { limit: 300 };\n')).toEqual([{ name: 'MAX', old: '3', new: '4', file: 'f.ts' }]);
|
||||
expect(changedLiterals('f.ts', 'const retired = { limit: 10 };\nconst page = { limit: 20 };\n', 'const page = { limit: 30 };\n')).toEqual([
|
||||
{ name: 'limit', old: '10', new: '30', file: 'f.ts' }, { name: 'limit', old: '20', new: '30', file: 'f.ts' },
|
||||
]);
|
||||
});
|
||||
// Value: protects=a sibling file that shares the module's stem (`postgres-unit-arms.txt` beside `postgres-unit-arms.ts`) is not the
|
||||
// module, so a test that only names it is not a consumer, while an extensionless import, a JS/TS import extension, the module's own
|
||||
// file name and a path ending at the stem (then a quote, space, bracket, sentence end or `.d.ts`) still are, and neither `<file>.snap` nor a JS twin of a
|
||||
@@ -519,6 +529,21 @@ describe('gstack-pregate (bin)', () => {
|
||||
expect(w.out).toMatch(/^preflight {2}strays {4}warn .*downgraded to warn by origin\/main@/m);
|
||||
});
|
||||
|
||||
test('GST-1211: probe.py and temporal-*.test.ts are not scratch names; zz-*, tmp*, scratch* and editor leftovers still are', () => {
|
||||
const repo = fixture({ pregate: { ...BASE_PREGATE, warn: [] } });
|
||||
commit(repo, { 'docs/comparison-systems/ext-temporal-graph/shipped-0.31/probe.py': 'print(1)\n', 'test/eval/temporal-graph-shipped.test.ts': 'export {};\n' });
|
||||
const ok = pregate(repo, ['--stage', 'preflight', '--only', 'strays']);
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
expect(ok.out).toMatch(/^preflight {2}strays {4}pass /m);
|
||||
commit(repo, { 'docs/zz-notes.md': '# n\n', 'lib/tmp-x.ts': 'export {};\n', 'test/scratch.py': 'pass\n', 'lib/util.js.orig': 'x\n' });
|
||||
const r = pregate(repo, ['--stage', 'preflight', '--only', 'strays']);
|
||||
expect(r.status).toBe(EXIT.fail);
|
||||
expect(r.out).toMatch(/^preflight {2}strays {4}fail .*4 stray file\(s\)/m);
|
||||
for (const f of ['docs/zz-notes.md', 'lib/tmp-x.ts', 'test/scratch.py', 'lib/util.js.orig']) expect(r.out).toContain(`strays: ${f}: scratch name (${f.split('/').pop()})`);
|
||||
expect(r.out).not.toContain('probe.py: scratch name');
|
||||
expect(r.out).not.toContain('temporal-graph-shipped.test.ts: scratch name');
|
||||
});
|
||||
|
||||
test('p0-4-heavy-old-constant: a nightly-only test still asserting the old literal fails literals with file:line', () => {
|
||||
const repo = fixture();
|
||||
commit(repo, { 'lib/retry.js': "exports.MAX_RETRIES = 3;\nexports.TIMEOUT_MS = 9000;\nexports.retry = (fn) => fn();\n" });
|
||||
@@ -532,6 +557,18 @@ describe('gstack-pregate (bin)', () => {
|
||||
expect(pregate(repo, ['--stage', 'preflight', '--only', 'literals']).status).toBe(EXIT.ok);
|
||||
});
|
||||
|
||||
test('GST-1211: a new object literal beside an existing `limit: 10` passes literals; editing that existing value still fails', () => {
|
||||
const repo = fixture({ baseFiles: { 'lib/query.js': 'exports.page = () => ({ limit: 10 });\n', 'test/query.test.js': "const test = require('node:test');\nconst assert = require('node:assert');\nconst { page } = require('../lib/query.js');\ntest('pages by ten', () => assert.strictEqual(page().limit, 10));\n" } });
|
||||
commit(repo, { 'lib/query.js': "exports.page = () => ({ limit: 10 });\nexports.selection = { order: 'asc', limit: 300 };\n" });
|
||||
const ok = pregate(repo, ['--stage', 'preflight', '--only', 'literals']);
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
expect(ok.out).not.toContain('test/query.test.js');
|
||||
commit(repo, { 'lib/query.js': "exports.page = () => ({ limit: 20 });\nexports.selection = { order: 'asc', limit: 300 };\n" });
|
||||
const r = pregate(repo, ['--stage', 'preflight', '--only', 'literals']);
|
||||
expect(r.status).toBe(EXIT.fail);
|
||||
expect(r.out).toContain('literals: test/query.test.js:4: limit still 10 (now 20 in lib/query.js)');
|
||||
});
|
||||
|
||||
// Value: protects=a real stale one-character constant still fails literals end to end after the #3492 narrowing, and an incidental
|
||||
// digit in the same file is not what it reports; fails_when=the statement binding drops a named assertion; why_new=only
|
||||
// multi-character values had a bin-level control; seam=none; no_claim=two-character values
|
||||
|
||||
@@ -84,7 +84,7 @@ test('report-dir names the filesystem cause when .gstack is not a directory', ()
|
||||
fs.writeFileSync(path.join(f.root, '.gstack'), 'not a directory\n');
|
||||
const r = f.tool('gstack-qa-evidence', 'report-dir');
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('report-dir failed: ENOTDIR');
|
||||
expect(r.stderr).toMatch(process.platform === 'win32' ? /report-dir failed: (ENOTDIR|ENOENT)/ : /report-dir failed: ENOTDIR/);
|
||||
});
|
||||
|
||||
test('report-dir refuses outside a git work tree instead of guessing a location', () => {
|
||||
|
||||
@@ -14,6 +14,8 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { rewriteStamps, tentativeMentions } from '../lib/restamp';
|
||||
import { STAMP_PLACEHOLDERS_KEY_VERSION } from '../lib/ship-policy';
|
||||
import { extractReceipt } from '../lib/ship-receipt';
|
||||
import type { GitFixtureTemplate } from './helpers/git-fixture-template';
|
||||
import { BASE_POLICY, cleanup, cloneFixture, fixtureTemplate, makePr, mergeMain, mergeToMain, readFile, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
@@ -425,10 +427,165 @@ describe('a branch-added row left at the fork version after the base moved (GST-
|
||||
const { repo, body } = carryAfterStamp('restamp-carry-mode-', [row('1.2.3.0', 'base_row')], row('1.2.3.0', 'mine'),
|
||||
[row('1.2.3.0', 'base_row'), '', row('1.2.5.0', 'mine')]);
|
||||
fs.chmodSync(path.join(repo.work, NOTICE), 0o755);
|
||||
repo.git('commit', '-qam', 'stamp 1.2.5.0, and make the file executable');
|
||||
repo.git('add', '-A');
|
||||
repo.git('update-index', '--chmod=+x', '--', NOTICE);
|
||||
repo.git('commit', '-qm', 'stamp 1.2.5.0, and make the file executable');
|
||||
expect(repo.git('ls-tree', 'HEAD', '--', NOTICE)).toStartWith('100755 ');
|
||||
const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
|
||||
expect(carried.status, carried.out).toBe(EXIT.refused);
|
||||
expect(carried.err).toContain('RECEIPT_CARRY_PRODUCT_CHANGED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('declared placeholders and the receipt carry (GST-1026, GST-1030)', () => {
|
||||
/** gbrain's policy (GST-1026): the since: template, and TBD declared as a placeholder when `declared`. */
|
||||
const placeholderPolicy = (declared: boolean) => ({ ...receiptPolicy(true), ...(declared ? { stamp_placeholders: ['TBD'], min_gstack_version: STAMP_PLACEHOLDERS_KEY_VERSION } : {}) });
|
||||
|
||||
/** A branch row written as since: 'TBD' at the claim 1.2.4.0, then the stamp to 1.2.5.0 with the row restamped by hand (gbrain's release:restamp). */
|
||||
function tbdStamp(prefix: string, pol: Record<string, unknown>, ownRow: string): { repo: FixtureRepo; gated: string } {
|
||||
const repo = makeFixtureRepo({ prefix, policy: pol }); repos.push(repo);
|
||||
write(repo, NOTICE, notice([row('TBD', 'base_row')]));
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
|
||||
makePr(repo, 'pr-tbd');
|
||||
expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok);
|
||||
repo.git('commit', '-qam', 'stamp 1.2.4.0');
|
||||
write(repo, NOTICE, notice([row('TBD', 'base_row'), '', ownRow]));
|
||||
repo.git('commit', '-qam', 'mine: a TBD row');
|
||||
const gated = repo.git('rev-parse', 'HEAD');
|
||||
expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok);
|
||||
return { repo, gated };
|
||||
}
|
||||
|
||||
// Value: protects=the tree receipt (stamp, verify, gate-ahead replay) accepts gbrain's restamp of the branch's own
|
||||
// since: 'TBD' row when the policy declares TBD, and refuses it undeclared, on a base row, or on a base row whose text
|
||||
// the branch duplicates (GST-1026); fails_when=classifyFile reads the TBD move as a hand edit, or accepts it off the
|
||||
// branch's own rows; seam=lib/tree-receipt.ts stampOnlyHunks
|
||||
test('the tree receipt accepts a declared TBD row restamped on the branch, and refuses the same move on a base row (GST-1026)', () => {
|
||||
const own = tbdStamp('restamp-tbd-receipt-', placeholderPolicy(true), row('TBD', 'mine'));
|
||||
write(own.repo, NOTICE, readFile(own.repo, NOTICE).replace(row('TBD', 'mine'), row('1.2.5.0', 'mine')));
|
||||
own.repo.git('commit', '-qam', 'stamp 1.2.5.0: the TBD row');
|
||||
const accepted = runBin(own.repo, 'gstack-tree-receipt', ['--gated', own.gated, '--base', 'main']);
|
||||
expect(accepted.out, accepted.err).toContain('TREE: same-modulo-stamps');
|
||||
write(own.repo, NOTICE, readFile(own.repo, NOTICE).replace(row('TBD', 'base_row'), row('1.2.5.0', 'base_row')));
|
||||
own.repo.git('commit', '-qam', 'move the base TBD row by hand');
|
||||
const base = runBin(own.repo, 'gstack-tree-receipt', ['--gated', own.gated, '--base', 'main']);
|
||||
expect(base.out, base.err).toContain('TREE: changed');
|
||||
expect(base.out).toContain("id: 'base_row'");
|
||||
});
|
||||
|
||||
test('the tree receipt refuses an undeclared TBD row restamped on the branch (GST-1026)', () => {
|
||||
const undeclared = tbdStamp('restamp-tbd-undeclared-', placeholderPolicy(false), row('TBD', 'mine'));
|
||||
write(undeclared.repo, NOTICE, readFile(undeclared.repo, NOTICE).replace(row('TBD', 'mine'), row('1.2.5.0', 'mine')));
|
||||
undeclared.repo.git('commit', '-qam', 'stamp 1.2.5.0: the TBD row');
|
||||
const refused = runBin(undeclared.repo, 'gstack-tree-receipt', ['--gated', undeclared.gated, '--base', 'main']);
|
||||
expect(refused.out, refused.err).toContain('TREE: changed');
|
||||
});
|
||||
|
||||
test('the tree receipt refuses a restamp of the base copy of a TBD row the branch duplicates (GST-1026)', () => {
|
||||
const dup = tbdStamp('restamp-tbd-dup-', placeholderPolicy(true), row('TBD', 'base_row'));
|
||||
write(dup.repo, NOTICE, notice([row('1.2.5.0', 'base_row'), '', row('TBD', 'base_row')]));
|
||||
dup.repo.git('commit', '-qam', 'move the base copy of the duplicated TBD row');
|
||||
const copy = runBin(dup.repo, 'gstack-tree-receipt', ['--gated', dup.gated, '--base', 'main']);
|
||||
expect(copy.out, copy.err).toContain('TREE: changed');
|
||||
});
|
||||
|
||||
/** A receipt written at the claim 1.2.4.0 after `own` changed the branch, then the stamp to 1.2.5.0, uncommitted. */
|
||||
function receiptThenStamp(prefix: string, pol: Record<string, unknown>, own: (repo: FixtureRepo) => void): { repo: FixtureRepo; body: string } {
|
||||
const repo = makeFixtureRepo({ prefix, policy: pol }); repos.push(repo);
|
||||
write(repo, NOTICE, notice([row('TBD', 'base_row')]));
|
||||
fs.rmSync(path.join(repo.work, 'package-lock.json'));
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows, no lockfile'); repo.git('push', '-q', 'origin', 'main');
|
||||
makePr(repo, 'pr-carry');
|
||||
expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok);
|
||||
repo.git('commit', '-qam', 'stamp 1.2.4.0');
|
||||
own(repo);
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'mine');
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
fs.writeFileSync(body, '## Summary\n');
|
||||
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '3', '--base', 'main', '--session-kind', 'x', '--body', body]);
|
||||
expect(w.status, w.err).toBe(EXIT.ok);
|
||||
expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok);
|
||||
return { repo, body };
|
||||
}
|
||||
const carry = (repo: FixtureRepo, body: string) => runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
|
||||
|
||||
// Value: protects=the receipt carry follows gbrain's restamp of the branch's own since: 'TBD' row when the policy
|
||||
// declares TBD, and refuses it undeclared (GST-1026); fails_when=productSpec's own-row check ignores the policy's
|
||||
// placeholders and refuses RECEIPT_CARRY_PRODUCT_CHANGED; seam=bin/gstack-ship-receipt productSpec ownRows
|
||||
test.each([['declared', true], ['undeclared', false]])('the carry and a TBD row restamped on the branch: %s', (_, declared) => {
|
||||
const { repo, body } = receiptThenStamp(`restamp-tbd-carry-${declared}-`, placeholderPolicy(declared), r => write(r, NOTICE, notice([row('TBD', 'base_row'), '', row('TBD', 'mine')])));
|
||||
write(repo, NOTICE, readFile(repo, NOTICE).replace(row('TBD', 'mine'), row('1.2.5.0', 'mine')));
|
||||
repo.git('commit', '-qam', 'stamp 1.2.5.0: the TBD row');
|
||||
const carried = carry(repo, body);
|
||||
if (declared) expect(carried.out, carried.err).toContain('CARRY: carried');
|
||||
else expect([carried.status, carried.err.includes('RECEIPT_CARRY_PRODUCT_CHANGED')], carried.err).toEqual([EXIT.refused, true]);
|
||||
});
|
||||
|
||||
// Value: protects=a carried receipt names the head's version, the one write would record there, after a main merge
|
||||
// and a restamp (GST-1030 point 1, GGST-1); fails_when=carryReceipt copies version from the old receipt;
|
||||
// seam=bin/gstack-ship-receipt cmdCarry
|
||||
test('the carried receipt records the head\'s version after a main merge and a restamp (GST-1030)', () => {
|
||||
const { repo, body } = receiptThenStamp('restamp-carry-version-', policy(true), r => write(r, 'src.ts', 'export const x = 0;\n'));
|
||||
repo.git('reset', '-q', '--hard');
|
||||
expect(readFile(repo, 'VERSION')).toBe('1.2.4.0\n');
|
||||
makePr(repo, 'pr-other');
|
||||
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
|
||||
repo.git('commit', '-qam', 'stamp other');
|
||||
mergeToMain(repo, 'pr-other');
|
||||
repo.git('checkout', '-q', 'pr-carry');
|
||||
mergeMain(repo);
|
||||
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
|
||||
repo.git('commit', '-qam', 'restamp after the lost slot');
|
||||
expect(readFile(repo, 'VERSION')).toBe('1.2.5.0\n');
|
||||
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt?.version).toBe('1.2.4.0');
|
||||
const carried = carry(repo, body);
|
||||
expect(carried.out, carried.err).toContain('CARRY: carried');
|
||||
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt?.version).toBe('1.2.5.0');
|
||||
});
|
||||
|
||||
/** package.json with its dependency pin set to `pin`, every other field kept. */
|
||||
const pinned = (repo: FixtureRepo, pin: string) => {
|
||||
const pkg = JSON.parse(readFile(repo, 'package.json'));
|
||||
pkg.dependencies = { ...pkg.dependencies, 'left-pad': pin };
|
||||
write(repo, 'package.json', JSON.stringify(pkg, null, 2) + '\n');
|
||||
};
|
||||
|
||||
// Value: protects=a pin-bump PR's receipt carries across a main merge and a restamp when package.json's only change since
|
||||
// review is its version field, and refuses when the pin itself moved (GST-1030, GBEV-3, gbrain-evals #306);
|
||||
// fails_when=the carry counts the PR's package.json as product and refuses RECEIPT_CARRY_PRODUCT_CHANGED on a version-only
|
||||
// move, or lets a changed dependency pin ride; seam=bin/gstack-ship-receipt cmdCarry walk
|
||||
test.each([['the pin unchanged since review', false], ['the pin moved since review', true]])('the carry and a pin-bump PR after a main merge: %s (GST-1030)', (_, moved) => {
|
||||
const { repo, body } = receiptThenStamp(`restamp-carry-pin-${moved}-`, policy(true), r => pinned(r, '1.3.0'));
|
||||
repo.git('reset', '-q', '--hard');
|
||||
makePr(repo, 'pr-other');
|
||||
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
|
||||
repo.git('commit', '-qam', 'stamp other');
|
||||
mergeToMain(repo, 'pr-other');
|
||||
repo.git('checkout', '-q', 'pr-carry');
|
||||
mergeMain(repo);
|
||||
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
|
||||
if (moved) pinned(repo, '1.3.1');
|
||||
repo.git('commit', '-qam', 'restamp after the lost slot');
|
||||
expect(JSON.parse(readFile(repo, 'package.json')).version).toBe('1.2.5');
|
||||
const carried = carry(repo, body);
|
||||
if (moved) expect([carried.status, carried.err.includes('RECEIPT_CARRY_PRODUCT_CHANGED')], carried.err).toEqual([EXIT.refused, true]);
|
||||
else expect(carried.out, carried.err).toContain('CARRY: carried');
|
||||
});
|
||||
|
||||
// Value: protects=a version-only restamp of EVIDENCE.md is release metadata to the carry when the policy declares it in
|
||||
// stamp_files, and product when it does not (GST-1030 point 2, GGST-1); fails_when=the carry counts the declared
|
||||
// file's stamp as a product change (RECEIPT_CARRY_PRODUCT_CHANGED), or lets an undeclared file's version edit ride;
|
||||
// seam=bin/gstack-ship-receipt productSpec, lib/ship-receipt.ts stampOnlyText
|
||||
test.each([['declared in stamp_files', true], ['undeclared', false]])('the carry and a version-only restamp of EVIDENCE.md: %s (GST-1030)', (_, declared) => {
|
||||
const base = policy(true);
|
||||
const pol = declared ? { ...base, stamp_files: [...base.stamp_files, 'EVIDENCE.md'] } : base;
|
||||
const { repo, body } = receiptThenStamp(`restamp-carry-evidence-${declared}-`, pol, r => write(r, 'EVIDENCE.md', '# Evidence\n\nVerified on v1.2.4.0 with the free suite\n'));
|
||||
if (!declared) write(repo, 'EVIDENCE.md', '# Evidence\n\nVerified on v1.2.5.0 with the free suite\n');
|
||||
expect(readFile(repo, 'EVIDENCE.md')).toBe('# Evidence\n\nVerified on v1.2.5.0 with the free suite\n');
|
||||
repo.git('commit', '-qam', 'stamp 1.2.5.0');
|
||||
const carried = carry(repo, body);
|
||||
if (declared) expect(carried.out, carried.err).toContain('CARRY: carried');
|
||||
else expect([carried.status, carried.err.includes('RECEIPT_CARRY_PRODUCT_CHANGED')], carried.err).toEqual([EXIT.refused, true]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,7 +149,7 @@ describe('GST-967 invariant: no gate-class owner exit before Step 11\'s voices;
|
||||
});
|
||||
|
||||
/**
|
||||
* The GST-967 dispatch-superset no-loss check (crew rule 2026-10-30; replay 'GST-967' in test/review-packets-removed.test.ts).
|
||||
* The GST-967 dispatch-superset no-loss check (crew rule 2026-10-30; replay 'GST-967' in test/fixtures/review-pins/replays/GST-967.json).
|
||||
* On a 1,440-case grid (five diffs: light, standard, gate, docs, tests; passes 0-2; last none, full or fix-check; every mix of
|
||||
* the four fix flags; Step 11 run or not), main's planReview at 1022f4f (v1.92.81.0) hashes to MAIN_PLANS. The branch's plan,
|
||||
* with each voices step read back as main's stop (its reason is the parenthesised tail, no dispatch), hashes to the same value,
|
||||
|
||||
@@ -11,6 +11,7 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { sectionTexts } from '../lib/review-brief';
|
||||
import { mergePinEdits, type PinMerge } from './helpers/pin-merge-fixture';
|
||||
|
||||
const ROOT = path.join(import.meta.dir, '..');
|
||||
const REMOVED = [
|
||||
@@ -49,72 +50,88 @@ describe('reviewer packets stay reverted (GSTA-167)', () => {
|
||||
* reviewer sees or how its findings rate, so it merges only after its preregistered no-loss replay
|
||||
* passes (GStack Minder crew rule, 2026-10-30); a cost replay alone is not enough (#3621 passed its
|
||||
* cost replay, then its no-loss replay found 11 of 30 blockers). Every pin names the replay that
|
||||
* admits its bytes: repin with that replay's id here and in the commit message, and merge only on
|
||||
* admits its bytes: repin with that replay's id in the pin file and in the commit message, and merge only on
|
||||
* its passing result.
|
||||
*
|
||||
* The pins live one file per pin under test/fixtures/review-pins (GST-1084), so two PRs that repin
|
||||
* different files, or add different replays, never edit the same lines and merge without a conflict:
|
||||
* files/<pinned path>.json {"sha", "replay"}: the sha256 of the file's LF-normalized bytes and the replay that admits them
|
||||
* replays/<slug>.json {"replay", "justification"}: one replay id and its written no-loss justification,
|
||||
* where <slug> is replaySlug(replay)
|
||||
* brief-bodies/<name>.json {"sha"}: one brief body lib/review-brief.ts cuts from the ship sections (below)
|
||||
*/
|
||||
const NO_LOSS_REPLAYS = {
|
||||
'#3638': 'no-loss round 3: main restored the pre-#3621 reviewers (v1.91.331.0)',
|
||||
'PR-a': "GSTA-167 variant G Arm 4: structural plan identity at the 14 cohort heads and the free exact re-score of saved C, D and A' findings (parts 4a and 4b)",
|
||||
'G1': "GSTA-167 variant G Arm G1: live no-loss replay of both prose voices with part 1's prompts, 2 passes, normalizer v2, paired rule against pooled C' (parts 1 and 3; specialists at medium as in C and C2)",
|
||||
'L': "GSTA-167 variant G Arm L: live no-loss replay of one batched reader (testing and security plus scoped checklists) and the Red Team on its findings, 2 passes, scored L∘G1 against G1 on the paired rule (part 2)",
|
||||
'GST-508': "GST-508 (#3744, GSTA-202) preregistered no-loss replay of review_exclude_paths at ef2760f: Arm V found 33 of 41 blockers vs R 33 of 41, exact sign flip with delta 4, p_NI 0.0279 (pass); Arm P identical depth, dispatch and reader sets on 26 of 26 units (GStack Minder drive handoffs/GSTA-202/noloss-results.md)",
|
||||
'GST-257': "GST-257 PR 1 dispatch-superset no-loss check (test/review-targeted.test.ts): on every fixture diff the branch dispatches main's reviewers at 8be7f21 unchanged plus only the targeted reader, and over the last 100 non-release commits on main it lost none; adds the targeted role and brief to the G1 and L bytes, whose existing reviewer text is unchanged",
|
||||
'GST-464': "GST-464 brief-identity no-loss check (the brief-body test below): every reviewer brief lib/review-brief.ts cuts from ship/sections/review-army.md and adversarial.md is byte-identical to the bodies at the L and GST-257 pins; only lead instructions outside the cut spans changed",
|
||||
'GST-850': "GST-850 dispatch-superset no-loss check: on the 26 GSTA-167 no-loss units the branch plan (wider security and money classes, write-up text in the batched diff) drops no reviewer role main dispatches, so no saved blocker can be lost, and over the last 100 main commits no commit with a gate or risk class drops depth (test/review-risk-routing.test.ts, test/review-risk-linear.test.ts); reviewer text is unchanged",
|
||||
'GSTA-224 H1': 'GSTA-224 H1 preregistered no-loss run: the PR-type checklist against main\'s checklist and a no-gstack arm (Opus 5.5, 81 calls), 5 behavior cases, 10 Round 7 application blockers and 2 clean controls; every bar passed (handoffs/GSTA-224/h1-noloss-results.md)',
|
||||
'R51': "R51 risk-tier routing no-loss re-score (GSTA-167): the saved panel findings (349 records over 24 of the 26 no-loss units) re-scored against the branch's plan lose no blocker whose only finders are roles it drops, and every gate, security, user-data and money fixture keeps main's reviewers plus the full panel (test/review-targeted.test.ts, test/review-risk-routing.test.ts); reviewer text is unchanged",
|
||||
'GST-508+R51': "wave review-53cfb5bfe011 merge of #3744 into main: lib/review-depth.ts keeps both admitted changes, the GST-508 no-loss replay of review_exclude_paths (Arm V 33 of 41 blockers vs R 33 of 41, p_NI 0.0279; GStack Minder drive handoffs/GSTA-202/noloss-results.md) and the R51 risk-tier re-score with GST-850's dispatch-superset no-loss check: excluded paths sit beside the added and removed line maps, and the exclusion filter keeps the publishes-docs rule",
|
||||
'GST-508+GST-464': "wave review-53cfb5bfe011 merge of #3744 into main: ship/sections/review-army.md and adversarial.md carry the GST-508 no-loss-replayed exclude pathspec placeholders inside the brief bodies (handoffs/GSTA-202/noloss-results.md) and the GST-464 lead-instruction edits outside the cut spans, which its brief-identity no-loss check admitted",
|
||||
'GST-986': "GST-986 brief-identity no-loss check (the brief-body test below): every reviewer brief lib/review-brief.ts cuts from ship/sections/adversarial.md is byte-identical to the bodies at the GST-257 pin; only the lead's Step 11 instruction naming the targeted record's path changed, outside the cut spans",
|
||||
'GST-942': "GST-942 dispatch-superset no-loss check: the rmSync rule no longer stops at the first ), a matching rm( rule is added, and both read a file's changed lines joined for a delete split across lines, so a recursive delete raises security on more diffs and never on fewer; over the last 100 first-parent main commits to 3d99c2249 (62,531 changed lines) the line rules match all 170 lines the old rule matched plus 19 more, and no commit changes depth, class or risk class; reviewer text is unchanged",
|
||||
'GST-911-redos': "GST-911 ReDoS fix verdict-identity no-loss check (wave review-e7b551ebd865): SVG_REFUSED's url() entry, rewritten to run in linear time, refuses exactly the strings #3838's pattern refused (test/review-risk-redos.test.ts: 60,000 seeded strings and the hand-picked edges, and every SVG blob in the repository's history, with zero differences); SVG_MARKUP now refuses a DOCTYPE naming any document but W3C's SVG DTD (test/review-planner-misreads.test.ts), which only keeps more SVGs code and so drops no reviewer; GST-911's no-loss replay holds unchanged; reviewer text is unchanged",
|
||||
'GST-911': "GST-911 no-loss replay (GSTA-249/13): the planner over the last 100 first-parent main commits to f5cb6ec1e, origin/main's code against this branch, gate scan fixed at 0, changes no commit's depth, class, risk classes, targeted classes or SCOPE_AUTH; the three misreads (a drawing-only SVG, a session-named data file, a bare base) are proven on fixtures (test/review-planner-misreads.test.ts), where every SVG that can run script stays code with the security class; reviewer text is unchanged",
|
||||
'GST-508+R51+GST-911': "wave review-53cfb5bfe011 merge of main 3d99c2249 (#3848) into #3852: lib/review-depth.ts keeps GST-508's review_exclude_paths (no-loss replay, GStack Minder drive handoffs/GSTA-202/noloss-results.md), R51 with GST-850's dispatch-superset no-loss check, and GST-911's SVG drawings with GST-911-redos (no-loss replay: 0 of 100 main commits changed, reports/GSTA-249-911-decisions.tsv; test/review-planner-misreads.test.ts, test/review-risk-redos.test.ts); lib/review-risk.ts is main's GST-911 bytes. Merged-head checks (handoffs/GSTA-202/merge-3d99c22-checks.md): Arm P on unit 3593 differs only in the exclude pathspec and the disclosure line, the same brief diff as before the merge, and GST-911 panel unit f5cb6ec1e plans, dispatches and renders byte-identical briefs under 3d99c2249 and the merged head",
|
||||
'GST-898': "GST-898 verdict-identity no-loss check: the linear rm rule, and the rmSync rule (which also raises security on any call with more than 500 characters before its closing parenthesis), give the same verdict as R51's on every changed line of the last 100 main commits (69,023 lines, a563f47 back 100 first-parent commits) and on 60,000 seeded lines per rule (test/review-risk-redos.test.ts), and no commit changes depth, class or risk class, so every diff dispatches main's reviewers unchanged; reviewer text is unchanged",
|
||||
'GST-967': "GST-967 dispatch-superset no-loss check (test/review-depth.test.ts, 'GST-967 dispatch-superset no-loss check'): over 1,440 planReview cases the branch plans exactly what main planned at 1022f4f except the 71 gate-class stops before Step 11, which add only Step 11's adversarial, outside and targeted voices, so no reviewer main dispatches is dropped or re-efforted; the brief bodies lib/review-brief.ts cuts from the ship sections hash to main's; only the lead's repeat-decision and Step 11 instructions changed; reviewer text is unchanged",
|
||||
'GST-508+GST-464+GST-967': "wave review-53cfb5bfe011 merge of main 68410828f into #3852: ship/sections/review-army.md and adversarial.md are main's GST-967 bytes plus exactly the GST-508+GST-464 lines (the merged file minus main equals the wave minus fc292e22f, line for line), so GST-967's dispatch-superset no-loss check and GST-508's no-loss replay both hold; merged-head checks in GStack Minder drive handoffs/GSTA-202/merge-6841-checks.md",
|
||||
'GST-508+R51+GST-911+GST-967': "wave review-53cfb5bfe011 merge of main 68410828f into #3852: lib/review-depth.ts is main's GST-967 bytes plus exactly the GST-508+R51+GST-911 lines (the merged file minus main equals the wave minus fc292e22f, line for line); GST-967's dispatch-superset no-loss check (test/review-depth.test.ts) passes on the merged file, and the GST-508+R51+GST-911 checks are rerun at the merged head (GStack Minder drive handoffs/GSTA-202/merge-6841-checks.md)",
|
||||
'GST-940': "GST-940 verdict no-loss check: svgDrawing reads a style element's opening tag as quoted pairs with ASCII whitespace, walks its body over the raw text, refuses any & in it, reads comments as CSS and refuses a non-ASCII element or attribute name; on 62,805 unique SVG files (21,658 with a style element) it gives the same verdict as main at 4bbad2422 on every file, and the planner over the last 100 first-parent main commits to 4bbad2422 changes no commit's depth, class or risk classes; the only verdicts it changes are the GST-940 cases in test/review-planner-misreads.test.ts; rerun on the merge with main at 8f08cb76a (GST-967 merged), with the same results; rerun on the merge with main at 5f705f367 (#3852 and #3864 merged), with the same results",
|
||||
'GST-508+GST-464+GST-967+GSTA-258-L4': "GSTA-258 L4 (GST-1168) brief-identity no-loss check (the brief-body test below): ship/sections/review-army.md is the GST-508+GST-464+GST-967 bytes plus Step 9.4 lead instructions outside the cut spans (a finding's ledger_id, the Review-Finding fix-commit trailer, logging a late defer when it is decided); every reviewer brief lib/review-brief.ts cuts from it still hashes to BRIEF_BODIES, so no reviewer sees a changed byte",
|
||||
} as const;
|
||||
type Replay = keyof typeof NO_LOSS_REPLAYS;
|
||||
const REVIEWER_BRIEFS: Record<string, { sha: string; replay: Replay }> = {
|
||||
'ship/sections/review-army.md': { sha: '22c37a64fd937f94ce231f8d815c401f232e0b06da8a1e57d3d15a18d3c9f2c0', replay: 'GST-508+GST-464+GST-967+GSTA-258-L4' },
|
||||
'ship/sections/adversarial.md': { sha: '0142d6bc3f2a5bceb9f8b2adef1d3a206e5836c99a0667fe995568da2e80a5a6', replay: 'GST-508+GST-464+GST-967' },
|
||||
'review/sections/review-army.md': { sha: '78329b64640260cd8831ca81a9189f3f8dcade9c406431631d3f2cd798cd3967', replay: '#3638' },
|
||||
'review/sections/adversarial.md': { sha: 'e6962fa25b78f362654f2954f79683e524d2ebd452d1494f8a2b099888e8e255', replay: 'G1' },
|
||||
'review/checklist.md': { sha: 'a32963ef38d9d3613c58c805e8517cd2e593c63769b30dd7d40e2592fa48fc78', replay: 'GSTA-224 H1' },
|
||||
'review/design-checklist.md': { sha: 'b9205f88313cc64902c0b13e9685722ab8138d3dcc58916154f47274abda7d83', replay: '#3638' },
|
||||
'review/gate-integrity.md': { sha: 'f570db344c9a01df633bcaa281d6017f4f12420fd3e0aa3078c1ce06e0df8332', replay: '#3638' },
|
||||
'review/specialists/api-contract.md': { sha: '263d23ac119dd601d315c191dbdbd503d47c00264c9c0ca81959559ca11d4e95', replay: '#3638' },
|
||||
'review/specialists/data-migration.md': { sha: 'b6fd9eb229002ea598f8fe9ff53b1cd8821e3bd37a7aa7b07b5526556c71ebca', replay: '#3638' },
|
||||
'review/specialists/maintainability.md': { sha: 'f8015d549e70297f782f0aabf6fe3a9fe31ec5ed61c311ab854e4a858282ed9b', replay: '#3638' },
|
||||
'review/specialists/performance.md': { sha: '545c294ae53638b4c8524e8cde08246a4ce3b5c287ec7c44e27f5556a3b0e8cc', replay: '#3638' },
|
||||
'review/specialists/red-team.md': { sha: '9ea05149f5b13d6a19ecec26285142d09d259cf80a515af828abdd1f43320427', replay: '#3638' },
|
||||
'review/specialists/security.md': { sha: '17bfe813eb3de22835af048ec359770720f1d7f371a26460169f7e32b1bcac9f', replay: '#3638' },
|
||||
'review/specialists/simplification.md': { sha: 'f33846b1dcf2fdeef82d0744ab2ef48fb4a33b34a737dafc33dd0e891756689f', replay: '#3638' },
|
||||
'review/specialists/state-machines.md': { sha: '597215e4438d85d9cc71b48097c61eef7cba4f4b09dc14c92b4b94893b3d3ea2', replay: '#3638' },
|
||||
'review/specialists/testing.md': { sha: 'd6cc2bef7be562387d0c0a22fefe646dff9c295f1d34d8453a6cb496584150e4', replay: '#3638' },
|
||||
'review/specialists/targeted.md': { sha: '7f92a0d0a8319c3c0e076fb3b1a4689c45120b2be2c14dfc7d4cb03aafa9d6a5', replay: 'GST-257' },
|
||||
'scripts/resolvers/fix-check.ts': { sha: '712bf5b0c439e96bac32f19cbe25394dabb4c94aa66600100bfde9b100e15a08', replay: '#3638' },
|
||||
'lib/review-depth.ts': { sha: '25050a7a61f81df649d1df0d0237dbf961671356395317c6a527741f5bb56877', replay: 'GST-940' },
|
||||
'lib/review-risk.ts': { sha: '08464b78a723ee463ca3a28d0abbc4eb8f4f512192061c703743707b66b5a311', replay: 'GST-942' },
|
||||
};
|
||||
const PINS = path.join(ROOT, 'test/fixtures/review-pins');
|
||||
const replaySlug = (replay: string) => replay.replace(/[^A-Za-z0-9+-]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
|
||||
/**
|
||||
* The reviewer brief bodies lib/review-brief.ts cuts out of the ship sections, at the bytes replays L and GST-257
|
||||
* admitted, except fixtures, redTeam and native, which carry the exclude pathspec placeholders the GST-508 replay
|
||||
* admitted (byte-identical to #3744 at 995e442). A section repin under GST-464 or GST-986 is valid only while every
|
||||
* body still hashes here: the section may change outside the cut spans, never inside them.
|
||||
*/
|
||||
const BRIEF_BODIES: Record<string, string> = {
|
||||
specialist: '1ebe47ddb3291a9e4fe90b5a260eb57e328144959549bc95c119b68d23652cb5',
|
||||
framing: 'aad667b5ea4c6d23d930dafffa38178c04be388cd4364fb73759fc92af17bb63',
|
||||
fixtures: 'fbe52f3a9e0f4442eb755f1747a95944b373b439bd05c61f3eb68345d1061a5b',
|
||||
redTeam: '6dd670ea040535ca6cceab81c29a395bb67ce5a2ab8fa2b81e51bc4711e4aada',
|
||||
native: 'a7cc08fb31b6ca65932a316d589f0b09677426d193d63f88ec74fee6d93ce053',
|
||||
outside: 'bd96406da28b03742384560425a333b6d6d7ed2be95bd2026c90e0039632cee2',
|
||||
};
|
||||
/** Every pin file under dir, keyed by its path below dir without `.json`; any other file is an error. */
|
||||
function readPinDir(dir: string): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const rel of (fs.readdirSync(dir, { recursive: true }) as string[]).map(f => f.split(path.sep).join('/')).sort()) {
|
||||
if (fs.statSync(path.join(dir, rel)).isDirectory()) continue;
|
||||
if (!rel.endsWith('.json')) throw new Error(`${dir}/${rel}: every pin file is <name>.json`);
|
||||
try {
|
||||
out[rel.slice(0, -'.json'.length)] = JSON.parse(fs.readFileSync(path.join(dir, rel), 'utf8'));
|
||||
} catch (e) {
|
||||
throw new Error(`${dir}/${rel}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The pin store at pins, with every file that is not exactly its kind's fields as non-empty strings, or is misnamed. */
|
||||
function readPins(pins: string) {
|
||||
const malformed: string[] = [];
|
||||
const kind = <K extends string>(sub: string, keys: readonly K[]) => {
|
||||
const out: Record<string, Record<K, string>> = {};
|
||||
for (const [name, value] of Object.entries(readPinDir(path.join(pins, sub)))) {
|
||||
const fields = typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
if (Object.keys(fields).sort().join() === [...keys].sort().join() && keys.every(k => typeof fields[k] === 'string' && fields[k] !== '')) out[name] = fields as Record<K, string>;
|
||||
else malformed.push(`${sub}/${name}.json (expected exactly ${keys.join(', ')})`);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const replayFiles = kind('replays', ['replay', 'justification']);
|
||||
for (const [name, r] of Object.entries(replayFiles)) if (name !== replaySlug(r.replay)) malformed.push(`replays/${name}.json (replay ${r.replay} belongs in replays/${replaySlug(r.replay)}.json)`);
|
||||
return {
|
||||
malformed,
|
||||
replays: Object.fromEntries(Object.values(replayFiles).map(r => [r.replay, r.justification])),
|
||||
files: kind('files', ['sha', 'replay']),
|
||||
bodies: Object.fromEntries(Object.entries(kind('brief-bodies', ['sha'])).map(([name, body]) => [name, body.sha])),
|
||||
};
|
||||
}
|
||||
type Pins = ReturnType<typeof readPins>;
|
||||
|
||||
/** Every source a reviewer brief, checklist or depth rule comes from: deleting one of these pins fails, never drops the check. */
|
||||
const MUST_PIN = [
|
||||
'lib/review-depth.ts',
|
||||
'lib/review-risk.ts',
|
||||
'review/checklist.md',
|
||||
'review/design-checklist.md',
|
||||
'review/gate-integrity.md',
|
||||
'review/sections/adversarial.md',
|
||||
'review/sections/review-army.md',
|
||||
'scripts/resolvers/fix-check.ts',
|
||||
'ship/sections/adversarial.md',
|
||||
'ship/sections/review-army.md',
|
||||
];
|
||||
|
||||
/** MUST_PIN entries and specialist checklists under root with no pin. */
|
||||
const unpinned = (pins: Pins, root: string) => [...MUST_PIN, ...fs.readdirSync(path.join(root, 'review/specialists')).filter(f => f.endsWith('.md')).map(f => `review/specialists/${f}`)]
|
||||
.filter(f => !Object.hasOwn(pins.files, f));
|
||||
|
||||
/** Pinned files whose LF-normalized bytes under root no longer hash to their pin. */
|
||||
const stalePins = (pins: Pins, root: string) => Object.entries(pins.files).filter(([file, pin]) => {
|
||||
const text = fs.readFileSync(path.join(root, file), 'utf8').replace(/\r\n/g, '\n');
|
||||
return createHash('sha256').update(text).digest('hex') !== pin.sha;
|
||||
}).map(([file, pin]) => `${file} (a repin needs a passing no-loss replay; the current pin names ${pin.replay})`);
|
||||
|
||||
/** Pins naming no replay in the store, and replays whose justification names no no-loss replay or re-score. */
|
||||
const unadmitted = (pins: Pins) => [
|
||||
...Object.entries(pins.files).filter(([, pin]) => !Object.hasOwn(pins.replays, pin.replay)).map(([file, pin]) => `${file} names unknown replay ${pin.replay}`),
|
||||
...Object.entries(pins.replays).filter(([, what]) => !/no-loss|re-score/.test(what)).map(([replay]) => `replay ${replay} is not a no-loss replay or re-score`),
|
||||
];
|
||||
|
||||
const PINNED = readPins(PINS);
|
||||
|
||||
describe('reviewer briefs stay at their no-loss-tested bytes (GSTA-167)', () => {
|
||||
// Value: protects=the GST-986 repin of ship/sections/adversarial.md admits only a lead-instruction edit: every brief a reviewer
|
||||
@@ -122,34 +139,103 @@ describe('reviewer briefs stay at their no-loss-tested bytes (GSTA-167)', () =>
|
||||
// section pin moves; seam=sectionTexts, the function every render uses; no_claim=the checklists (pinned by file hash below)
|
||||
test('every reviewer brief cut from the ship sections is byte-identical to its pinned body', () => {
|
||||
const hashes = (root: string) => Object.fromEntries(Object.entries(sectionTexts(root)).map(([k, v]) => [k, createHash('sha256').update(v).digest('hex')]));
|
||||
expect(hashes(ROOT)).toEqual(BRIEF_BODIES);
|
||||
expect(hashes(ROOT)).toEqual(PINNED.bodies);
|
||||
const copy = fs.mkdtempSync(path.join(os.tmpdir(), 'brief-bodies-'));
|
||||
try {
|
||||
fs.cpSync(path.join(ROOT, 'ship', 'sections'), path.join(copy, 'ship', 'sections'), { recursive: true });
|
||||
const adversarial = path.join(copy, 'ship', 'sections', 'adversarial.md');
|
||||
fs.writeFileSync(adversarial, fs.readFileSync(adversarial, 'utf8').replace('Recommendation:', 'Recommendation (short):'));
|
||||
expect(hashes(copy)).not.toEqual(BRIEF_BODIES);
|
||||
expect(hashes(copy)).not.toEqual(PINNED.bodies);
|
||||
} finally {
|
||||
fs.rmSync(copy, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('every pinned reviewer brief, checklist and depth rule hashes to its pin', () => {
|
||||
const drift = Object.entries(REVIEWER_BRIEFS).filter(([file, pin]) => {
|
||||
const text = fs.readFileSync(path.join(ROOT, file), 'utf8').replace(/\r\n/g, '\n');
|
||||
return createHash('sha256').update(text).digest('hex') !== pin.sha;
|
||||
}).map(([file]) => `${file} (a repin needs a passing no-loss replay; the current pin names ${REVIEWER_BRIEFS[file]!.replay})`);
|
||||
expect(drift).toEqual([]);
|
||||
expect(PINNED.malformed).toEqual([]);
|
||||
expect(stalePins(PINNED, ROOT)).toEqual([]);
|
||||
});
|
||||
|
||||
test('every pin names the preregistered no-loss replay that admits its bytes', () => {
|
||||
const unnamed = Object.entries(REVIEWER_BRIEFS).filter(([, pin]) => !(pin.replay in NO_LOSS_REPLAYS)).map(([file]) => file);
|
||||
expect(unnamed).toEqual([]);
|
||||
for (const what of Object.values(NO_LOSS_REPLAYS)) expect(what).toMatch(/no-loss|re-score/);
|
||||
expect(unadmitted(PINNED)).toEqual([]);
|
||||
});
|
||||
|
||||
test('the pins cover every specialist checklist', () => {
|
||||
const specialists = fs.readdirSync(path.join(ROOT, 'review/specialists')).filter(f => f.endsWith('.md')).map(f => `review/specialists/${f}`);
|
||||
expect(specialists.filter(f => !(f in REVIEWER_BRIEFS))).toEqual([]);
|
||||
expect(specialists.filter(f => !Object.hasOwn(PINNED.files, f))).toEqual([]);
|
||||
});
|
||||
|
||||
test('every source a reviewer brief, checklist or depth rule comes from has a pin (GST-1084)', () => {
|
||||
expect(unpinned(PINNED, ROOT)).toEqual([]);
|
||||
});
|
||||
|
||||
// Value: protects=the one-file-per-pin layout keeps every check the in-file table had; fails_when=a stale sha, an unknown or
|
||||
// unjustified replay, a malformed or misnamed pin file, or a stray file passes; seam=readPins, stalePins and unadmitted, the
|
||||
// functions the checks above call; no_claim=that a repin's replay actually passed (the commit and PR carry that)
|
||||
test('a stale pin, an unknown or unjustified replay and a malformed pin file each fail (GST-1084)', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'review-pins-'));
|
||||
try {
|
||||
const src = path.join(tmp, 'src'), pins = path.join(tmp, 'pins');
|
||||
for (const file of Object.keys(PINNED.files)) fs.cpSync(path.join(ROOT, file), path.join(src, file));
|
||||
fs.cpSync(PINS, pins, { recursive: true });
|
||||
expect(stalePins(readPins(pins), src)).toEqual([]);
|
||||
fs.writeFileSync(path.join(src, 'review/checklist.md'), fs.readFileSync(path.join(src, 'review/checklist.md'), 'utf8').replace(/\n/g, '\r\n'));
|
||||
expect(stalePins(readPins(pins), src)).toEqual([]);
|
||||
fs.appendFileSync(path.join(src, 'review/checklist.md'), 'One more rule.\r\n');
|
||||
expect(stalePins(readPins(pins), src)).toEqual([`review/checklist.md (a repin needs a passing no-loss replay; the current pin names ${PINNED.files['review/checklist.md']!.replay})`]);
|
||||
|
||||
const pin = (file: string, value: unknown) => fs.writeFileSync(path.join(pins, 'files', `${file}.json`), `${JSON.stringify(value)}\n`);
|
||||
pin('review/design-checklist.md', { sha: PINNED.files['review/design-checklist.md']!.sha, replay: 'GST-0000' });
|
||||
pin('lib/review-risk.ts', { sha: PINNED.files['lib/review-risk.ts']!.sha, replay: 'toString' });
|
||||
fs.writeFileSync(path.join(pins, 'replays', 'GST-0001.json'), `${JSON.stringify({ replay: 'GST-0001', justification: 'reviewed by hand' })}\n`);
|
||||
expect(unadmitted(readPins(pins))).toEqual(['lib/review-risk.ts names unknown replay toString', 'review/design-checklist.md names unknown replay GST-0000',
|
||||
'replay GST-0001 is not a no-loss replay or re-score']);
|
||||
|
||||
expect(unpinned(readPins(pins), ROOT)).toEqual([]);
|
||||
fs.rmSync(path.join(pins, 'files', 'lib/review-risk.ts.json'));
|
||||
fs.rmSync(path.join(pins, 'files', 'review/specialists/testing.md.json'));
|
||||
expect(unpinned(readPins(pins), ROOT)).toEqual(['lib/review-risk.ts', 'review/specialists/testing.md']);
|
||||
|
||||
pin('review/gate-integrity.md', { ...PINNED.files['review/gate-integrity.md'], note: 'extra' });
|
||||
fs.writeFileSync(path.join(pins, 'replays', 'GST-0002.json'), `${JSON.stringify({ replay: 'GST-0003', justification: 'a no-loss replay' })}\n`);
|
||||
expect(readPins(pins).malformed).toEqual(['replays/GST-0002.json (replay GST-0003 belongs in replays/GST-0003.json)',
|
||||
'files/review/gate-integrity.md.json (expected exactly sha, replay)']);
|
||||
fs.writeFileSync(path.join(pins, 'files', 'review/gate-integrity.md.json'), '<<<<<<< a\n{}\n');
|
||||
expect(() => readPins(pins)).toThrow('files/review/gate-integrity.md.json: ');
|
||||
fs.rmSync(path.join(pins, 'files', 'review/gate-integrity.md.json'));
|
||||
fs.writeFileSync(path.join(pins, 'brief-bodies', 'specialist.txt'), 'stray\n');
|
||||
expect(() => readPins(pins)).toThrow('every pin file is <name>.json');
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Value: protects=PRs that repin different review files merge without a conflict (five main-merge conflicts on 2026-11-02 came
|
||||
// from the in-file table); fails_when=two repins of adjacent pins, each with its own new replay, conflict in a real merge, or
|
||||
// two repins of one pin merge silently; seam=git merge-tree --write-tree on two branches; no_claim=semantic merge correctness
|
||||
test('repins of two different files, each with its own new replay, merge cleanly in a real git merge (GST-1084)', () => {
|
||||
const repin = (file: string, sha: string, replay: string) => (root: string) => {
|
||||
fs.writeFileSync(path.join(root, 'files', `${file}.json`), `${JSON.stringify({ sha, replay })}\n`);
|
||||
fs.writeFileSync(path.join(root, 'replays', `${replaySlug(replay)}.json`), `${JSON.stringify({ replay, justification: `${replay} no-loss replay` })}\n`);
|
||||
};
|
||||
const seed = (root: string) => fs.cpSync(PINS, root, { recursive: true });
|
||||
const army = 'ship/sections/review-army.md', adversarial = 'ship/sections/adversarial.md';
|
||||
const merges: PinMerge[] = [];
|
||||
try {
|
||||
const clean = mergePinEdits(seed, repin(army, 'a'.repeat(64), 'GST-9001+GST-967'), repin(adversarial, 'b'.repeat(64), 'GST-9002'));
|
||||
merges.push(clean);
|
||||
const conflict = mergePinEdits(seed, repin(army, 'a'.repeat(64), 'GST-9001'), repin(army, 'c'.repeat(64), 'GST-9003'));
|
||||
merges.push(conflict);
|
||||
expect(clean.output).not.toContain('CONFLICT');
|
||||
expect(clean.clean).toBe(true);
|
||||
expect(JSON.parse(clean.read(`files/${army}.json`))).toEqual({ sha: 'a'.repeat(64), replay: 'GST-9001+GST-967' });
|
||||
expect(JSON.parse(clean.read(`files/${adversarial}.json`))).toEqual({ sha: 'b'.repeat(64), replay: 'GST-9002' });
|
||||
expect(JSON.parse(clean.read('replays/GST-9001+GST-967.json')).replay).toBe('GST-9001+GST-967');
|
||||
expect(JSON.parse(clean.read('replays/GST-9002.json')).replay).toBe('GST-9002');
|
||||
expect(conflict.clean).toBe(false);
|
||||
expect(conflict.output).toContain(`CONFLICT (content): Merge conflict in files/${army}.json`);
|
||||
} finally {
|
||||
for (const merge of merges) fs.rmSync(merge.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -64,7 +64,8 @@ describe('verifyRun', () => {
|
||||
['run_stale', world({ run: { ...base.run, updated_at: '2026-10-28T10:00:00Z' } })],
|
||||
['job_missing', world({ jobs: [{ name: 'lint', conclusion: 'success' }] })],
|
||||
['job_ambiguous', world({ jobs: [{ name: 'full-suite', conclusion: 'success' }, { name: 'full-suite', conclusion: 'success' }] })],
|
||||
['job_not_success', world({ jobs: [{ name: 'full-suite', conclusion: 'skipped' }] })],
|
||||
['job_skipped', world({ jobs: [{ name: 'full-suite', conclusion: 'skipped' }] })],
|
||||
['job_not_success', world({ jobs: [{ name: 'full-suite', conclusion: 'cancelled' }] })],
|
||||
['artifact_missing', world({ artifact: new Error('no artifact named full-suite-tree') })],
|
||||
['artifact_mismatch', world({ artifact: { ...(base.artifact as TreeRecord), run_id: 99 } })],
|
||||
['artifact_mismatch', world({ artifact: { ...(base.artifact as TreeRecord), run_attempt: 2 } })],
|
||||
|
||||
+455
-7
@@ -27,14 +27,15 @@ const runs = (f: ShipFx, name: string) => { const p = path.join(f.root, name); r
|
||||
const at = (minutesAgo: number) => new Date(Date.now() - minutesAgo * 60_000).toISOString();
|
||||
|
||||
/** One run of free.yml at HEAD (id 7 unless named), green with a tree.json naming HEAD's tree, unless overridden. */
|
||||
function world(f: ShipFx, over: { run?: Record<string, unknown>; tree?: Record<string, unknown>; others?: Array<Record<string, unknown>>; jobs?: Array<{ name: string; conclusion: string | null }> } = {}): void {
|
||||
function world(f: ShipFx, over: { run?: Record<string, unknown>; tree?: Record<string, unknown>; others?: Array<Record<string, unknown>>; jobs?: Array<{ name: string; conclusion: string | null }>; attempts?: Record<string, Array<{ name: string; conclusion: string | null }>> } = {}): void {
|
||||
const head = f.git('rev-parse', 'HEAD').stdout.trim();
|
||||
const tree = f.git('rev-parse', 'HEAD^{tree}').stdout.trim();
|
||||
const run = { id: 7, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'workflow_dispatch', head_sha: head, 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' }, ...over.run };
|
||||
const w: FakeWorld = {
|
||||
runs: [run, ...((over.others ?? []).map(o => ({ ...run, ...o })))] as FakeWorld['runs'],
|
||||
jobs: { 7: (over.jobs ?? [{ name: 'suite', conclusion: 'success' }]).map(j => ({ ...j, completed_at: at(5) })), 9: [{ name: 'suite', conclusion: 'failure', completed_at: at(1) }] },
|
||||
trees: { 7: { run_id: 7, run_attempt: 1, event: 'workflow_dispatch', sha: head, tree, files: ['test/a.test.ts', 'test/b.test.ts'], tests: 12, os: 'Linux', ...over.tree } },
|
||||
jobs: { 7: (over.jobs ?? [{ name: 'suite', conclusion: 'success' }]).map(j => ({ ...j, completed_at: at(5) })), 9: [{ name: 'suite', conclusion: 'failure', completed_at: at(1) }],
|
||||
...Object.fromEntries(Object.entries(over.attempts ?? {}).map(([k, js]) => [k, js.map(j => ({ ...j, completed_at: at(5) }))])) },
|
||||
trees: { 7: { run_id: 7, run_attempt: 1, event: run.event, sha: head, ...(run.event === 'pull_request' ? { head_sha: head } : {}), tree, files: ['test/a.test.ts', 'test/b.test.ts'], tests: 12, os: 'Linux', ...over.tree } },
|
||||
};
|
||||
const file = path.join(f.root, 'world.json');
|
||||
fs.writeFileSync(file, JSON.stringify(w));
|
||||
@@ -51,7 +52,7 @@ describe('Step 5 lanes: CI carry and the tree cache', () => {
|
||||
world(f);
|
||||
const first = preflight(f);
|
||||
expect(first.status).toBe(0);
|
||||
expect(lane(first.stdout, 'free')).toBe('LANE: free carried run=7/1 tests_ran=12');
|
||||
expect(lane(first.stdout, 'free')).toBe('LANE: free carried run=7/1 tests_ran=2 (files)');
|
||||
expect(lane(first.stdout, 'unit')).toMatch(/^LANE: unit pass exit=0 tests_ran=2 log=\S+$/);
|
||||
expect([runs(f, 'free-runs'), runs(f, 'unit-runs')]).toEqual([0, 1]);
|
||||
const again = preflight(f);
|
||||
@@ -103,7 +104,7 @@ describe('Step 5 lanes: CI carry and the tree cache', () => {
|
||||
expect(ev(['check', '--label', 'free', '--expect-cmd', FREE]).stdout).not.toContain('EVIDENCE: FRESH');
|
||||
expect(JSON.parse(ev(['carried', '--json']).stdout)).toEqual([]);
|
||||
const ok = ev(['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
|
||||
expect([ok.status, ok.stderr]).toEqual([0, expect.stringContaining('tests_ran=12 log=- source=ci runs=7/1 policy=origin/main@')]);
|
||||
expect([ok.status, ok.stderr]).toEqual([0, expect.stringContaining('tests_ran=2 unit=files log=- source=ci runs=7/1 policy=origin/main@')]);
|
||||
const row = JSON.parse(fs.readFileSync(findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl')[0]!, 'utf8').trim().split('\n').at(-1)!);
|
||||
expect(row.policy).toMatch(/^origin\/main@[0-9a-f]{12}:\.gstack\/pregate\.json$/);
|
||||
});
|
||||
@@ -151,8 +152,8 @@ describe('finish, push --early and accept (A1)', () => {
|
||||
world(f);
|
||||
const ok = f.run(RELEASE, ['accept', '--base', 'main', '--lane', `free=${FREE}`, '--wait', '0']);
|
||||
expect(ok.stdout).toContain('CI_WAIT: green ci/free run=7/1');
|
||||
expect(lane(ok.stdout, 'free')).toBe('LANE: free carried run=7/1 tests_ran=12');
|
||||
expect(lineOf(ok.stdout, 'EVIDENCE')).toMatch(/^EVIDENCE: FRESH label=free exit=0 tests_ran=12 .* source=ci runs=7\/1$/);
|
||||
expect(lane(ok.stdout, 'free')).toBe('LANE: free carried run=7/1 tests_ran=2 (files)');
|
||||
expect(lineOf(ok.stdout, 'EVIDENCE')).toMatch(/^EVIDENCE: FRESH label=free exit=0 tests_ran=2 unit=files .* source=ci runs=7\/1$/);
|
||||
expect(ok.stdout).toContain('CI_CARRY: accepted ci-carry tree=');
|
||||
expect(ok.stdout).toContain('NEXT: Step 18');
|
||||
expect(ok.status).toBe(0);
|
||||
@@ -192,3 +193,450 @@ describe('gstack-pregate tests stage and gstack-ci-carry', () => {
|
||||
expect(cli(['bogus']).status).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GST-1186: pregate guards carry like lanes (founderstar shape: guards only, no lanes)', () => {
|
||||
// Value: protects=a guard whose exact command the base's ship_lanes_from maps is carried from the verified CI run at this tree, and a guard
|
||||
// whose exact command already passed at this tree in the ledger is reused, so guard-only repos stop rerunning suites CI or /ship already ran;
|
||||
// fails_when=check-quick reruns beside a green CI run, play-through reruns after a same-tree ledger pass, or a dirty tree still carries;
|
||||
// why_new=FOUN-74 (founderstar GST-1184): about 6 minutes of guard suites rerun per /ship; seam=gstack-evidence record-ci and passed; no_claim=CI
|
||||
const script = (name: string, tests: number) => `printf x >> ../${name}-runs\nprintf ' ${tests} pass\\n 0 fail\\nRan ${tests} tests across 1 file. [1.00ms]\\n' >&2\n`;
|
||||
const CHECK = 'bash tests/check.sh --quick';
|
||||
const PLAY = 'bash tests/play-through.sh';
|
||||
const guard = (id: string, command: string) => ({ id, when: ['src/**'], command, fix: `rerun ${id}` });
|
||||
const FOUNDERSTAR = {
|
||||
...BASE,
|
||||
'.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [CHECK]: { profiles: ['ci/free'], inventory: "printf 'test/a.test.ts\\ntest/b.test.ts\\n'" } },
|
||||
guards: [guard('check-quick', CHECK), guard('play-through', PLAY), guard('assets', 'bash tests/assets.sh')] }, null, 2) + '\n',
|
||||
'tests/check.sh': script('check', 4), 'tests/play-through.sh': script('play', 2), 'tests/assets.sh': script('assets', 1),
|
||||
};
|
||||
const guards = (f: ShipFx) => f.run(path.join(BIN, 'gstack-pregate'), ['--only', 'guards', '--allow-repo-commands', '--base', 'main']);
|
||||
const counts = (f: ShipFx) => [runs(f, 'check-runs'), runs(f, 'play-runs'), runs(f, 'assets-runs')];
|
||||
|
||||
test('check-quick carries from CI, play-through reuses a same-tree ledger pass, assets runs; a dirty tree carries nothing', () => {
|
||||
const f = shipFixture(FOUNDERSTAR); fixtures.push(f);
|
||||
world(f);
|
||||
const first = guards(f);
|
||||
expect(first.status, first.stdout + first.stderr).toBe(0);
|
||||
expect(first.stdout).toContain('GUARD: check-quick carried runs=7/1');
|
||||
expect(first.stdout).toContain('3 guard(s) passed: check-quick, play-through, assets (1 from an earlier pass at this tree)');
|
||||
expect(counts(f)).toEqual([0, 1, 1]);
|
||||
expect(f.run(path.join(BIN, 'gstack-evidence'), ['run', '--label', 'play', '--', PLAY]).status).toBe(0);
|
||||
const second = guards(f);
|
||||
expect(second.status).toBe(0);
|
||||
expect(second.stdout).toMatch(/GUARD: play-through reused label=play source=local ts=\S+/);
|
||||
expect(counts(f)).toEqual([0, 2, 2]);
|
||||
f.write({ 'src/b.ts': 'export const b = 3;\n' });
|
||||
const dirty = guards(f);
|
||||
expect(dirty.stdout).toMatch(/CI_CARRY: none guard=check-quick \(dirty_tree/);
|
||||
expect(dirty.stdout).not.toContain('reused');
|
||||
expect(counts(f)).toEqual([1, 3, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GST-1189: a lane carries by named CI jobs instead of an inventory', () => {
|
||||
// Value: protects=a ship_lanes_from entry that declares { profiles, jobs } carries only when every named job succeeded in the newest attempt
|
||||
// of the verified run at HEAD's tree, and refuses with a named reason otherwise; fails_when=a typecheck-only lane cannot carry (no test
|
||||
// files to inventory), or carries beside a missing, skipped or older-attempt job, or another tree's run;
|
||||
// why_new=gbrain run 41564913688 (tree 13570abc): verify and typecheck passed in CI but no inventory could name their files; seam=fake gh
|
||||
const VERIFY = 'bun run verify';
|
||||
const pregateJson = (entry: Record<string, unknown>) => JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [VERIFY]: { profiles: ['ci/free'], ...entry } } }, null, 2) + '\n';
|
||||
const GBRAIN = { ...BASE, '.gstack/pregate.json': pregateJson({ jobs: ['verify', 'typecheck'] }) };
|
||||
const ALL = [{ name: 'suite', conclusion: 'success' }, { name: 'verify', conclusion: 'success' }, { name: 'typecheck', conclusion: 'success' }];
|
||||
const NO_FILES = { files: [], tests: 0 };
|
||||
const gbrain = () => { const f = shipFixture(GBRAIN); fixtures.push(f); return f; };
|
||||
const find = (f: ShipFx) => f.run(path.join(BIN, 'gstack-ci-carry'), ['find', '--base', 'main', '--command', VERIFY, '--allow-repo-commands']);
|
||||
|
||||
test('every named job green at the same tree carries, with no test files; record-ci records the jobs', () => {
|
||||
const f = gbrain();
|
||||
world(f, { jobs: ALL, tree: NO_FILES });
|
||||
const ok = find(f);
|
||||
expect([ok.status, ok.stdout]).toEqual([0, expect.stringMatching(/^CI_CARRY: accepted ci-carry tree=[0-9a-f]{12} jobs=verify,typecheck runs=7\/1 profiles=ci\/free /m)]);
|
||||
const rec = f.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'verify', '--base', 'main', '--', VERIFY]);
|
||||
expect([rec.status, rec.stderr]).toEqual([0, expect.stringContaining('tests_ran=n/a log=- source=ci runs=7/1')]);
|
||||
const row = JSON.parse(fs.readFileSync(findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl')[0]!, 'utf8').trim().split('\n').at(-1)!);
|
||||
expect([row.jobs, row.tests_ran]).toEqual([['verify', 'typecheck'], 'n/a']);
|
||||
});
|
||||
|
||||
test('a missing job refuses as job_missing', () => {
|
||||
const f = gbrain();
|
||||
world(f, { jobs: ALL.filter(j => j.name !== 'typecheck'), tree: NO_FILES });
|
||||
expect(find(f).stdout).toContain('CI_CARRY: none (no_verified_run: ci/free: run 7: job_missing)');
|
||||
});
|
||||
|
||||
test('a skipped job refuses as job_skipped', () => {
|
||||
const f = gbrain();
|
||||
world(f, { jobs: ALL.map(j => j.name === 'typecheck' ? { ...j, conclusion: 'skipped' } : j), tree: NO_FILES });
|
||||
const r = find(f);
|
||||
expect([r.status, r.stdout]).toEqual([1, expect.stringContaining('CI_CARRY: none (no_verified_run: ci/free: run 7: job_skipped)')]);
|
||||
});
|
||||
|
||||
test('only the newest attempt counts: a rerun that passed carries, a rerun that skipped the job does not', () => {
|
||||
const f = gbrain();
|
||||
const failedFirst = ALL.map(j => j.name === 'typecheck' ? { ...j, conclusion: 'failure' } : j);
|
||||
world(f, { run: { run_attempt: 2 }, tree: { ...NO_FILES, run_attempt: 2 }, attempts: { '7/1': failedFirst, '7/2': ALL } });
|
||||
expect(find(f).stdout).toMatch(/^CI_CARRY: accepted ci-carry tree=[0-9a-f]{12} jobs=verify,typecheck runs=7\/2 /m);
|
||||
world(f, { run: { run_attempt: 2 }, tree: { ...NO_FILES, run_attempt: 2 }, attempts: { '7/1': ALL, '7/2': ALL.map(j => j.name === 'typecheck' ? { ...j, conclusion: 'skipped' } : j) } });
|
||||
expect(find(f).stdout).toContain('CI_CARRY: none (no_verified_run: ci/free: run 7: job_skipped)');
|
||||
});
|
||||
|
||||
test('a run that tested another tree refuses as tree_mismatch', () => {
|
||||
const f = gbrain();
|
||||
world(f, { jobs: ALL, tree: { ...NO_FILES, tree: '1'.repeat(40) } });
|
||||
expect(find(f).stdout).toContain('CI_CARRY: none (no_verified_run: ci/free: run 7: tree_mismatch)');
|
||||
});
|
||||
|
||||
test('an entry declares exactly one of inventory or jobs', () => {
|
||||
const both = shipFixture({ ...BASE, '.gstack/pregate.json': pregateJson({ jobs: ['verify'], inventory: 'printf x' }) }); fixtures.push(both);
|
||||
world(both, { jobs: ALL, tree: NO_FILES });
|
||||
expect(find(both).stdout).toContain('CI_CARRY: none (profiles_unavailable');
|
||||
const neither = shipFixture({ ...BASE, '.gstack/pregate.json': pregateJson({}) }); fixtures.push(neither);
|
||||
world(neither, { jobs: ALL, tree: NO_FILES });
|
||||
expect(find(neither).stdout).toContain('CI_CARRY: none (profiles_unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GST-1190: aliases, newer-gstack keys, narrow counts, and proving a mapping without carrying it', () => {
|
||||
// Value: protects=a lane carries under an exact alias the base declares (never an implicit env-prefix rewrite); a narrow lane never reports
|
||||
// the whole run's test count; an unknown key under a project floor above this gstack says to upgrade; --simulate proves a head map while
|
||||
// writing and clearing nothing; carried never lists a row another base's policy mapped;
|
||||
// fails_when=an env-prefixed lane reruns beside a green run, a 46-test lane records 9677, a proof run's rows land in the PR's own receipt,
|
||||
// or a branch's own map carries; why_new=HARN-7 (harness-evals #240), ALPH-14 (alphaclaw #310), GEVA-235 (gstack-evals #180), GGST-15; seam=fake gh
|
||||
const ev = (f: ShipFx, args: string[]) => f.run(path.join(BIN, 'gstack-evidence'), args);
|
||||
const ledger = (f: ShipFx) => fs.existsSync(path.join(f.state, 'projects')) ? findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl') : [];
|
||||
const ALL = [{ name: 'suite', conclusion: 'success' }, { name: 'typecheck', conclusion: 'success' }];
|
||||
|
||||
test('a declared alias carries like its key; an undeclared env prefix does not', () => {
|
||||
const TESTS = 'bun test tests/';
|
||||
const ALIAS = 'env -u ANTHROPIC_API_KEY bun test tests/';
|
||||
const f = shipFixture({ ...BASE, '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [TESTS]: { profiles: ['ci/free'], inventory: "printf 'test/a.test.ts\\n'", aliases: [ALIAS] } } }) + '\n' }); fixtures.push(f);
|
||||
world(f);
|
||||
const rec = ev(f, ['record-ci', '--label', 'tests', '--base', 'main', '--', ALIAS]);
|
||||
expect([rec.status, rec.stderr]).toEqual([0, expect.stringContaining('tests_ran=1 unit=files log=- source=ci runs=7/1')]);
|
||||
const find = (command: string) => f.run(path.join(BIN, 'gstack-ci-carry'), ['find', '--base', 'main', '--command', command, '--allow-repo-commands']).stdout;
|
||||
expect(find(ALIAS)).toMatch(/^CI_CARRY: accepted ci-carry tree=[0-9a-f]{12} files=1 /m);
|
||||
expect(find('env -i HOME=/tmp bun test tests/')).toContain('CI_CARRY: none (not_mapped');
|
||||
});
|
||||
|
||||
test('an alias that repeats a key is invalid config', () => {
|
||||
const f = shipFixture({ ...BASE, '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { a: { profiles: ['ci/free'], jobs: ['suite'] }, b: { profiles: ['ci/free'], jobs: ['suite'], aliases: ['a'] } } }) + '\n' }); fixtures.push(f);
|
||||
world(f);
|
||||
expect(f.run(path.join(BIN, 'gstack-ci-carry'), ['find', '--base', 'main']).stdout).toContain('CI_CARRY: none (profiles_unavailable');
|
||||
});
|
||||
|
||||
test('a carried narrow lane counts its covered inventory files, never the run total (alphaclaw: 46 tests recorded as 9677)', () => {
|
||||
const f = fx();
|
||||
world(f, { tree: { files: ['test/a.test.ts', 'test/b.test.ts', 'test/c.test.ts'], tests: 9677 } });
|
||||
const rec = ev(f, ['record-ci', '--label', 'guards', '--base', 'main', '--', FREE]);
|
||||
expect([rec.status, rec.stderr]).toEqual([0, expect.stringContaining('tests_ran=2 unit=files log=- source=ci')]);
|
||||
expect(rec.stderr).not.toContain('9677');
|
||||
});
|
||||
|
||||
test('a jobs-mapped guard carries in the guards stage (harness-evals: bunx tsc --noEmit) and reports n/a', () => {
|
||||
const TSC = 'bunx tsc --noEmit';
|
||||
const f = shipFixture({ ...BASE, '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [TSC]: { profiles: ['ci/free'], jobs: ['typecheck'] } }, guards: [{ id: 'typecheck', when: ['src/**'], command: TSC, fix: 'bunx tsc --noEmit' }] }) + '\n' }); fixtures.push(f);
|
||||
world(f, { jobs: ALL, tree: { files: [], tests: 0 } });
|
||||
const g = f.run(path.join(BIN, 'gstack-pregate'), ['--only', 'guards', '--allow-repo-commands', '--base', 'main']);
|
||||
expect([g.status, g.stdout]).toEqual([0, expect.stringContaining('GUARD: typecheck carried runs=7/1')]);
|
||||
const row = JSON.parse(fs.readFileSync(ledger(f)[0]!, 'utf8').trim().split('\n').at(-1)!);
|
||||
expect([row.tests_ran, row.jobs]).toEqual(['n/a', ['typecheck']]);
|
||||
});
|
||||
|
||||
test('an unknown key under a project floor above this gstack says to upgrade; without the floor it stays the generic error', () => {
|
||||
const conf = JSON.stringify({ future_lanes: {} }) + '\n';
|
||||
const floor = shipFixture({}, { '.gstack/pregate.json': conf, '.gstack/ship-policy.json': JSON.stringify({ min_gstack_version: '99.0.0.0' }) + '\n' }); fixtures.push(floor);
|
||||
const newer = floor.run(path.join(BIN, 'gstack-pregate'), ['validate']);
|
||||
expect(newer.status).not.toBe(0);
|
||||
expect(newer.stderr).toContain('needs gstack >= 99.0.0.0; upgrade');
|
||||
expect(newer.stderr).toContain('(PREGATE_GSTACK_TOO_OLD)');
|
||||
const plain = shipFixture({}, { '.gstack/pregate.json': conf }); fixtures.push(plain);
|
||||
const typo = plain.run(path.join(BIN, 'gstack-pregate'), ['validate']);
|
||||
expect([typo.stderr.includes('(PREGATE_CONFIG_INVALID)'), typo.stderr.includes('needs gstack')]).toEqual([true, false]);
|
||||
});
|
||||
|
||||
test('--simulate proves the branch map: every line says SIMULATION, nothing is written, nothing clears, and head policy never carries', () => {
|
||||
const VERIFY = 'bun run verify';
|
||||
const head = JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [VERIFY]: { profiles: ['ci/free'], jobs: ['typecheck'] } } }) + '\n';
|
||||
const f = shipFixture({ '.github/workflows/free.yml': BASE['.github/workflows/free.yml'] }, { '.gstack/pregate.json': head }); fixtures.push(f);
|
||||
world(f, { jobs: ALL, tree: { files: [], tests: 0 } });
|
||||
const sim = f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--policy-from', 'head', '--simulate', '--lane', `verify=${VERIFY}`, '--lane', `unit=${UNIT}`]);
|
||||
const lines = sim.stdout.trim().split('\n');
|
||||
expect(lines.every(l => l.startsWith('SIMULATION: ')), sim.stdout + sim.stderr).toBe(true);
|
||||
expect(sim.stdout).toContain('SIMULATION: LANE: verify would carry run=7/1 tests_ran=n/a');
|
||||
expect(sim.stdout).toContain('SIMULATION: LANE: unit not mapped');
|
||||
expect([ledger(f), runs(f, 'unit-runs'), fs.existsSync(path.join(f.work, '.gstack', 'tmp', 'pregate.json'))]).toEqual([[], 0, false]);
|
||||
expect(JSON.parse(ev(f, ['carried', '--json']).stdout)).toEqual([]);
|
||||
const real = ev(f, ['record-ci', '--label', 'verify', '--base', 'main', '--', VERIFY]);
|
||||
expect([real.status, real.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: not_mapped: /m)]);
|
||||
expect(f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--simulate', '--lane', `verify=${VERIFY}`]).status).toBe(2);
|
||||
});
|
||||
|
||||
test('carried lists only rows the receipt base mapped: a proof run against the PR branch never reaches its receipt', () => {
|
||||
const f = fx();
|
||||
expect(f.git('push', '-q', 'origin', 'feat').status).toBe(0);
|
||||
world(f);
|
||||
expect(ev(f, ['record-ci', '--label', 'free', '--base', 'feat', '--', FREE]).status).toBe(0);
|
||||
expect(JSON.parse(ev(f, ['carried', '--base', 'main', '--json']).stdout)).toEqual([]);
|
||||
expect(JSON.parse(ev(f, ['carried', '--base', 'feat', '--json']).stdout)).toEqual([expect.objectContaining({ lane: 'free', run: 7, attempt: 1 })]);
|
||||
expect(JSON.parse(ev(f, ['carried', '--json']).stdout)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GST-1190: a file whose every test skipped on CI is not carried', () => {
|
||||
// Value: protects=a lane never carries a test file that ran nothing on the CI runner (Docker images, drive paths, an installed gstack), so
|
||||
// those tests run here instead of silently dropping; a partly skipped file and an older tree.json without skipped_files carry as before;
|
||||
// fails_when=harness-evals' 75 runner-skipped tests carry as passed; why_new=HARN-7 (harness-evals #240); seam=fake gh
|
||||
test('a fully skipped inventory file runs the lane here; a partly skipped one (not listed) and an older tree.json still carry', () => {
|
||||
const f = fx();
|
||||
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
|
||||
const rec = f.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
|
||||
expect([rec.status, rec.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: inventory_skipped: 1 of 2 inventory file\(s\) skipped every test in CI and the lane declares no per_file runner: test\/b\.test\.ts$/m)]);
|
||||
const out = preflight(f, [`free=${FREE}`]);
|
||||
expect(out.stdout).toContain('CI_CARRY: none label=free (inventory_skipped');
|
||||
expect(runs(f, 'free-runs')).toBe(1);
|
||||
world(f, { tree: { skipped_files: [] } });
|
||||
expect(f.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]).status).toBe(0);
|
||||
world(f, { tree: { skipped_files: 'test/b.test.ts' } });
|
||||
expect(f.run(path.join(BIN, 'gstack-ci-carry'), ['find', '--base', 'main', '--command', FREE, '--allow-repo-commands']).stdout).toContain('artifact_mismatch');
|
||||
});
|
||||
|
||||
test('the tests stage runs a selected file the run skipped entirely, and accepts the rest', () => {
|
||||
const conf = JSON.stringify({ runner: 'bun test {files}', dependencies: { 'src/**': ['test/a.test.ts', 'test/b.test.ts'] }, run_profiles: { 'ci/free': PROFILE } }) + '\n';
|
||||
const t = "import { test, expect } from 'bun:test';\ntest('x', () => expect(1).toBe(1));\n";
|
||||
const f = shipFixture({ ...BASE, '.gstack/pregate.json': conf, 'test/a.test.ts': t, 'test/b.test.ts': t }); fixtures.push(f);
|
||||
const stage = () => f.run(path.join(BIN, 'gstack-pregate'), ['--stage', 'tests', '--allow-repo-commands', '--base', 'main']);
|
||||
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
|
||||
const skipped = stage();
|
||||
expect(skipped.stdout + skipped.stderr).toMatch(/REUSE: accepted 1 of 2 selected local test file\(s\) from run .*; 1 still run \(1 skipped every test in the run\)/);
|
||||
world(f);
|
||||
expect(stage().stdout + stage().stderr).toMatch(/REUSE: accepted 2 of 2 selected local test file\(s\) from run .*; 0 still run/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GST-1190: a path-skipped CI job counts only under an unchanged gate', () => {
|
||||
// Value: protects=a lane whose CI job the workflow skipped by path is not owed only when the gate job that decided the skip succeeded and the
|
||||
// workflow and the gate's scripts are byte-identical to origin/<base>; otherwise the branch controlled the skip and the lane runs here;
|
||||
// fails_when=a branch edits the workflow or the gate script to skip its own typecheck and the lane still passes; why_new=TRUT-7 (truther #1169); seam=fake gh
|
||||
const TSC = 'bunx tsc --noEmit';
|
||||
const SKIP_PROFILE = { ...PROFILE, jobs: ['suite'], skippable: { jobs: ['typecheck'], gate: 'changes', scripts: ['scripts/changes.sh'] } };
|
||||
const WORKFLOW = 'name: Free\non: [pull_request, workflow_dispatch]\njobs:\n changes:\n runs-on: ubuntu-latest\n steps:\n - run: bash scripts/changes.sh\n suite:\n runs-on: ubuntu-latest\n steps:\n - run: bun test\n typecheck:\n needs: changes\n runs-on: ubuntu-latest\n steps:\n - run: bunx tsc --noEmit\n';
|
||||
const SKIP_BASE = {
|
||||
'.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': SKIP_PROFILE }, ship_lanes_from: { [TSC]: { profiles: ['ci/free'], jobs: ['typecheck'] } } }) + '\n',
|
||||
'.github/workflows/free.yml': WORKFLOW,
|
||||
'scripts/changes.sh': 'echo "typecheck=false" >> "$GITHUB_OUTPUT"\n',
|
||||
};
|
||||
const JOBS = (gate: string) => [{ name: 'changes', conclusion: gate }, { name: 'suite', conclusion: 'success' }, { name: 'typecheck', conclusion: 'skipped' }];
|
||||
const setup = (feat: Record<string, string> = { 'src/b.ts': 'export const b = 2;\n' }, gate = 'success', event = 'pull_request') => {
|
||||
const f = shipFixture(SKIP_BASE, feat); fixtures.push(f);
|
||||
world(f, { jobs: JOBS(gate), run: { event }, tree: { files: [], tests: 0, event } });
|
||||
return f;
|
||||
};
|
||||
const lane = (f: ShipFx) => preflight(f, [`tsc=${TSC}`]);
|
||||
|
||||
test('a skip the unchanged gate decided is not owed', () => {
|
||||
const f = setup();
|
||||
const out = lane(f);
|
||||
expect(out.stdout).toContain('LANE: tsc not owed: CI skipped at this tree (gate job changes, workflow unchanged) run=7/1');
|
||||
const row = JSON.parse(fs.readFileSync(findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl')[0]!, 'utf8').trim().split('\n').at(-1)!);
|
||||
expect([row.tests_ran, row.not_owed]).toEqual(['n/a', 'CI skipped at this tree (gate job changes, workflow unchanged)']);
|
||||
});
|
||||
|
||||
test('a branch that changed the workflow decided its own skip: the lane runs here', () => {
|
||||
const f = setup({ '.github/workflows/free.yml': WORKFLOW + '# skip more\n' });
|
||||
expect(lane(f).stdout).toContain('CI_CARRY: none label=tsc (no_verified_run: ci/free: run 7: job_skipped)');
|
||||
});
|
||||
|
||||
test('a branch that changed the gate script decided its own skip: the lane runs here', () => {
|
||||
const f = setup({ 'scripts/changes.sh': 'echo "typecheck=false" >> "$GITHUB_OUTPUT" # always\n' });
|
||||
expect(lane(f).stdout).toContain('CI_CARRY: none label=tsc (no_verified_run: ci/free: run 7: job_skipped)');
|
||||
});
|
||||
|
||||
test('a gate job that failed decided nothing: the lane runs here', () => {
|
||||
const f = setup(undefined, 'failure');
|
||||
expect(lane(f).stdout).toContain('CI_CARRY: none label=tsc (no_verified_run: ci/free: run 7: job_skipped)');
|
||||
});
|
||||
|
||||
test('only a pull_request run\'s skip counts: a push or dispatch gate diffs against the last push, not the PR base', () => {
|
||||
const f = setup(undefined, 'success', 'workflow_dispatch');
|
||||
expect(lane(f).stdout).toContain('CI_CARRY: none label=tsc (no_verified_run: ci/free: run 7: job_skipped)');
|
||||
});
|
||||
|
||||
test('gstack-ci-carry find --policy-from head --simulate checks the branch map, says SIMULATION on every line and writes nothing', () => {
|
||||
const VERIFY = 'bun run verify';
|
||||
const head = JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [VERIFY]: { profiles: ['ci/free'], jobs: ['suite'] } } }) + '\n';
|
||||
const f = shipFixture({ '.github/workflows/free.yml': BASE['.github/workflows/free.yml'] }, { '.gstack/pregate.json': head }); fixtures.push(f);
|
||||
world(f, { tree: { files: [], tests: 0 } });
|
||||
const cli = (args: string[]) => f.run(path.join(BIN, 'gstack-ci-carry'), args);
|
||||
const sim = cli(['find', '--base', 'main', '--policy-from', 'head', '--simulate', '--command', VERIFY, '--allow-repo-commands']);
|
||||
expect(sim.status).toBe(0);
|
||||
expect(sim.stdout.trim().split('\n').every(l => l.startsWith('SIMULATION: '))).toBe(true);
|
||||
expect(sim.stdout).toMatch(/^SIMULATION: CI_CARRY: accepted ci-carry tree=[0-9a-f]{12} jobs=suite /m);
|
||||
expect(fs.existsSync(path.join(f.state, 'projects')) ? findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl') : []).toEqual([]);
|
||||
expect(cli(['find', '--base', 'main', '--command', VERIFY, '--allow-repo-commands']).stdout).toContain('CI_CARRY: none (not_mapped');
|
||||
expect(cli(['find', '--base', 'main', '--simulate']).status).toBe(2);
|
||||
expect(cli(['wait', '--base', 'main', '--policy-from', 'head', '--simulate']).status).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GST-1190: a lane carries the covered files and runs only the skipped remainder with its per_file runner', () => {
|
||||
// Value: protects=a fully skipped CI file runs here through the lane's declared per_file runner while the rest carries, a failing remainder
|
||||
// carries nothing, and a lane with no runner fails closed by name; fails_when=gstack's 6 runner-skipped files make the whole free suite rerun,
|
||||
// or a remainder failure still records a carried pass; why_new=HARN-7 decision (GSTA-258, per-file lane carry); seam=fake gh, gstack-evidence record-ci
|
||||
const PER = (exit: number) => `printf '%s\\n' {files} >> ../perfile-args; printf ' 1 pass\\n 0 fail\\nRan 1 tests across 1 file. [1.00ms]\\n' >&2; exit ${exit}`;
|
||||
const withRunner = (exit: number) => ({ ...BASE, '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [FREE]: { profiles: ['ci/free'], inventory: "printf 'test/a.test.ts\\ntest/b.test.ts\\n'", per_file: PER(exit) } } }, null, 2) + '\n' });
|
||||
const args = (f: ShipFx) => { const p = path.join(f.root, 'perfile-args'); return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : ''; };
|
||||
const rows = (f: ShipFx) => findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl').flatMap(p => fs.readFileSync(p, 'utf8').trim().split('\n').map(l => JSON.parse(l)));
|
||||
|
||||
test('the covered file carries; only the skipped file runs here, and both rows are recorded', () => {
|
||||
const f = shipFixture(withRunner(0)); fixtures.push(f);
|
||||
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
|
||||
const out = preflight(f, [`free=${FREE}`]);
|
||||
expect(out.status, out.stdout + out.stderr).toBe(0);
|
||||
expect(lane(out.stdout, 'free')).toBe('LANE: free carried run=7/1 tests_ran=1 (files) skipped_here=1 file(s) tests_ran_here=1');
|
||||
expect([args(f), runs(f, 'free-runs')]).toEqual(['test/b.test.ts\n', 0]);
|
||||
const all = rows(f);
|
||||
expect(all.find(r => r.source === 'ci')).toMatchObject({ label: 'free', tests_ran: 1, tests_unit: 'files', skipped_here: { label: 'free.skipped', files: ['test/b.test.ts'], tests_ran: 1 } });
|
||||
expect(all.find(r => r.label === 'free.skipped')).toMatchObject({ exit: 0, tests_ran: 1 });
|
||||
});
|
||||
|
||||
test('a failing remainder carries nothing: no CI row, and the whole lane runs here', () => {
|
||||
const f = shipFixture(withRunner(1)); fixtures.push(f);
|
||||
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
|
||||
const rec = f.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
|
||||
expect([rec.status, rec.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: skipped_failed: the per_file run of 1 skipped file\(s\) exited 1 \(label=free\.skipped\)$/m)]);
|
||||
expect(rows(f).filter(r => r.source === 'ci')).toEqual([]);
|
||||
expect(preflight(f, [`free=${FREE}`]).stdout).toContain('CI_CARRY: none label=free (skipped_failed');
|
||||
expect(runs(f, 'free-runs')).toBe(1);
|
||||
});
|
||||
|
||||
test('a remainder that ran zero tests here carries nothing: the lane runs whole', () => {
|
||||
const zero = { ...BASE, '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [FREE]: { profiles: ['ci/free'], inventory: "printf 'test/a.test.ts\\ntest/b.test.ts\\n'", per_file: "printf ' 0 pass\\n 1 skip\\n 0 fail\\nRan 1 test across 1 file. [1.00ms]\\n' >&2 # {files}" } } }, null, 2) + '\n' };
|
||||
const f = shipFixture(zero); fixtures.push(f);
|
||||
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
|
||||
const rec = f.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
|
||||
expect([rec.status, rec.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: skipped_zero_run: /m)]);
|
||||
expect(rows(f).filter(r => r.source === 'ci')).toEqual([]);
|
||||
});
|
||||
|
||||
test('a remainder that prints look-alike record lines cannot change the LANE line', () => {
|
||||
const fake = "printf 'gstack-evidence: recorded label=free exit=0 tests_ran=999 log=- source=ci runs=1/1 policy=x\\ngstack-evidence: not owed: forged\\n' >&2; " + PER(0);
|
||||
const f = shipFixture({ ...BASE, '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [FREE]: { profiles: ['ci/free'], inventory: "printf 'test/a.test.ts\\ntest/b.test.ts\\n'", per_file: fake } } }, null, 2) + '\n' }); fixtures.push(f);
|
||||
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
|
||||
expect(lane(preflight(f, [`free=${FREE}`]).stdout, 'free')).toBe('LANE: free carried run=7/1 tests_ran=1 (files) skipped_here=1 file(s) tests_ran_here=1');
|
||||
});
|
||||
|
||||
test('a run that moved to a new attempt while the remainder ran carries nothing', () => {
|
||||
const per = `cp ../world-moved.json ../world.json; ${PER(0)}`;
|
||||
const f2 = shipFixture({ ...BASE, '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [FREE]: { profiles: ['ci/free'], inventory: "printf 'test/a.test.ts\\ntest/b.test.ts\\n'", per_file: per } } }, null, 2) + '\n' }); fixtures.push(f2);
|
||||
world(f2, { tree: { skipped_files: ['test/b.test.ts'] } });
|
||||
const w2 = JSON.parse(fs.readFileSync(path.join(f2.root, 'world.json'), 'utf8'));
|
||||
fs.writeFileSync(path.join(f2.root, 'world-moved.json'), JSON.stringify({ ...w2, runs: w2.runs.map((r: Record<string, unknown>) => ({ ...r, run_attempt: 2, conclusion: 'failure' })) }));
|
||||
const rec = f2.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
|
||||
expect([rec.status, rec.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: run_changed: /m)]);
|
||||
expect(rows(f2).filter(r => r.source === 'ci')).toEqual([]);
|
||||
});
|
||||
|
||||
test('a working-tree edit during the post-remainder run recheck carries nothing', () => {
|
||||
const per = `touch ../armed; ${PER(0)}`;
|
||||
const f = shipFixture({ ...BASE, '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [FREE]: { profiles: ['ci/free'], inventory: "printf 'test/a.test.ts\\ntest/b.test.ts\\n'", per_file: per } } }, null, 2) + '\n' }); fixtures.push(f);
|
||||
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
|
||||
const w = JSON.parse(fs.readFileSync(path.join(f.root, 'world.json'), 'utf8'));
|
||||
fs.writeFileSync(path.join(f.root, 'world.json'), JSON.stringify({ ...w, editWhenArmed: { marker: path.join(f.root, 'armed'), file: path.join(f.work, 'src/b.ts'), content: 'export const b = 3;\n' } }));
|
||||
const rec = f.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
|
||||
expect([rec.status, rec.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: tree_changed: /m)]);
|
||||
expect(rows(f).filter(r => r.source === 'ci')).toEqual([]);
|
||||
});
|
||||
|
||||
test('per_file is validated: it needs {files} and an inventory entry', async () => {
|
||||
const { shipLanesErrors } = await import('../lib/ci-carry');
|
||||
expect(shipLanesErrors({ x: { profiles: ['p'], inventory: 'ls', per_file: 'bun test {files}' } }, ['p'])).toEqual([]);
|
||||
expect(shipLanesErrors({ x: { profiles: ['p'], inventory: 'ls', per_file: 'bun test' } }, ['p'])).toEqual([expect.objectContaining({ path: 'ship_lanes_from["x"].per_file' })]);
|
||||
expect(shipLanesErrors({ x: { profiles: ['p'], jobs: ['j'], per_file: 'bun test {files}' } }, ['p'])).toEqual([expect.objectContaining({ path: 'ship_lanes_from["x"].per_file' })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GSTA-258 L6 review fixes: gate inputs, ledger CI rows, simulate consent', () => {
|
||||
// Value: protects=a branch cannot move a path skip through a gate file gstack failed to compare, a stale CI ledger row never passes a guard
|
||||
// after record-ci refused, and --simulate runs a branch's inventory only with --allow-repo-commands; fails_when=the l6c-p1 reproductions pass
|
||||
// again; why_new=l6c-p1 findings (readers 15 and 16); seam=fake gh, gstack-evidence
|
||||
const TSC = 'bunx tsc --noEmit';
|
||||
const flow = (gateJob: string, before = '') => `name: Free\non: [pull_request, workflow_dispatch]\njobs:\n${before} changes:\n runs-on: ubuntu-latest\n${gateJob} suite:\n runs-on: ubuntu-latest\n steps:\n - run: bun test\n typecheck:\n needs: changes\n runs-on: ubuntu-latest\n steps:\n - run: bunx tsc --noEmit\n`;
|
||||
const profile = (scripts: string[] | undefined) => ({ ...PROFILE, jobs: ['suite'], skippable: { jobs: ['typecheck'], gate: 'changes', ...(scripts ? { scripts } : {}) } });
|
||||
const base = (workflow: string, extra: Record<string, string>, scripts: string[] | undefined = ['scripts/changes.sh']) => ({
|
||||
'.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': profile(scripts) }, ship_lanes_from: { [TSC]: { profiles: ['ci/free'], jobs: ['typecheck'] } } }) + '\n',
|
||||
'.github/workflows/free.yml': workflow, 'scripts/changes.sh': 'echo typecheck=false >> "$GITHUB_OUTPUT"\n', ...extra,
|
||||
});
|
||||
const JOBS = [{ name: 'changes', conclusion: 'success' }, { name: 'suite', conclusion: 'success' }, { name: 'typecheck', conclusion: 'skipped' }];
|
||||
const tsc = (b: Record<string, string>, feat: Record<string, string>) => { const f = shipFixture(b, feat); fixtures.push(f); world(f, { jobs: JOBS, run: { event: 'pull_request' }, tree: { files: [], tests: 0, event: 'pull_request' } }); return preflight(f, [`tsc=${TSC}`]).stdout; };
|
||||
const REFUSED = 'CI_CARRY: none label=tsc (no_verified_run: ci/free: run 7: job_skipped)';
|
||||
|
||||
test('a gate script reached through ${{ github.workspace }} is compared', () => {
|
||||
const b = base(flow(' steps:\n - run: bash scripts/changes.sh && bash ${{ github.workspace }}/scripts/gate.sh\n'), { 'scripts/gate.sh': 'exit 0\n' });
|
||||
expect(tsc(b, { 'scripts/gate.sh': 'exit 0 # moved\n' })).toContain(REFUSED);
|
||||
});
|
||||
|
||||
test('a gate script under the job\'s working-directory is compared', () => {
|
||||
const b = base(flow(' defaults:\n run:\n working-directory: tools\n steps:\n - run: bash ../scripts/changes.sh && bash gate2.sh\n'), { 'tools/gate2.sh': 'exit 0\n' });
|
||||
expect(tsc(b, { 'tools/gate2.sh': 'exit 0 # moved\n' })).toContain(REFUSED);
|
||||
});
|
||||
|
||||
test('a gate that runs a package script compares package.json', () => {
|
||||
const b = base(flow(' steps:\n - run: bash scripts/changes.sh && bun run detect\n'), { 'package.json': '{"scripts":{"detect":"true"}}\n' });
|
||||
expect(tsc(b, { 'package.json': '{"scripts":{"detect":"echo typecheck=false"}}\n' })).toContain(REFUSED);
|
||||
});
|
||||
|
||||
test('the gate is the job, not a same-named step in an earlier job', () => {
|
||||
const b = base(flow(' steps:\n - run: bash scripts/changes.sh && bash scripts/extra.sh\n', ' lint:\n runs-on: ubuntu-latest\n steps:\n - run: echo lint\n name: changes\n'), { 'scripts/extra.sh': 'exit 0\n' });
|
||||
expect(tsc(b, { 'scripts/extra.sh': 'exit 0 # moved\n' })).toContain(REFUSED);
|
||||
});
|
||||
|
||||
test('a guard whose CI job the unchanged gate skipped says not owed, never carried', () => {
|
||||
const b = base(flow(' steps:\n - run: bash scripts/changes.sh\n'), {});
|
||||
const conf = JSON.parse(b['.gstack/pregate.json']);
|
||||
b['.gstack/pregate.json'] = JSON.stringify({ ...conf, guards: [{ id: 'tsc', when: ['src/**'], command: TSC }] }) + '\n';
|
||||
const f = shipFixture(b, { 'src/b.ts': 'export const b = 2;\n' }); fixtures.push(f);
|
||||
world(f, { jobs: JOBS, run: { event: 'pull_request' }, tree: { files: [], tests: 0, event: 'pull_request' } });
|
||||
const out = f.run(path.join(BIN, 'gstack-pregate'), ['--only', 'guards', '--allow-repo-commands', '--base', 'main']);
|
||||
expect(out.stdout, out.stdout + out.stderr).toContain('GUARD: tsc not owed: CI skipped at this tree (gate job changes, workflow unchanged) runs=7/1');
|
||||
});
|
||||
|
||||
test('a skippable profile must declare its gate scripts', async () => {
|
||||
const { runProfileErrors } = await import('../lib/run-evidence');
|
||||
expect(runProfileErrors({ p: profile(undefined) }, undefined)).toEqual([expect.objectContaining({ path: 'run_profiles["p"].skippable' })]);
|
||||
expect(runProfileErrors({ p: profile(['scripts/changes.sh']) }, undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
test('passed never counts a CI row: a carry under another base or after a refusal does not pass a guard', () => {
|
||||
const f = fx();
|
||||
expect(f.git('push', '-q', 'origin', 'feat').status).toBe(0);
|
||||
world(f);
|
||||
const ev = (a: string[]) => f.run(path.join(BIN, 'gstack-evidence'), a);
|
||||
expect(ev(['record-ci', '--label', 'free', '--base', 'feat', '--', FREE]).status).toBe(0);
|
||||
const p = ev(['passed', '--', FREE]);
|
||||
expect([p.status, p.stdout]).toEqual([1, expect.stringMatching(/^NOT_PASSED: /m)]);
|
||||
});
|
||||
|
||||
test('passed: a newer failure of the same command at this tree vetoes the older pass', () => {
|
||||
const f = fx();
|
||||
const CMD = "test -e ../fail-flag && exit 1; printf ' 1 pass\\n 0 fail\\nRan 1 tests across 1 file. [1.00ms]\\n' >&2";
|
||||
const ev = (a: string[]) => f.run(path.join(BIN, 'gstack-evidence'), a);
|
||||
expect(ev(['run', '--label', 'g', '--', CMD]).status).toBe(0);
|
||||
expect(ev(['passed', '--', CMD]).stdout).toMatch(/^PASSED: label=g source=local /m);
|
||||
fs.writeFileSync(path.join(f.root, 'fail-flag'), '');
|
||||
expect(ev(['run', '--label', 'g', '--', CMD]).status).toBe(1);
|
||||
expect(ev(['passed', '--', CMD]).stdout).toMatch(/^NOT_PASSED: /m);
|
||||
});
|
||||
|
||||
test('--simulate runs a branch inventory only with --allow-repo-commands', () => {
|
||||
const INV = "printf x >> ../inv-runs; printf 'test/a.test.ts\\ntest/b.test.ts\\n'";
|
||||
const head = JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [FREE]: { profiles: ['ci/free'], inventory: INV } } }) + '\n';
|
||||
const f = shipFixture({ '.github/workflows/free.yml': BASE['.github/workflows/free.yml'] }, { '.gstack/pregate.json': head }); fixtures.push(f);
|
||||
world(f);
|
||||
const sim = (extra: string[]) => f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--policy-from', 'head', '--simulate', '--lane', `free=${FREE}`, ...extra]);
|
||||
const no = sim([]);
|
||||
expect([no.status, runs(f, 'inv-runs')]).toEqual([1, 0]);
|
||||
expect(no.stdout).toContain('pass --allow-repo-commands');
|
||||
const yes = sim(['--allow-repo-commands']);
|
||||
expect([yes.status, runs(f, 'inv-runs')], yes.stdout).toEqual([0, 1]);
|
||||
expect(yes.stdout).toContain('SIMULATION: LANE: free would carry run=7/1 tests_ran=2 unit=files');
|
||||
});
|
||||
});
|
||||
@@ -240,3 +240,68 @@ describe('carry after a stack parent squash-merges, receipt written against main
|
||||
expect(landedStackParent(repo.work, { rangeBase: fork, reviewed, currentBase: mainTip }, ['.', ':(exclude,glob)test/**'])).toBeNull();
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('carry after a stack parent lands, the child having merged main before its receipt (GST-1214)', () => {
|
||||
/** main <- parent (lib/util.js) <- feature (lib/retry.js), which merges main (wiki/) once before its receipt is written against main. */
|
||||
function mergedStack(repo: FixtureRepo, mainFiles: Record<string, string | null> = { 'wiki/a.md': '# a\n', 'wiki/b.md': '# b\n' }): { parent: string; reviewed: string; body: string } {
|
||||
repo.git('checkout', '-q', '-b', 'parent');
|
||||
const parent = commit(repo, { 'lib/util.js': SUB }, 'parent: sub');
|
||||
repo.git('push', '-q', 'origin', 'parent');
|
||||
repo.git('checkout', '-q', 'feature');
|
||||
repo.git('reset', '-q', '--hard', parent);
|
||||
commit(repo, { 'lib/retry.js': RETRY4 }, 'child: four retries');
|
||||
repo.git('checkout', '-q', 'main');
|
||||
commit(repo, mainFiles, 'main: wiki');
|
||||
repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-q', 'feature');
|
||||
repo.git('merge', '-q', '--no-edit', 'origin/main');
|
||||
const reviewed = repo.git('rev-parse', 'HEAD');
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '8', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]);
|
||||
expect(w.status).toBe(EXIT.ok);
|
||||
return { parent, reviewed, body };
|
||||
}
|
||||
|
||||
// Value: protects=a stacked child that merged main before its receipt keeps it once the parent squash-lands and main is
|
||||
// merged again (gstack-evals#178 refused on 20 wiki/ paths its first base merge brought in); fails_when=the range from the
|
||||
// landed parent counts paths that hold main's content at the reviewed head; seam=none; no_claim=gh pr edit (--body stands in)
|
||||
test('the child carries when only its earlier base merge separates the parent range from HEAD\'s diff against main', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const { parent, reviewed, body } = mergedStack(repo);
|
||||
landOnMain(repo, { 'lib/util.js': SUB });
|
||||
const head = repo.git('rev-parse', 'HEAD');
|
||||
const out = carry(repo, body);
|
||||
expect(out.err).not.toContain('RECEIPT_CARRY_PRODUCT_CHANGED');
|
||||
expect(out.status).toBe(EXIT.ok);
|
||||
expect(out.out).toContain(`CARRY: carried from ${reviewed.slice(0, 12)} to ${head.slice(0, 12)}`);
|
||||
expect(receiptIn(body).carry!.reason).toBe(`stack parent ${parent.slice(0, 12)} landed + base merge`);
|
||||
}, 90_000);
|
||||
|
||||
// Value: protects=fail closed: an edit to a file the earlier base merge brought in is product and refuses; fails_when=every
|
||||
// base-merged path drops out of both sides, the child's own edit included; seam=none; no_claim=-
|
||||
test('an edit to a base-merged file after the receipt refuses with RECEIPT_CARRY_PRODUCT_CHANGED', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const { reviewed, body } = mergedStack(repo);
|
||||
landOnMain(repo, { 'lib/util.js': SUB });
|
||||
commit(repo, { 'wiki/a.md': '# a, edited by the child\n' }, 'child: edit wiki');
|
||||
const out = carry(repo, body);
|
||||
expect(out.status).toBe(EXIT.refused);
|
||||
expect(out.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)');
|
||||
expect(out.err).toContain('wiki/a.md');
|
||||
expect(receiptIn(body).head).toBe(reviewed);
|
||||
}, 90_000);
|
||||
|
||||
// Value: protects=fail closed when the earlier base merge turned a file into a directory: leaving out the old file must not
|
||||
// hide an edit beneath it (a pathspec names a path and everything under it); fails_when=the exclusion covers a changed
|
||||
// descendant; seam=none; no_claim=-
|
||||
test('a base merge that replaced a file with a directory never hides an edit beneath it', () => {
|
||||
const repo = makeFixtureRepo({ baseFiles: { services: 'old\n' } }); repos.push(repo);
|
||||
const { reviewed, body } = mergedStack(repo, { services: null, 'services/worker.js': 'exports.allowed = false;\n' });
|
||||
landOnMain(repo, { 'lib/util.js': SUB });
|
||||
commit(repo, { 'services/worker.js': 'exports.allowed = true;\n' }, 'child: allow');
|
||||
const out = carry(repo, body);
|
||||
expect(out.status).toBe(EXIT.refused);
|
||||
expect(out.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)');
|
||||
expect(receiptIn(body).head).toBe(reviewed);
|
||||
}, 90_000);
|
||||
});
|
||||
@@ -309,25 +309,34 @@ describe('gstack-ship-release push: branch names', () => {
|
||||
});
|
||||
|
||||
describe('GST-1031: a non-behavioral harness re-pin after review is release metadata', () => {
|
||||
// Value: protects=a scripts/harness-version.json edit after review that only records changed harness files as non-behavioral at the same
|
||||
// Value: protects=a scripts/harness-pins/<path>.json edit after review that only records changed harness files as non-behavioral at the same
|
||||
// version routes as metadata, so a clean round 2 is not sent back through 5-11.5, while a version bump, a pattern change or an entry
|
||||
// without its reason stays behavior; fails_when=the re-pin stops finish, or a bump or bare re-pin routes as metadata;
|
||||
// why_new=GSTA-150 GST-1027 (icm_01M6ERWWBZ3VWXZY07F2W5ZSFB); seam=route; no_claim=that the recorded reason is true
|
||||
const pin = (version: number, entry: Record<string, unknown>, patterns = ['test/helpers/x.ts']) => JSON.stringify({ version, patterns, files: { 'test/helpers/x.ts': entry } }) + '\n';
|
||||
const finish = (after: string) => {
|
||||
const PIN = 'scripts/harness-pins/test/helpers/x.ts.json';
|
||||
const pin = (version: number, entry: Record<string, unknown>, patterns = ['test/helpers/x.ts']) => ({
|
||||
'scripts/harness-version.json': JSON.stringify({ version, patterns }) + '\n',
|
||||
[PIN]: JSON.stringify(entry) + '\n',
|
||||
});
|
||||
const finish = (after: Record<string, string>) => {
|
||||
const f = fx();
|
||||
f.write({ 'scripts/harness-version.json': pin(6, { blob: 'a'.repeat(40), since: 6 }) }); f.commit('pin');
|
||||
f.write(pin(6, { blob: 'a'.repeat(40), since: 6 })); f.commit('pin');
|
||||
const reviewed = wtree(f);
|
||||
const post = snapshot(f);
|
||||
f.write({ 'scripts/harness-version.json': after }); f.commit('re-pin');
|
||||
f.write(after); f.commit('re-pin');
|
||||
return f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE]).stdout;
|
||||
};
|
||||
test('a non-behavioral re-pin is metadata; a bump, a pattern change or an entry without its reason is behavior', () => {
|
||||
const repin = finish(pin(6, { blob: 'b'.repeat(40), since: 6, nonBehavioral: 'comment-only edit: no verdict can change' }));
|
||||
expect(lineOf(repin, 'ROUTE')).toMatch(/behavior=0 docs=0 metadata=1/);
|
||||
expect(repin).not.toContain('STOP: behavior');
|
||||
for (const after of [pin(7, { blob: 'b'.repeat(40), since: 7 }), pin(6, { blob: 'b'.repeat(40), since: 6 }), pin(6, { blob: 'b'.repeat(40), since: 6, nonBehavioral: 'comment-only edit: no verdict can change' }, ['test/helpers/**'])])
|
||||
expect(finish(after)).toContain('STOP: behavior, tests or build inputs changed since review (scripts/harness-version.json)');
|
||||
expect(finish(pin(7, { blob: 'b'.repeat(40), since: 7 }))).toContain(`STOP: behavior, tests or build inputs changed since review (${PIN},scripts/harness-version.json)`);
|
||||
expect(finish(pin(6, { blob: 'b'.repeat(40), since: 6 }))).toContain(`STOP: behavior, tests or build inputs changed since review (${PIN})`);
|
||||
expect(finish(pin(6, { blob: 'b'.repeat(40), since: 6, nonBehavioral: 'comment-only edit: no verdict can change' }, ['test/helpers/**']))).toContain(`STOP: behavior, tests or build inputs changed since review (${PIN},scripts/harness-version.json)`);
|
||||
});
|
||||
test('the manifest regaining a files table is behavior', () => {
|
||||
const f = { 'scripts/harness-version.json': JSON.stringify({ version: 6, patterns: ['test/helpers/x.ts'], files: {} }) + '\n' };
|
||||
expect(finish(f)).toContain('STOP: behavior, tests or build inputs changed since review (scripts/harness-version.json)');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { comparePath } from '../lib/ship-patch';
|
||||
import { STAMP_PLACEHOLDERS_KEY_VERSION } from '../lib/ship-policy';
|
||||
import { extractReceipt, placeReceiptFirst } from '../lib/ship-receipt';
|
||||
import { cleanup, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture';
|
||||
import { lineOf, PREFLIGHT, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
|
||||
@@ -266,6 +267,37 @@ describe('a restamp after a lost slot (GST-895, GBRA-218, gbrain #8134)', () =>
|
||||
const base = kept([['0.9.0.0', 'old'], ['1.0.1.0', 'base'], ['1.0.0.0', 'mine']]);
|
||||
expect([base.status, lineOf(base.stdout, 'STOP')]).toEqual([3, behaviorStop]);
|
||||
});
|
||||
|
||||
// Value: protects=gbrain's restamp of the PR's own `since: 'TBD'` row to the new version routes as metadata when the
|
||||
// policy declares TBD in stamp_placeholders (GST-1026, gbrain #8444); an undeclared placeholder, a base row moved the
|
||||
// same way, a duplicated base copy, or a line no stamp_pattern matches still stops; fails_when=stampedFiles reads the
|
||||
// move as behavior (gbrain #8444) or accepts it off the branch's own rows; seam=lib/ship-bin-common.ts stampedFiles
|
||||
test('a declared placeholder on the branch row is metadata; undeclared, a base row or no template stops (GST-1026)', () => {
|
||||
const declared = (placeholders: string[] | undefined, templates = ["since: '{version}'"]) => JSON.stringify({ stamp_files: ['VERSION', 'src/notice.ts'], stamp_patterns: templates,
|
||||
...(placeholders ? { stamp_placeholders: placeholders, min_gstack_version: STAMP_PLACEHOLDERS_KEY_VERSION } : {}) });
|
||||
const restamped = (pol: string, baseRows: [string, string][], ownRows: [string, string][], rows: [string, string][]) => {
|
||||
const f = fx({ '.gstack/ship-policy.json': pol, VERSION: '1.0.0.0\n', 'src/notice.ts': notice(...baseRows) },
|
||||
{ VERSION: '1.0.1.0\n', 'src/notice.ts': notice(...baseRows, ...ownRows) });
|
||||
const reviewed = wtree(f);
|
||||
const post = snapshot(f);
|
||||
f.write({ VERSION: '1.0.2.0\n', 'src/notice.ts': notice(...rows) }); f.commit('restamp: TBD rows to 1.0.2.0');
|
||||
return finish(f, reviewed, post, ['--reviewed-base', mergeBase(f)]);
|
||||
};
|
||||
const base: [string, string][] = [['0.9.0.0', 'old'], ['TBD', 'base']];
|
||||
const metadata = / behavior=0 docs=0 metadata=2 tests=0 /;
|
||||
const own = restamped(declared(['TBD']), base, [['TBD', 'mine']], [...base, ['1.0.2.0', 'mine']]);
|
||||
expect(lineOf(own.stdout, 'ROUTE'), own.stdout).toEqual(expect.stringMatching(metadata));
|
||||
const undeclared = restamped(declared(undefined), base, [['TBD', 'mine']], [...base, ['1.0.2.0', 'mine']]);
|
||||
expect([undeclared.status, lineOf(undeclared.stdout, 'STOP')]).toEqual([3, behaviorStop]);
|
||||
const other = restamped(declared(['WIP']), base, [['TBD', 'mine']], [...base, ['1.0.2.0', 'mine']]);
|
||||
expect([other.status, lineOf(other.stdout, 'STOP')]).toEqual([3, behaviorStop]);
|
||||
const baseRow = restamped(declared(['TBD']), base, [['TBD', 'mine']], [['0.9.0.0', 'old'], ['1.0.2.0', 'base'], ['1.0.2.0', 'mine']]);
|
||||
expect([baseRow.status, lineOf(baseRow.stdout, 'STOP')]).toEqual([3, behaviorStop]);
|
||||
const dup = restamped(declared(['TBD']), base, [['TBD', 'base']], [['0.9.0.0', 'old'], ['1.0.2.0', 'base'], ['TBD', 'base']]);
|
||||
expect([dup.status, lineOf(dup.stdout, 'STOP')]).toEqual([3, behaviorStop]);
|
||||
const noTemplate = restamped(declared(['TBD'], ['version: {version}']), base, [['TBD', 'mine']], [...base, ['1.0.2.0', 'mine']]);
|
||||
expect([noTemplate.status, lineOf(noTemplate.stdout, 'STOP')]).toEqual([3, behaviorStop]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adjacent base edits (GST-384, #3631, founderstar#441)', () => {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"bytes": 14,
|
||||
"why": "GST-1138: ship names the push guards in Step 17 and the early push"
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { sentences } from '../lib/skill-triggers';
|
||||
import { CONTRACT_CHECKER_SHIPPED, contractFromTemplate, generateContractDoneMeans, parseSkillContract, renderDoneMeans } from '../scripts/resolvers/contract-done-means';
|
||||
import { HOST_PATHS } from '../scripts/resolvers/types';
|
||||
|
||||
/**
|
||||
* GST-1088 discovery targets: each says when to use it in the part of its
|
||||
* description every host shows, and every contract block matches the frozen
|
||||
* v1 schema, so a malformed block fails here instead of failing open in the
|
||||
* Stop check.
|
||||
*/
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const SKILLS = ['investigate', 'review', 'qa', 'verify'];
|
||||
// splitCatalogDescription cuts a longer Claude lead with "...", which drops the end of the when-clause.
|
||||
const LEAD_CAP = 200;
|
||||
// test/catalog-budget.test.ts PER_SKILL_BYTE_CAP: name + Claude description bytes.
|
||||
const CATALOG_ENTRY_CAP = 260;
|
||||
// Codex, Gemini, Copilot and agy reject a longer description (hosts/*.ts descriptionLimit).
|
||||
const DESCRIPTION_CAP = 1024;
|
||||
const TRIGGER_CAP = 32;
|
||||
const BT = '\u0060';
|
||||
|
||||
const fix = (skill: string) => 'Fix: edit ' + skill + '/SKILL.md.tmpl, then bun run gen:skill-docs';
|
||||
const read = (rel: string) => readFileSync(join(ROOT, rel), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
function frontmatter(rel: string): Record<string, unknown> {
|
||||
const m = read(rel).match(/^---\n([\s\S]*?)\n---\n/);
|
||||
if (!m) throw new Error(rel + ': no frontmatter');
|
||||
return Bun.YAML.parse(m[1]) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const body = (rel: string) => read(rel).replace(/^---\n[\s\S]*?\n---\n/, '');
|
||||
const triggersOf = (skill: string) => (frontmatter(skill + '/SKILL.md.tmpl').triggers ?? []) as string[];
|
||||
|
||||
describe('discovery-target descriptions (GST-1088)', () => {
|
||||
for (const skill of SKILLS) {
|
||||
const tmpl = skill + '/SKILL.md.tmpl';
|
||||
|
||||
test(skill + ': the template description opens with a "Use when ..." sentence', () => {
|
||||
const first = sentences(String(frontmatter(tmpl).description ?? ''))[0] ?? '';
|
||||
expect(/^Use when\b/.test(first), tmpl + ': the first sentence is "' + first.slice(0, 80) + '", not a "Use when ..." clause. ' + fix(skill)).toBe(true);
|
||||
});
|
||||
|
||||
test(skill + ': the Claude catalog entry keeps the when-clause within its caps', () => {
|
||||
const rel = skill + '/SKILL.md';
|
||||
const fm = frontmatter(rel);
|
||||
const description = String(fm.description ?? '');
|
||||
const lead = description.replace(/\s*\(gstack\)\s*$/, '');
|
||||
expect(/^Use when\b/.test(lead), rel + ': the catalog description "' + lead.slice(0, 80) + '" does not start with "Use when". ' + fix(skill)).toBe(true);
|
||||
expect(lead.length, rel + ': the catalog lead is ' + lead.length + ' chars against the ' + LEAD_CAP + '-char cap. ' + fix(skill)).toBeLessThanOrEqual(LEAD_CAP);
|
||||
const entry = Buffer.byteLength(String(fm.name)) + Buffer.byteLength(description);
|
||||
expect(entry, rel + ': name + description is ' + entry + ' bytes against the ' + CATALOG_ENTRY_CAP + '-byte cap. ' + fix(skill)).toBeLessThanOrEqual(CATALOG_ENTRY_CAP);
|
||||
});
|
||||
|
||||
test(skill + ': the full description fits every host limit', () => {
|
||||
const full = String(frontmatter(tmpl).description ?? '').trim();
|
||||
expect(full.length, tmpl + ': the description is ' + full.length + ' chars against the ' + DESCRIPTION_CAP + '-char cap. ' + fix(skill)).toBeLessThanOrEqual(DESCRIPTION_CAP);
|
||||
});
|
||||
|
||||
test(skill + ': triggers are short and distinct', () => {
|
||||
const own = triggersOf(skill);
|
||||
expect(own.length, tmpl + ': no triggers. ' + fix(skill)).toBeGreaterThan(0);
|
||||
expect(new Set(own).size, tmpl + ': duplicate triggers. ' + fix(skill)).toBe(own.length);
|
||||
for (const t of own) {
|
||||
expect(t.length, tmpl + ': trigger "' + t + '" is ' + t.length + ' chars against the ' + TRIGGER_CAP + '-char cap. ' + fix(skill)).toBeLessThanOrEqual(TRIGGER_CAP);
|
||||
expect(t, tmpl + ': trigger "' + t + '" must be lowercase. ' + fix(skill)).toBe(t.toLowerCase());
|
||||
for (const other of SKILLS.filter(s => s !== skill)) {
|
||||
const clash = triggersOf(other).find(o => o.includes(t) || t.includes(o));
|
||||
expect(clash, tmpl + ': trigger "' + t + '" overlaps ' + other + "'s \"" + clash + '". ' + fix(skill)).toBeUndefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const PROMPTS: Array<[string, string]> = [
|
||||
['this test fails, fix it', 'investigate'],
|
||||
['why is this broken?', 'investigate'],
|
||||
['the build is broken, here is the stack trace', 'investigate'],
|
||||
['review this PR', 'review'],
|
||||
['can you look over my diff', 'review'],
|
||||
['review my changes before I merge', 'review'],
|
||||
['test the site and fix what you find', 'qa'],
|
||||
['can you qa test this page', 'qa'],
|
||||
['run the checks and make it green', 'verify'],
|
||||
['verify my change', 'verify'],
|
||||
];
|
||||
for (const [prompt, want] of PROMPTS) {
|
||||
test('untold prompt "' + prompt + '" carries a ' + want + ' trigger and no other target trigger', () => {
|
||||
const p = prompt.toLowerCase();
|
||||
const hits = SKILLS.filter(s => triggersOf(s).some(t => p.includes(t)));
|
||||
expect(hits, '"' + prompt + '" matches [' + hits.join(', ') + '], want [' + want + ']. ' + fix(want)).toEqual([want]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('contract blocks (frozen v1 schema)', () => {
|
||||
const templates = readdirSync(ROOT, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory() && existsSync(join(ROOT, d.name, 'SKILL.md.tmpl')))
|
||||
.map(d => d.name)
|
||||
.sort();
|
||||
|
||||
test('investigate and review declare contracts', () => {
|
||||
for (const skill of ['investigate', 'review']) {
|
||||
const tmpl = skill + '/SKILL.md.tmpl';
|
||||
expect(contractFromTemplate(read(tmpl), tmpl), tmpl + ': no contract block. ' + fix(skill)).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
for (const skill of templates) {
|
||||
const tmpl = skill + '/SKILL.md.tmpl';
|
||||
const contract = contractFromTemplate(read(tmpl), tmpl);
|
||||
if (!contract) {
|
||||
test(skill + ': no contract, no Done-means placeholder', () => {
|
||||
expect(read(tmpl).includes('{{CONTRACT_DONE_MEANS}}'), tmpl + ': {{CONTRACT_DONE_MEANS}} without a contract block. ' + fix(skill)).toBe(false);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
test(skill + ': the body carries the Done-means placeholder and the render carries the list', () => {
|
||||
expect(body(tmpl).includes('{{CONTRACT_DONE_MEANS}}'), tmpl + ': a contract block needs {{CONTRACT_DONE_MEANS}} at the top of the body. ' + fix(skill)).toBe(true);
|
||||
expect(read(skill + '/SKILL.md').includes(renderDoneMeans(contract)), skill + '/SKILL.md: the Done-means list is stale. ' + fix(skill)).toBe(true);
|
||||
});
|
||||
|
||||
test(skill + ': every contract marker appears in the skill text', () => {
|
||||
const sectionDir = join(ROOT, skill, 'sections');
|
||||
const sections = existsSync(sectionDir)
|
||||
? readdirSync(sectionDir).filter(f => f.endsWith('.md')).map(f => read(skill + '/sections/' + f))
|
||||
: [];
|
||||
const text = [body(tmpl), ...sections].join('\n');
|
||||
for (const item of contract.items) {
|
||||
const markers = item.kind === 'report_labels' ? [item.heading, ...item.labels.map(l => l + ':')]
|
||||
: item.kind === 'selected_specialists_dispatched' ? [item.marker]
|
||||
: item.kind === 'agent_dispatch' ? [item.match]
|
||||
: [];
|
||||
for (const marker of markers) {
|
||||
expect(text.includes(marker), tmpl + ': contract item ' + item.id + ' needs "' + marker + '", which the skill text never prints. ' + fix(skill)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('contract-done-means renderer', () => {
|
||||
const all = parseSkillContract({
|
||||
version: 1,
|
||||
items: [
|
||||
{ id: 'debug-report', kind: 'report_labels', heading: 'DEBUG REPORT', labels: ['Symptom', 'Root cause', 'Status'] },
|
||||
{ id: 'check-after-last-edit', kind: 'check_after_last_edit' },
|
||||
{ id: 'specialists-dispatched', kind: 'selected_specialists_dispatched', marker: 'Dispatching N specialists:' },
|
||||
{ id: 'adversarial-pass', kind: 'agent_dispatch', match: 'chaos engineer' },
|
||||
],
|
||||
}, 'fixture/SKILL.md.tmpl');
|
||||
const out = renderDoneMeans(all, true);
|
||||
|
||||
test('names each item kind in plain words', () => {
|
||||
expect(out).toContain('## Done means');
|
||||
expect(out).toContain('- ' + BT + 'debug-report' + BT + ': a ' + BT + 'DEBUG REPORT' + BT + ' with Symptom, Root cause and Status filled in (' + BT + 'none: <why>' + BT + " where the skill's own stop rule leaves one empty).");
|
||||
expect(out).toContain('- ' + BT + 'check-after-last-edit' + BT + ': a test or repro command run after your last edit.');
|
||||
expect(out).toContain('- ' + BT + 'specialists-dispatched' + BT + ': the specialist selection printed (' + BT + 'Dispatching N specialists:' + BT + ' ...), then one Agent dispatch per specialist it names (none when a small diff skips them).');
|
||||
expect(out).toContain('- ' + BT + 'adversarial-pass' + BT + ': an Agent dispatch for the adversarial pass, its prompt including "chaos engineer".');
|
||||
});
|
||||
|
||||
test('points to check and waive in the routing-and-contracts doc, never the off switch', () => {
|
||||
expect(out).toContain(BT + 'gstack-skill-contract check' + BT);
|
||||
expect(out).toContain(BT + 'gstack-skill-contract waive <item> --reason "<why>"' + BT);
|
||||
expect(out).toContain('docs/skill-routing-and-contracts.md');
|
||||
expect(out).not.toContain('skill_contracts');
|
||||
});
|
||||
|
||||
const one = (item: Record<string, unknown>) => ({ version: 1, items: [item] });
|
||||
const MALFORMED: Array<[string, unknown, RegExp]> = [
|
||||
['not a mapping', 'yes', /must be a mapping/],
|
||||
['wrong version', { version: 2, items: [{ id: 'a', kind: 'check_after_last_edit' }] }, /version must be 1/],
|
||||
['no items', { version: 1, items: [] }, /items must be a non-empty list/],
|
||||
['unknown top-level key', { version: 1, items: [{ id: 'a', kind: 'check_after_last_edit' }], extra: 1 }, /unknown key "extra"/],
|
||||
['unknown kind', one({ id: 'a', kind: 'vibes' }), /kind "vibes" is not one of/],
|
||||
['bad id', one({ id: 'Debug Report', kind: 'check_after_last_edit' }), /id must be lowercase kebab-case/],
|
||||
['duplicate id', { version: 1, items: [{ id: 'a', kind: 'check_after_last_edit' }, { id: 'a', kind: 'agent_dispatch', match: 'x' }] }, /duplicate id "a"/],
|
||||
['labels missing', one({ id: 'a', kind: 'report_labels', heading: 'R' }), /labels must be a non-empty list/],
|
||||
['placeholder label', one({ id: 'a', kind: 'report_labels', heading: 'R', labels: ['[what the user observed]'] }), /placeholder/],
|
||||
['heading missing', one({ id: 'a', kind: 'report_labels', labels: ['Symptom'] }), /heading must be a non-empty string/],
|
||||
['marker missing', one({ id: 'a', kind: 'selected_specialists_dispatched' }), /marker must be a non-empty string/],
|
||||
['match missing', one({ id: 'a', kind: 'agent_dispatch' }), /match must be a non-empty string/],
|
||||
['field from another kind', one({ id: 'a', kind: 'check_after_last_edit', match: 'x' }), /unknown key "match"/],
|
||||
];
|
||||
for (const [name, value, want] of MALFORMED) {
|
||||
test('a malformed block fails loudly: ' + name, () => {
|
||||
let message = '';
|
||||
try { parseSkillContract(value, 'demo/SKILL.md.tmpl'); } catch (e) { message = (e as Error).message; }
|
||||
expect(message).toMatch(want);
|
||||
expect(message).toContain('CONTRACT_INVALID demo/SKILL.md.tmpl');
|
||||
expect(message).toContain('Fix: edit demo/SKILL.md.tmpl, then bun run gen:skill-docs');
|
||||
});
|
||||
}
|
||||
|
||||
test('check and waive appear only once bin/gstack-skill-contract ships', () => {
|
||||
const shipped = existsSync(join(ROOT, 'bin', 'gstack-skill-contract'));
|
||||
expect(CONTRACT_CHECKER_SHIPPED, 'CONTRACT_CHECKER_SHIPPED in scripts/resolvers/contract-done-means.ts must be ' + shipped + ' while bin/gstack-skill-contract ' + (shipped ? 'exists' : 'is missing') + '. Fix: set it, then bun run gen:skill-docs').toBe(shipped);
|
||||
for (const skill of ['investigate', 'review']) {
|
||||
expect(read(skill + '/SKILL.md').includes('gstack-skill-contract'), skill + '/SKILL.md names gstack-skill-contract while bin/gstack-skill-contract ' + (shipped ? 'exists' : 'is missing') + '. Fix: bun run gen:skill-docs').toBe(shipped);
|
||||
}
|
||||
if (shipped) expect(existsSync(join(ROOT, 'docs', 'skill-routing-and-contracts.md')), 'docs/skill-routing-and-contracts.md is missing').toBe(true);
|
||||
expect(renderDoneMeans(all, false)).not.toContain('gstack-skill-contract');
|
||||
});
|
||||
|
||||
test('a host that suppresses, or a loadout that excludes, the step an item checks leaves that item out', () => {
|
||||
const render = (host: string, excluded: string[] = []) => generateContractDoneMeans({ skillName: 'review', tmplPath: join(ROOT, 'review', 'SKILL.md.tmpl'), host, paths: HOST_PATHS[host], modules: excluded.length ? { name: 'test', excluded: new Set(excluded) } : undefined } as unknown as Parameters<typeof generateContractDoneMeans>[0]);
|
||||
const ids = (host: string, excluded: string[] = []) => [...render(host, excluded).matchAll(/^- \u0060([a-z-]+)\u0060:/gm)].map(m => m[1]);
|
||||
expect(ids('claude', ['section:review/review-army', 'section:review/adversarial'])).toEqual(['review-report']);
|
||||
expect(ids('claude')).toEqual(['specialists-dispatched', 'adversarial-pass', 'review-report']);
|
||||
expect(ids('codex')).toEqual(['adversarial-pass', 'review-report']);
|
||||
expect(ids('hermes')).toEqual(['review-report']);
|
||||
expect(renderDoneMeans({ version: 1, items: [] })).toBe('');
|
||||
});
|
||||
|
||||
test('a placeholder with no contract block fails the render', () => {
|
||||
expect(() => contractFromTemplate('---\nname: demo\n---\n{{CONTRACT_DONE_MEANS}}\n', 'demo/SKILL.md.tmpl', { required: true }))
|
||||
.toThrow(/CONTRACT_INVALID demo\/SKILL.md.tmpl: \{\{CONTRACT_DONE_MEANS\}\} needs a contract block/);
|
||||
});
|
||||
|
||||
test('invalid frontmatter YAML fails the render', () => {
|
||||
expect(() => contractFromTemplate('---\nname: demo\ncontract: [unclosed\n---\n', 'demo/SKILL.md.tmpl'))
|
||||
.toThrow(/CONTRACT_INVALID demo\/SKILL.md.tmpl: frontmatter is not valid YAML/);
|
||||
});
|
||||
});
|
||||
@@ -121,6 +121,36 @@ describe('tree receipt: stamps, stamp-sensitive gates and negative fixtures', ()
|
||||
expect(stampOnlyLine("mine since: '1.2.3.0'", "mine since: '1.2.4.0'", '1.2.4.0', '1.2.4.0', tpl, mine, 5)).toBe(true);
|
||||
});
|
||||
|
||||
// Value: protects=a declared placeholder on the branch's own row moves to the new version as a stamp, only inside a
|
||||
// stamp_patterns template (gbrain's `since: 'TBD'` row, GST-1026); fails_when=stampOnlyLine reads the move as a hand
|
||||
// edit, or accepts an undeclared placeholder, a base row, a duplicated base line, a built-in form or a line no
|
||||
// template matches; seam=lib/stamp-only.ts stampOnlyLine placeholders
|
||||
test('a declared placeholder on a branch row moves to the new version inside a stamp_patterns template, and nowhere else (GST-1026)', () => {
|
||||
const tpl = ["since: '{version}'"];
|
||||
const row = " { since: 'TBD', id: 'mine' },";
|
||||
const moved = " { since: '0.60.553.0', id: 'mine' },";
|
||||
const side = (r: string, at = 10) => ({ fork: '0.60.552.0', added: new Set([r]), at: new Set([at]) });
|
||||
const line = (r: string, a: string, opts: { templates?: string[]; at?: number; placeholders?: string[]; oldV?: string } = {}) =>
|
||||
stampOnlyLine(r, a, opts.oldV ?? '0.60.552.0', '0.60.553.0', opts.templates ?? tpl, side(r), opts.at ?? 10, opts.placeholders ?? ['TBD']);
|
||||
expect(line(row, moved)).toBe(true);
|
||||
expect(line(row, moved, { oldV: '0.60.553.0' })).toBe(true);
|
||||
expect(line(row, moved, { placeholders: [] })).toBe(false);
|
||||
expect(line(row, moved, { placeholders: ['WIP'] })).toBe(false);
|
||||
expect(line(" { since: 'WIP', id: 'mine' },", moved)).toBe(false);
|
||||
expect(line(row, moved, { at: 2 })).toBe(false);
|
||||
expect(stampOnlyLine(row, moved, '0.60.552.0', '0.60.553.0', tpl, undefined, undefined, ['TBD'])).toBe(false);
|
||||
expect(stampOnlyLine(row, moved, '0.60.552.0', '0.60.553.0', tpl, side(" { since: 'TBD', id: 'other' },"), 10, ['TBD'])).toBe(false);
|
||||
expect(line(row, moved, { templates: [] })).toBe(false);
|
||||
expect(line(" { release: 'TBD', id: 'mine' },", " { release: '0.60.553.0', id: 'mine' },")).toBe(false);
|
||||
expect(line(' { since: TBD, id: mine },', ' { since: 0.60.553.0, id: mine },')).toBe(false);
|
||||
expect(line(row, " { since: '0.60.553.0', id: 'yours' },")).toBe(false);
|
||||
expect(line(row, " { since: '0.60.554.0', id: 'mine' },")).toBe(false);
|
||||
const both = " { since: 'TBD', note: 'Added in v0.60.552.0' },";
|
||||
expect(line(both, " { since: '0.60.553.0', note: 'Added in v0.60.553.0' },")).toBe(true);
|
||||
expect(line(both, " { since: '0.60.553.0', note: 'Added in v0.60.551.0' },")).toBe(false);
|
||||
expect(line(' // TBD: tidy', ' // 0.60.553.0: tidy', { templates: ['{version}'] })).toBe(false);
|
||||
});
|
||||
|
||||
test('negative fixture: a dependency pin equal to the old version is `changed`, not a stamp, even inside stamp_paths', () => {
|
||||
const repo = fixture();
|
||||
const g = gated(repo);
|
||||
|
||||
Reference in new issue
Block a user