mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-14 04:17:33 +01:00
Merge origin/main into fix/3263-cso-go-not-python; v1.91.200.0 stamp (#3378 slot 4)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
commit
4de272bb51
89 files changed
+3786
-401
No files matched your search
+15
-15
@@ -3,7 +3,7 @@
|
||||
* bin/gstack-bun-version.sh and `resolveBun` / `launchRuntime` in
|
||||
* lib/pregate/runtime.ts must pick the same binary (by realpath) for the
|
||||
* GBRA-60 layout: the image's old Bun first on PATH, Bun 1.4.2 in
|
||||
* $BUN_INSTALL/bin, Bun 1.4.3 elsewhere, a project pinning 1.4.3 only through
|
||||
* $BUN_INSTALL/bin, Bun 1.4.4 elsewhere, a project pinning 1.4.4 only through
|
||||
* a CI lane, an unpinned project and a directory outside any project. A file
|
||||
* carrying the shim marker is never returned, and a candidate is chosen only
|
||||
* when it satisfies every applicable pin.
|
||||
@@ -41,7 +41,7 @@ function layout(opts: { storeVersions?: string[]; homeVersion?: string } = {}) {
|
||||
const pinned = path.join(base, 'gbrain');
|
||||
fs.mkdirSync(path.join(pinned, '.github/workflows'), { recursive: true });
|
||||
fs.writeFileSync(path.join(pinned, 'package.json'), '{ "name": "gbrain", "engines": { "bun": ">=1.4.0" } }\n');
|
||||
fs.writeFileSync(path.join(pinned, '.github/workflows/ci.yml'), 'jobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: oven-sh/setup-bun@v2\n with:\n bun-version: 1.4.3\n');
|
||||
fs.writeFileSync(path.join(pinned, '.github/workflows/ci.yml'), 'jobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: oven-sh/setup-bun@v2\n with:\n bun-version: 1.4.4\n');
|
||||
const unpinned = path.join(base, 'plain');
|
||||
fs.mkdirSync(unpinned);
|
||||
fs.writeFileSync(path.join(unpinned, 'package.json'), '{ "name": "plain" }\n');
|
||||
@@ -74,24 +74,24 @@ function tsResolve(l: ReturnType<typeof layout>, project: string | null): string
|
||||
}
|
||||
|
||||
describe('one Bun resolution contract, two twins', () => {
|
||||
test('GBRA-60: 1.4.3 in the store, 1.4.2 in $BUN_INSTALL, a CI-lane pin of 1.4.3 — both twins pick the store 1.4.3 for the project and for an unpinned or outside directory', () => {
|
||||
const l = layout({ storeVersions: ['1.4.3'] });
|
||||
const store143 = path.join(bunStore(l.state), '1.4.3/bun');
|
||||
for (const [project, want] of [[l.pinned, `${store143} 1.4.3 pin`], [l.unpinned, `${store143} 1.4.3 default`], [null, `${store143} 1.4.3 default`]] as const) {
|
||||
test('GBRA-60: 1.4.4 in the store, 1.4.2 in $BUN_INSTALL, a CI-lane pin of 1.4.4 — both twins pick the store 1.4.4 for the project and for an unpinned or outside directory', () => {
|
||||
const l = layout({ storeVersions: ['1.4.4'] });
|
||||
const store144 = path.join(bunStore(l.state), '1.4.4/bun');
|
||||
for (const [project, want] of [[l.pinned, `${store144} 1.4.4 pin`], [l.unpinned, `${store144} 1.4.4 default`], [null, `${store144} 1.4.4 default`]] as const) {
|
||||
expect(bashResolve(l, project)).toBe(want);
|
||||
expect(tsResolve(l, project)).toBe(want);
|
||||
}
|
||||
const lanes = launchRuntime(l.pinned, l.env);
|
||||
expect(path.join(lanes.dir!, 'bun')).toBe(store143);
|
||||
expect(path.join(lanes.dir!, 'bun')).toBe(store144);
|
||||
expect(lanes.verdict).toBe('skipped');
|
||||
expect(lanes.label).toContain('install verdict: not checked');
|
||||
});
|
||||
|
||||
test('1.4.2 in $BUN_INSTALL and 1.4.3 in ~/.bun/bin, nothing in the store: the pinned project skips the ineligible 1.4.2; unpinned directories take $BUN_INSTALL first', () => {
|
||||
const l = layout({ homeVersion: '1.4.3' });
|
||||
const home143 = path.join(l.home, '.bun/bin/bun');
|
||||
test('1.4.2 in $BUN_INSTALL and 1.4.4 in ~/.bun/bin, nothing in the store: the pinned project skips the ineligible 1.4.2; unpinned directories take $BUN_INSTALL first', () => {
|
||||
const l = layout({ homeVersion: '1.4.4' });
|
||||
const home144 = path.join(l.home, '.bun/bin/bun');
|
||||
const bi142 = path.join(l.bunInstall, 'bin/bun');
|
||||
for (const [project, want] of [[l.pinned, `${home143} 1.4.3 home`], [l.unpinned, `${bi142} 1.4.2 BUN_INSTALL`], [null, `${bi142} 1.4.2 BUN_INSTALL`]] as const) {
|
||||
for (const [project, want] of [[l.pinned, `${home144} 1.4.4 home`], [l.unpinned, `${bi142} 1.4.2 BUN_INSTALL`], [null, `${bi142} 1.4.2 BUN_INSTALL`]] as const) {
|
||||
expect(bashResolve(l, project)).toBe(want);
|
||||
expect(tsResolve(l, project)).toBe(want);
|
||||
}
|
||||
@@ -106,20 +106,20 @@ describe('one Bun resolution contract, two twins', () => {
|
||||
});
|
||||
|
||||
test('a symlinked candidate resolves to its target realpath in both twins (portable readlink loop, no realpath(1))', () => {
|
||||
const l = layout({ storeVersions: ['1.4.3'] });
|
||||
const l = layout({ storeVersions: ['1.4.4'] });
|
||||
const linkDir = path.join(l.base, 'links');
|
||||
fs.mkdirSync(linkDir);
|
||||
fs.symlinkSync('../home/.gstack/runtimes/bun/1.4.3/bun', path.join(linkDir, 'bun'));
|
||||
fs.symlinkSync('../home/.gstack/runtimes/bun/1.4.4/bun', path.join(linkDir, 'bun'));
|
||||
fs.rmSync(path.join(l.bunInstall, 'bin/bun'));
|
||||
const env = { ...l.env, GSTACK_STATE_ROOT: path.join(l.base, 'elsewhere'), PATH: `${linkDir}:${l.env.PATH}` };
|
||||
const want = `${path.join(bunStore(l.state), '1.4.3/bun')} 1.4.3 PATH`;
|
||||
const want = `${path.join(bunStore(l.state), '1.4.4/bun')} 1.4.4 PATH`;
|
||||
expect(bashResolve({ ...l, env, state: env.GSTACK_STATE_ROOT }, null)).toBe(want);
|
||||
expect(tsResolve({ ...l, env, state: env.GSTACK_STATE_ROOT }, null)).toBe(want);
|
||||
});
|
||||
|
||||
// Integration item 8: the gstack checkout itself binds the 'none' verdict.
|
||||
test('launchRuntime on the gstack checkout reads the none verdict and launches its recorded Bun', () => {
|
||||
const l = layout({ storeVersions: ['1.4.3'] });
|
||||
const l = layout({ storeVersions: ['1.4.4'] });
|
||||
const { installVerdictPath } = require('../lib/install-verdict') as typeof import('../lib/install-verdict');
|
||||
const checkout = fs.realpathSync(ROOT);
|
||||
const file = installVerdictPath(checkout, l.state, null);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* CHANGELOG merges that would carry damage to released history (GSTA-67,
|
||||
* from #3345 on 2026-10-22). #3345's pin 1eb7092 replaced main's
|
||||
* `## [1.91.179.0]` heading with `## [Unreleased]` instead of inserting
|
||||
* above it, and mergeChangelogs returned non-null output with 179's body
|
||||
* duplicated into [Unreleased]; the train's T1 6755ab8 carried it. The
|
||||
* fixtures under test/fixtures/merge-train-3345/ are the real CHANGELOGs at
|
||||
* those commits, cut below 1.91.177.0. A null merge means a hand resolve.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { changelogDamage, mergeChangelogs, ownEntryAboveMain, parseChangelog } from '../lib/changelog-check';
|
||||
|
||||
const fixture = (name: string) => fs.readFileSync(path.join(import.meta.dir, 'fixtures', 'merge-train-3345', name), 'utf8');
|
||||
const base = fixture('base-a59b5e4.md');
|
||||
const pin = fixture('pin-1eb7092.md');
|
||||
const main = fixture('main-4a98e40.md');
|
||||
const keys = (text: string) => parseChangelog(text).sections.map(s => s.version ?? 'Unreleased');
|
||||
|
||||
describe('mergeChangelogs refuses damage to released history', () => {
|
||||
test('GSTA-64 repro: a released heading replaced by [Unreleased] is null, not a merge with the 1.0.1 body duplicated', () => {
|
||||
const ours = '# Changelog\n\n## [Unreleased]\n\nNew entry.\n\nOld 1.0.1 body.\n\n## [1.0.0] - d\n\nOld 1.0.0 body.\n';
|
||||
const theirs = '# Changelog\n\n## [1.0.2] - d\n\nMain body.\n\n## [1.0.1] - d\n\nOld 1.0.1 body.\n\n## [1.0.0] - d\n\nOld 1.0.0 body.\n';
|
||||
expect(changelogDamage(ours, theirs)).toContain("ours's Unreleased section holds released 1.0.1 text");
|
||||
expect(mergeChangelogs(ours, theirs)).toBeNull();
|
||||
const baseText = '# Changelog\n\n## [1.0.1] - d\n\nOld 1.0.1 body.\n\n## [1.0.0] - d\n\nOld 1.0.0 body.\n';
|
||||
expect(changelogDamage(ours, theirs, baseText)).toBe('ours drops released heading 1.0.1 that the merge base carries');
|
||||
});
|
||||
|
||||
test('#3345: pin 1eb7092 against main 4a98e40 is null with or without the merge base; the train T1 it produced duplicated 179', () => {
|
||||
expect(changelogDamage(pin, main, base)).toBe('ours drops released heading 1.91.179.0 that the merge base carries');
|
||||
expect(changelogDamage(pin, main)).toMatch(/^ours's Unreleased section holds released 1\.91\.179\.0 text/);
|
||||
expect(mergeChangelogs(pin, main, base)).toBeNull();
|
||||
expect(mergeChangelogs(pin, main)).toBeNull();
|
||||
const t1 = fixture('train-T1-6755ab8.md');
|
||||
expect(keys(t1)).toEqual(['Unreleased', '1.91.181.0', '1.91.180.0', '1.91.179.0', '1.91.178.0', '1.91.177.0']);
|
||||
expect(changelogDamage(t1, main)).toMatch(/^ours's Unreleased section holds released 1\.91\.179\.0 text/);
|
||||
});
|
||||
|
||||
test('either side, and a renamed heading, count as damage', () => {
|
||||
expect(changelogDamage(main, pin, base)).toBe('theirs drops released heading 1.91.179.0 that the merge base carries');
|
||||
const renamed = main.replace('## [1.91.178.0]', '## [1.91.178.9]');
|
||||
expect(changelogDamage(renamed, main, base)).toBe('ours drops released heading 1.91.178.0 that the merge base carries');
|
||||
expect(changelogDamage(renamed, main)).toMatch(/^ours's 1\.91\.178\.9 section holds released 1\.91\.178\.0 text/);
|
||||
});
|
||||
|
||||
test('negative controls: the real hand resolve, an entry above a newer main, a shared tentative stamp and two [Unreleased] blocks are not damage', () => {
|
||||
const fixed = pin.replace(/\n\n\*\*docsync-fault-interface/, '\n\n## [1.91.179.0] - 2026-10-22\n\n**docsync-fault-interface');
|
||||
expect(keys(fixed)).toEqual(['Unreleased', '1.91.179.0', '1.91.178.0', '1.91.177.0']);
|
||||
expect(changelogDamage(fixed, main, base)).toBeNull();
|
||||
const merged = mergeChangelogs(fixed, main, base)!;
|
||||
expect(keys(merged)).toEqual(['Unreleased', '1.91.181.0', '1.91.180.0', '1.91.179.0', '1.91.178.0', '1.91.177.0']);
|
||||
expect(merged.split('**docsync-fault-interface fits its budgets').length).toBe(2);
|
||||
const b = '## [1.2.3.0] - d\n\n- base release entry\n';
|
||||
expect(mergeChangelogs(`# C\n\n## [1.2.4.0] - d\n\n- mine, a new entry\n\n${b}`, `# C\n\n## [1.2.4.0] - e\n\n- theirs, a new entry\n\n${b}`, `# C\n\n${b}`)).not.toBeNull();
|
||||
expect(changelogDamage(`# C\n\n## [Unreleased]\n\n- #2 change, its entry\n\n${b}`, `# C\n\n## [Unreleased]\n\n- #1 change, its entry\n\n${b}`, `# C\n\n${b}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ownEntryAboveMain: a merged-in head's CHANGELOG however it was resolved", () => {
|
||||
const dir = path.join(import.meta.dir, 'fixtures', 'merge-train-3345');
|
||||
const diff = spawnSync('git', ['diff', '--no-index', '--unified=0', '--no-color', path.join(dir, 'base-a59b5e4.md'), path.join(dir, 'pin-1eb7092.md')], { encoding: 'utf8', timeout: 30_000 }).stdout;
|
||||
const authored = new Set(diff.split('\n').filter(l => l.startsWith('+') && !l.startsWith('+++')).map(l => l.slice(1)));
|
||||
const stamped = fixture('stamped-de23f5d.md');
|
||||
|
||||
test("#3345's stamped head de23f5d is main 4a98e40's CHANGELOG plus its own entry; the damaged train T1 is not", () => {
|
||||
expect(authored.has('### Fixed')).toBe(true);
|
||||
expect(authored.has('## [1.91.179.0] - 2026-10-22')).toBe(false);
|
||||
expect(ownEntryAboveMain(stamped, main, authored)).toBeNull();
|
||||
expect(ownEntryAboveMain(fixture('train-T1-6755ab8.md'), main, authored)).toMatch(/^the PR's entry holds a line its pin never added: "\*\*docsync-fault-interface/);
|
||||
});
|
||||
|
||||
test('negative controls: an invented line, a dropped or edited main section, a changed header and a duplicate heading are refused', () => {
|
||||
expect(ownEntryAboveMain(stamped.replace('### Fixed\n', '### Fixed\n- added after the gate\n'), main, authored)).toBe('the PR\'s entry holds a line its pin never added: "- added after the gate"');
|
||||
expect(ownEntryAboveMain(stamped.replace(/## \[1\.91\.180\.0\][^\n]*\n/, ''), main, authored)).toBe("main's ## [1.91.181.0] - 2026-10-23 is not carried unchanged below the PR's entry");
|
||||
expect(ownEntryAboveMain(stamped.replace('## [1.91.178.0]', '## [1.91.178.0]\n\n- smuggled'), main, authored)).toBe("main's ## [1.91.178.0] - 2026-10-22 is not carried unchanged below the PR's entry");
|
||||
expect(ownEntryAboveMain(stamped.replace('# Changelog', '# Change log'), main, authored)).toBe('the header differs from main');
|
||||
expect(ownEntryAboveMain(stamped.replace('## [1.91.182.0]', '## [1.91.181.0]'), main, authored)).toMatch(/is headed twice$/);
|
||||
});
|
||||
});
|
||||
@@ -185,6 +185,52 @@ describe('version gate: unstamped heads under a train policy', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('a CHANGELOG over 1 MiB (GSTA-67: #3345 head 1eb7092 passed because the 1.67 MB CHANGELOG overflowed the read)', () => {
|
||||
const excerpt = (name: string) => fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'merge-train-3345', name), 'utf8');
|
||||
const baseTop = excerpt('base-a59b5e4.md');
|
||||
const padding = Array.from({ length: 2400 }, (_, i) => `## [1.0.${2400 - i}.0] - 2026-01-01\n\nOlder release ${i}: ${'x'.repeat(440)}\n`).join('\n');
|
||||
const big = `${baseTop}\n${padding}`;
|
||||
const bigFixture = () => {
|
||||
const r = fixture();
|
||||
write(r, 'CHANGELOG.md', big);
|
||||
commit(r, 'main with a full-size CHANGELOG');
|
||||
r.git('push', '-q', 'origin', 'main');
|
||||
r.git('fetch', '-q', 'origin');
|
||||
branch(r);
|
||||
expect(Buffer.byteLength(big)).toBeGreaterThan(1.05 * 1024 * 1024);
|
||||
return r;
|
||||
};
|
||||
|
||||
test("#3345's change (the 1.91.179.0 heading replaced by [Unreleased]) is refused, naming the dropped heading", () => {
|
||||
const r = bigFixture();
|
||||
write(r, 'CHANGELOG.md', big.replace(baseTop, excerpt('pin-1eb7092.md')));
|
||||
const res = run(r, commit(r, 'the 1eb7092 CHANGELOG edit'));
|
||||
expect(res.code).toBe(1);
|
||||
expect(res.out).toContain('::error::PR #3320: CHANGELOG.md drops released heading 1.91.179.0; an unstamped head may only add its [Unreleased] section above the latest release.');
|
||||
});
|
||||
|
||||
test('control: an additive [Unreleased] entry above the latest release still passes at full size', () => {
|
||||
const r = bigFixture();
|
||||
write(r, 'CHANGELOG.md', big.replace('## [1.91.179.0]', '## [Unreleased]\n\n- the PR entry\n\n## [1.91.179.0]'));
|
||||
const res = run(r, commit(r, 'additive entry'));
|
||||
expect(res.code).toBe(0);
|
||||
expect(res.out).toContain('is unstamped under the base');
|
||||
});
|
||||
|
||||
test('a CHANGELOG that cannot be read is refused, never passed', () => {
|
||||
const r = bigFixture();
|
||||
write(r, 'CHANGELOG.md', big.replace('## [1.91.179.0]', '## [Unreleased]\n\n- the PR entry\n\n## [1.91.179.0]'));
|
||||
const head = commit(r, 'additive entry, then a damaged object');
|
||||
const blob = r.git('rev-parse', `${head}:CHANGELOG.md`);
|
||||
const loose = path.join(r.work, '.git', 'objects', blob.slice(0, 2), blob.slice(2));
|
||||
fs.chmodSync(loose, 0o644);
|
||||
fs.writeFileSync(loose, fs.readFileSync(loose).subarray(0, 2000));
|
||||
const res = run(r, head);
|
||||
expect(res.code).toBe(1);
|
||||
expect(res.out).toContain(`::error::PR #3320: cannot read CHANGELOG.md at ${head}.`);
|
||||
});
|
||||
});
|
||||
|
||||
test('the workflow runs the gate from the merge commit with the base ref and head sha', () => {
|
||||
const workflow = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, '.github', 'workflows', 'version-gate.yml'), 'utf8')) as any;
|
||||
const steps = workflow.jobs.check.steps;
|
||||
|
||||
+10
-9
@@ -788,7 +788,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
|
||||
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
|
||||
|
||||
3. **Queue-aware pick** (workspace-aware ship):
|
||||
3. **Queue-aware pick:**
|
||||
```bash
|
||||
QUEUE_JSON=$(bun --no-env-file --config=/dev/null run ~/.gemini/antigravity-cli/skills/gstack/bin/gstack-next-version --base <base> --bump "$BUMP_LEVEL" --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
|
||||
CANDIDATE_VERSION=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
|
||||
@@ -798,6 +798,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
Offline output without that fallback, failure, malformed output or an empty version
|
||||
is unusable, even when it contains a version-looking string.
|
||||
|
||||
**Incomplete** (`complete:false`): PRs in `unknown_claims` went unread; the pick may
|
||||
collide. Rerun item 3 once; still incomplete, AskUserQuestion: use it (recommended;
|
||||
Version Gate CI rereads the queue) or stop.
|
||||
|
||||
- **Usable candidate:** print warnings and claimed queue. FRESH sets `NEW_VERSION=CANDIDATE_VERSION`.
|
||||
ALREADY_BUMPED compares it with `currentVersion`: if different, ask to rebump
|
||||
(refresh CHANGELOG/PR title) or keep current (CI rejects a collision).
|
||||
@@ -809,16 +813,13 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
|
||||
4. **Write the bump** (FRESH, or an approved rebump):
|
||||
```bash
|
||||
bun --no-env-file --config=/dev/null run ~/.gemini/antigravity-cli/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest
|
||||
bun --no-env-file --config=/dev/null run ~/.gemini/antigravity-cli/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest --changelog CHANGELOG.md
|
||||
```
|
||||
The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver), writes
|
||||
VERSION, the manifest (`--package-json-path` → `.gstack/package-json-path` →
|
||||
`./package.json`) and existing npm lockfiles, never creating one, with the 3-digit
|
||||
translation (`1.67.0.0` → `1.67.0`); VERSION is authoritative. Exit 3 is a half-write:
|
||||
reclassify and `repair` DRIFT_STALE_PKG.
|
||||
It writes VERSION, manifest and lockfiles, and moves `## [Unreleased]` under NEW_VERSION
|
||||
for Step 13. Exit 2 wrote nothing: show stderr, stop.
|
||||
Exit 3 is a half-write: reclassify and `repair` DRIFT_STALE_PKG.
|
||||
|
||||
`--regen-digest` runs repo code (`scripts/gen-agents-digest.ts`) with Step 5's privileges
|
||||
only when it and committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is
|
||||
`--regen-digest` runs `scripts/gen-agents-digest.ts` only when it and a committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is
|
||||
false, run `bun --no-env-file --config=/dev/null scripts/gen-agents-digest.ts` and stage the digest with the bump. Before
|
||||
push, verify the committed digest matches generation for the selected VERSION.
|
||||
|
||||
|
||||
+10
-9
@@ -793,7 +793,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
|
||||
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
|
||||
|
||||
3. **Queue-aware pick** (workspace-aware ship):
|
||||
3. **Queue-aware pick:**
|
||||
```bash
|
||||
QUEUE_JSON=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version --base <base> --bump "$BUMP_LEVEL" --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
|
||||
CANDIDATE_VERSION=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
|
||||
@@ -803,6 +803,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
Offline output without that fallback, failure, malformed output or an empty version
|
||||
is unusable, even when it contains a version-looking string.
|
||||
|
||||
**Incomplete** (`complete:false`): PRs in `unknown_claims` went unread; the pick may
|
||||
collide. Rerun item 3 once; still incomplete, AskUserQuestion: use it (recommended;
|
||||
Version Gate CI rereads the queue) or stop.
|
||||
|
||||
- **Usable candidate:** print warnings and claimed queue. FRESH sets `NEW_VERSION=CANDIDATE_VERSION`.
|
||||
ALREADY_BUMPED compares it with `currentVersion`: if different, ask to rebump
|
||||
(refresh CHANGELOG/PR title) or keep current (CI rejects a collision).
|
||||
@@ -814,16 +818,13 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
|
||||
4. **Write the bump** (FRESH, or an approved rebump):
|
||||
```bash
|
||||
bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest
|
||||
bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest --changelog CHANGELOG.md
|
||||
```
|
||||
The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver), writes
|
||||
VERSION, the manifest (`--package-json-path` → `.gstack/package-json-path` →
|
||||
`./package.json`) and existing npm lockfiles, never creating one, with the 3-digit
|
||||
translation (`1.67.0.0` → `1.67.0`); VERSION is authoritative. Exit 3 is a half-write:
|
||||
reclassify and `repair` DRIFT_STALE_PKG.
|
||||
It writes VERSION, manifest and lockfiles, and moves `## [Unreleased]` under NEW_VERSION
|
||||
for Step 13. Exit 2 wrote nothing: show stderr, stop.
|
||||
Exit 3 is a half-write: reclassify and `repair` DRIFT_STALE_PKG.
|
||||
|
||||
`--regen-digest` runs repo code (`scripts/gen-agents-digest.ts`) with Step 5's privileges
|
||||
only when it and committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is
|
||||
`--regen-digest` runs `scripts/gen-agents-digest.ts` only when it and a committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is
|
||||
false, run `bun --no-env-file --config=/dev/null scripts/gen-agents-digest.ts` and stage the digest with the bump. Before
|
||||
push, verify the committed digest matches generation for the selected VERSION.
|
||||
|
||||
|
||||
+10
-9
@@ -810,7 +810,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
|
||||
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
|
||||
|
||||
3. **Queue-aware pick** (workspace-aware ship):
|
||||
3. **Queue-aware pick:**
|
||||
```bash
|
||||
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack;[ -d "$_r/bin" ]||_r=${CODEX_HOME:-~/.codex}/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host codex from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
|
||||
QUEUE_JSON=$(bun --no-env-file --config=/dev/null run $GSTACK_ROOT/bin/gstack-next-version --base <base> --bump "$BUMP_LEVEL" --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
|
||||
@@ -821,6 +821,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
Offline output without that fallback, failure, malformed output or an empty version
|
||||
is unusable, even when it contains a version-looking string.
|
||||
|
||||
**Incomplete** (`complete:false`): PRs in `unknown_claims` went unread; the pick may
|
||||
collide. Rerun item 3 once; still incomplete, AskUserQuestion: use it (recommended;
|
||||
Version Gate CI rereads the queue) or stop.
|
||||
|
||||
- **Usable candidate:** print warnings and claimed queue. FRESH sets `NEW_VERSION=CANDIDATE_VERSION`.
|
||||
ALREADY_BUMPED compares it with `currentVersion`: if different, ask to rebump
|
||||
(refresh CHANGELOG/PR title) or keep current (CI rejects a collision).
|
||||
@@ -833,16 +837,13 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
4. **Write the bump** (FRESH, or an approved rebump):
|
||||
```bash
|
||||
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack;[ -d "$_r/bin" ]||_r=${CODEX_HOME:-~/.codex}/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host codex from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
|
||||
bun --no-env-file --config=/dev/null run $GSTACK_ROOT/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest
|
||||
bun --no-env-file --config=/dev/null run $GSTACK_ROOT/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest --changelog CHANGELOG.md
|
||||
```
|
||||
The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver), writes
|
||||
VERSION, the manifest (`--package-json-path` → `.gstack/package-json-path` →
|
||||
`./package.json`) and existing npm lockfiles, never creating one, with the 3-digit
|
||||
translation (`1.67.0.0` → `1.67.0`); VERSION is authoritative. Exit 3 is a half-write:
|
||||
reclassify and `repair` DRIFT_STALE_PKG.
|
||||
It writes VERSION, manifest and lockfiles, and moves `## [Unreleased]` under NEW_VERSION
|
||||
for Step 13. Exit 2 wrote nothing: show stderr, stop.
|
||||
Exit 3 is a half-write: reclassify and `repair` DRIFT_STALE_PKG.
|
||||
|
||||
`--regen-digest` runs repo code (`scripts/gen-agents-digest.ts`) with Step 5's privileges
|
||||
only when it and committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is
|
||||
`--regen-digest` runs `scripts/gen-agents-digest.ts` only when it and a committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is
|
||||
false, run `bun --no-env-file --config=/dev/null scripts/gen-agents-digest.ts` and stage the digest with the bump. Before
|
||||
push, verify the committed digest matches generation for the selected VERSION.
|
||||
|
||||
|
||||
+10
-9
@@ -791,7 +791,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
|
||||
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
|
||||
|
||||
3. **Queue-aware pick** (workspace-aware ship):
|
||||
3. **Queue-aware pick:**
|
||||
```bash
|
||||
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.factory/skills/gstack;[ -d "$_r/bin" ]||_r=~/.factory/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host factory from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
|
||||
QUEUE_JSON=$(bun --no-env-file --config=/dev/null run $GSTACK_ROOT/bin/gstack-next-version --base <base> --bump "$BUMP_LEVEL" --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
|
||||
@@ -802,6 +802,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
Offline output without that fallback, failure, malformed output or an empty version
|
||||
is unusable, even when it contains a version-looking string.
|
||||
|
||||
**Incomplete** (`complete:false`): PRs in `unknown_claims` went unread; the pick may
|
||||
collide. Rerun item 3 once; still incomplete, AskUserQuestion: use it (recommended;
|
||||
Version Gate CI rereads the queue) or stop.
|
||||
|
||||
- **Usable candidate:** print warnings and claimed queue. FRESH sets `NEW_VERSION=CANDIDATE_VERSION`.
|
||||
ALREADY_BUMPED compares it with `currentVersion`: if different, ask to rebump
|
||||
(refresh CHANGELOG/PR title) or keep current (CI rejects a collision).
|
||||
@@ -814,16 +818,13 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
4. **Write the bump** (FRESH, or an approved rebump):
|
||||
```bash
|
||||
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.factory/skills/gstack;[ -d "$_r/bin" ]||_r=~/.factory/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host factory from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
|
||||
bun --no-env-file --config=/dev/null run $GSTACK_ROOT/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest
|
||||
bun --no-env-file --config=/dev/null run $GSTACK_ROOT/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest --changelog CHANGELOG.md
|
||||
```
|
||||
The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver), writes
|
||||
VERSION, the manifest (`--package-json-path` → `.gstack/package-json-path` →
|
||||
`./package.json`) and existing npm lockfiles, never creating one, with the 3-digit
|
||||
translation (`1.67.0.0` → `1.67.0`); VERSION is authoritative. Exit 3 is a half-write:
|
||||
reclassify and `repair` DRIFT_STALE_PKG.
|
||||
It writes VERSION, manifest and lockfiles, and moves `## [Unreleased]` under NEW_VERSION
|
||||
for Step 13. Exit 2 wrote nothing: show stderr, stop.
|
||||
Exit 3 is a half-write: reclassify and `repair` DRIFT_STALE_PKG.
|
||||
|
||||
`--regen-digest` runs repo code (`scripts/gen-agents-digest.ts`) with Step 5's privileges
|
||||
only when it and committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is
|
||||
`--regen-digest` runs `scripts/gen-agents-digest.ts` only when it and a committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is
|
||||
false, run `bun --no-env-file --config=/dev/null scripts/gen-agents-digest.ts` and stage the digest with the bump. Before
|
||||
push, verify the committed digest matches generation for the selected VERSION.
|
||||
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.179.0] - 2026-10-22
|
||||
|
||||
**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed.
|
||||
|
||||
### Fixed
|
||||
- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms.
|
||||
- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls.
|
||||
- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical.
|
||||
- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint.
|
||||
|
||||
## [1.91.178.0] - 2026-10-22
|
||||
|
||||
**A /qa run no longer gets a hint that another report root could change its verdict.** Train gate 39646086155 failed qa-functional-webhook-fix once, and gate 39166935531 failed qa-functional-cli-fix the same way on 2026-10-18. Both times the agent ran `gstack-qa-evidence materialize`, read a fail verdict that ended "this verdict is final for this report root", and its very next call wrote a stray placeholder file in the fixture's parent directory (`qa-reports-placeholder-unused`, `qa-Q3vLqZ-placeholder`) before writing `report.md`. Each agent then reported the stray file itself. The out-of-fixture write detector was right both times and is unchanged.
|
||||
|
||||
### Fixed
|
||||
- **`lib/qa-evidence.ts`**: materialize's `next` instruction now ends "this verdict is final. Next, write the Markdown report beside evidence.json." It names the next write and where it goes, instead of qualifying the verdict by report root.
|
||||
- **`test/helpers/qa-functional-eval.ts`**: the functional fixture's declared interface says that every Write/Edit targets an owned path inside the working directory, and that a write anywhere else, including the parent directory, fails acceptance. That was already the acceptance rule; the prompt now states it.
|
||||
|
||||
### Gate integrity
|
||||
- No detector, threshold, trial count or case kind changed. `test/qa-evidence.test.ts` pins the new `next` wording and checks that it no longer mentions a report root.
|
||||
|
||||
## [1.91.177.0] - 2026-10-22
|
||||
|
||||
**The /cso runtime promotion workflow can finish.** Promote run 39691485727 failed after its `cso-runtime-release` approval. Its "Generate a fail-closed catalog candidate" step calls `gh api …/compare` to prove the staged commit is an ancestor of the qualification head, but the step had no `GH_TOKEN`. On a hosted runner `gh` then exits 4 ("set the GH_TOKEN environment variable"). The run stopped before it signed, pushed or opened anything.
|
||||
|
||||
### Fixed
|
||||
- **`.github/workflows/cso-runtime-promote.yml`**: the candidate step gets `GH_TOKEN: ${{ github.token }}`, like every other step there that calls `gh`.
|
||||
- **`test/workflow-gh-token.test.ts`** (new): fails when any step in any workflow invokes `gh` with no `GH_TOKEN` or `GITHUB_TOKEN` in its step, job or workflow env. actionlint does not check this. This step was the only one in the repository missing a token.
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.181.0] - 2026-10-23
|
||||
|
||||
**/cso can run its contained runtime checks.** Before this release the runtime catalog qualified nothing, so /cso always fell back to static assessment. It now qualifies ten pinned images: Node 24.4.0, Bun 1.3.10, Python 3.13.4 with uv 0.8.0, Rails on Ruby 3.4.4, and PostgreSQL 17.2, each for linux/amd64 and linux/arm64. With Docker installed, comprehensive /cso boots the target app inside the matching image and tests findings and repairs against it. The images passed the private evaluator's release qualification (run-5 statements, re-delivered under Garry's approval). The ingress then checked them in qualify run 39674065531 at 514fbfd.
|
||||
|
||||
### Added
|
||||
- **`lib/cso/runtime-catalog.json`** is now catalog `cso-v3-39674065531-133cb360842d`, which replaces `cso-v3-build-reviewed-2026-09-10`. Promote run 39841502986 generated it from qualification run 39674065531's authenticated statements and attested it. GitHub Actions committed the attested bytes (`sha256:078e0bf6…`). Each runtime's image is pinned to a public `ghcr.io/garrytan/gstack/cso-staging/…@sha256` digest, and each entry records its source commit (133cb36), SBOM and provenance digests, and requalification triggers (`preparationSha256` `c721b585…`).
|
||||
|
||||
### For contributors
|
||||
- **The /cso free tests no longer assume the committed runtime catalog is empty.** Six tests pinned `runtimes: []` and broke as soon as a promotion landed. Tests that need an unqualified catalog now build one with `unqualifiedRuntimeCatalog()`. The replay tests expect whichever fail-closed rejection the committed catalog produces, so the suite passes with the catalog both before and after a promotion.
|
||||
|
||||
## [1.91.180.0] - 2026-10-22
|
||||
|
||||
**The /cso docs say what the qualified catalogs now allow.** AGENTS.md, the README and `docs/cso-release-qualification.md` still said that no qualified runtime or scanner image existed, so /cso ran static assessment only. The scanner catalog has been qualified since #3042. The runtime catalog becomes qualified with the runtime promotion PR that ships alongside this one.
|
||||
|
||||
### Changed
|
||||
- **AGENTS.md**, **README.md** and **`docs/cso-release-qualification.md`**: the /cso entries name the ten qualified runtime images and the twelve scanner images, and say when /cso still falls back to static assessment: without Docker, or where no profile matches.
|
||||
|
||||
## [1.91.179.0] - 2026-10-22
|
||||
|
||||
**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed.
|
||||
|
||||
### Fixed
|
||||
- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms.
|
||||
- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls.
|
||||
- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical.
|
||||
- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint.
|
||||
|
||||
## [1.91.178.0] - 2026-10-22
|
||||
|
||||
**A /qa run no longer gets a hint that another report root could change its verdict.** Train gate 39646086155 failed qa-functional-webhook-fix once, and gate 39166935531 failed qa-functional-cli-fix the same way on 2026-10-18. Both times the agent ran `gstack-qa-evidence materialize`, read a fail verdict that ended "this verdict is final for this report root", and its very next call wrote a stray placeholder file in the fixture's parent directory (`qa-reports-placeholder-unused`, `qa-Q3vLqZ-placeholder`) before writing `report.md`. Each agent then reported the stray file itself. The out-of-fixture write detector was right both times and is unchanged.
|
||||
|
||||
### Fixed
|
||||
- **`lib/qa-evidence.ts`**: materialize's `next` instruction now ends "this verdict is final. Next, write the Markdown report beside evidence.json." It names the next write and where it goes, instead of qualifying the verdict by report root.
|
||||
- **`test/helpers/qa-functional-eval.ts`**: the functional fixture's declared interface says that every Write/Edit targets an owned path inside the working directory, and that a write anywhere else, including the parent directory, fails acceptance. That was already the acceptance rule; the prompt now states it.
|
||||
|
||||
### Gate integrity
|
||||
- No detector, threshold, trial count or case kind changed. `test/qa-evidence.test.ts` pins the new `next` wording and checks that it no longer mentions a report root.
|
||||
|
||||
## [1.91.177.0] - 2026-10-22
|
||||
|
||||
**The /cso runtime promotion workflow can finish.** Promote run 39691485727 failed after its `cso-runtime-release` approval. Its "Generate a fail-closed catalog candidate" step calls `gh api …/compare` to prove the staged commit is an ancestor of the qualification head, but the step had no `GH_TOKEN`. On a hosted runner `gh` then exits 4 ("set the GH_TOKEN environment variable"). The run stopped before it signed, pushed or opened anything.
|
||||
|
||||
### Fixed
|
||||
- **`.github/workflows/cso-runtime-promote.yml`**: the candidate step gets `GH_TOKEN: ${{ github.token }}`, like every other step there that calls `gh`.
|
||||
- **`test/workflow-gh-token.test.ts`** (new): fails when any step in any workflow invokes `gh` with no `GH_TOKEN` or `GITHUB_TOKEN` in its step, job or workflow env. actionlint does not check this. This step was the only one in the repository missing a token.
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**gstack installs on a project that pins Bun 1.4.4 again.** gbrain master moved its CI to Bun 1.4.4 (gbrain #7374, the Brotli CVE-2026-111497 fix), and gstack's Capy installer then stopped every gbrain session with `RUNTIME_PIN_CONFLICT` ("verified archives: 1.4.2 1.4.3 … pins exactly 1.4.4"), because it installs only Bun archives whose SHA-256 it carries. It now carries 1.4.4, so the same run installs the pinned Bun and ends `gstack: ok`.
|
||||
|
||||
### Fixed
|
||||
- **`bin/gstack-bun-version.sh`**: `GSTACK_BUN_VERIFIED` is now 1.4.2 1.4.3 1.4.4, with 1.4.4's checksums for linux-x64, linux-x64-baseline and linux-aarch64, the targets 1.4.3 covers. They come from the release's `SHASUMS256.txt.asc`, whose clearsigned signature verifies against Bun's release key F3DCC08A8572C0749B3E18888EAB4D40A7B22B59 (Robobun, the key oven-sh/bun's own Dockerfiles assert), and each archive was downloaded and hashed to the same value. The 1.4.2 and 1.4.3 entries are unchanged and verify the same way. gstack's own minimum (1.4.2), install default (1.4.3) and CI pins do not move.
|
||||
- **`test/gstack-capy-install.test.ts`**: a gbrain-like repo whose CI pins only 1.4.4 refuses `~/.bun` 1.4.3 and installs the 1.4.4 archive. **`test/bun-version-drift.test.ts`**: every verified version must carry a checksum for each installer target. The existing "verified archives" text assertions list 1.4.4.
|
||||
|
||||
|
||||
**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed.
|
||||
|
||||
### Fixed
|
||||
- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms.
|
||||
- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls.
|
||||
- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical.
|
||||
- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint.
|
||||
|
||||
## [1.91.178.0] - 2026-10-22
|
||||
|
||||
**A /qa run no longer gets a hint that another report root could change its verdict.** Train gate 39646086155 failed qa-functional-webhook-fix once, and gate 39166935531 failed qa-functional-cli-fix the same way on 2026-10-18. Both times the agent ran `gstack-qa-evidence materialize`, read a fail verdict that ended "this verdict is final for this report root", and its very next call wrote a stray placeholder file in the fixture's parent directory (`qa-reports-placeholder-unused`, `qa-Q3vLqZ-placeholder`) before writing `report.md`. Each agent then reported the stray file itself. The out-of-fixture write detector was right both times and is unchanged.
|
||||
|
||||
### Fixed
|
||||
- **`lib/qa-evidence.ts`**: materialize's `next` instruction now ends "this verdict is final. Next, write the Markdown report beside evidence.json." It names the next write and where it goes, instead of qualifying the verdict by report root.
|
||||
- **`test/helpers/qa-functional-eval.ts`**: the functional fixture's declared interface says that every Write/Edit targets an owned path inside the working directory, and that a write anywhere else, including the parent directory, fails acceptance. That was already the acceptance rule; the prompt now states it.
|
||||
|
||||
### Gate integrity
|
||||
- No detector, threshold, trial count or case kind changed. `test/qa-evidence.test.ts` pins the new `next` wording and checks that it no longer mentions a report root.
|
||||
|
||||
## [1.91.177.0] - 2026-10-22
|
||||
|
||||
**The /cso runtime promotion workflow can finish.** Promote run 39691485727 failed after its `cso-runtime-release` approval. Its "Generate a fail-closed catalog candidate" step calls `gh api …/compare` to prove the staged commit is an ancestor of the qualification head, but the step had no `GH_TOKEN`. On a hosted runner `gh` then exits 4 ("set the GH_TOKEN environment variable"). The run stopped before it signed, pushed or opened anything.
|
||||
|
||||
### Fixed
|
||||
- **`.github/workflows/cso-runtime-promote.yml`**: the candidate step gets `GH_TOKEN: ${{ github.token }}`, like every other step there that calls `gh`.
|
||||
- **`test/workflow-gh-token.test.ts`** (new): fails when any step in any workflow invokes `gh` with no `GH_TOKEN` or `GITHUB_TOKEN` in its step, job or workflow env. actionlint does not check this. This step was the only one in the repository missing a token.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.182.0] - 2026-10-23
|
||||
|
||||
**gstack installs on a project that pins Bun 1.4.4 again.** gbrain master moved its CI to Bun 1.4.4 (gbrain #7374, the Brotli CVE-2026-111497 fix), and gstack's Capy installer then stopped every gbrain session with `RUNTIME_PIN_CONFLICT` ("verified archives: 1.4.2 1.4.3 … pins exactly 1.4.4"), because it installs only Bun archives whose SHA-256 it carries. It now carries 1.4.4, so the same run installs the pinned Bun and ends `gstack: ok`.
|
||||
|
||||
### Fixed
|
||||
- **`bin/gstack-bun-version.sh`**: `GSTACK_BUN_VERIFIED` is now 1.4.2 1.4.3 1.4.4, with 1.4.4's checksums for linux-x64, linux-x64-baseline and linux-aarch64, the targets 1.4.3 covers. They come from the release's `SHASUMS256.txt.asc`, whose clearsigned signature verifies against Bun's release key F3DCC08A8572C0749B3E18888EAB4D40A7B22B59 (Robobun, the key oven-sh/bun's own Dockerfiles assert), and each archive was downloaded and hashed to the same value. The 1.4.2 and 1.4.3 entries are unchanged and verify the same way. gstack's own minimum (1.4.2), install default (1.4.3) and CI pins do not move.
|
||||
- **`test/gstack-capy-install.test.ts`**: a gbrain-like repo whose CI pins only 1.4.4 refuses `~/.bun` 1.4.3 and installs the 1.4.4 archive. **`test/bun-version-drift.test.ts`**: every verified version must carry a checksum for each installer target. The existing "verified archives" text assertions list 1.4.4.
|
||||
|
||||
## [1.91.181.0] - 2026-10-23
|
||||
|
||||
**/cso can run its contained runtime checks.** Before this release the runtime catalog qualified nothing, so /cso always fell back to static assessment. It now qualifies ten pinned images: Node 24.4.0, Bun 1.3.10, Python 3.13.4 with uv 0.8.0, Rails on Ruby 3.4.4, and PostgreSQL 17.2, each for linux/amd64 and linux/arm64. With Docker installed, comprehensive /cso boots the target app inside the matching image and tests findings and repairs against it. The images passed the private evaluator's release qualification (run-5 statements, re-delivered under Garry's approval). The ingress then checked them in qualify run 39674065531 at 514fbfd.
|
||||
|
||||
### Added
|
||||
- **`lib/cso/runtime-catalog.json`** is now catalog `cso-v3-39674065531-133cb360842d`, which replaces `cso-v3-build-reviewed-2026-09-10`. Promote run 39841502986 generated it from qualification run 39674065531's authenticated statements and attested it. GitHub Actions committed the attested bytes (`sha256:078e0bf6…`). Each runtime's image is pinned to a public `ghcr.io/garrytan/gstack/cso-staging/…@sha256` digest, and each entry records its source commit (133cb36), SBOM and provenance digests, and requalification triggers (`preparationSha256` `c721b585…`).
|
||||
|
||||
### For contributors
|
||||
- **The /cso free tests no longer assume the committed runtime catalog is empty.** Six tests pinned `runtimes: []` and broke as soon as a promotion landed. Tests that need an unqualified catalog now build one with `unqualifiedRuntimeCatalog()`. The replay tests expect whichever fail-closed rejection the committed catalog produces, so the suite passes with the catalog both before and after a promotion.
|
||||
|
||||
## [1.91.180.0] - 2026-10-22
|
||||
|
||||
**The /cso docs say what the qualified catalogs now allow.** AGENTS.md, the README and `docs/cso-release-qualification.md` still said that no qualified runtime or scanner image existed, so /cso ran static assessment only. The scanner catalog has been qualified since #3042. The runtime catalog becomes qualified with the runtime promotion PR that ships alongside this one.
|
||||
|
||||
### Changed
|
||||
- **AGENTS.md**, **README.md** and **`docs/cso-release-qualification.md`**: the /cso entries name the ten qualified runtime images and the twelve scanner images, and say when /cso still falls back to static assessment: without Docker, or where no profile matches.
|
||||
|
||||
## [1.91.179.0] - 2026-10-22
|
||||
|
||||
**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed.
|
||||
|
||||
### Fixed
|
||||
- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms.
|
||||
- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls.
|
||||
- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical.
|
||||
- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint.
|
||||
|
||||
## [1.91.178.0] - 2026-10-22
|
||||
|
||||
**A /qa run no longer gets a hint that another report root could change its verdict.** Train gate 39646086155 failed qa-functional-webhook-fix once, and gate 39166935531 failed qa-functional-cli-fix the same way on 2026-10-18. Both times the agent ran `gstack-qa-evidence materialize`, read a fail verdict that ended "this verdict is final for this report root", and its very next call wrote a stray placeholder file in the fixture's parent directory (`qa-reports-placeholder-unused`, `qa-Q3vLqZ-placeholder`) before writing `report.md`. Each agent then reported the stray file itself. The out-of-fixture write detector was right both times and is unchanged.
|
||||
|
||||
### Fixed
|
||||
- **`lib/qa-evidence.ts`**: materialize's `next` instruction now ends "this verdict is final. Next, write the Markdown report beside evidence.json." It names the next write and where it goes, instead of qualifying the verdict by report root.
|
||||
- **`test/helpers/qa-functional-eval.ts`**: the functional fixture's declared interface says that every Write/Edit targets an owned path inside the working directory, and that a write anywhere else, including the parent directory, fails acceptance. That was already the acceptance rule; the prompt now states it.
|
||||
|
||||
### Gate integrity
|
||||
- No detector, threshold, trial count or case kind changed. `test/qa-evidence.test.ts` pins the new `next` wording and checks that it no longer mentions a report root.
|
||||
|
||||
## [1.91.177.0] - 2026-10-22
|
||||
|
||||
**The /cso runtime promotion workflow can finish.** Promote run 39691485727 failed after its `cso-runtime-release` approval. Its "Generate a fail-closed catalog candidate" step calls `gh api …/compare` to prove the staged commit is an ancestor of the qualification head, but the step had no `GH_TOKEN`. On a hosted runner `gh` then exits 4 ("set the GH_TOKEN environment variable"). The run stopped before it signed, pushed or opened anything.
|
||||
|
||||
### Fixed
|
||||
- **`.github/workflows/cso-runtime-promote.yml`**: the candidate step gets `GH_TOKEN: ${{ github.token }}`, like every other step there that calls `gh`.
|
||||
- **`test/workflow-gh-token.test.ts`** (new): fails when any step in any workflow invokes `gh` with no `GH_TOKEN` or `GITHUB_TOKEN` in its step, job or workflow env. actionlint does not check this. This step was the only one in the repository missing a token.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**gstack installs on a project that pins Bun 1.4.4 again.** gbrain master moved its CI to Bun 1.4.4 (gbrain #7374, the Brotli CVE-2026-111497 fix), and gstack's Capy installer then stopped every gbrain session with `RUNTIME_PIN_CONFLICT` ("verified archives: 1.4.2 1.4.3 … pins exactly 1.4.4"), because it installs only Bun archives whose SHA-256 it carries. It now carries 1.4.4, so the same run installs the pinned Bun and ends `gstack: ok`.
|
||||
|
||||
### Fixed
|
||||
- **`bin/gstack-bun-version.sh`**: `GSTACK_BUN_VERIFIED` is now 1.4.2 1.4.3 1.4.4, with 1.4.4's checksums for linux-x64, linux-x64-baseline and linux-aarch64, the targets 1.4.3 covers. They come from the release's `SHASUMS256.txt.asc`, whose clearsigned signature verifies against Bun's release key F3DCC08A8572C0749B3E18888EAB4D40A7B22B59 (Robobun, the key oven-sh/bun's own Dockerfiles assert), and each archive was downloaded and hashed to the same value. The 1.4.2 and 1.4.3 entries are unchanged and verify the same way. gstack's own minimum (1.4.2), install default (1.4.3) and CI pins do not move.
|
||||
- **`test/gstack-capy-install.test.ts`**: a gbrain-like repo whose CI pins only 1.4.4 refuses `~/.bun` 1.4.3 and installs the 1.4.4 archive. **`test/bun-version-drift.test.ts`**: every verified version must carry a checksum for each installer target. The existing "verified archives" text assertions list 1.4.4.
|
||||
|
||||
|
||||
**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed.
|
||||
|
||||
### Fixed
|
||||
- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms.
|
||||
- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls.
|
||||
- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical.
|
||||
- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint.
|
||||
|
||||
## [1.91.181.0] - 2026-10-23
|
||||
|
||||
**/cso can run its contained runtime checks.** Before this release the runtime catalog qualified nothing, so /cso always fell back to static assessment. It now qualifies ten pinned images: Node 24.4.0, Bun 1.3.10, Python 3.13.4 with uv 0.8.0, Rails on Ruby 3.4.4, and PostgreSQL 17.2, each for linux/amd64 and linux/arm64. With Docker installed, comprehensive /cso boots the target app inside the matching image and tests findings and repairs against it. The images passed the private evaluator's release qualification (run-5 statements, re-delivered under Garry's approval). The ingress then checked them in qualify run 39674065531 at 514fbfd.
|
||||
|
||||
### Added
|
||||
- **`lib/cso/runtime-catalog.json`** is now catalog `cso-v3-39674065531-133cb360842d`, which replaces `cso-v3-build-reviewed-2026-09-10`. Promote run 39841502986 generated it from qualification run 39674065531's authenticated statements and attested it. GitHub Actions committed the attested bytes (`sha256:078e0bf6…`). Each runtime's image is pinned to a public `ghcr.io/garrytan/gstack/cso-staging/…@sha256` digest, and each entry records its source commit (133cb36), SBOM and provenance digests, and requalification triggers (`preparationSha256` `c721b585…`).
|
||||
|
||||
### For contributors
|
||||
- **The /cso free tests no longer assume the committed runtime catalog is empty.** Six tests pinned `runtimes: []` and broke as soon as a promotion landed. Tests that need an unqualified catalog now build one with `unqualifiedRuntimeCatalog()`. The replay tests expect whichever fail-closed rejection the committed catalog produces, so the suite passes with the catalog both before and after a promotion.
|
||||
|
||||
## [1.91.180.0] - 2026-10-22
|
||||
|
||||
**The /cso docs say what the qualified catalogs now allow.** AGENTS.md, the README and `docs/cso-release-qualification.md` still said that no qualified runtime or scanner image existed, so /cso ran static assessment only. The scanner catalog has been qualified since #3042. The runtime catalog becomes qualified with the runtime promotion PR that ships alongside this one.
|
||||
|
||||
### Changed
|
||||
- **AGENTS.md**, **README.md** and **`docs/cso-release-qualification.md`**: the /cso entries name the ten qualified runtime images and the twelve scanner images, and say when /cso still falls back to static assessment: without Docker, or where no profile matches.
|
||||
|
||||
## [1.91.179.0] - 2026-10-22
|
||||
|
||||
**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed.
|
||||
|
||||
### Fixed
|
||||
- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms.
|
||||
- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls.
|
||||
- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical.
|
||||
- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint.
|
||||
|
||||
## [1.91.178.0] - 2026-10-22
|
||||
|
||||
**A /qa run no longer gets a hint that another report root could change its verdict.** Train gate 39646086155 failed qa-functional-webhook-fix once, and gate 39166935531 failed qa-functional-cli-fix the same way on 2026-10-18. Both times the agent ran `gstack-qa-evidence materialize`, read a fail verdict that ended "this verdict is final for this report root", and its very next call wrote a stray placeholder file in the fixture's parent directory (`qa-reports-placeholder-unused`, `qa-Q3vLqZ-placeholder`) before writing `report.md`. Each agent then reported the stray file itself. The out-of-fixture write detector was right both times and is unchanged.
|
||||
|
||||
### Fixed
|
||||
- **`lib/qa-evidence.ts`**: materialize's `next` instruction now ends "this verdict is final. Next, write the Markdown report beside evidence.json." It names the next write and where it goes, instead of qualifying the verdict by report root.
|
||||
- **`test/helpers/qa-functional-eval.ts`**: the functional fixture's declared interface says that every Write/Edit targets an owned path inside the working directory, and that a write anywhere else, including the parent directory, fails acceptance. That was already the acceptance rule; the prompt now states it.
|
||||
|
||||
### Gate integrity
|
||||
- No detector, threshold, trial count or case kind changed. `test/qa-evidence.test.ts` pins the new `next` wording and checks that it no longer mentions a report root.
|
||||
|
||||
## [1.91.177.0] - 2026-10-22
|
||||
|
||||
**The /cso runtime promotion workflow can finish.** Promote run 39691485727 failed after its `cso-runtime-release` approval. Its "Generate a fail-closed catalog candidate" step calls `gh api …/compare` to prove the staged commit is an ancestor of the qualification head, but the step had no `GH_TOKEN`. On a hosted runner `gh` then exits 4 ("set the GH_TOKEN environment variable"). The run stopped before it signed, pushed or opened anything.
|
||||
|
||||
### Fixed
|
||||
- **`.github/workflows/cso-runtime-promote.yml`**: the candidate step gets `GH_TOKEN: ${{ github.token }}`, like every other step there that calls `gh`.
|
||||
- **`test/workflow-gh-token.test.ts`** (new): fails when any step in any workflow invokes `gh` with no `GH_TOKEN` or `GITHUB_TOKEN` in its step, job or workflow env. actionlint does not check this. This step was the only one in the repository missing a token.
|
||||
@@ -9,6 +9,7 @@ This page lists all API deprecations, along with recommended replacements.
|
||||
| Shutdown date | Model / system | Recommended replacement |
|
||||
| --- | --- | --- |
|
||||
| Apr 1, 2027 | `gpt-5.3-codex` | `gpt-6-sol` |
|
||||
| Feb 16, 2027 | GPT-4o (`gpt-4o-2024-08-06`, `gpt-4o-2024-11-20`) | `gpt-6-sol` |
|
||||
|
||||
## Past deprecations
|
||||
|
||||
|
||||
+11
-11
@@ -5,7 +5,7 @@
|
||||
"agy/gstack-autoplan": 130504,
|
||||
"agy/gstack-design-consultation": 108184,
|
||||
"agy/gstack-design-review": 143528,
|
||||
"agy/gstack-land-and-deploy": 116611,
|
||||
"agy/gstack-land-and-deploy": 117722,
|
||||
"agy/gstack-plan-design-review": 140013,
|
||||
"agy/gstack-plan-devex-review": 135760,
|
||||
"agy/gstack-plan-eng-review": 152701,
|
||||
@@ -14,7 +14,7 @@
|
||||
"codex/gstack-autoplan": 130628,
|
||||
"codex/gstack-design-consultation": 109452,
|
||||
"codex/gstack-design-review": 145028,
|
||||
"codex/gstack-land-and-deploy": 121188,
|
||||
"codex/gstack-land-and-deploy": 122272,
|
||||
"codex/gstack-office-hours": 102339,
|
||||
"codex/gstack-plan-design-review": 142466,
|
||||
"codex/gstack-plan-devex-review": 138649,
|
||||
@@ -24,7 +24,7 @@
|
||||
"copilot/gstack-codex": 100235,
|
||||
"copilot/gstack-design-consultation": 109536,
|
||||
"copilot/gstack-design-review": 144833,
|
||||
"copilot/gstack-land-and-deploy": 117881,
|
||||
"copilot/gstack-land-and-deploy": 118977,
|
||||
"copilot/gstack-office-hours": 100495,
|
||||
"copilot/gstack-plan-design-review": 140884,
|
||||
"copilot/gstack-plan-devex-review": 136912,
|
||||
@@ -33,7 +33,7 @@
|
||||
"cursor/gstack-autoplan": 129278,
|
||||
"cursor/gstack-design-consultation": 107222,
|
||||
"cursor/gstack-design-review": 142507,
|
||||
"cursor/gstack-land-and-deploy": 115550,
|
||||
"cursor/gstack-land-and-deploy": 116645,
|
||||
"cursor/gstack-plan-design-review": 138968,
|
||||
"cursor/gstack-plan-devex-review": 134591,
|
||||
"cursor/gstack-plan-eng-review": 151555,
|
||||
@@ -42,7 +42,7 @@
|
||||
"factory/gstack-codex": 100655,
|
||||
"factory/gstack-design-consultation": 108670,
|
||||
"factory/gstack-design-review": 144297,
|
||||
"factory/gstack-land-and-deploy": 119733,
|
||||
"factory/gstack-land-and-deploy": 120817,
|
||||
"factory/gstack-office-hours": 101374,
|
||||
"factory/gstack-plan-design-review": 141749,
|
||||
"factory/gstack-plan-devex-review": 137960,
|
||||
@@ -50,34 +50,34 @@
|
||||
"factory/gstack-review": 144529,
|
||||
"gbrain/gstack-autoplan": 129815,
|
||||
"gbrain/gstack-design-review": 132842,
|
||||
"gbrain/gstack-land-and-deploy": 116072,
|
||||
"gbrain/gstack-land-and-deploy": 117167,
|
||||
"gbrain/gstack-plan-design-review": 127855,
|
||||
"gbrain/gstack-plan-devex-review": 115129,
|
||||
"gbrain/gstack-plan-eng-review": 134287,
|
||||
"hermes/gstack-autoplan": 129905,
|
||||
"hermes/gstack-design-review": 132949,
|
||||
"hermes/gstack-land-and-deploy": 116174,
|
||||
"hermes/gstack-land-and-deploy": 117269,
|
||||
"hermes/gstack-plan-design-review": 127964,
|
||||
"hermes/gstack-plan-devex-review": 115215,
|
||||
"hermes/gstack-plan-eng-review": 134372,
|
||||
"kiro/gstack-autoplan": 129040,
|
||||
"kiro/gstack-design-consultation": 107064,
|
||||
"kiro/gstack-design-review": 142321,
|
||||
"kiro/gstack-land-and-deploy": 115440,
|
||||
"kiro/gstack-land-and-deploy": 116533,
|
||||
"kiro/gstack-plan-design-review": 138808,
|
||||
"kiro/gstack-plan-devex-review": 134431,
|
||||
"kiro/gstack-plan-eng-review": 151405,
|
||||
"kiro/gstack-review": 140562,
|
||||
"openclaw/gstack-autoplan": 129520,
|
||||
"openclaw/gstack-design-review": 131647,
|
||||
"openclaw/gstack-land-and-deploy": 115660,
|
||||
"openclaw/gstack-land-and-deploy": 116757,
|
||||
"openclaw/gstack-plan-design-review": 126706,
|
||||
"openclaw/gstack-plan-devex-review": 113968,
|
||||
"openclaw/gstack-plan-eng-review": 132684,
|
||||
"opencode/gstack-autoplan": 131640,
|
||||
"opencode/gstack-design-consultation": 109357,
|
||||
"opencode/gstack-design-review": 144705,
|
||||
"opencode/gstack-land-and-deploy": 117658,
|
||||
"opencode/gstack-land-and-deploy": 118762,
|
||||
"opencode/gstack-office-hours": 100329,
|
||||
"opencode/gstack-plan-design-review": 140738,
|
||||
"opencode/gstack-plan-devex-review": 136812,
|
||||
@@ -86,7 +86,7 @@
|
||||
"slate/gstack-autoplan": 129159,
|
||||
"slate/gstack-design-consultation": 107143,
|
||||
"slate/gstack-design-review": 142414,
|
||||
"slate/gstack-land-and-deploy": 115495,
|
||||
"slate/gstack-land-and-deploy": 116589,
|
||||
"slate/gstack-plan-design-review": 138888,
|
||||
"slate/gstack-plan-devex-review": 134511,
|
||||
"slate/gstack-plan-eng-review": 151480,
|
||||
|
||||
@@ -131,6 +131,41 @@ describe('free-tests workflow wiring', () => {
|
||||
} finally { fs.rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('every Linux Playwright install has a version-keyed browser cache, 5-minute attempts and one retry of the install alone (run 39641712129)', () => {
|
||||
const jobs = (Bun.YAML.parse(source) as any).jobs;
|
||||
const installing = Object.keys(jobs).filter(name => jobs[name].steps.some((step: any) => step.run?.includes('playwright install --with-deps')));
|
||||
expect(installing.sort()).toEqual(['free-suite', 'quick-bun-default']);
|
||||
for (const name of installing) {
|
||||
const steps = jobs[name].steps;
|
||||
const version = steps.findIndex((step: any) => step.id === 'playwright');
|
||||
const cache = steps.findIndex((step: any) => step.with?.path === '~/.cache/ms-playwright');
|
||||
const install = steps.findIndex((step: any) => step.id === 'playwright_install');
|
||||
const retry = steps.findIndex((step: any) => step.if === "steps.playwright_install.outcome == 'failure'");
|
||||
expect(steps[version].run, name).toContain('require("playwright-core/package.json").version');
|
||||
expect(steps[cache].with.key, name).toBe('linux-playwright-chromium-${{ steps.playwright.outputs.version }}');
|
||||
expect(steps[cache].with['restore-keys'], name).toBeUndefined();
|
||||
expect(version, name).toBeLessThan(cache);
|
||||
expect(cache, name).toBeLessThan(install);
|
||||
expect(retry, name).toBe(install + 1);
|
||||
for (const step of [steps[install], steps[retry]]) {
|
||||
expect(step['timeout-minutes'], name).toBe(5);
|
||||
expect(step.run, name).toBe('npx playwright install --with-deps chromium');
|
||||
}
|
||||
expect(steps[install]['continue-on-error'], name).toBe(true);
|
||||
expect(steps.filter((step: any) => step['continue-on-error']), name).toEqual([steps[install]]);
|
||||
}
|
||||
});
|
||||
|
||||
test('test jobs give PR and dispatch runs the local main that push runs get from checkout (#3237)', () => {
|
||||
const jobs = (Bun.YAML.parse(source) as any).jobs;
|
||||
for (const name of ['free-suite', 'quick-bun-default']) {
|
||||
const [checkout, guard] = jobs[name].steps;
|
||||
expect(checkout.uses, name).toStartWith('actions/checkout@');
|
||||
expect(guard.if, name).toBe("github.ref != 'refs/heads/main'");
|
||||
expect(guard.run, name).toBe('git checkout -q -B main');
|
||||
}
|
||||
});
|
||||
|
||||
test('least-privilege token: contents read-only, credentials not persisted', () => {
|
||||
// The job executes PR-controlled code (install lifecycle scripts + the
|
||||
// suite itself). A default-grant GITHUB_TOKEN persisted into .git/config
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, test, expect, afterEach } from "bun:test";
|
||||
import { spawn } from "child_process";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import { join } from "path";
|
||||
@@ -78,6 +79,52 @@ describe("detectAutopilot", () => {
|
||||
expect(r.signal).toBe("process:gbrain autopilot");
|
||||
});
|
||||
|
||||
// #3360: the probe stays machine-wide; the refusal names whose autopilot it is.
|
||||
test("a matched autopilot process is named by pid and command, with how to proceed", () => {
|
||||
const r = detectAutopilot(process.env, {
|
||||
lockPaths: [],
|
||||
processRunning: () => [{ pid: 4242, command: "gbrain autopilot --repo /src/other-brain" }],
|
||||
});
|
||||
expect(r.active).toBe(true);
|
||||
expect(r.signal).toBe(
|
||||
"process:gbrain autopilot pid 4242 `gbrain autopilot --repo /src/other-brain`; this check counts any process on this machine " +
|
||||
"whose command line contains \"gbrain autopilot\", even an autopilot serving another brain or repo. Stop that autopilot " +
|
||||
"(or its service, if `gbrain autopilot --install` set one up), or end the process that only mentions it",
|
||||
);
|
||||
});
|
||||
|
||||
test("every matched autopilot process is listed", () => {
|
||||
const r = detectAutopilot(process.env, {
|
||||
lockPaths: [],
|
||||
processRunning: () => [
|
||||
{ pid: 4242, command: "gbrain autopilot --repo /a" },
|
||||
{ pid: 4343, command: "gbrain autopilot --repo /b" },
|
||||
],
|
||||
});
|
||||
expect(r.signal).toContain("pid 4242 `gbrain autopilot --repo /a`, pid 4343 `gbrain autopilot --repo /b`;");
|
||||
});
|
||||
|
||||
test("no matched process is inactive", () => {
|
||||
const r = detectAutopilot(process.env, { lockPaths: [], processRunning: () => [] });
|
||||
expect(r).toEqual({ active: false, signal: null });
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("the real probe names a live process anywhere on the machine, credentials masked", async () => {
|
||||
const url = ["postgres://u", "hunter2@h/db"].join(":");
|
||||
const child = spawn(process.execPath, ["-e", "console.log('ready'); setTimeout(() => {}, 30000)", `gbrain autopilot --repo /tmp/gstack-3360 --db ${url}`], { stdio: ["ignore", "pipe", "ignore"] });
|
||||
try {
|
||||
await new Promise(resolve => child.stdout!.once("data", resolve));
|
||||
const r = detectAutopilot(process.env, { lockPaths: [] });
|
||||
expect(r.active).toBe(true);
|
||||
expect(r.signal).toContain(`pid ${child.pid} \``);
|
||||
expect(r.signal).toContain("gbrain autopilot --repo /tmp/gstack-3360 --db postgres://***@h/db");
|
||||
expect(r.signal).not.toContain("hunter2");
|
||||
expect(r.signal).toContain("Stop that autopilot");
|
||||
} finally {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
});
|
||||
|
||||
test("a lock with no parseable pid stays conservative (active, no pid in signal)", () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), "ap-"));
|
||||
const lock = join(tmp, "autopilot.lock");
|
||||
|
||||
@@ -19,7 +19,7 @@ import * as path from "path";
|
||||
import * as os from "os";
|
||||
import { spawnSync } from "child_process";
|
||||
|
||||
import { repoPolicyTier, repoPolicyTierBatch } from "../lib/gbrain-repo-policy-client";
|
||||
import { hasRepoPolicyStore, memorableRecallPolicy, repoPolicyTier, repoPolicyTierBatch } from "../lib/gbrain-repo-policy-client";
|
||||
import { canonicalizeRemote } from "../lib/gstack-memory-helpers";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "..");
|
||||
@@ -219,3 +219,54 @@ describe("repoPolicyTier timeoutMs (hook deadline seam)", () => {
|
||||
expect(repoPolicyTier(url, env(), 1)).toEqual({ tier: "none", error: "unreadable" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("memorableRecallPolicy (#3359: per-remote Memorable recall opt-in)", () => {
|
||||
const A = "https://github.com/acme/client-a.git";
|
||||
test("no store: tier none, opt-in false, no error (fast path)", () => {
|
||||
expect(memorableRecallPolicy(A, env())).toEqual({ tier: "none", memorableOptIn: false });
|
||||
expect(fs.existsSync(policyFile())).toBe(false);
|
||||
});
|
||||
test("read-only defaults to off; the explicit opt-in turns it on for that remote only", () => {
|
||||
expect(run(["set", A, "read-only"]).status).toBe(0);
|
||||
expect(run(["set", "https://github.com/acme/client-b", "read-only"]).status).toBe(0);
|
||||
expect(memorableRecallPolicy(A, env())).toEqual({ tier: "read-only", memorableOptIn: false });
|
||||
expect(run(["memorable", "set", A, "allow"]).status).toBe(0);
|
||||
expect(memorableRecallPolicy(A, env())).toEqual({ tier: "read-only", memorableOptIn: true });
|
||||
expect(memorableRecallPolicy("https://github.com/acme/client-b", env())).toEqual({ tier: "read-only", memorableOptIn: false });
|
||||
expect(memorableRecallPolicy("https://github.com/acme/other", env())).toEqual({ tier: "none", memorableOptIn: false });
|
||||
});
|
||||
test("deny with a hand-written opt-in: opt-in false", () => {
|
||||
fs.writeFileSync(policyFile(), JSON.stringify({ _schema_version: 2, "github.com/acme/client-a": "deny", _memorable_recall: { "github.com/acme/client-a": "allow" } }));
|
||||
expect(memorableRecallPolicy(A, env())).toEqual({ tier: "deny", memorableOptIn: false });
|
||||
});
|
||||
test("corrupt store: unreadable, never read as no policy, and not quarantined", () => {
|
||||
fs.writeFileSync(policyFile(), "{not json", { mode: 0o600 });
|
||||
expect(memorableRecallPolicy(A, env())).toEqual({ tier: "none", memorableOptIn: false, error: "unreadable" });
|
||||
expect(fs.readFileSync(policyFile(), "utf-8")).toBe("{not json");
|
||||
});
|
||||
test("a tier the client does not know (hand-edited store) is unreadable, never no-policy", () => {
|
||||
fs.writeFileSync(policyFile(), JSON.stringify({ _schema_version: 2, "github.com/acme/client-a": 7, _memorable_recall: { "github.com/acme/client-a": "allow" } }));
|
||||
expect(memorableRecallPolicy(A, env())).toEqual({ tier: "none", memorableOptIn: false, error: "unreadable" });
|
||||
});
|
||||
test("a dangling symlink as the only store is not the no-store fast path: unreadable", () => {
|
||||
fs.symlinkSync(path.join(tmpHome, "missing.json"), policyFile());
|
||||
expect(hasRepoPolicyStore(env())).toBe(true);
|
||||
expect(memorableRecallPolicy(A, env())).toEqual({ tier: "none", memorableOptIn: false, error: "unreadable" });
|
||||
});
|
||||
test("a dangling symlink above the store, or a file in its path, is not the no-store fast path", () => {
|
||||
fs.symlinkSync(path.join(tmpHome, "missing-dir"), path.join(tmpHome, "link"));
|
||||
const viaLink = { ...env(), GSTACK_TEST_LEGACY_ROOT: path.join(tmpHome, "link", "child") };
|
||||
expect(hasRepoPolicyStore(viaLink)).toBe(true);
|
||||
expect(memorableRecallPolicy(A, viaLink)).toEqual({ tier: "none", memorableOptIn: false, error: "unreadable" });
|
||||
fs.writeFileSync(path.join(tmpHome, "plain-file"), "x");
|
||||
expect(hasRepoPolicyStore({ ...env(), GSTACK_TEST_LEGACY_ROOT: path.join(tmpHome, "plain-file", "child") })).toBe(true);
|
||||
fs.mkdirSync(path.join(tmpHome, "real-dir"));
|
||||
fs.symlinkSync(path.join(tmpHome, "real-dir"), path.join(tmpHome, "good-link"));
|
||||
expect(hasRepoPolicyStore({ ...env(), GSTACK_TEST_LEGACY_ROOT: path.join(tmpHome, "good-link", "child") })).toBe(false);
|
||||
expect(hasRepoPolicyStore({ ...env(), GSTACK_TEST_LEGACY_ROOT: path.join(tmpHome, "no", "such", "root") })).toBe(false);
|
||||
});
|
||||
test("a timed-out spawn classifies as unreadable", () => {
|
||||
expect(run(["set", A, "read-only"]).status).toBe(0);
|
||||
expect(memorableRecallPolicy(A, env(), 1)).toEqual({ tier: "none", memorableOptIn: false, error: "unreadable" });
|
||||
});
|
||||
});
|
||||
@@ -418,3 +418,181 @@ describe('gstack-gbrain-sync code stage honors the repo policy (#2140 sync path)
|
||||
expect(r.text).not.toContain('refused');
|
||||
});
|
||||
});
|
||||
|
||||
describe('memorable recall opt-in (#3359)', () => {
|
||||
const A = 'https://github.com/acme/client-a.git';
|
||||
const memo = (url: string, extra: Record<string, string> = {}) => run(['memorable', 'get', url], { env: extra });
|
||||
|
||||
test('off by default: a read-only remote reports off until it is opted in', () => {
|
||||
expect(run(['set', A, 'read-only']).status).toBe(0);
|
||||
expect(memo(A).stdout).toBe('read-only\toff');
|
||||
const r = run(['memorable', 'set', A, 'allow']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(memo(A).stdout).toBe('read-only\tallow');
|
||||
expect(readPolicy()._memorable_recall).toEqual({ 'github.com/acme/client-a': 'allow' });
|
||||
expect(run(['list']).stdout).toBe('github.com/acme/client-a\tread-only\tmemorable=allow');
|
||||
});
|
||||
|
||||
test('the opt-in is per remote: another remote, a prefix-sharing remote and an unset remote stay off; the SSH spelling of the same remote is the same key', () => {
|
||||
for (const u of [A, 'https://github.com/acme/client-b', 'https://github.com/acme/client-a-fork', 'https://github.com/acme/client']) {
|
||||
expect(run(['set', u, 'read-only']).status).toBe(0);
|
||||
}
|
||||
expect(run(['memorable', 'set', A, 'allow']).status).toBe(0);
|
||||
expect(memo('https://github.com/acme/client-b').stdout).toBe('read-only\toff');
|
||||
expect(memo('https://github.com/acme/client-a-fork').stdout).toBe('read-only\toff');
|
||||
expect(memo('https://github.com/acme/client').stdout).toBe('read-only\toff');
|
||||
expect(memo('https://gitlab.com/acme/client-a').stdout).toBe('unset\toff');
|
||||
expect(memo('git@github.com:acme/client-a.git').stdout).toBe('read-only\tallow');
|
||||
});
|
||||
|
||||
test('deny always wins: the opt-in is refused on deny, and a hand-written opt-in on a deny remote reads off', () => {
|
||||
expect(run(['set', A, 'deny']).status).toBe(0);
|
||||
const before = fs.readFileSync(policyFile(), 'utf-8');
|
||||
const r = run(['memorable', 'set', A, 'allow']);
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('deny tier always vetoes');
|
||||
expect(fs.readFileSync(policyFile(), 'utf-8')).toBe(before);
|
||||
fs.writeFileSync(policyFile(), JSON.stringify({ ...readPolicy(), _memorable_recall: { 'github.com/acme/client-a': 'allow' } }));
|
||||
expect(memo(A).stdout).toBe('deny\toff');
|
||||
});
|
||||
|
||||
test('the opt-in is refused on read-write and unset remotes (nothing to lift)', () => {
|
||||
expect(run(['set', A, 'read-write']).status).toBe(0);
|
||||
expect(run(['memorable', 'set', A, 'allow']).status).toBe(2);
|
||||
expect(run(['memorable', 'set', 'https://github.com/acme/never-set', 'allow']).status).toBe(2);
|
||||
expect(readPolicy()._memorable_recall).toBeUndefined();
|
||||
});
|
||||
|
||||
test('changing the tier clears the opt-in, re-setting the same tier keeps it, and off removes it', () => {
|
||||
run(['set', A, 'read-only']);
|
||||
run(['memorable', 'set', A, 'allow']);
|
||||
expect(run(['set', A, 'read-only']).stdout).not.toContain('Cleared');
|
||||
expect(memo(A).stdout).toBe('read-only\tallow');
|
||||
const flip = run(['set', A, 'read-write']);
|
||||
expect(flip.stdout).toContain('Cleared the Memorable recall opt-in');
|
||||
run(['set', A, 'read-only']);
|
||||
expect(memo(A).stdout).toBe('read-only\toff');
|
||||
run(['memorable', 'set', A, 'allow']);
|
||||
expect(run(['memorable', 'set', A, 'off']).status).toBe(0);
|
||||
expect(memo(A).stdout).toBe('read-only\toff');
|
||||
expect(readPolicy()._memorable_recall).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a malformed opt-in reads off: any value but "allow", or a map that is not an object', () => {
|
||||
run(['set', A, 'read-only']);
|
||||
for (const bad of [{ 'github.com/acme/client-a': true }, { 'github.com/acme/client-a': 'ALLOW' }, 'allow', ['github.com/acme/client-a'], null]) {
|
||||
fs.writeFileSync(policyFile(), JSON.stringify({ _schema_version: 2, 'github.com/acme/client-a': 'read-only', _memorable_recall: bad }));
|
||||
expect(memo(A).stdout).toBe('read-only\toff');
|
||||
}
|
||||
});
|
||||
|
||||
test('a corrupt store fails closed: exit 2, never quarantined, store left as is', () => {
|
||||
fs.writeFileSync(policyFile(), '{"_schema_version":2,"github.com/acme/client-a":"read-only",', { mode: 0o600 });
|
||||
const r = memo(A);
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stdout).toBe('');
|
||||
expect(r.stderr).toContain('corrupt');
|
||||
expect(fs.readFileSync(policyFile(), 'utf-8')).toContain('"read-only",');
|
||||
expect(fs.readdirSync(tmpHome).some((f) => f.includes('.corrupt-'))).toBe(false);
|
||||
});
|
||||
|
||||
test('a second store that makes the remote read-only must opt it in too; its deny still wins; a corrupt second store fails closed', () => {
|
||||
const legacy = path.join(tmpHome, 'legacy');
|
||||
fs.mkdirSync(legacy);
|
||||
const extra = { GSTACK_TEST_LEGACY_ROOT: legacy };
|
||||
const legacyStore = path.join(legacy, 'gbrain-repo-policy.json');
|
||||
run(['set', A, 'read-only'], { env: extra });
|
||||
expect(run(['memorable', 'set', A, 'allow'], { env: extra }).status).toBe(0);
|
||||
expect(memo(A, extra).stdout).toBe('read-only\tallow');
|
||||
fs.writeFileSync(legacyStore, JSON.stringify({ _schema_version: 2, 'github.com/acme/client-a': 'read-only' }));
|
||||
expect(memo(A, extra).stdout).toBe('read-only\toff');
|
||||
expect(run(['memorable', 'set', A, 'allow'], { env: extra }).stderr).toContain('Opt in there too');
|
||||
fs.writeFileSync(legacyStore, JSON.stringify({ _schema_version: 2, 'github.com/acme/client-a': 'read-only', _memorable_recall: { 'github.com/acme/client-a': 'allow' } }));
|
||||
expect(memo(A, extra).stdout).toBe('read-only\tallow');
|
||||
fs.writeFileSync(legacyStore, JSON.stringify({ _schema_version: 2, 'github.com/acme/client-a': 'deny', _memorable_recall: { 'github.com/acme/client-a': 'allow' } }));
|
||||
expect(memo(A, extra).stdout).toBe('deny\toff');
|
||||
fs.writeFileSync(legacyStore, '{');
|
||||
expect(memo(A, extra).status).toBe(2);
|
||||
});
|
||||
|
||||
test('a store path that is not a readable file fails closed, in either root', () => {
|
||||
fs.mkdirSync(policyFile());
|
||||
expect(memo(A).status).toBe(2);
|
||||
fs.rmdirSync(policyFile());
|
||||
const legacy = path.join(tmpHome, 'legacy');
|
||||
fs.mkdirSync(path.join(legacy, 'gbrain-repo-policy.json'), { recursive: true });
|
||||
const r = memo(A, { GSTACK_TEST_LEGACY_ROOT: legacy });
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('not a readable file');
|
||||
});
|
||||
|
||||
test('review hardening: empty, non-object or multi-document stores, and invalid tier values, fail closed in either root', () => {
|
||||
const legacy = path.join(tmpHome, 'legacy');
|
||||
fs.mkdirSync(legacy);
|
||||
const extra = { GSTACK_TEST_LEGACY_ROOT: legacy };
|
||||
const legacyStore = path.join(legacy, 'gbrain-repo-policy.json');
|
||||
run(['set', A, 'read-only']);
|
||||
for (const bad of ['', ' \n', 'null', '[]', '7', '{}{}']) {
|
||||
fs.writeFileSync(legacyStore, bad);
|
||||
const r = memo(A, extra);
|
||||
expect([bad, r.status, r.stdout]).toEqual([bad, 2, '']);
|
||||
}
|
||||
fs.rmSync(legacyStore);
|
||||
for (const value of [false, null, 7, 'none', 'Read-Only']) {
|
||||
fs.writeFileSync(policyFile(), JSON.stringify({ _schema_version: 2, 'github.com/acme/client-a': value }));
|
||||
expect([value, memo(A).status]).toEqual([value, 2]);
|
||||
}
|
||||
fs.writeFileSync(policyFile(), JSON.stringify({ _schema_version: 2, 'github.com/acme/client-a': false }));
|
||||
fs.writeFileSync(legacyStore, JSON.stringify({ 'github.com/acme/client-a': 'read-only', _memorable_recall: { 'github.com/acme/client-a': 'allow' } }));
|
||||
expect(memo(A, extra).status).toBe(2);
|
||||
});
|
||||
|
||||
test('review hardening: a dangling symlink or an unsearchable directory is never read as no store', () => {
|
||||
fs.symlinkSync(path.join(tmpHome, 'missing.json'), policyFile());
|
||||
expect(memo(A).status).toBe(2);
|
||||
fs.rmSync(policyFile());
|
||||
const legacy = path.join(tmpHome, 'legacy');
|
||||
fs.mkdirSync(legacy);
|
||||
fs.symlinkSync(path.join(tmpHome, 'missing.json'), path.join(legacy, 'gbrain-repo-policy.json'));
|
||||
expect(memo(A, { GSTACK_TEST_LEGACY_ROOT: legacy }).status).toBe(2);
|
||||
fs.rmSync(path.join(legacy, 'gbrain-repo-policy.json'));
|
||||
if (!canRevokeReads()) return;
|
||||
fs.writeFileSync(path.join(legacy, 'gbrain-repo-policy.json'), JSON.stringify({ 'github.com/acme/client-a': 'deny' }));
|
||||
fs.chmodSync(legacy, 0o600);
|
||||
try {
|
||||
expect(memo(A, { GSTACK_TEST_LEGACY_ROOT: legacy }).status).toBe(2);
|
||||
} finally {
|
||||
fs.chmodSync(legacy, 0o700);
|
||||
}
|
||||
const nested = path.join(tmpHome, 'ancestor', 'nested');
|
||||
fs.mkdirSync(nested, { recursive: true });
|
||||
fs.writeFileSync(path.join(nested, 'gbrain-repo-policy.json'), JSON.stringify({ 'github.com/acme/client-a': 'deny' }));
|
||||
fs.chmodSync(path.join(tmpHome, 'ancestor'), 0o600);
|
||||
try {
|
||||
const r = memo(A, { GSTACK_TEST_LEGACY_ROOT: nested });
|
||||
expect([r.status, r.stdout]).toEqual([2, '']);
|
||||
expect(r.stderr).toContain('not a searchable directory');
|
||||
} finally {
|
||||
fs.chmodSync(path.join(tmpHome, 'ancestor'), 0o700);
|
||||
}
|
||||
const absentUnder = path.join(tmpHome, 'no', 'such', 'root');
|
||||
expect(memo(A, { GSTACK_TEST_LEGACY_ROOT: absentUnder }).stdout).toBe('unset\toff');
|
||||
});
|
||||
|
||||
test('review hardening: the lookup never writes: a schema-less store is not migrated or rewritten', () => {
|
||||
const legacyShape = JSON.stringify({ 'github.com/acme/client-a': 'read-only', 'github.com/acme/old': 'allow', _memorable_recall: { 'github.com/acme/client-a': 'allow' } });
|
||||
fs.writeFileSync(policyFile(), legacyShape);
|
||||
expect(memo(A).stdout).toBe('read-only\tallow');
|
||||
expect(memo('https://github.com/acme/old').stdout).toBe('read-write\toff');
|
||||
expect(fs.readFileSync(policyFile(), 'utf-8')).toBe(legacyShape);
|
||||
});
|
||||
|
||||
test('the two metadata keys are never stored as remotes; the _unattributed sentinel still is', () => {
|
||||
const r = run(['set', '_memorable_recall', 'read-write']);
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('reserved key');
|
||||
expect(run(['set', '_schema_version', 'deny']).status).toBe(2);
|
||||
expect(run(['set', '_unattributed', 'deny']).status).toBe(0);
|
||||
expect(run(['list']).stdout).toBe('_unattributed\tdeny');
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const REPO = path.resolve(import.meta.dir, '..');
|
||||
const BUN_DEFAULT = fs.readFileSync(path.join(REPO, 'bin/gstack-bun-version.sh'), 'utf8').match(/^GSTACK_BUN_DEFAULT="([^"]+)"$/m)![1];
|
||||
const bases: string[] = [];
|
||||
afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); });
|
||||
|
||||
@@ -211,17 +212,17 @@ describe('gstack-capy-install', () => {
|
||||
const f = fixture({ bun: '1.3.14' });
|
||||
const zip = path.join(f.base, 'bad.zip');
|
||||
fs.mkdirSync(path.join(f.base, 'z/bun-linux-x64'), { recursive: true });
|
||||
script(path.join(f.base, 'z/bun-linux-x64/bun'), ['echo 1.4.3']);
|
||||
script(path.join(f.base, 'z/bun-linux-x64/bun'), [`echo ${BUN_DEFAULT}`]);
|
||||
expect(sh(`cd "${f.base}/z" && zip -qr "${zip}" bun-linux-x64`).status).toBe(0);
|
||||
const r = run(f, [], { STUB_CURL_ZIP: zip });
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain('SHA-256 mismatch');
|
||||
expect(r.stderr).toContain('nothing installed');
|
||||
expect(f.calls().filter(l => l.startsWith('curl '))).toHaveLength(1);
|
||||
expect(f.calls()[0]).toContain('https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-linux-x64');
|
||||
expect(f.calls()[0]).toContain(`https://github.com/oven-sh/bun/releases/download/bun-v${BUN_DEFAULT}/bun-linux-x64`);
|
||||
expect(fs.existsSync(path.join(f.home, '.bun/bin/bun'))).toBe(false);
|
||||
expect(fs.existsSync(store(f, '1.4.3'))).toBe(false);
|
||||
expect(fs.readdirSync(path.dirname(store(f, '1.4.3')))).toEqual([]);
|
||||
expect(fs.existsSync(store(f, BUN_DEFAULT))).toBe(false);
|
||||
expect(fs.readdirSync(path.dirname(store(f, BUN_DEFAULT)))).toEqual([]);
|
||||
expect(f.calls().some(l => l.startsWith('setup '))).toBe(false);
|
||||
});
|
||||
|
||||
@@ -230,16 +231,16 @@ describe('gstack-capy-install', () => {
|
||||
const target = sh('grep -q avx2 /proc/cpuinfo && echo linux-x64 || echo linux-x64-baseline').stdout.trim();
|
||||
const zip = path.join(f.base, 'good.zip');
|
||||
fs.mkdirSync(path.join(f.base, `z/bun-${target}`), { recursive: true });
|
||||
script(path.join(f.base, `z/bun-${target}/bun`), ['echo 1.4.3']);
|
||||
script(path.join(f.base, `z/bun-${target}/bun`), [`echo ${BUN_DEFAULT}`]);
|
||||
expect(sh(`cd "${f.base}/z" && zip -qr "${zip}" bun-${target}`).status).toBe(0);
|
||||
trustZip(f.root, '1.4.3', target, zip);
|
||||
trustZip(f.root, BUN_DEFAULT, target, zip);
|
||||
const r = run(f, ['--for', 'autoplan'], { STUB_CURL_ZIP: zip });
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain('installing bun 1.4.3 from its release archive');
|
||||
expect(r.stdout).toContain(`bun 1.4.3 installed at ${store(f, '1.4.3')}/bun`);
|
||||
expect(r.stdout).toContain(`bun 1.4.3 at ${store(f, '1.4.3')}/bun (gstack's install default, from gstack's runtime store;`);
|
||||
expect(r.stdout).toContain(`installing bun ${BUN_DEFAULT} from its release archive`);
|
||||
expect(r.stdout).toContain(`bun ${BUN_DEFAULT} installed at ${store(f, BUN_DEFAULT)}/bun`);
|
||||
expect(r.stdout).toContain(`bun ${BUN_DEFAULT} at ${store(f, BUN_DEFAULT)}/bun (gstack's install default, from gstack's runtime store;`);
|
||||
expect(fs.existsSync(path.join(f.home, '.bun/bin/bun'))).toBe(false);
|
||||
expect(f.calls().filter(l => l.startsWith('env '))).toEqual(Array(2).fill('env GSTACK_SKIP_PLAYWRIGHT= GSTACK_SKIP_FONTS= bun=1.4.3'));
|
||||
expect(f.calls().filter(l => l.startsWith('env '))).toEqual(Array(2).fill(`env GSTACK_SKIP_PLAYWRIGHT= GSTACK_SKIP_FONTS= bun=${BUN_DEFAULT}`));
|
||||
expect(lastLine(r.stdout)).toBe('gstack: ok 1.2.3 for=autoplan');
|
||||
});
|
||||
|
||||
@@ -333,15 +334,15 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd
|
||||
|
||||
test.skipIf(process.platform !== 'linux')('after installing Bun the launch line carries its PATH; a second run reuses it (one download) and skips setup on the same HEAD', () => {
|
||||
const f = fixture({ bun: '1.3.14' });
|
||||
const zip = zipBun(f, '1.4.3');
|
||||
const bunDir = store(f, '1.4.3');
|
||||
const zip = zipBun(f, BUN_DEFAULT);
|
||||
const bunDir = store(f, BUN_DEFAULT);
|
||||
const first = run(f, ['--for', 'autoplan'], { STUB_CURL_ZIP: zip });
|
||||
expect(first.status, first.stdout + first.stderr).toBe(0);
|
||||
expect(first.stdout).toContain(`GSTACK_LAUNCH: PATH=${bunDir}:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=${path.join(f.home, '.gstack-state')} GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=`);
|
||||
expect(first.stdout).toContain(`GSTACK_LAUNCH_PATH: ${bunDir}`);
|
||||
const second = run(f, ['--for', 'autoplan'], { STUB_CURL_ZIP: zip });
|
||||
expect(second.status, second.stdout + second.stderr).toBe(0);
|
||||
expect(second.stdout).toContain(`bun 1.4.3 at ${bunDir}/bun`);
|
||||
expect(second.stdout).toContain(`bun ${BUN_DEFAULT} at ${bunDir}/bun`);
|
||||
expect(second.stdout).toContain(`GSTACK_LAUNCH: PATH=${bunDir}:$PATH `);
|
||||
expect(f.calls().filter(l => l.startsWith('curl '))).toHaveLength(1);
|
||||
expect(f.calls().filter(l => l.startsWith('setup '))).toHaveLength(2);
|
||||
@@ -477,19 +478,19 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: one resoluti
|
||||
expectLaunch(outside, `${bi}/bin/bun`);
|
||||
});
|
||||
|
||||
// Integration item 1: gstack's own CI pins 1.4.2 on every lane and 1.4.3 on
|
||||
// the install-default job. They are alternatives, so the real checkout as
|
||||
// --project certifies with either Bun; a repo whose CI pins only 1.4.3 still
|
||||
// refuses 1.4.2.
|
||||
test('the real gstack checkout as --project with a separate global install certifies with ~/.bun 1.4.2 and with 1.4.3; nothing is downloaded', () => {
|
||||
for (const version of ['1.4.2', '1.4.3']) {
|
||||
// Integration item 1: gstack's own CI pins 1.4.2 on every lane and the
|
||||
// install default on the install-default job. They are alternatives, so the
|
||||
// real checkout as --project certifies with either Bun; a repo whose CI pins
|
||||
// only 1.4.3 still refuses 1.4.2.
|
||||
test('the real gstack checkout as --project with a separate global install certifies with ~/.bun 1.4.2 and with the install default; nothing is downloaded', () => {
|
||||
for (const version of ['1.4.2', BUN_DEFAULT]) {
|
||||
const f = fixture({ bun: '1.3.14' });
|
||||
withPins(f);
|
||||
realBun(path.join(f.home, '.bun/bin/bun'), version);
|
||||
const r = run(f, ['--for', 'review', '--project', REPO], {}, f.home);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stderr).not.toContain('RUNTIME_PIN_CONFLICT');
|
||||
expect(r.stdout).toContain(`bun ${version} at ${f.home}/.bun/bin/bun ($BUN_INSTALL/bin; satisfies gstack's supported minimum 1.4.2 and the project's CI pins (alternatives: 1.4.2 1.4.3))`);
|
||||
expect(r.stdout).toContain(`bun ${version} at ${f.home}/.bun/bin/bun ($BUN_INSTALL/bin; satisfies gstack's supported minimum 1.4.2 and the project's CI pins (alternatives: 1.4.2 ${BUN_DEFAULT}))`);
|
||||
expect(f.calls().some(l => l.startsWith('curl '))).toBe(false);
|
||||
expect(verdictFor(f, r)).toMatchObject({ project: REPO, bun_version: version, bun_rule: 'BUN_INSTALL' });
|
||||
expect(run(f, ['--check', '--for', 'review', '--project', REPO], {}, f.home).status).toBe(0);
|
||||
@@ -499,9 +500,9 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: one resoluti
|
||||
test('CI alternatives with nothing installed: the install default is published when it is one of them; a gbrain-like repo (CI 1.4.3 only) refuses ~/.bun 1.4.2 and installs 1.4.3', () => {
|
||||
const f = fixture({ bun: '1.3.14' });
|
||||
withPins(f);
|
||||
const r = run(f, ['--for', 'review', '--project', REPO], { STUB_CURL_ZIP: zipFor(f, '1.4.3') }, f.home);
|
||||
const r = run(f, ['--for', 'review', '--project', REPO], { STUB_CURL_ZIP: zipFor(f, BUN_DEFAULT) }, f.home);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain(`bun 1.4.3 at ${store(f, '1.4.3')}/bun (`);
|
||||
expect(r.stdout).toContain(`bun ${BUN_DEFAULT} at ${store(f, BUN_DEFAULT)}/bun (`);
|
||||
const g = fixture({ bun: '1.3.14' });
|
||||
withPins(g);
|
||||
realBun(path.join(g.home, '.bun/bin/bun'), '1.4.2');
|
||||
@@ -546,13 +547,13 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: one resoluti
|
||||
test('a broken store entry is repaired in place: the version directory is never moved aside, bun is renamed over its predecessor and bunx is published', () => {
|
||||
const f = fixture({ bun: '1.3.14' });
|
||||
withPins(f);
|
||||
const dir = store(f, '1.4.3');
|
||||
const dir = store(f, BUN_DEFAULT);
|
||||
realBun(path.join(dir, 'bun'), '1.4.0');
|
||||
const inode = fs.statSync(dir).ino;
|
||||
const r = run(f, ['--for', 'autoplan'], { BUN_INSTALL: '', STUB_CURL_ZIP: zipFor(f, '1.4.3') }, f.base);
|
||||
const r = run(f, ['--for', 'autoplan'], { BUN_INSTALL: '', STUB_CURL_ZIP: zipFor(f, BUN_DEFAULT) }, f.base);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(fs.statSync(dir).ino).toBe(inode);
|
||||
expect(sh(`"${dir}/bun" --version`).stdout.trim()).toBe('1.4.3');
|
||||
expect(sh(`"${dir}/bun" --version`).stdout.trim()).toBe(BUN_DEFAULT);
|
||||
expect(fs.readlinkSync(path.join(dir, 'bunx'))).toBe('bun');
|
||||
expect(fs.readdirSync(path.dirname(dir)).filter(n => n.startsWith('.tmp'))).toEqual([]);
|
||||
});
|
||||
@@ -588,10 +589,10 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: one resoluti
|
||||
const unset = run(f, ['--check', '--for', 'autoplan'], { BUN_INSTALL: '' });
|
||||
expect(unset.status, unset.stdout + unset.stderr).toBe(0);
|
||||
expectLaunch(unset, `${bi}/bin/bun`);
|
||||
realBun(path.join(store(f, '1.4.3'), 'bun'), '1.4.3');
|
||||
realBun(path.join(store(f, BUN_DEFAULT), 'bun'), BUN_DEFAULT);
|
||||
const newer = run(f, ['--check', '--for', 'autoplan'], { BUN_INSTALL: bi });
|
||||
expect(newer.status, newer.stdout + newer.stderr).toBe(0);
|
||||
expect(newer.stdout).toContain(`note: the recorded bun 1.4.2 at ${bi}/bin/bun stays certified; bun 1.4.3 at ${store(f, '1.4.3')}/bun (default) would now resolve first; run ${f.root}/bin/gstack-capy-install --for autoplan to record the current resolution`);
|
||||
expect(newer.stdout).toContain(`note: the recorded bun 1.4.2 at ${bi}/bin/bun stays certified; bun ${BUN_DEFAULT} at ${store(f, BUN_DEFAULT)}/bun (default) would now resolve first; run ${f.root}/bin/gstack-capy-install --for autoplan to record the current resolution`);
|
||||
expectLaunch(newer, `${bi}/bin/bun`);
|
||||
expect(newer.stdout).not.toContain('BUN_RESOLUTION_DRIFT');
|
||||
expect(run(f, ['--for', 'autoplan'], { BUN_INSTALL: bi }).status).toBe(0);
|
||||
@@ -622,7 +623,7 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: one resoluti
|
||||
expect(f.calls().some(l => l.startsWith('curl ') || l.startsWith('setup '))).toBe(false);
|
||||
const r = run(f, ['--for', 'autoplan'], { GSTACK_BUN_INSTALL_VERSION: '1.4.2', STUB_CURL_ZIP: zipFor(f, '1.4.2') });
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain('install version 1.4.2 from GSTACK_BUN_INSTALL_VERSION (default 1.4.3)');
|
||||
expect(r.stdout).toContain(`install version 1.4.2 from GSTACK_BUN_INSTALL_VERSION (default ${BUN_DEFAULT})`);
|
||||
expect(r.stdout).toContain(`bun 1.4.2 at ${store(f, '1.4.2')}/bun (gstack's install default, from gstack's runtime store;`);
|
||||
});
|
||||
|
||||
@@ -662,8 +663,8 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire
|
||||
const state = path.join(f.base, 'custom-state');
|
||||
const storeDir = path.join(state, 'runtimes/bun');
|
||||
realBun(path.join(storeDir, '1.4.2/bun'), '1.4.2');
|
||||
realBun(path.join(storeDir, '1.4.3/bun'), '1.4.3');
|
||||
const a = repo(f, 'a', '1.4.2'), b = repo(f, 'b', '1.4.3'), c = repo(f, 'c');
|
||||
realBun(path.join(storeDir, `${BUN_DEFAULT}/bun`), BUN_DEFAULT);
|
||||
const a = repo(f, 'a', '1.4.2'), b = repo(f, 'b', BUN_DEFAULT), c = repo(f, 'c');
|
||||
for (const dir of [a, b, c]) {
|
||||
const r = run(f, ['--for', 'review'], { GSTACK_STATE_ROOT: state }, dir);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
@@ -673,14 +674,14 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire
|
||||
expect(shim).toContain(`store='${storeDir}'`);
|
||||
const outside = path.join(f.base, 'outside');
|
||||
fs.mkdirSync(outside);
|
||||
for (const [dir, want] of [[a, '1.4.2'], [b, '1.4.3'], [a, '1.4.2'], [c, '1.4.3'], [outside, '1.4.3'], [b, '1.4.3']] as const) {
|
||||
for (const [dir, want] of [[a, '1.4.2'], [b, BUN_DEFAULT], [a, '1.4.2'], [c, BUN_DEFAULT], [outside, BUN_DEFAULT], [b, BUN_DEFAULT]] as const) {
|
||||
const r = fresh(f, dir, 'bun --version');
|
||||
expect(r.stdout.trim(), `${dir}\n${r.stderr}`).toBe(want);
|
||||
expect(r.stderr).toBe('');
|
||||
}
|
||||
const explained = fresh(f, path.join(a), 'mkdir -p sub && cd sub && bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' });
|
||||
expect(explained.stderr).toMatch(new RegExp(`^gstack bun shim: project=${a} record=${state}/install-verdicts/[0-9a-f]{64}/[0-9a-f]{64}\\.json chose=${storeDir}/1\\.4\\.2/bun rule=record\\n$`));
|
||||
expect(fresh(f, outside, 'bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }).stderr).toBe(`gstack bun shim: project=none record=none chose=${storeDir}/1.4.3/bun rule=default\n`);
|
||||
expect(fresh(f, outside, 'bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }).stderr).toBe(`gstack bun shim: project=none record=none chose=${storeDir}/${BUN_DEFAULT}/bun rule=default\n`);
|
||||
const bunx = fresh(f, a, 'bunx --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' });
|
||||
expect(bunx.stderr).toContain(`chose=${storeDir}/1.4.2/bun rule=record`);
|
||||
const unrecordedPinned = repo(f, 'd', '1.4.2');
|
||||
@@ -690,7 +691,7 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire
|
||||
// Integration item 6: an unrecorded, unpinned repo falls through quietly;
|
||||
// only GSTACK_BUN_SHIM_EXPLAIN=1 names the missing record.
|
||||
const e = fresh(f, unrecorded, 'bun --version');
|
||||
expect([e.stdout.trim(), e.stderr]).toEqual(['1.4.3', '']);
|
||||
expect([e.stdout.trim(), e.stderr]).toEqual([BUN_DEFAULT, '']);
|
||||
expect(fresh(f, unrecorded, 'bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }).stderr).toContain(`gstack bun shim: ${unrecorded} has no recorded Bun and no exact pin; run ${f.root}/bin/gstack-capy-install --check here to record one\n`);
|
||||
});
|
||||
|
||||
@@ -700,7 +701,7 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire
|
||||
fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true });
|
||||
const state = path.join(f.base, 'state');
|
||||
const storeDir = path.join(state, 'runtimes/bun');
|
||||
realBun(path.join(storeDir, '1.4.3/bun'), '1.4.3');
|
||||
realBun(path.join(storeDir, `${BUN_DEFAULT}/bun`), BUN_DEFAULT);
|
||||
expect(run(f, ['--for', 'autoplan'], { GSTACK_STATE_ROOT: state }, f.base).status).toBe(0);
|
||||
const pwned = path.join(f.base, 'pwned');
|
||||
const payload = (dir: string) => script(path.join(dir, 'bun'), [`touch "${pwned}"`, 'echo payload']);
|
||||
@@ -709,15 +710,15 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire
|
||||
write(path.join(evilRepo, '.bun-version'), '../../../repo/payload\n');
|
||||
expect(fs.existsSync(path.join(storeDir, '../../../repo/payload/bun'))).toBe(true);
|
||||
const t = fresh(f, evilRepo, 'bun --version');
|
||||
expect(t.stdout.trim()).toBe('1.4.3');
|
||||
expect(t.stdout.trim()).toBe(BUN_DEFAULT);
|
||||
expect(t.stderr).toContain(`${evilRepo} pins bun ../../../repo/payload, which is not in gstack's runtime store`);
|
||||
payload(path.join(f.base, 'evil'));
|
||||
fs.symlinkSync(path.join(f.base, 'evil'), path.join(storeDir, '1.4.9'));
|
||||
const escape = fresh(f, repo(f, 'escape', '1.4.9'), 'bun --version');
|
||||
expect(escape.stdout.trim()).toBe('1.4.3');
|
||||
expect(escape.stdout.trim()).toBe(BUN_DEFAULT);
|
||||
write(path.join(storeDir, '1.4.8/bun'), '#!/bin/sh\n# gstack-bun-shim: gstack-owned; delete this file to remove it\necho loop\n', 0o755);
|
||||
const marked = fresh(f, repo(f, 'marked', '1.4.8'), 'bun --version');
|
||||
expect(marked.stdout.trim()).toBe('1.4.3');
|
||||
expect(marked.stdout.trim()).toBe(BUN_DEFAULT);
|
||||
expect(fs.existsSync(pwned)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -732,7 +733,7 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire
|
||||
const r = run(f, ['--for', 'autoplan'], { BUN_INSTALL: '' }, outside);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain(`bun 1.4.2 at ${f.home}/.bun/bin/bun (~/.bun/bin;`);
|
||||
expect(fs.existsSync(path.join(f.home, '.gstack-state/runtimes/bun/1.4.3'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(f.home, `.gstack-state/runtimes/bun/${BUN_DEFAULT}`))).toBe(false);
|
||||
for (const dir of [outside, f.root]) {
|
||||
const e = fresh(f, dir, 'bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' });
|
||||
expect(e.stdout.trim(), e.stderr).toBe('1.4.2');
|
||||
@@ -752,13 +753,13 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire
|
||||
const f = fixture({ bun: '1.3.14' });
|
||||
fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true });
|
||||
const state = path.join(f.base, "it's state");
|
||||
realBun(path.join(state, 'runtimes/bun/1.4.3/bun'), '1.4.3');
|
||||
realBun(path.join(state, `runtimes/bun/${BUN_DEFAULT}/bun`), BUN_DEFAULT);
|
||||
expect(run(f, ['--for', 'autoplan'], { GSTACK_STATE_ROOT: state }, f.base).status).toBe(0);
|
||||
expect(sh(`sh -n "${f.home}/.local/bin/bun"`).status).toBe(0);
|
||||
const r = fresh(f, f.base, 'bun --version && bunx --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.stdout.trim().split('\n')[0]).toBe('1.4.3');
|
||||
expect(r.stderr.split(`chose=${state}/runtimes/bun/1.4.3/bun rule=record`), r.stderr).toHaveLength(3);
|
||||
expect(r.stdout.trim().split('\n')[0]).toBe(BUN_DEFAULT);
|
||||
expect(r.stderr.split(`chose=${state}/runtimes/bun/${BUN_DEFAULT}/bun rule=record`), r.stderr).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('a foreign ~/.local/bin/bun or a dangling link is never touched; GSTACK_BUN_SHIM=0 writes nothing; a rerun rewrites only gstack\'s own file', () => {
|
||||
@@ -789,8 +790,8 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire
|
||||
test('the shim adds little over the Bun it runs (fixture timing bound)', () => {
|
||||
const f = fixture();
|
||||
fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true });
|
||||
const storeBun = path.join(f.home, '.gstack-state/runtimes/bun/1.4.3/bun');
|
||||
realBun(storeBun, '1.4.3');
|
||||
const storeBun = path.join(f.home, `.gstack-state/runtimes/bun/${BUN_DEFAULT}/bun`);
|
||||
realBun(storeBun, BUN_DEFAULT);
|
||||
expect(run(f).status).toBe(0);
|
||||
const time = (cmd: string) => {
|
||||
const samples = Array.from({ length: 7 }, () => { const t = performance.now(); fresh(f, f.base, cmd); return performance.now() - t; }).sort((x, y) => x - y);
|
||||
|
||||
@@ -9,8 +9,11 @@ import { describe, test, expect, afterAll } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { execFileSync, spawnSync } from 'child_process';
|
||||
import { classifyState, VERSION_RE } from '../bin/gstack-version-bump';
|
||||
import { checkChangelog, normalizeChangelog, parseChangelog } from '../lib/changelog-check';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { cleanup, makeFixtureRepo, makePr, readFile, restamp, runBin } from './helpers/restamp-fixture';
|
||||
|
||||
const BIN = path.join(import.meta.dir, '..', 'bin', 'gstack-version-bump');
|
||||
|
||||
@@ -962,3 +965,115 @@ describe('G1: version source outcomes (absent / valid / ambiguous / broken)', ()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('write --changelog re-heads [Unreleased] (opt-in, restamp-compatible)', () => {
|
||||
const dirs: string[] = [];
|
||||
afterAll(() => { for (const d of dirs) fs.rmSync(d, { recursive: true, force: true }); });
|
||||
const RELEASED = '## [1.0.0.0] - 2026-10-01\n\n- first release\n';
|
||||
const ENTRY = '## [Unreleased]\n\n### Fixed\n- the branch change\n';
|
||||
const repo = (changelog: string | null) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-changelog-'));
|
||||
dirs.push(dir);
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '1.0.0.0\n');
|
||||
if (changelog !== null) fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), changelog);
|
||||
return dir;
|
||||
};
|
||||
const run = (dir: string, args: string[]) => {
|
||||
const r = spawnSync('bun', [BIN, 'write', ...args], { cwd: dir, encoding: 'utf8', timeout: 30_000 });
|
||||
return { code: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
||||
};
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
const readCl = (dir: string) => fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
|
||||
|
||||
test('without the flag write leaves [Unreleased] and the output shape unchanged', () => {
|
||||
const before = `# Changelog\n\n${ENTRY}\n${RELEASED}`;
|
||||
const dir = repo(before);
|
||||
const r = run(dir, ['--version', '1.0.1.0']);
|
||||
expect(r.code).toBe(0);
|
||||
expect(JSON.parse(r.stdout)).not.toHaveProperty('changelog');
|
||||
expect(readCl(dir)).toBe(before);
|
||||
});
|
||||
|
||||
test('re-heads [Unreleased] to the written version, dated today (UTC), keeping every other byte', () => {
|
||||
const before = `# Changelog\n\n${ENTRY}\n${RELEASED}`;
|
||||
const dir = repo(before);
|
||||
const d0 = today();
|
||||
const r = run(dir, ['--version', '1.0.1.0', '--changelog', 'CHANGELOG.md']);
|
||||
const d1 = today();
|
||||
expect(r.code).toBe(0);
|
||||
expect(JSON.parse(r.stdout).changelog).toEqual({ path: 'CHANGELOG.md', moved: true, detail: 'Unreleased -> 1.0.1.0' });
|
||||
const after = readCl(dir);
|
||||
expect([d0, d1].map(d => `# Changelog\n\n## [1.0.1.0] - ${d}\n\n### Fixed\n- the branch change\n\n${RELEASED}`)).toContain(after);
|
||||
expect(normalizeChangelog(after)).toBe(normalizeChangelog(before));
|
||||
expect(checkChangelog(after, '1.0.1.0')).toEqual([]);
|
||||
});
|
||||
|
||||
test('an [Unreleased] entry below the predecessor (after a main merge) moves to the top', () => {
|
||||
const dir = repo(`# Changelog\n\n${RELEASED}\n${ENTRY}`);
|
||||
expect(run(dir, ['--version', '1.0.1.0', '--changelog', 'CHANGELOG.md']).code).toBe(0);
|
||||
const sections = parseChangelog(readCl(dir)).sections;
|
||||
expect(sections.map(s => s.version)).toEqual(['1.0.1.0', '1.0.0.0']);
|
||||
expect(sections[1]!.lines.join('\n').trimEnd()).toBe(RELEASED.trimEnd());
|
||||
});
|
||||
|
||||
test('a rerun is a no-op, and a CHANGELOG with no [Unreleased] never has a released heading renamed', () => {
|
||||
const dir = repo(`# Changelog\n\n${ENTRY}\n${RELEASED}`);
|
||||
expect(run(dir, ['--version', '1.0.1.0', '--changelog', 'CHANGELOG.md']).code).toBe(0);
|
||||
const once = readCl(dir);
|
||||
const again = run(dir, ['--version', '1.0.1.0', '--changelog', 'CHANGELOG.md']);
|
||||
expect(JSON.parse(again.stdout).changelog).toEqual({ path: 'CHANGELOG.md', moved: false, detail: 'no [Unreleased] section' });
|
||||
expect(readCl(dir)).toBe(once);
|
||||
const released = repo(`# Changelog\n\n${RELEASED}`);
|
||||
expect(run(released, ['--version', '1.0.1.0', '--changelog', 'CHANGELOG.md']).code).toBe(0);
|
||||
expect(readCl(released)).toBe(`# Changelog\n\n${RELEASED}`);
|
||||
});
|
||||
|
||||
test('an absent CHANGELOG is a no-op that creates nothing', () => {
|
||||
const dir = repo(null);
|
||||
const r = run(dir, ['--version', '1.0.1.0', '--changelog', 'CHANGELOG.md']);
|
||||
expect(r.code).toBe(0);
|
||||
expect(JSON.parse(r.stdout).changelog).toEqual({ path: 'CHANGELOG.md', moved: false, detail: 'CHANGELOG.md absent' });
|
||||
expect(fs.existsSync(path.join(dir, 'CHANGELOG.md'))).toBe(false);
|
||||
});
|
||||
|
||||
test('a heading the move would duplicate refuses with exit 2 before VERSION is written', () => {
|
||||
const before = `# Changelog\n\n${ENTRY}\n## [1.0.1.0] - 2026-10-02\n\n- taken\n\n${RELEASED}`;
|
||||
const dir = repo(before);
|
||||
const r = run(dir, ['--version', '1.0.1.0', '--changelog', 'CHANGELOG.md']);
|
||||
expect(r.code).toBe(2);
|
||||
expect(r.stderr).toContain('1.0.1.0 already headed');
|
||||
expect(fs.readFileSync(path.join(dir, 'VERSION'), 'utf8')).toBe('1.0.0.0\n');
|
||||
expect(readCl(dir)).toBe(before);
|
||||
});
|
||||
|
||||
test('a missing value or an escaping path refuses with exit 2 and writes nothing', () => {
|
||||
const dir = repo(`# Changelog\n\n${ENTRY}`);
|
||||
for (const args of [['--changelog'], ['--changelog', '--regen-digest'], ['--changelog', '../CHANGELOG.md']]) {
|
||||
const r = run(dir, ['--version', '1.0.1.0', ...args]);
|
||||
expect(r.code).toBe(2);
|
||||
expect(fs.readFileSync(path.join(dir, 'VERSION'), 'utf8')).toBe('1.0.0.0\n');
|
||||
}
|
||||
});
|
||||
|
||||
test('gstack-restamp composes: an already re-headed entry is kept, and a later stamp moves it as the branch stamp', () => {
|
||||
const fx = makeFixtureRepo({ prefix: 'vbump-restamp-' });
|
||||
try {
|
||||
makePr(fx, 'pr-a');
|
||||
const w = runBin(fx, 'gstack-version-bump', ['write', '--version', '1.2.4.0', '--changelog', 'CHANGELOG.md']);
|
||||
expect(w.status).toBe(0);
|
||||
fx.git('commit', '-qam', 'bump 1.2.4.0');
|
||||
const headed = readFile(fx, 'CHANGELOG.md');
|
||||
const same = restamp(fx, ['--version', '1.2.4.0']);
|
||||
expect(same.status).toBe(EXIT.ok);
|
||||
expect(same.out).toContain('already headed 1.2.4.0');
|
||||
expect(readFile(fx, 'CHANGELOG.md')).toBe(headed);
|
||||
const next = restamp(fx, ['--version', '1.2.5.0']);
|
||||
expect(next.status).toBe(EXIT.ok);
|
||||
expect(next.out).toContain('1.2.4.0 -> 1.2.5.0');
|
||||
expect(parseChangelog(readFile(fx, 'CHANGELOG.md')).sections.map(s => s.version)).toEqual(['1.2.5.0', '1.2.3.0', '1.2.2.0']);
|
||||
} finally {
|
||||
cleanup(fx);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -539,7 +539,7 @@ do not launch the downstream skill or open a browser.`,
|
||||
},
|
||||
behavioral: 'prompt',
|
||||
maxSkeletonBytes: 74_500, // + Aside browser contract for Step 7 canary ({{ASIDE_SETUP}}); measured 73_523
|
||||
maxSizeRatio: 1.1120, // + v1.81 Aside contract + gstack-browser fallback block; measured 1.077. + both untrusted-content marker formats in the browser fallback (W7f) + the readiness gate's project test command and live eval-store paths (s06 H6/H7); measured 1.1076 (2026-10-02). Re-measured 1.0943 (2026-10-03); cap = measured + 0.005 headroom. + Oct 7 wave B6: Aside rules 1, 4, 6 and 7 (offer a target-origin tab before openTab, a second sign-in wall means a tab- or URL-bound session, no early-return hint, fast [ok without the sentinel is an abort); measured 115,226 / 104,578 = 1.1018 (2026-10-07). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10).
|
||||
maxSizeRatio: 1.125, // + #3318 Step 3.4 reads the ship policy and prints one VERSION_CHECK verdict (owner-approved raise, Garry 2026-10-24: "do it"); measured 1.1201 (2026-10-24). Before: 1.1120. + v1.81 Aside contract + gstack-browser fallback block; measured 1.077. + both untrusted-content marker formats in the browser fallback (W7f) + the readiness gate's project test command and live eval-store paths (s06 H6/H7); measured 1.1076 (2026-10-02). Re-measured 1.0943 (2026-10-03); cap = measured + 0.005 headroom. + Oct 7 wave B6: Aside rules 1, 4, 6 and 7 (offer a target-origin tab before openTab, a second sign-in wall means a tab- or URL-bound session, no early-return hint, fast [ok without the sentinel is an abort); measured 115,226 / 104,578 = 1.1018 (2026-10-07). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10).
|
||||
minUnionBytes: 91_000, // Phase 4 wave 1; estimated union ~94.9KB
|
||||
mustContain: ['readiness', 'merge', 'canary', 'revert', 'staging'],
|
||||
},
|
||||
|
||||
@@ -138,6 +138,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
|
||||
'test/fixtures/investigate-owned-question-39195396438.json': ['test/workflow-boundaries-fixture.test.ts'],
|
||||
'test/fixtures/judge-calibration/**': ['scripts/judge-calibration.ts', 'test/judge-calibration.test.ts'],
|
||||
'test/fixtures/judge-panel-replay-2026-10-05.json': ['test/judge-panel-median.test.ts'],
|
||||
'test/fixtures/merge-train-3345/**': ['test/changelog-merge-damage.test.ts', 'test/compare-pr-version.test.ts'],
|
||||
'test/fixtures/module-size-ratchet.json': ['test/helpers/module-size.ts', 'test/module-size-ratchet.test.ts'],
|
||||
'test/fixtures/module-size/**': ['test/helpers/module-size.ts', 'test/module-size-ratchet.test.ts'],
|
||||
'test/fixtures/multi-agent-wave/**': ['test/headless-artifacts.test.ts', 'test/helpers/autoplan-run-fixture.ts', 'test/plan-reality.test.ts'],
|
||||
|
||||
@@ -68,8 +68,8 @@ export const SAFETY_RULES: readonly SafetyRule[] = [
|
||||
evalId: 'safety-ios-demo-ui-only',
|
||||
source: 'ios-qa/SKILL.md.tmpl',
|
||||
section: '## Demo mode',
|
||||
match: /demo mode overrides/i,
|
||||
sha256: '8d42208c888d76af49ebe6e4d58e85537c979f53ecb32fff7e85e95e6859182a',
|
||||
match: /drive every action through visible UI/i,
|
||||
sha256: '0ba3230bff9de32f03719daec35c6cef762e15c550264fcffd342dde52291e21',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* artifact downloads and check-run reads. Every call is logged, so tests can
|
||||
* prove what was (and was not) dispatched.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
@@ -58,7 +59,7 @@ export function repoKey(fx: TrainFixture): string {
|
||||
}
|
||||
|
||||
/** A same-repo PR: a branch from origin/main touching `file`, published as refs/pull/<n>/head (and /merge), known to the gh shim. */
|
||||
export function openPr(fx: TrainFixture, pr: string, opts: { file?: string; text?: string; changelog?: boolean; cross?: boolean; from?: string } = {}): string {
|
||||
export function openPr(fx: TrainFixture, pr: string, opts: { file?: string; text?: string; changelog?: boolean | ((text: string) => string); cross?: boolean; from?: string } = {}): string {
|
||||
const { repo } = fx;
|
||||
repo.git('checkout', '-q', opts.from ?? 'origin/main', '-B', `pr-${pr}`);
|
||||
const file = opts.file ?? `pr-${pr}.txt`;
|
||||
@@ -66,7 +67,7 @@ export function openPr(fx: TrainFixture, pr: string, opts: { file?: string; text
|
||||
fs.writeFileSync(path.join(repo.work, file), opts.text ?? `change from #${pr}\n`);
|
||||
if (opts.changelog !== false) {
|
||||
const cl = fs.readFileSync(path.join(repo.work, 'CHANGELOG.md'), 'utf8');
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), cl.replace('# Changelog\n', `# Changelog\n\n## [Unreleased]\n\n- #${pr} change\n`));
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), typeof opts.changelog === 'function' ? opts.changelog(cl) : cl.replace('# Changelog\n', `# Changelog\n\n## [Unreleased]\n\n- #${pr} change\n`));
|
||||
}
|
||||
repo.git('add', '-A');
|
||||
repo.git('commit', '-q', '-m', `feat #${pr}`);
|
||||
@@ -76,9 +77,19 @@ export function openPr(fx: TrainFixture, pr: string, opts: { file?: string; text
|
||||
return head;
|
||||
}
|
||||
|
||||
/** The repo key of a GitHub-form origin URL: sha256 of the URL with .git and trailing slashes dropped. */
|
||||
export const urlKey = (url: string) => createHash('sha256').update(url.replace(/\.git$/, '').replace(/\/+$/, '')).digest('hex').slice(0, 16);
|
||||
|
||||
/** Point the fixture clone's origin at a GitHub-form URL that git rewrites to the local bare origin (url.insteadOf). */
|
||||
export function githubOrigin(fx: TrainFixture, url: string, cwd = fx.repo.work): void {
|
||||
const git = (...args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...fx.repo.env } });
|
||||
git('config', `url.${fx.repo.origin}.insteadOf`, url);
|
||||
git('remote', 'set-url', 'origin', url);
|
||||
}
|
||||
|
||||
/** A READY receipt as `ready --screen|--no-screen` writes it (the lane side is tested separately). */
|
||||
export function readyReceipt(fx: TrainFixture, pr: string, head: string, opts: { screen?: boolean; at?: string } = {}): void {
|
||||
const key = repoKey(fx);
|
||||
export function readyReceipt(fx: TrainFixture, pr: string, head: string, opts: { screen?: boolean; at?: string; key?: string } = {}): void {
|
||||
const key = opts.key ?? repoKey(fx);
|
||||
const file = path.join(fx.state, 'ship-queue', 'ready', key, `${pr}-${head}.json`);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const at = opts.at ?? '2026-10-18T08:00:00.000Z';
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* /land-and-deploy Step 3.4 VERSION check (#3318). The old step compared the
|
||||
* branch VERSION with the next free patch slot, so it stopped stamp-at-merge
|
||||
* PRs that share a tentative stamp, a claim-at-open PR whose sibling claimed a
|
||||
* higher slot, and every MICRO bump. These tests run the template's own block
|
||||
* against a real git fixture and the real gstack-ship-policy, with a stubbed
|
||||
* gstack-next-version reply, and pin each verdict, including the real stops.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const STUB = `process.stdout.write(process.env.FAKE_QUEUE_JSON ?? '');
|
||||
process.exit(Number(process.env.FAKE_QUEUE_CODE ?? 0));
|
||||
`;
|
||||
|
||||
function step34(): string {
|
||||
const tmpl = readFileSync(join(ROOT, 'land-and-deploy', 'SKILL.md.tmpl'), 'utf8');
|
||||
const step = tmpl.slice(tmpl.indexOf('## Step 3.4:'), tmpl.indexOf('{{SECTION:readiness-gate}}'));
|
||||
const blocks = [...step.matchAll(/```bash\n([\s\S]*?)\n```/g)].map(m => m[1]);
|
||||
expect(blocks).toHaveLength(1);
|
||||
return blocks[0];
|
||||
}
|
||||
|
||||
let root: string;
|
||||
let dir: string;
|
||||
let repo: string;
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'lad-version-'));
|
||||
writeFileSync(join(root, 'next-version.ts'), STUB);
|
||||
});
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
function git(...args: string[]): string {
|
||||
const r = spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@example.com', ...args], { cwd: repo, encoding: 'utf8', timeout: 15_000 });
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
const changelog = (...versions: string[]) => `# Changelog\n\n${versions.map(v => `## [${v}] - 2026-10-24\n\n- entry\n`).join('\n')}`;
|
||||
|
||||
interface Tree { version?: string; changelog?: string; policy?: string }
|
||||
function commit(tree: Tree, message: string): string {
|
||||
for (const [file, body] of [['VERSION', tree.version], ['CHANGELOG.md', tree.changelog], ['.gstack/ship-policy.json', tree.policy]] as const) {
|
||||
if (body === undefined) continue;
|
||||
mkdirSync(join(repo, file, '..'), { recursive: true });
|
||||
writeFileSync(join(repo, file), body.endsWith('\n') ? body : `${body}\n`);
|
||||
}
|
||||
git('add', '-A');
|
||||
git('commit', '-q', '--allow-empty', '-m', message);
|
||||
return git('rev-parse', 'HEAD');
|
||||
}
|
||||
|
||||
interface Case {
|
||||
mode?: 'stamp-at-merge' | 'claim-at-open' | 'none' | 'invalid';
|
||||
base: Tree;
|
||||
head: Tree;
|
||||
queue?: object | string;
|
||||
queueCode?: number;
|
||||
shell?: 'bash' | 'zsh';
|
||||
}
|
||||
function verdict(c: Case): string {
|
||||
dir = mkdtempSync(join(root, 'case-'));
|
||||
repo = join(dir, 'repo');
|
||||
mkdirSync(repo);
|
||||
git('init', '-q', '-b', 'main');
|
||||
const policy = c.mode === 'none' || c.mode === undefined ? undefined
|
||||
: c.mode === 'invalid' ? '{"queue_mode": '
|
||||
: JSON.stringify({ queue_mode: c.mode, bump: 'patch' });
|
||||
const baseSha = commit({ ...c.base, policy }, 'base');
|
||||
spawnSync('git', ['init', '-q', '--bare', join(dir, 'origin.git')], { timeout: 15_000 });
|
||||
git('remote', 'add', 'origin', join(dir, 'origin.git'));
|
||||
git('push', '-q', 'origin', 'main');
|
||||
git('checkout', '-q', '-b', 'feature');
|
||||
const head = commit(c.head, 'head');
|
||||
const queue = c.queue ?? { offline: false, claimed: [], complete: true };
|
||||
const block = step34()
|
||||
.replaceAll('~/.claude/skills/gstack/bin/gstack-ship-policy', join(ROOT, 'bin', 'gstack-ship-policy'))
|
||||
.replaceAll('~/.claude/skills/gstack/bin/gstack-next-version', join(root, 'next-version.ts'));
|
||||
const r = spawnSync(c.shell ?? 'bash', ['-c', block], {
|
||||
cwd: repo,
|
||||
encoding: 'utf8',
|
||||
timeout: 60_000,
|
||||
env: {
|
||||
...process.env,
|
||||
PR_HEAD: head, BASE_SHA: baseSha, BASE_BRANCH: 'main', PR_NUMBER: '7',
|
||||
FAKE_QUEUE_JSON: typeof queue === 'string' ? queue : JSON.stringify(queue),
|
||||
FAKE_QUEUE_CODE: String(c.queueCode ?? 0),
|
||||
},
|
||||
});
|
||||
const line = r.stdout.split('\n').find(l => l.startsWith('VERSION_CHECK: '));
|
||||
if (!line) throw new Error(`no VERSION_CHECK line (exit ${r.status}):\n${r.stdout}\n${r.stderr}`);
|
||||
return line;
|
||||
}
|
||||
|
||||
const claim = (pr: number, version: string) => ({ pr, branch: `b${pr}`, version });
|
||||
const BASE: Tree = { version: '1.91.186.0', changelog: changelog('1.91.186.0') };
|
||||
const stamped = (version: string, below = '1.91.186.0'): Tree => ({ version, changelog: changelog(version, below) });
|
||||
|
||||
describe('stamp-at-merge: verify the exact head stamp, ignore claims', () => {
|
||||
test('a sibling sharing the tentative stamp no longer stops the PR', () => {
|
||||
expect(verdict({ mode: 'stamp-at-merge', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0')], complete: true } }))
|
||||
.toBe('VERSION_CHECK: ok (stamp-at-merge: 1.91.187.0, base 1.91.186.0)');
|
||||
});
|
||||
|
||||
test('a numeric one-bump stamp passes (9 -> 10)', () => {
|
||||
expect(verdict({ mode: 'stamp-at-merge', base: { version: '1.91.9.0', changelog: changelog('1.91.9.0') }, head: { version: '1.91.10.0', changelog: changelog('1.91.10.0', '1.91.9.0') } })).toStartWith('VERSION_CHECK: ok (stamp-at-merge:');
|
||||
});
|
||||
|
||||
test('a MICRO stamp is one bump and passes', () => {
|
||||
expect(verdict({ mode: 'stamp-at-merge', base: BASE, head: stamped('1.91.186.1') })).toStartWith('VERSION_CHECK: ok (stamp-at-merge:');
|
||||
});
|
||||
|
||||
test('claims offline do not matter under stamp-at-merge', () => {
|
||||
expect(verdict({ mode: 'stamp-at-merge', base: BASE, head: stamped('1.91.187.0'), queue: '', queueCode: 1 })).toStartWith('VERSION_CHECK: ok (stamp-at-merge:');
|
||||
});
|
||||
|
||||
test('stops when the base already reached the stamp (a sibling landed first)', () => {
|
||||
expect(verdict({ mode: 'stamp-at-merge', base: stamped('1.91.187.0'), head: stamped('1.91.187.0') }))
|
||||
.toBe('VERSION_CHECK: STOP stamp stale (stamp-at-merge: 1.91.187.0, base 1.91.187.0)');
|
||||
});
|
||||
|
||||
test('stops when the base moved under a stamp that is still one bump above it', () => {
|
||||
expect(verdict({ mode: 'stamp-at-merge', base: stamped('1.91.186.1'), head: stamped('1.91.187.0') })).toStartWith('VERSION_CHECK: STOP stamp stale');
|
||||
});
|
||||
|
||||
test('stops on an unstamped [Unreleased] entry and on a stamp more than one bump up', () => {
|
||||
expect(verdict({ mode: 'stamp-at-merge', base: BASE, head: { version: '1.91.187.0', changelog: changelog('Unreleased', '1.91.186.0') } })).toStartWith('VERSION_CHECK: STOP stamp stale');
|
||||
expect(verdict({ mode: 'stamp-at-merge', base: BASE, head: stamped('1.91.188.0') })).toStartWith('VERSION_CHECK: STOP stamp stale');
|
||||
});
|
||||
});
|
||||
|
||||
describe('claim-at-open: stop only on a real collision', () => {
|
||||
test('versions compare numerically, and 3-digit semver works', () => {
|
||||
expect(verdict({ mode: 'claim-at-open', base: { version: '1.91.9.0' }, head: { version: '1.91.10.0' } })).toStartWith('VERSION_CHECK: ok (claim-at-open:');
|
||||
expect(verdict({ mode: 'claim-at-open', base: { version: '1.91.10.0' }, head: { version: '1.91.9.0' } })).toStartWith('VERSION_CHECK: STOP base is at or above it');
|
||||
expect(verdict({ mode: 'claim-at-open', base: { version: '2.14.9' }, head: { version: '2.14.10' } })).toStartWith('VERSION_CHECK: ok (claim-at-open:');
|
||||
expect(verdict({ mode: 'claim-at-open', base: { version: '2.14.9' }, head: { version: '2.14.9.1' } })).toStartWith('VERSION_CHECK: unavailable: bad version');
|
||||
});
|
||||
|
||||
test('a sibling holding a higher claim no longer stops the PR', () => {
|
||||
expect(verdict({ mode: 'claim-at-open', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.188.0')], complete: true } }))
|
||||
.toBe('VERSION_CHECK: ok (claim-at-open: 1.91.187.0, base 1.91.186.0)');
|
||||
});
|
||||
|
||||
test('a MICRO bump passes', () => {
|
||||
expect(verdict({ mode: 'claim-at-open', base: BASE, head: stamped('1.91.186.1') })).toStartWith('VERSION_CHECK: ok (claim-at-open:');
|
||||
});
|
||||
|
||||
test('stops when another open PR holds the same VERSION', () => {
|
||||
expect(verdict({ mode: 'claim-at-open', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0'), claim(9, '1.91.188.0')], complete: true } }))
|
||||
.toBe('VERSION_CHECK: STOP also claimed by #8 (claim-at-open: 1.91.187.0, base 1.91.186.0)');
|
||||
});
|
||||
|
||||
test('stops when the base is at or above the branch VERSION', () => {
|
||||
expect(verdict({ mode: 'claim-at-open', base: stamped('1.91.187.0'), head: stamped('1.91.187.0') })).toStartWith('VERSION_CHECK: STOP base is at or above it');
|
||||
expect(verdict({ mode: 'claim-at-open', base: stamped('1.91.188.0'), head: stamped('1.91.187.0') })).toStartWith('VERSION_CHECK: STOP base is at or above it');
|
||||
});
|
||||
|
||||
test('a repo with no policy file gets the claim-at-open default', () => {
|
||||
expect(verdict({ mode: 'none', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0')], complete: true } })).toStartWith('VERSION_CHECK: STOP also claimed by #8');
|
||||
});
|
||||
|
||||
test('offline claims and unreadable claims are unavailable, but a known collision still stops', () => {
|
||||
expect(verdict({ mode: 'claim-at-open', base: BASE, head: stamped('1.91.187.0'), queue: { offline: true } })).toStartWith('VERSION_CHECK: unavailable: claims unread');
|
||||
expect(verdict({ mode: 'claim-at-open', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [], unknown_claims: [9], complete: false } })).toStartWith('VERSION_CHECK: unavailable: claims unread');
|
||||
expect(verdict({ mode: 'claim-at-open', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0')], unknown_claims: [9], complete: false } })).toStartWith('VERSION_CHECK: STOP also claimed by #8');
|
||||
});
|
||||
});
|
||||
|
||||
describe('either mode', () => {
|
||||
test.skipIf(!Bun.which('zsh'))('the block gives the same verdicts under zsh', () => {
|
||||
expect(verdict({ shell: 'zsh', mode: 'stamp-at-merge', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0')], complete: true } })).toStartWith('VERSION_CHECK: ok (stamp-at-merge:');
|
||||
expect(verdict({ shell: 'zsh', mode: 'stamp-at-merge', base: stamped('1.91.186.1'), head: stamped('1.91.187.0') })).toStartWith('VERSION_CHECK: STOP stamp stale');
|
||||
expect(verdict({ shell: 'zsh', mode: 'claim-at-open', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0')], complete: true } })).toStartWith('VERSION_CHECK: STOP also claimed by #8');
|
||||
});
|
||||
|
||||
test('no configured version source is not applicable', () => {
|
||||
expect(verdict({ mode: 'stamp-at-merge', base: {}, head: {}, queue: { no_version: true, version: null } })).toStartWith('VERSION_CHECK: NO_VERSION');
|
||||
});
|
||||
|
||||
test('a VERSION missing on one side is unavailable, not green', () => {
|
||||
expect(verdict({ mode: 'claim-at-open', base: { changelog: changelog('1.91.186.0') }, head: stamped('1.91.187.0') })).toStartWith('VERSION_CHECK: unavailable: bad version');
|
||||
});
|
||||
|
||||
test('an invalid policy is unavailable, but a base at or above the branch still stops', () => {
|
||||
expect(verdict({ mode: 'invalid', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0')], complete: true } })).toStartWith('VERSION_CHECK: unavailable: no policy');
|
||||
expect(verdict({ mode: 'invalid', base: stamped('1.91.187.0'), head: stamped('1.91.187.0') })).toStartWith('VERSION_CHECK: STOP base is at or above it');
|
||||
});
|
||||
});
|
||||
@@ -243,6 +243,121 @@ describe('gate on: the mediated hand-off', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('#3359 per-remote opt-in: lifts read-only for that remote only; default, other remotes, deny and a corrupt store stay blocked', () => {
|
||||
gateOn();
|
||||
const policy = (...args: string[]) => spawnSync('bash', [POLICY, ...args], { env, encoding: 'utf8', timeout: 20_000 });
|
||||
const store = path.join(home, '.gstack', 'gbrain-repo-policy.json');
|
||||
const repos: Record<string, string> = {};
|
||||
try {
|
||||
for (const [name, url] of [['a', 'https://github.com/example/client-a.git'], ['b', 'https://github.com/example/client-b.git']]) {
|
||||
const repo = fs.mkdtempSync(path.join(os.tmpdir(), `gstack-memo-optin-${name}-`));
|
||||
spawnSync('git', ['init', '-q'], { cwd: repo, timeout: 10_000 });
|
||||
spawnSync('git', ['remote', 'add', 'origin', url], { cwd: repo, timeout: 10_000 });
|
||||
isolateGitRemote(repo, url);
|
||||
expect(policy('set', url, 'read-only').status).toBe(0);
|
||||
repos[name] = repo;
|
||||
}
|
||||
const hook = (name: string) => {
|
||||
fs.rmSync(path.join(home, 'calls.log'), { force: true });
|
||||
return runHook(JSON.stringify({ prompt: 'hello', cwd: repos[name] }), {}, repos[name]);
|
||||
};
|
||||
// default: read-only stays blocked
|
||||
expect(hook('a').stdout).toBe('');
|
||||
expect(calls()).toBe('');
|
||||
expect(errLog()).toContain('memorable set <origin-url> allow');
|
||||
// opt-in on client-a: recall runs there, and does not leak to client-b
|
||||
expect(policy('memorable', 'set', 'https://github.com/example/client-a.git', 'allow').status).toBe(0);
|
||||
expect(hook('a').stdout).toContain('remembered');
|
||||
expect(calls()).toBe('hook user-prompt\n');
|
||||
expect(hook('b').stdout).toBe('');
|
||||
expect(calls()).toBe('');
|
||||
// deny beats an opt-in, even one written into the store by hand
|
||||
expect(policy('set', 'https://github.com/example/client-a.git', 'deny').status).toBe(0);
|
||||
const s = JSON.parse(fs.readFileSync(store, 'utf8'));
|
||||
fs.writeFileSync(store, JSON.stringify({ ...s, _memorable_recall: { 'github.com/example/client-a': 'allow' } }));
|
||||
expect(hook('a').stdout).toBe('');
|
||||
expect(calls()).toBe('');
|
||||
// a corrupt store fails closed and is left in place (never quarantined into "no policy")
|
||||
fs.writeFileSync(store, '{"_schema_version":2,"github.com/example/client-b":"read-only",');
|
||||
expect(hook('b')).toEqual({ status: 0, stdout: '', stderr: '' });
|
||||
expect(calls()).toBe('');
|
||||
expect(errLog()).toContain('trust policy lookup failed');
|
||||
expect(fs.readFileSync(store, 'utf8')).toContain('"read-only",');
|
||||
expect(hook('b').stdout).toBe('');
|
||||
expect(calls()).toBe('');
|
||||
} finally {
|
||||
for (const repo of Object.values(repos)) fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('#3359 review: a url.insteadOf rewrite to another host does not dodge a deny, and a dangling-symlink store fails closed', () => {
|
||||
gateOn();
|
||||
const url = 'https://fixture.invalid/org/denied.git';
|
||||
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-memo-rewrite-'));
|
||||
try {
|
||||
spawnSync('git', ['init', '-q'], { cwd: repo, timeout: 10_000 });
|
||||
spawnSync('git', ['remote', 'add', 'origin', url], { cwd: repo, timeout: 10_000 });
|
||||
spawnSync('git', ['config', '--local', 'url.https://mirror.invalid/.insteadOf', 'https://fixture.invalid/'], { cwd: repo, timeout: 10_000 });
|
||||
expect(spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: repo, encoding: 'utf8', timeout: 10_000 }).stdout.trim()).toBe('https://mirror.invalid/org/denied.git');
|
||||
expect(spawnSync('bash', [POLICY, 'set', url, 'deny'], { env, encoding: 'utf8', timeout: 20_000 }).status).toBe(0);
|
||||
const prompt = JSON.stringify({ prompt: 'hello', cwd: repo });
|
||||
expect(runHook(prompt, {}, repo).stdout).toBe('');
|
||||
expect(calls()).toBe('');
|
||||
const store = path.join(home, '.gstack', 'gbrain-repo-policy.json');
|
||||
fs.rmSync(store);
|
||||
fs.symlinkSync(path.join(home, 'missing.json'), store);
|
||||
expect(runHook(prompt, {}, repo)).toEqual({ status: 0, stdout: '', stderr: '' });
|
||||
expect(calls()).toBe('');
|
||||
expect(errLog()).toContain('trust policy lookup failed');
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('#3359 review: a second configured origin URL cannot dodge the first URL\'s deny, in either order', () => {
|
||||
gateOn();
|
||||
const denied = 'https://fixture.invalid/org/denied.git';
|
||||
const other = 'https://fixture.invalid/org/unlisted.git';
|
||||
expect(spawnSync('bash', [POLICY, 'set', denied, 'deny'], { env, encoding: 'utf8', timeout: 20_000 }).status).toBe(0);
|
||||
for (const order of [[denied, other], [other, denied]]) {
|
||||
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-memo-multi-'));
|
||||
try {
|
||||
spawnSync('git', ['init', '-q'], { cwd: repo, timeout: 10_000 });
|
||||
spawnSync('git', ['remote', 'add', 'origin', order[0]], { cwd: repo, timeout: 10_000 });
|
||||
spawnSync('git', ['config', '--local', '--add', 'remote.origin.url', order[1]], { cwd: repo, timeout: 10_000 });
|
||||
for (const u of order) spawnSync('git', ['config', '--local', `url.${u}.insteadOf`, u], { cwd: repo, timeout: 10_000 });
|
||||
fs.rmSync(path.join(home, 'calls.log'), { force: true });
|
||||
expect(runHook(JSON.stringify({ prompt: 'hello', cwd: repo }), {}, repo).stdout).toBe('');
|
||||
expect(calls()).toBe('');
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('#3359 review: an origin URL with a newline or edge whitespace (valid local paths) is looked up verbatim, so its deny holds', () => {
|
||||
gateOn();
|
||||
for (const name of ['remote\nname.git', 'remote.git ', ' remote.git']) {
|
||||
const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-memo-nl-'));
|
||||
try {
|
||||
const remote = path.join(parent, name);
|
||||
fs.mkdirSync(remote);
|
||||
expect(spawnSync('git', ['init', '-q', '--bare', remote], { timeout: 10_000 }).status).toBe(0);
|
||||
const repo = path.join(parent, 'work');
|
||||
fs.mkdirSync(repo);
|
||||
spawnSync('git', ['init', '-q'], { cwd: repo, timeout: 10_000 });
|
||||
spawnSync('git', ['remote', 'add', 'origin', remote], { cwd: repo, timeout: 10_000 });
|
||||
expect(spawnSync('bash', [POLICY, 'set', remote, 'deny'], { env, encoding: 'utf8', timeout: 20_000 }).status).toBe(0);
|
||||
expect(spawnSync('bash', [POLICY, 'memorable', 'get', remote], { env, encoding: 'utf8', timeout: 20_000 }).stdout).toBe('deny\toff\n');
|
||||
fs.rmSync(path.join(home, 'calls.log'), { force: true });
|
||||
expect([name, runHook(JSON.stringify({ prompt: 'hello', cwd: repo }), {}, repo).stdout]).toEqual([name, '']);
|
||||
expect(calls()).toBe('');
|
||||
} finally {
|
||||
fs.rmSync(parent, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('gate flipped off between two runs: the second run spawns nothing (the mid-flight re-check itself cannot be interleaved from outside)', () => {
|
||||
// The pre-spawn re-check reads the same store; a deterministic mid-flight flip would need a
|
||||
// seam inside main(). This pins the observable contract only: once off, no spawn.
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('official recommendation and lifecycle structures', () => {
|
||||
expect(anthropic.recommendations).toEqual({ frontier: 'claude-fable-5-1', smart: 'claude-opus-5-5' });
|
||||
expect(anthropic.lineup).not.toContain('claude-mythos-5-1');
|
||||
const openai = parseOfficialSource('openai-lifecycle', freshnessFixture('openai-lifecycle'));
|
||||
expect(openai.lifecycle?.map(row => row.modelId)).toEqual(['gpt-5.3-codex', 'gpt-old', 'gpt-unicode-date', 'gpt-iso-date', 'gpt-date-floor']);
|
||||
expect(openai.lifecycle?.map(row => row.modelId)).toEqual(['gpt-5.3-codex', 'gpt-4o-2024-08-06', 'gpt-4o-2024-11-20', 'gpt-old', 'gpt-unicode-date', 'gpt-iso-date', 'gpt-date-floor']);
|
||||
expect(openai.lifecycle?.find(row => row.modelId === 'gpt-date-floor')).toMatchObject({ deadline: null, supportFloor: '2024-06-13' });
|
||||
});
|
||||
|
||||
@@ -57,6 +57,16 @@ describe('official recommendation and lifecycle structures', () => {
|
||||
expect(report.state.lifecycle[0]).toMatchObject({ modelId: 'gpt-6-astra', deadline: '2027-04-01' });
|
||||
});
|
||||
|
||||
test('OpenAI deprecated cells with a display name before the backticked IDs yield every ID', () => {
|
||||
const openai = parseOfficialSource('openai-lifecycle', freshnessFixture('openai-lifecycle'));
|
||||
expect(openai.lifecycle?.filter(row => row.modelId.startsWith('gpt-4o-'))).toEqual([
|
||||
{ modelId: 'gpt-4o-2024-08-06', state: 'deprecated', deadline: '2027-02-16', supportFloor: null },
|
||||
{ modelId: 'gpt-4o-2024-11-20', state: 'deprecated', deadline: '2027-02-16', supportFloor: null },
|
||||
]);
|
||||
const unticked = freshnessFixture('openai-lifecycle').replace('GPT-4o (`gpt-4o-2024-08-06`, `gpt-4o-2024-11-20`)', 'GPT-4o (gpt-4o-2024-08-06, gpt-4o-2024-11-20)');
|
||||
expect(() => parseOfficialSource('openai-lifecycle', unticked)).toThrow('unrecognized-deprecated-model-cell');
|
||||
});
|
||||
|
||||
test('missing required structures and semantic drift fail closed', () => {
|
||||
expect(() => parseOfficialSource('anthropic-models', freshnessFixture('anthropic-models').replace('Claude API ID', 'Google Cloud ID'))).toThrow('missing-api-id-row');
|
||||
expect(() => parseOfficialSource('anthropic-models', freshnessFixture('anthropic-models').replace('for demanding reasoning', 'for unexplained goals'))).toThrow('unrecognized-recommendation');
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
* FINDINGS: none` for all of them. The fixtures in
|
||||
* test/fixtures/outside-review-severity/codex-3105/ are synthesized from the
|
||||
* forms the issue documents (it describes 13 replies but does not include them).
|
||||
* #3329: that same gate then printed `clean` for a parsed advisory finding
|
||||
* whose last `Verdict:` line says `not ready`. `bare-dash-p2.txt` is unchanged.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { classifyOutsideReview, type OutsideGate } from '../lib/outside-review-result';
|
||||
import { classifyOutsideReview, validateOutsideReview, type OutsideGate } from '../lib/outside-review-result';
|
||||
import { GATE_OUTCOMES } from '../lib/gate-outcomes';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
@@ -26,7 +28,7 @@ describe('#3105 Codex reply shapes under the execution gate', () => {
|
||||
for (const [name, status, stdout] of [
|
||||
['plan-bold-dash.txt', 3, 'VERDICT: findings\nFINDINGS: P1\n'],
|
||||
['diff-numbered-dash.txt', 3, 'VERDICT: findings\nFINDINGS: P1\n'],
|
||||
['bare-dash-p2.txt', 0, 'VERDICT: clean\nFINDINGS: P2\n'],
|
||||
['bare-dash-p2.txt', 3, 'VERDICT: findings\nFINDINGS: P2\n'],
|
||||
['ready-one-p3.txt', 0, 'VERDICT: clean\nFINDINGS: P3\n'],
|
||||
['table-p1.txt', 3, 'VERDICT: findings\nFINDINGS: P1\n'],
|
||||
['not-ready-unlabeled.txt', 3, 'VERDICT: findings\nFINDINGS: unspecified\n'],
|
||||
@@ -112,3 +114,73 @@ describe('#3105 severity forms are read by every gate that summarises a review',
|
||||
expect(classifyOutsideReview({ text: proposal, gate: 'proposal' }).verdict).toBe('clean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#3329 a last not-ready Verdict blocks even with advisory findings', () => {
|
||||
const ISSUE = '1. **P2 — The docs lag the new flag.**\n2. **P3 — Rename the helper.**\nVerdict: not ready (1 P1/P2)';
|
||||
const P2 = '1. **P2 — The docs lag the new flag.**';
|
||||
const textFor = (gate: OutsideGate, text: string) => gate === 'review' ? `${text}\n${REC}` : text;
|
||||
const see = (text: string, gate: OutsideGate) => {
|
||||
const r = classifyOutsideReview({ text: textFor(gate, text), gate });
|
||||
return { verdict: r.verdict as string, highest: r.findings.highest as string | null, reason: (r.reason ?? null) as string | null, unparsed: r.unparsed ?? null };
|
||||
};
|
||||
|
||||
test('numbered bold P2 plus P3 with not ready is findings/P2 on execution, structured and review', () => {
|
||||
for (const gate of ['execution', 'structured', 'review'] as const)
|
||||
expect({ gate, ...see(ISSUE, gate) }).toEqual({ gate, verdict: 'findings', highest: 'P2', reason: null, unparsed: null });
|
||||
expect(validateOutsideReview(ISSUE, 'structured')).toEqual({ completed: true, gate: 'fail' });
|
||||
});
|
||||
|
||||
test('P3-only and unlabeled not-ready replies block; P0/P1 block even when the last Verdict says ready', () => {
|
||||
for (const gate of ['execution', 'structured', 'review'] as const) {
|
||||
expect({ gate, ...see('1. **P3 — Rename the helper.**\nVerdict: not ready', gate) }).toEqual({ gate, verdict: 'findings', highest: 'P3', reason: null, unparsed: null });
|
||||
expect({ gate, ...see('The retry path can double-send.\nVerdict: not ready', gate) }).toEqual({ gate, verdict: 'findings', highest: null, reason: null, unparsed: null });
|
||||
}
|
||||
expect(see('1. **P1 — The cap is ignored.**\nVerdict: ready', 'review')).toMatchObject({ verdict: 'findings', highest: 'P1' });
|
||||
expect(see('**P0 — Secrets reach the log.**\nVerdict: ready', 'execution')).toMatchObject({ verdict: 'findings', highest: 'P0' });
|
||||
});
|
||||
|
||||
test('advisory P2/P3 with ready or no Verdict stay clean', () => {
|
||||
for (const text of [
|
||||
`${P2}\n2. **P3 — Rename the helper.**\nVerdict: ready`,
|
||||
`${P2}\n2. **P3 — Rename the helper.**`,
|
||||
'1. **P3 — Rename the helper.**\nVerdict: ready (1 P3)',
|
||||
]) {
|
||||
const r = see(text, 'execution');
|
||||
expect({ text, verdict: r.verdict, highest: r.highest }).toEqual({ text, verdict: 'clean', highest: text.includes('P2') ? 'P2' : 'P3' });
|
||||
}
|
||||
expect(see(`${P2}\nVerdict: ready`, 'review').verdict).toBe('clean');
|
||||
expect(validateOutsideReview(`${P2}\nVerdict: ready`, 'structured')).toEqual({ completed: true, gate: 'pass' });
|
||||
});
|
||||
|
||||
test('the last recognized Verdict wins, including supported formatting; quotes do not', () => {
|
||||
expect(see(`${P2}\nVerdict: ready\nVerdict: not ready (1 P1/P2)`, 'execution')).toMatchObject({ verdict: 'findings', highest: 'P2' });
|
||||
expect(see(`${P2}\nVerdict: not ready (1 P1/P2)\nVerdict: ready`, 'execution').verdict).toBe('clean');
|
||||
for (const verdictLine of ['**Verdict:** not ready', '- Verdict: not ready', '### Verdict: not ready'])
|
||||
expect({ verdictLine, ...see(`${P2}\n${verdictLine}`, 'execution') }).toMatchObject({ verdict: 'findings', highest: 'P2' });
|
||||
expect(see(`${P2}\nVerdict: not ready\n### Verdict: ready`, 'execution').verdict).toBe('clean');
|
||||
const fence = ['```', 'Verdict: not ready (2 P2)', '```'].join('\n');
|
||||
expect(see(`${P2}\nVerdict: ready\n${fence}`, 'execution').verdict).toBe('clean');
|
||||
expect(see(`${fence}\n${P2}\nVerdict: not ready`, 'execution')).toMatchObject({ verdict: 'findings', highest: 'P2' });
|
||||
expect(see(`${P2}\nVerdict: ready\nThe rubric says \`Verdict: not ready\`.`, 'execution').verdict).toBe('clean');
|
||||
expect(see(`${P2}\nThe rubric says \`Verdict: not ready\`.`, 'execution').verdict).toBe('clean');
|
||||
expect(see(`No issues found.\n${fence}`, 'review').verdict).toBe('clean');
|
||||
});
|
||||
|
||||
test('parsed P2 plus an unparsed label and not-ready still blocks and keeps the diagnostic', () => {
|
||||
const text = `${P2}\n## P1/P2 findings\nVerdict: not ready`;
|
||||
expect(see(text, 'execution')).toEqual({ verdict: 'findings', highest: 'P2', reason: null, unparsed: 'line 2: "## P1/P2 findings"' });
|
||||
expect(see(`${P2}\n## P1/P2 findings`, 'structured')).toMatchObject({ verdict: 'unverified', reason: 'unparsed_severity', unparsed: 'line 2: "## P1/P2 findings"' });
|
||||
});
|
||||
|
||||
test('missing markers and failed execution stay unavailable; proposal and spec ignore the verdict', () => {
|
||||
expect(classifyOutsideReview({ text: `${P2}\nVerdict: not ready`, gate: 'review' }).reason).toBe('missing_markers');
|
||||
const failed = classifyOutsideReview({ text: textFor('review', `${P2}\nVerdict: not ready`), gate: 'review', exit: 1, stderr: 'fatal: provider exploded' });
|
||||
expect(failed.verdict).toBe('unavailable');
|
||||
expect(failed.reason).toBe('execution_failed');
|
||||
expect(classifyOutsideReview({ text: `${P2}\nVerdict: not ready\n${REC}`, gate: 'proposal' }).verdict).toBe('clean');
|
||||
const passing = classifyOutsideReview({ text: `SCORE: 8\nAMBIGUITIES: NONE\n${P2}\nVerdict: not ready`, gate: 'spec' });
|
||||
expect([passing.verdict, passing.score]).toEqual(['clean', 8]);
|
||||
const failing = classifyOutsideReview({ text: 'SCORE: 6\nAMBIGUITIES: NONE\nVerdict: ready', gate: 'spec' });
|
||||
expect([failing.verdict, failing.score]).toEqual(['findings', 6]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* C8 `lanes` for a Rust repo: a `.rs` file of a crate (the nearest Cargo.toml
|
||||
* with `[package]`) is a test when it sits under `<crate>/tests/` or holds a
|
||||
* `#[cfg(test)]`; every `.rs` file of a crate and its Cargo.toml select that
|
||||
* crate's tests and the tests of every crate depending on it; selected tests
|
||||
* run as `cargo test -p <name>` from the workspace root, judged on the summed
|
||||
* `test result:` lines. The end-to-end fixture carries no workflows, so every
|
||||
* selected test is a local lane on any host platform; `cargo` is a PATH shim.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as path from 'node:path';
|
||||
import { bunIsolationArgs } from '../lib/bun-isolation';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { parseSummary, planRuns } from '../lib/pregate/checks/lanes';
|
||||
import { DEFAULT_CONFIG } from '../lib/pregate/config';
|
||||
import { buildGraph, testsFor } from '../lib/pregate/import-graph';
|
||||
import { draftConfig } from '../lib/pregate/init-draft';
|
||||
import { cargoModel, hasCfgTest, rustEdges } from '../lib/pregate/rust';
|
||||
import type { PregateContext } from '../lib/pregate/types';
|
||||
import { cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
|
||||
const RUST_FILES: Record<string, string> = {
|
||||
'engine/Cargo.toml': '[workspace]\nresolver = "3"\nmembers = ["crates/*"]\n\n[workspace.dependencies]\nalpha = { path = "crates/alpha" }\n',
|
||||
'engine/crates/alpha/Cargo.toml': '[package]\nname = "alpha"\nversion = "0.1.0"\nedition = "2024"\n',
|
||||
'engine/crates/alpha/src/lib.rs': 'pub mod util;\npub fn one() -> u32 { 1 }\n\n#[cfg(test)]\nmod tests {\n #[test]\n fn one_is_one() { assert_eq!(super::one(), 1); }\n}\n',
|
||||
'engine/crates/alpha/src/util.rs': 'pub fn two() -> u32 { 2 }\n',
|
||||
'engine/crates/alpha/src/flags.rs': '#[cfg(feature = "test")]\npub fn only_with_the_test_feature() {}\n',
|
||||
'engine/crates/alpha/tests/it.rs': '#[test]\nfn it() { assert_eq!(alpha::one(), 1); }\n',
|
||||
'engine/crates/beta/Cargo.toml': '[package]\nname = "beta-core"\nversion = "0.1.0"\n\n[dependencies]\nalpha = { workspace = true } # the shared base\n',
|
||||
'engine/crates/beta/src/lib.rs': 'pub fn b() -> u32 { alpha::one() }\n\n#[cfg(all(test, unix))]\nmod tests {\n #[test]\n fn b() {}\n}\n',
|
||||
'engine/crates/gamma/Cargo.toml': '[package]\nname = "gamma"\nversion = "0.1.0"\n\n[target.\'cfg(unix)\'.dev-dependencies.base]\npackage = "beta-core"\npath = "../beta"\n',
|
||||
'engine/crates/gamma/src/lib.rs': 'pub fn g() {}\n',
|
||||
'engine/crates/gamma/tests/g.rs': '#[test]\nfn g() {}\n',
|
||||
'engine/crates/lonely/Cargo.toml': '[package]\nname = "lonely"\nversion = "0.1.0"\n',
|
||||
'engine/crates/lonely/src/lib.rs': 'pub fn alone() {}\n',
|
||||
'standalone/Cargo.toml': '[package]\nname = "standalone"\nversion = "0.1.0"\n',
|
||||
'standalone/src/main.rs': 'fn main() {}\n\n#[cfg(test)]\nmod tests {\n #[test]\n fn t() {}\n}\n',
|
||||
'tools/tests/loose.rs': 'fn main() {}\n',
|
||||
};
|
||||
|
||||
const ALPHA_TESTS = ['engine/crates/alpha/src/lib.rs', 'engine/crates/alpha/tests/it.rs'];
|
||||
|
||||
/** The fixture plus RUST_FILES on main, with no base policy and no workflows, so every selected test runs locally. */
|
||||
function rustRepo(): FixtureRepo {
|
||||
const repo = makeFixtureRepo({ pregate: null });
|
||||
repos.push(repo);
|
||||
commit(repo, { ...RUST_FILES, '.github/workflows/free-tests.yml': null, '.github/workflows/windows.yml': null }, 'rust');
|
||||
repo.git('push', '-q', 'origin', 'HEAD:main');
|
||||
repo.git('fetch', '-q', 'origin');
|
||||
return repo;
|
||||
}
|
||||
|
||||
const CARGO_OK = [
|
||||
'echo "ran in $(basename "$PWD"): $*"',
|
||||
'echo "running 2 tests"',
|
||||
'echo "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s"',
|
||||
'echo " Doc-tests $3"',
|
||||
'echo "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s"',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* A `cargo` on PATH running `body` (default: reports its cwd and argv, then two passed tests and an empty doc-test binary).
|
||||
* The lane puts its Bun's directory first on PATH, and that directory can hold a real cargo (/usr/local/bin,
|
||||
* /opt/homebrew/bin), so on POSIX the lane's bash puts the shim first again through BASH_ENV.
|
||||
*/
|
||||
function cargoShim(repo: FixtureRepo, body = CARGO_OK): Record<string, string> {
|
||||
const dir = path.join(repo.root, 'shim');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'cargo'), `#!/usr/bin/env bash\n${body}\n`, { mode: 0o755 });
|
||||
fs.writeFileSync(path.join(dir, 'path.sh'), `PATH='${dir}':"$PATH"\n`);
|
||||
const PATH = [dir, process.env.PATH ?? ''].join(path.delimiter);
|
||||
return process.platform === 'win32' ? { PATH } : { PATH, BASH_ENV: path.join(dir, 'path.sh') };
|
||||
}
|
||||
|
||||
const rustTests = (tests: Iterable<string>) => [...tests].filter(t => t.endsWith('.rs')).sort();
|
||||
|
||||
describe('Rust import graph', () => {
|
||||
// Value: protects=in-source `#[cfg(test)]` modules and `<crate>/tests/` files are tests only inside a crate, and a cfg naming a
|
||||
// feature called "test" is not; fails_when=Rust tests are never recognised (every touched .rs is PREGATE_NO_LANE) or any
|
||||
// tests/*.rs counts without a Cargo.toml; why_new=no Rust support existed; seam=none; no_claim=a Rust parser (a cfg attribute scan)
|
||||
test('cfg(test) files and <crate>/tests/ files are tests; a loose tests/*.rs and a cfg(feature = "test") file are not', () => {
|
||||
const g = buildGraph(rustRepo().work);
|
||||
expect(rustTests(g.tests)).toEqual([...ALPHA_TESTS, 'engine/crates/beta/src/lib.rs', 'engine/crates/gamma/tests/g.rs', 'standalone/src/main.rs']);
|
||||
});
|
||||
|
||||
// Value: protects=a src file without its own tests selects its crate's tests and, transitively, the tests of crates depending on it
|
||||
// (workspace = true, a renamed target-specific dev-dependency); an in-source test selects itself; a crate with no tests stays
|
||||
// no-lane; fails_when=the mapping narrows to the file, misses dependents, or invents a lane; why_new=no Rust mapping existed;
|
||||
// seam=none; no_claim=cargo's own resolver (manifest dependency keys are read)
|
||||
test('a crate file selects its crate and every crate depending on it; a crate without tests has no lane', () => {
|
||||
const g = buildGraph(rustRepo().work);
|
||||
const everyAlphaDependent = [...ALPHA_TESTS, 'engine/crates/beta/src/lib.rs', 'engine/crates/gamma/tests/g.rs'];
|
||||
expect(testsFor(g, 'engine/crates/alpha/src/util.rs').tests).toEqual(everyAlphaDependent);
|
||||
expect(testsFor(g, 'engine/crates/alpha/src/lib.rs').tests).toEqual(everyAlphaDependent);
|
||||
expect(testsFor(g, 'engine/crates/alpha/Cargo.toml').tests).toEqual(everyAlphaDependent);
|
||||
expect(testsFor(g, 'engine/crates/beta/src/lib.rs').tests).toEqual(['engine/crates/beta/src/lib.rs', 'engine/crates/gamma/tests/g.rs']);
|
||||
expect(testsFor(g, 'engine/crates/gamma/src/lib.rs').tests).toEqual(['engine/crates/gamma/tests/g.rs']);
|
||||
expect(testsFor(g, 'standalone/src/main.rs').tests).toEqual(['standalone/src/main.rs']);
|
||||
expect(testsFor(g, 'engine/crates/lonely/src/lib.rs').tests).toEqual([]);
|
||||
expect(testsFor(g, 'tools/tests/loose.rs').tests).toEqual([]);
|
||||
});
|
||||
|
||||
// Value: protects=`gstack-pregate init` tells a Rust repo its tests run per crate (no runner to declare), and Rust does not make a
|
||||
// JS selection "mixed"; fails_when=init tells the user to set a runner for Rust tests, which would override the per-crate plan;
|
||||
// why_new=init grouped .rs tests as "other"; seam=none; no_claim=none beyond protects
|
||||
test('init notes that Rust tests run per crate with cargo test, not that a runner must be set', () => {
|
||||
const note = draftConfig(rustRepo().work).runnerNote;
|
||||
expect(note).toContain('5 Rust test file(s) run per crate with cargo test -p');
|
||||
expect(note).not.toContain('needs runner set');
|
||||
expect(note).not.toMatch(/no runner detected for \d+ (?:other|rust)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rust runner and summary (pure)', () => {
|
||||
// Value: protects=cargo prints one `test result:` line per test binary (unit, each integration test, doc-tests); all are summed,
|
||||
// ignored and filtered tests are not runs, and an all-zero run is ZERO-RUN; fails_when=only the first or last line counts, or the
|
||||
// generic `N passed` fallback reads cargo output; why_new=cargo output was unparsed (ran: null); seam=none; no_claim=a real cargo
|
||||
test('cargo test: every test result line counts, doc-tests included; all zeros are a zero run', () => {
|
||||
const cargo = 'cargo test -p alpha';
|
||||
const ok = [
|
||||
' Compiling alpha v0.1.0', ' Running unittests src/lib.rs (target/debug/deps/alpha-1)', '', 'running 2 tests', 'test tests::a ... ok', 'test tests::b ... ok', '',
|
||||
'test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s', '',
|
||||
' Running tests/it.rs (target/debug/deps/it-2)', '', 'running 2 tests', 'test it ... ok', 'test slow ... ignored', '',
|
||||
'test result: ok. 1 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s', '',
|
||||
' Doc-tests alpha', '', 'running 3 tests', 'test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.31s',
|
||||
].join('\n');
|
||||
expect(parseSummary(ok, cargo)).toEqual({ ran: 6, failed: 0 });
|
||||
const failed = 'test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s\n\nfailures:\n tests::b\n\ntest result: FAILED. 1 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s\n\nerror: test failed, to rerun pass `-p alpha --lib`';
|
||||
expect(parseSummary(failed, cargo)).toEqual({ ran: 7, failed: 2 });
|
||||
const zero = 'running 0 tests\n\ntest result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s\n\n Doc-tests alpha\n\nrunning 0 tests\n\ntest result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s';
|
||||
expect(parseSummary(zero, cargo)).toEqual({ ran: 0, failed: 0 });
|
||||
expect(parseSummary('\x1b[1m\x1b[32mtest result: ok\x1b[0m. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s', cargo)).toEqual({ ran: 5, failed: 0 });
|
||||
expect(parseSummary('error[E0425]: cannot find value `x` in this scope\nerror: could not compile `alpha`', cargo)).toEqual({ ran: null, failed: null });
|
||||
});
|
||||
|
||||
// Value: protects=each selected crate runs once as `cargo test -p <name>` from its workspace root (else its own directory), crates
|
||||
// group apart, a .rs file outside any crate is unclaimed, and a missing cargo is incomplete; fails_when=.rs files reach the JS
|
||||
// runner, run from the repo root, merge across crates, or a missing cargo is judged as a test failure (exit 127); why_new=no cargo
|
||||
// runner existed; seam=none; no_claim=the runners themselves (plan only)
|
||||
test('without a declared runner, Rust tests group by crate from the workspace root; a declared runner takes every file', () => {
|
||||
const repo = rustRepo();
|
||||
const ctx = { repoRoot: repo.work, config: DEFAULT_CONFIG, env: cargoShim(repo) } as unknown as PregateContext;
|
||||
const files = [...ALPHA_TESTS, 'engine/crates/beta/src/lib.rs', 'standalone/src/main.rs', 'tools/tests/loose.rs'];
|
||||
expect(planRuns(ctx, files)).toEqual({ groups: [
|
||||
{ runner: "cargo test -p 'alpha'", cwd: 'engine', files: ['crates/alpha/src/lib.rs', 'crates/alpha/tests/it.rs'] },
|
||||
{ runner: "cargo test -p 'beta-core'", cwd: 'engine', files: ['crates/beta/src/lib.rs'] },
|
||||
{ runner: "cargo test -p 'standalone'", cwd: 'standalone', files: ['src/main.rs'] },
|
||||
], unclaimed: ['tools/tests/loose.rs'], missing: [], noPubspec: [] });
|
||||
const noCargo = { ...ctx, env: { PATH: path.join(repo.root, 'empty-path') } } as PregateContext;
|
||||
expect(planRuns(noCargo, ALPHA_TESTS)).toEqual({ groups: [], unclaimed: ALPHA_TESTS, missing: ['cargo'], noPubspec: [] });
|
||||
const declared = { ...ctx, config: { ...DEFAULT_CONFIG, runner: 'bash scripts/test.sh {files}' } } as PregateContext;
|
||||
expect(planRuns(declared, files).groups).toEqual([{ runner: 'bash scripts/test.sh {files}', cwd: '', files }]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Manifest shapes a line scan misreads (review of the first cut): a renamed
|
||||
* dependency inherited from `[workspace.dependencies]`, TOML dotted keys, the
|
||||
* `[dependencies.<name>]` table form, target-specific tables, and a crate the
|
||||
* workspace excludes. Dependencies resolve by path first, then by package name.
|
||||
*/
|
||||
const MANIFEST_FILES: Record<string, string> = {
|
||||
'Cargo.toml': '[workspace]\nmembers = ["crates/*"]\nexclude = ["crates/helper"]\n\n[workspace.dependencies]\nbase = { package = "alpha", path = "crates/a" }\n',
|
||||
'crates/a/Cargo.toml': '[package]\nname = "alpha"\nversion = "0.1.0"\n',
|
||||
'crates/a/src/lib.rs': 'pub fn a() {}\n',
|
||||
'crates/a/tests/a.rs': '#[test]\nfn a() {}\n',
|
||||
'crates/beta/Cargo.toml': '[package]\nname = "beta"\nversion = "0.1.0"\n\n[dependencies]\nbase = { workspace = true }\n',
|
||||
'crates/beta/tests/b.rs': '#[test]\nfn b() {}\n',
|
||||
'crates/dotted/Cargo.toml': '[package]\nname = "dotted"\nversion = "0.1.0"\n\n[dependencies]\nfoo.path = "../a"\n',
|
||||
'crates/dotted/tests/d.rs': '#[test]\nfn d() {}\n',
|
||||
'crates/table/Cargo.toml': '[package]\nname = "table"\nversion = "0.1.0"\n\n[dev-dependencies.whatever]\npath = "../a"\n',
|
||||
'crates/table/tests/t.rs': '#[test]\nfn t() {}\n',
|
||||
'crates/targeted/Cargo.toml': '[package]\nname = "targeted"\nversion = "0.1.0"\n\n[target.\'cfg(unix)\'.build-dependencies]\nrenamed = { path = "../a" }\n',
|
||||
'crates/targeted/tests/x.rs': '#[test]\nfn x() {}\n',
|
||||
'crates/plain/Cargo.toml': '[package]\nname = "plain"\nversion = "0.1.0"\n',
|
||||
'crates/plain/tests/p.rs': '#[test]\nfn p() {}\n',
|
||||
'crates/helper/Cargo.toml': '[package]\nname = "helper"\nversion = "0.1.0"\n',
|
||||
'crates/helper/src/lib.rs': '#[cfg(test)]\nmod tests {\n #[test]\n fn h() {}\n}\n',
|
||||
};
|
||||
|
||||
function manifestRepo(): FixtureRepo {
|
||||
const repo = makeFixtureRepo({ pregate: null });
|
||||
repos.push(repo);
|
||||
commit(repo, MANIFEST_FILES, 'cargo manifests');
|
||||
return repo;
|
||||
}
|
||||
|
||||
describe('Cargo manifests are parsed as TOML', () => {
|
||||
// Value: protects=every dependency spelling Cargo accepts links a crate to its dependents: an inherited renamed workspace dependency,
|
||||
// a dotted `foo.path` key, a `[dev-dependencies.<name>]` table and a target-specific build dependency, all resolved by path;
|
||||
// fails_when=the manifest is read by a line scan again and alpha's change misses these dependents (a false green);
|
||||
// why_new=the first cut matched dependency keys against package names only; seam=none; no_claim=cargo's own resolver
|
||||
test('renamed, inherited, dotted, table-form and target-specific dependencies all select the dependent crates', () => {
|
||||
const g = buildGraph(manifestRepo().work);
|
||||
expect(testsFor(g, 'crates/a/src/lib.rs').tests).toEqual(['crates/a/tests/a.rs', 'crates/beta/tests/b.rs', 'crates/dotted/tests/d.rs', 'crates/table/tests/t.rs', 'crates/targeted/tests/x.rs']);
|
||||
});
|
||||
|
||||
// Value: protects=a crate the workspace excludes runs from its own directory, a member from the workspace root; fails_when=the
|
||||
// nearest [workspace] is taken without checking members/exclude and `cargo test -p helper` runs where helper is no member
|
||||
// (cargo fails, or tests a same-named member); why_new=membership was not read; seam=none; no_claim=a real cargo (plan only)
|
||||
test('an excluded crate runs from its own directory; a member runs from the workspace root', () => {
|
||||
const repo = manifestRepo();
|
||||
const ctx = { repoRoot: repo.work, config: DEFAULT_CONFIG, env: cargoShim(repo) } as unknown as PregateContext;
|
||||
expect(planRuns(ctx, ['crates/beta/tests/b.rs', 'crates/helper/src/lib.rs']).groups).toEqual([
|
||||
{ runner: "cargo test -p 'beta'", cwd: '', files: ['crates/beta/tests/b.rs'] },
|
||||
{ runner: "cargo test -p 'helper'", cwd: 'crates/helper', files: ['src/lib.rs'] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cargo membership and test detection (port review)', () => {
|
||||
const inMemory = (files: Record<string, string>) => [Object.keys(files), (f: string) => files[f] ?? null] as const;
|
||||
const pkg = (name: string, deps = '') => `[package]\nname = "${name}"\nversion = "0.1.0"\n${deps}`;
|
||||
const dependentsOf = (files: Record<string, string>, manifest: string) => rustEdges(...inMemory(files)).edges.filter(e => e.dependency === manifest).map(e => e.dependent).sort();
|
||||
|
||||
// Value: protects=Cargo's member globs ([ab] classes, ** spans) admit the crates Cargo admits, so a member inherits
|
||||
// [workspace.dependencies] and its tests are selected; fails_when=a character class is matched literally, the crate is
|
||||
// no member, the inherited path dependency is dropped and alpha's change misses beta's tests (a false green);
|
||||
// why_new=port review found classes escaped; seam=none; no_claim=cargo's own glob crate
|
||||
test('a members glob with a character class or ** admits the crate, so an inherited dependency links its dependent', () => {
|
||||
const files = {
|
||||
'Cargo.toml': '[workspace]\nmembers = ["crates/[ab]", "nested/**/leaf", "unicode/?"]\n\n[workspace.dependencies]\nbase = { package = "alpha", path = "crates/a" }\n',
|
||||
'crates/a/Cargo.toml': pkg('alpha'), 'crates/a/tests/a.rs': '#[test]\nfn a() {}\n',
|
||||
'crates/b/Cargo.toml': pkg('beta', '\n[dependencies]\nbase = { workspace = true }\n'), 'crates/b/tests/b.rs': '#[test]\nfn b() {}\n',
|
||||
'crates/c/Cargo.toml': pkg('gamma'),
|
||||
'nested/x/y/leaf/Cargo.toml': pkg('leaf'),
|
||||
'unicode/📦/Cargo.toml': pkg('boxed'),
|
||||
};
|
||||
const { crates } = cargoModel(...inMemory(files));
|
||||
expect(['crates/a', 'crates/b', 'crates/c', 'nested/x/y/leaf', 'unicode/📦'].map(r => crates.get(r)!.workspace)).toEqual(['', '', 'crates/c', '', '']);
|
||||
expect(dependentsOf(files, 'crates/a/Cargo.toml')).toEqual(['crates/a/tests/a.rs', 'crates/b/tests/b.rs']);
|
||||
});
|
||||
|
||||
// Value: protects=a plain #[test] function or a #[tokio::test] in src is a Rust test, so a dependent crate tested that way
|
||||
// runs when its dependency changes; fails_when=only cfg(test) modules and tests/ count and the dependent's failing test
|
||||
// never runs (a false green); why_new=port review; seam=none; no_claim=custom [[test]] targets outside tests/
|
||||
test('a plain #[test] or #[tokio::test] in src is a test; cfg(feature = "test") and #[testing] are not', () => {
|
||||
expect(['#[test]\nfn a() {}', '#[tokio::test(flavor = "multi_thread")]\nasync fn a() {}', '#[ test ]\nfn a() {}'].map(hasCfgTest)).toEqual([true, true, true]);
|
||||
expect(['#[cfg(feature = "test")]\nfn a() {}', '#[testing]\nfn a() {}', '// attests', '#[cfg(feature = "a]b")]\nfn a() {}'].map(hasCfgTest)).toEqual([false, false, false, false]);
|
||||
expect(hasCfgTest(`#[cfg(any(test, feature = "${'f'.repeat(300)}"))]\nmod t {}`)).toBe(true);
|
||||
const files = {
|
||||
'a/Cargo.toml': pkg('a'), 'a/src/lib.rs': 'pub fn one() -> u32 { 1 }\n#[cfg(test)]\nmod t { #[test] fn one() {} }\n',
|
||||
'b/Cargo.toml': pkg('b', '\n[dependencies]\na = { path = "../a" }\n'), 'b/src/lib.rs': 'pub fn two() -> u32 { a::one() + 1 }\n#[test]\nfn two() { assert_eq!(super::two(), 2); }\n',
|
||||
};
|
||||
expect(dependentsOf(files, 'a/Cargo.toml')).toEqual(['a/src/lib.rs', 'b/src/lib.rs']);
|
||||
});
|
||||
|
||||
// Value: protects=past the members-glob size cap, where a crate's tests run is undecided, so its files are unclaimed (the
|
||||
// lane incomplete) instead of running `cargo test -p <name>` from a root where a same-named member answers; fails_when=an
|
||||
// oversized workspace admits every crate as runnable and the orphan's failing test is replaced by the member's passing
|
||||
// ones (a false green); why_new=third port review round; seam=none; no_claim=a real cargo (plan only)
|
||||
test('past the members size cap a same-named non-member is unclaimed, not run as the member', () => {
|
||||
const repo = makeFixtureRepo({ pregate: null });
|
||||
repos.push(repo);
|
||||
commit(repo, {
|
||||
'Cargo.toml': `[workspace]\nmembers = ["[${'m'.repeat(4100)}]ember"]\n`,
|
||||
'member/Cargo.toml': pkg('same'), 'member/tests/m.rs': '#[test]\nfn m() {}\n',
|
||||
'orphan/Cargo.toml': pkg('same'), 'orphan/tests/t.rs': '#[test]\nfn t() { panic!() }\n',
|
||||
}, 'oversized members');
|
||||
const ctx = { repoRoot: repo.work, config: DEFAULT_CONFIG, env: cargoShim(repo) } as unknown as PregateContext;
|
||||
expect(planRuns(ctx, ['orphan/tests/t.rs'])).toEqual({ groups: [], unclaimed: ['orphan/tests/t.rs'], missing: [], noPubspec: [] });
|
||||
});
|
||||
|
||||
// Value: protects=a hostile members glob or a file of unclosed cfg attributes cannot stall lane selection, which runs before
|
||||
// any runner deadline; each probe runs in its own supervised Bun so a regression is killed and fails instead of hanging the
|
||||
// file; fails_when=member globs backtrack again (an exponential '*a*a…b', or a rescan per unmatched '[') or the cfg scan
|
||||
// rescans from every start (quadratic: about 80 s on 600 KB, against tens of ms now); why_new=port review; seam=none;
|
||||
// no_claim=a wall-clock budget for selection (the 30 s kill only separates linear from super-linear)
|
||||
const supervised = (code: string) => spawnSync(process.execPath, [...bunIsolationArgs(), '-e', `import { cargoModel, hasCfgTest } from ${JSON.stringify(path.join(import.meta.dir, '..', 'lib', 'pregate', 'rust.ts'))};\n${code}`], { encoding: 'utf8', timeout: 30_000 });
|
||||
test('a hostile members glob is matched without backtracking', () => {
|
||||
const r = supervised(`
|
||||
const pkg = n => '[package]\\nname = "' + n + '"\\nversion = "0.1.0"\\n';
|
||||
const run = (members, dirs) => { const files = { 'Cargo.toml': '[workspace]\\nmembers = ' + JSON.stringify(members) + '\\n' }; dirs.forEach((d, i) => { files[d + '/Cargo.toml'] = pkg('k' + i); }); return cargoModel(Object.keys(files), f => files[f] ?? null); };
|
||||
const exp = run(['*a'.repeat(20) + 'b'], ['a'.repeat(40)]).crates.get('a'.repeat(40)).workspace;
|
||||
const brackets = run(['['.repeat(1000000)], Array.from({ length: 10 }, (_, i) => 'c' + i)).crates.get('c0').workspace;
|
||||
console.log(JSON.stringify([exp, brackets]));`);
|
||||
expect(r.error).toBeUndefined();
|
||||
expect(r.stdout.trim()).toBe(JSON.stringify(['a'.repeat(40), null]));
|
||||
}, 60_000);
|
||||
|
||||
test('a run of unclosed cfg attributes is scanned once and counts as a test', () => {
|
||||
const r = supervised(`console.log(JSON.stringify([hasCfgTest('#[cfg('.repeat(100000)), hasCfgTest('#[cfg(unix)] fn a() {}\\n'.repeat(50000))]));`);
|
||||
expect(r.error).toBeUndefined();
|
||||
expect(r.stdout.trim()).toBe('[true,false]');
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('gstack-pregate on a Rust workspace', () => {
|
||||
const run = (repo: FixtureRepo, env: Record<string, string>) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', '--only', 'lanes'], env);
|
||||
// The cargo stand-in is a bash script the lane's Git Bash must find before the runner's real cargo; on windows-latest it
|
||||
// never did (three PATH repairs, the lane still exited 1 with an empty log), so the two cases that run it are POSIX-only,
|
||||
// like test/pregate-dart.test.ts's stand-ins. Selection, planning and the hostile-name case still run on Windows.
|
||||
const posixShim = test.skipIf(process.platform === 'win32');
|
||||
const log = (out: string) => fs.readFileSync(/log: (\S+)/.exec(out)![1]!, 'utf8');
|
||||
|
||||
// Value: protects=the reported case end to end: a touched src file of a workspace crate is not PREGATE_NO_LANE, its crate and the
|
||||
// dependent crates run with `cargo test -p` from the workspace root, and the summed count is reported; fails_when=any link of
|
||||
// graph, plan or summary regresses; why_new=no Rust run existed; seam=none; no_claim=a real cargo (PATH shim reports cwd and argv)
|
||||
posixShim('a touched src file runs its crate and the crates depending on it from the workspace root', () => {
|
||||
const repo = rustRepo();
|
||||
commit(repo, { 'engine/crates/alpha/src/util.rs': 'pub fn two() -> u32 { 1 + 1 }\n' });
|
||||
const r = run(repo, cargoShim(repo));
|
||||
expect(r.out).not.toContain('has no test lane');
|
||||
expect(r.out).toMatch(/^tests {6}lanes {5}pass .*6 tests ran in 4 file\(s\), exit 0/m);
|
||||
expect(r.out).toContain("ran: (cd 'engine' && cargo test -p 'alpha') && (cd 'engine' && cargo test -p 'beta-core') && (cd 'engine' && cargo test -p 'gamma')");
|
||||
expect(log(r.out)).toContain('ran in engine: test -p alpha');
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=a crate whose test binaries all report 0 tests is ZERO-RUN (exit 0 is not a pass), and a failing cargo run is
|
||||
// PREGATE_LANE_FAILED with its counts; fails_when=cargo's summary is unparsed (count unavailable passes) or the exit is ignored;
|
||||
// why_new=the pure parser test does not run the lane; seam=none; no_claim=a real cargo (PATH shim)
|
||||
posixShim('zero tests is ZERO-RUN; a failing cargo run is PREGATE_LANE_FAILED', () => {
|
||||
const repo = rustRepo();
|
||||
commit(repo, { 'engine/crates/gamma/src/lib.rs': 'pub fn g() { }\n' });
|
||||
const zero = run(repo, cargoShim(repo, 'echo "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s"'));
|
||||
expect(zero.status).not.toBe(EXIT.ok);
|
||||
expect(zero.out).toMatch(/^tests {6}lanes {5}fail .*ZERO-RUN: the runner reported 0 tests in \(cd 'engine' && cargo test -p 'gamma'\)/m);
|
||||
const red = run(repo, cargoShim(repo, 'echo "test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s"\nexit 101'));
|
||||
expect(red.status).not.toBe(EXIT.ok);
|
||||
expect(red.out).toMatch(/^tests {6}lanes {5}fail .*local lane exited 101 \(1 failed of 2 ran; 1 files; pipefail\)/m);
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=a crate name is data from the repo's Cargo.toml that reaches the lane's shell and the {files} expansion,
|
||||
// so a name Cargo would reject never runs (its files are unclaimed, the lane incomplete) and a valid one, Unicode
|
||||
// included, still runs quoted; fails_when=the name is interpolated unchecked (`;touch` runs a command, `{files}` splices
|
||||
// the quoted file list into the name); why_new=the crate runner was new; seam=none; no_claim=a real cargo (PATH shim)
|
||||
test('a crate name with shell syntax or {files} never reaches the shell; a valid Unicode name still runs', () => {
|
||||
const repo = rustRepo();
|
||||
const lib = '#[cfg(test)]\nmod tests {\n #[test]\n fn t() {}\n}\n';
|
||||
commit(repo, {
|
||||
'engine/crates/evil/Cargo.toml': '[package]\nname = "evil;touch pwned-by-crate-name"\nversion = "0.1.0"\n',
|
||||
'engine/crates/evil/src/lib.rs': lib,
|
||||
'engine/crates/splice/Cargo.toml': '[package]\nname = "evil{files}"\nversion = "0.1.0"\n',
|
||||
'engine/crates/splice/src/lib.rs': lib,
|
||||
'engine/crates/uni/Cargo.toml': '[package]\nname = "naïve_crate-2"\nversion = "0.1.0"\n',
|
||||
'engine/crates/uni/src/lib.rs': lib,
|
||||
}, 'odd crate names');
|
||||
repo.git('push', '-q', 'origin', 'HEAD:main');
|
||||
repo.git('fetch', '-q', 'origin');
|
||||
const env = cargoShim(repo);
|
||||
const ctx = { repoRoot: repo.work, config: DEFAULT_CONFIG, env } as unknown as PregateContext;
|
||||
expect(planRuns(ctx, ['engine/crates/evil/src/lib.rs', 'engine/crates/splice/src/lib.rs', 'engine/crates/uni/src/lib.rs'])).toEqual({
|
||||
groups: [{ runner: "cargo test -p 'naïve_crate-2'", cwd: 'engine', files: ['crates/uni/src/lib.rs'] }],
|
||||
unclaimed: ['engine/crates/evil/src/lib.rs', 'engine/crates/splice/src/lib.rs'], missing: [], noPubspec: [],
|
||||
});
|
||||
commit(repo, { 'engine/crates/evil/src/lib.rs': lib.replace('fn t() {}', 'fn t() { }') });
|
||||
const r = run(repo, env);
|
||||
expect(fs.existsSync(path.join(repo.work, 'engine', 'pwned-by-crate-name'))).toBe(false);
|
||||
expect(r.out).not.toContain('cargo test -p');
|
||||
expect(r.out).toContain('engine/crates/evil/src/lib.rs');
|
||||
expect(r.status).not.toBe(EXIT.ok);
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=a crate with no tests still fails closed (no lane), so the crate mapping never invents coverage;
|
||||
// fails_when=crate membership alone counts as a lane; why_new=negative control for the new mapping; seam=none; no_claim=none
|
||||
test('a touched file of a crate without tests still has no test lane', () => {
|
||||
const repo = rustRepo();
|
||||
commit(repo, { 'engine/crates/lonely/src/lib.rs': 'pub fn alone() { }\n' });
|
||||
const r = run(repo, cargoShim(repo));
|
||||
expect(r.status).not.toBe(EXIT.ok);
|
||||
expect(r.out).toContain('FAIL lanes — engine/crates/lonely/src/lib.rs has no test lane');
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -14,11 +14,12 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { changedLiterals, constantsIn } from '../lib/pregate/checks/literals';
|
||||
import { parseSummary } from '../lib/pregate/checks/lanes';
|
||||
import { validateConfig } from '../lib/pregate/config';
|
||||
import { parseSummary, planRuns } from '../lib/pregate/checks/lanes';
|
||||
import { DEFAULT_CONFIG, validateConfig } from '../lib/pregate/config';
|
||||
import { isDocsEmptyLane } from '../lib/pregate/docs-lane';
|
||||
import { buildGraph, testsFor } from '../lib/pregate/import-graph';
|
||||
import { CHECK_IDS } from '../lib/pregate/index';
|
||||
import type { PregateContext } from '../lib/pregate/types';
|
||||
import { parseWorkflow } from '../lib/pregate/workflows';
|
||||
import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture';
|
||||
|
||||
@@ -82,6 +83,55 @@ describe('import graph (static imports ∪ repo-path literals ∪ bin convention
|
||||
expect(testsFor(g, 'notes/scratch.md')).toMatchObject({ tests: [], declaredNone: false });
|
||||
});
|
||||
|
||||
test('#3340: an extensionless import of a .jsx, .mts or .cts file maps to that file', () => {
|
||||
const repo = fixture();
|
||||
commit(repo, {
|
||||
'src/components/Foo.jsx': 'export default function Foo() { return null; }\n',
|
||||
'src/lib/m.mts': 'export const m = 1;\n',
|
||||
'src/lib/c.cts': 'export const c = 1;\n',
|
||||
'test/foo.test.jsx': "import Foo from '../src/components/Foo';\nimport { m } from '../src/lib/m';\nimport { c } from '../src/lib/c';\n",
|
||||
});
|
||||
const g = buildGraph(repo.work);
|
||||
for (const f of ['src/components/Foo.jsx', 'src/lib/m.mts', 'src/lib/c.cts']) {
|
||||
expect(testsFor(g, f).tests).toEqual(['test/foo.test.jsx']);
|
||||
expect(testsFor(g, f).provenance[0]).toContain('(import)');
|
||||
}
|
||||
});
|
||||
|
||||
test('#3340: a directory import resolves to its index.jsx, index.tsx or index.mjs, not only the dir/** fallback', () => {
|
||||
const repo = fixture();
|
||||
commit(repo, {
|
||||
'src/Bar/index.jsx': 'export default 1;\n',
|
||||
'src/Bar/other.js': 'export default 2;\n',
|
||||
'src/Baz/index.tsx': 'export default 1;\n',
|
||||
'src/Baz/other.js': 'export default 2;\n',
|
||||
'src/Qux/index.mjs': 'export default 1;\n',
|
||||
'src/Qux/other.js': 'export default 2;\n',
|
||||
'test/dirs.test.js': "import Bar from '../src/Bar';\nimport Baz from '../src/Baz';\nimport Qux from '../src/Qux';\n",
|
||||
});
|
||||
const g = buildGraph(repo.work);
|
||||
for (const f of ['src/Bar/index.jsx', 'src/Baz/index.tsx', 'src/Qux/index.mjs']) {
|
||||
expect(testsFor(g, f).tests).toEqual(['test/dirs.test.js']);
|
||||
expect(testsFor(g, f).provenance[0]).not.toContain('/**');
|
||||
}
|
||||
for (const f of ['src/Bar/other.js', 'src/Baz/other.js', 'src/Qux/other.js']) expect(testsFor(g, f).tests).toEqual([]);
|
||||
});
|
||||
|
||||
test('#3340: an import through a jsconfig paths alias reaches a .jsx file; an existing .js sibling still wins', () => {
|
||||
const repo = fixture();
|
||||
commit(repo, {
|
||||
'jsconfig.json': '{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }\n',
|
||||
'src/ui/Button.jsx': 'export default function Button() { return null; }\n',
|
||||
'src/ui/Card.js': 'export default 1;\n',
|
||||
'src/ui/Card.jsx': 'export default 2;\n',
|
||||
'test/ui.test.jsx': "import Button from '@/ui/Button';\nimport Card from '@/ui/Card';\n",
|
||||
});
|
||||
const g = buildGraph(repo.work);
|
||||
expect(testsFor(g, 'src/ui/Button.jsx').tests).toEqual(['test/ui.test.jsx']);
|
||||
expect(testsFor(g, 'src/ui/Card.js').tests).toEqual(['test/ui.test.jsx']);
|
||||
expect(testsFor(g, 'src/ui/Card.jsx').tests).toEqual([]);
|
||||
});
|
||||
|
||||
test('python: absolute, from-import submodule, relative and parenthesised imports and mock.patch targets map; stdlib, comments and docstrings do not', () => {
|
||||
const repo = fixture();
|
||||
commit(repo, {
|
||||
@@ -515,3 +565,85 @@ describe('gstack-pregate (bin)', () => {
|
||||
expect(runBin(repo, 'gstack-pregate', ['validate']).status).toBe(EXIT.ok);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nested JS packages run their own runner from their own directory', () => {
|
||||
const APP = {
|
||||
'app/package.json': JSON.stringify({ name: 'app', scripts: { test: 'vitest run' }, devDependencies: { vitest: '^3.2.0' } }) + '\n',
|
||||
'app/pnpm-lock.yaml': "lockfileVersion: '9.0'\n",
|
||||
'app/src/sum.ts': 'export const sum = (a: number, b: number) => a + b;\n',
|
||||
'app/src/sum.test.ts': "import { expect, test } from 'vitest';\nimport { sum } from './sum';\ntest('sum', () => expect(sum(1, 2)).toBe(3));\n",
|
||||
};
|
||||
/**
|
||||
* A `pnpm` (and `yarn`) on PATH that reports its cwd and argv and prints a vitest summary for the files it is given.
|
||||
* The lane puts its Bun's directory first on PATH, and that directory can hold a real pnpm (/usr/local/bin), which
|
||||
* would install from the network; the lane's bash puts the shim first again through BASH_ENV.
|
||||
*/
|
||||
const pmShim = (repo: FixtureRepo): { PATH: string; BASH_ENV: string } => {
|
||||
const dir = path.join(repo.root, 'shim');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const tool of ['pnpm', 'yarn']) fs.writeFileSync(path.join(dir, tool), '#!/usr/bin/env bash\necho "ran in $(basename "$PWD"): $*"\necho " Test Files 1 passed (1)"\necho " Tests 3 passed (3)"\n', { mode: 0o755 });
|
||||
fs.writeFileSync(path.join(dir, 'path.sh'), `PATH='${dir}':"$PATH"\n`);
|
||||
return { PATH: `${dir}:${process.env.PATH ?? ''}`, BASH_ENV: path.join(dir, 'path.sh') };
|
||||
};
|
||||
const ctxFor = (repo: FixtureRepo, env: Record<string, string>) => ({ repoRoot: repo.work, config: DEFAULT_CONFIG, env }) as unknown as PregateContext;
|
||||
|
||||
// Value: protects=a test owned by a nested package that declares vitest runs from that package with its lockfile's package manager
|
||||
// and package-relative paths, while root-owned tests keep the root runner; fails_when=the root package.json alone decides again
|
||||
// (the reported `bun test` from the repo root: vi.stubGlobal missing); why_new=only root package.json detection was tested;
|
||||
// seam=none; no_claim=the runners themselves (plan only)
|
||||
test('a nested vitest package plans its own group with the lockfile\'s package manager; root tests keep the root runner', () => {
|
||||
const repo = fixture({ pregate: null, baseFiles: APP });
|
||||
const ctx = ctxFor(repo, pmShim(repo));
|
||||
expect(planRuns(ctx, ['app/src/sum.test.ts', 'test/util.test.js'])).toEqual({ groups: [
|
||||
{ runner: 'node --test {files}', cwd: '', files: ['test/util.test.js'] },
|
||||
{ runner: 'pnpm exec vitest run {files}', cwd: 'app', files: ['src/sum.test.ts'] },
|
||||
], unclaimed: [], missing: [], noPubspec: [] });
|
||||
// The lockfile is found walking up, and a packageManager field outranks it.
|
||||
fs.rmSync(path.join(repo.work, 'app/pnpm-lock.yaml'));
|
||||
write(repo.work, 'yarn.lock', '# yarn lockfile v1\n');
|
||||
expect(planRuns(ctx, ['app/src/sum.test.ts']).groups).toEqual([{ runner: 'yarn vitest run {files}', cwd: 'app', files: ['src/sum.test.ts'] }]);
|
||||
write(repo.work, 'app/package.json', JSON.stringify({ name: 'app', packageManager: 'pnpm@9.15.0', devDependencies: { jest: '^29.0.0' } }) + '\n');
|
||||
expect(planRuns(ctx, ['app/src/sum.test.ts']).groups).toEqual([{ runner: 'pnpm exec jest {files}', cwd: 'app', files: ['src/sum.test.ts'] }]);
|
||||
// Value: protects=a package manager missing from PATH is incomplete naming it, never exit 127 judged as a test failure;
|
||||
// fails_when=the PATH check is dropped; why_new=new detection path; seam=none; no_claim=a real pnpm install
|
||||
expect(planRuns(ctxFor(repo, { PATH: path.join(repo.root, 'empty-path') }), ['app/src/sum.test.ts'])).toEqual({ groups: [], unclaimed: ['app/src/sum.test.ts'], missing: ['pnpm'], noPubspec: [] });
|
||||
fs.rmSync(path.join(repo.work, 'yarn.lock'));
|
||||
write(repo.work, 'app/package.json', JSON.stringify({ name: 'app', devDependencies: { vitest: '^3.2.0' } }) + '\n');
|
||||
expect(planRuns(ctx, ['app/src/sum.test.ts']).groups).toEqual([{ runner: 'npx vitest run {files}', cwd: 'app', files: ['src/sum.test.ts'] }]);
|
||||
});
|
||||
|
||||
// Value: protects=repos where root detection works today are unchanged: a nested package that declares no test runner stays with the
|
||||
// root runner from the root, a root vitest stays `npx vitest run` from the root, and a declared runner takes every file;
|
||||
// fails_when=every nested package.json splits a group or the root command changes; why_new=negative controls for the new
|
||||
// nested detection; seam=none; no_claim=the runners themselves (plan only)
|
||||
test('negative controls: a nested package without a test runner, a root vitest repo and a declared runner keep today\'s plan', () => {
|
||||
const repo = fixture({ pregate: null, baseFiles: { ...APP, 'app/package.json': JSON.stringify({ name: 'app', scripts: { build: 'tsc' } }) + '\n' } });
|
||||
const ctx = ctxFor(repo, pmShim(repo));
|
||||
write(repo.work, 'bun.lock', '{}\n');
|
||||
expect(planRuns(ctx, ['app/src/sum.test.ts', 'test/util.test.js']).groups).toEqual([{ runner: 'bun test {files}', cwd: '', files: ['app/src/sum.test.ts', 'test/util.test.js'] }]);
|
||||
fs.rmSync(path.join(repo.work, 'bun.lock'));
|
||||
write(repo.work, 'package.json', JSON.stringify({ name: 'fixture', devDependencies: { vitest: '^3.2.0' } }) + '\n');
|
||||
write(repo.work, 'pnpm-lock.yaml', "lockfileVersion: '9.0'\n");
|
||||
expect(planRuns(ctx, ['app/src/sum.test.ts', 'test/util.test.js']).groups).toEqual([{ runner: 'npx vitest run {files}', cwd: '', files: ['app/src/sum.test.ts', 'test/util.test.js'] }]);
|
||||
const declared = { ...ctx, config: { ...DEFAULT_CONFIG, runner: 'bun test {files}' } } as PregateContext;
|
||||
write(repo.work, 'app/package.json', APP['app/package.json']);
|
||||
expect(planRuns(declared, ['app/src/sum.test.ts']).groups).toEqual([{ runner: 'bun test {files}', cwd: '', files: ['app/src/sum.test.ts'] }]);
|
||||
});
|
||||
|
||||
// Value: protects=the reported case end to end: a touched source file in a nested pnpm+vitest package runs `pnpm exec vitest run`
|
||||
// in that package with the package-relative path and passes on vitest's summary; fails_when=the lane runs from the repo root or
|
||||
// with the root runner; why_new=no nested package run existed; seam=none; no_claim=a real pnpm or vitest (PATH shim)
|
||||
test('a touched file in a nested pnpm+vitest package runs vitest there', () => {
|
||||
const repo = fixture({ pregate: null, baseFiles: APP });
|
||||
// No workflows, so the Windows lane's unknown matrix cannot turn the selection into a requires-remote obligation.
|
||||
commit(repo, { '.github/workflows/free-tests.yml': null, '.github/workflows/windows.yml': null }, 'no workflows');
|
||||
repo.git('push', '-q', 'origin', 'HEAD:main');
|
||||
repo.git('fetch', '-q', 'origin');
|
||||
commit(repo, { 'app/src/sum.ts': 'export const sum = (a: number, b: number) => b + a;\n' });
|
||||
const r = runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', '--only', 'lanes'], pmShim(repo));
|
||||
expect(r.out).toMatch(/^tests {6}lanes {5}pass .*3 tests ran in 1 file\(s\), exit 0/m);
|
||||
expect(r.out).toContain("ran: (cd 'app' && pnpm exec vitest run 'src/sum.test.ts')");
|
||||
expect(fs.readFileSync(/log: (\S+)/.exec(r.out)![1]!, 'utf8')).toContain('ran in app: exec vitest run src/sum.test.ts');
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { bunIsolationArgs } from '../lib/bun-isolation';
|
||||
import { generateQAMethodology } from '../scripts/resolvers/utility';
|
||||
import { HOST_PATHS } from '../scripts/resolvers/types';
|
||||
|
||||
@@ -31,3 +35,47 @@ describe('QA health rubric scoring contract', () => {
|
||||
expect(rubric).toContain('Round only the final score');
|
||||
});
|
||||
});
|
||||
|
||||
const healthTmpl = fs.readFileSync(path.join(import.meta.dir, '..', 'health', 'SKILL.md.tmpl'), 'utf-8');
|
||||
const gbrainSection = healthTmpl.split('**GBrain sub-score computation:**')[1] ?? '';
|
||||
const gbrainScript = gbrainSection.match(/```bash\nbun --no-env-file --config=\/dev\/null -e '\n([\s\S]*?)\n'\n```/)?.[1] ?? '';
|
||||
|
||||
function gbrainScore(doctor: number | null, queue: number | null, pushHours: number | null): string {
|
||||
const script = gbrainScript
|
||||
.replace('<N|null>', String(doctor))
|
||||
.replace('<N|null>', String(queue))
|
||||
.replace('<N|null>', String(pushHours));
|
||||
const run = spawnSync(process.execPath, [...bunIsolationArgs(), '-e', script], { encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(run.status).toBe(0);
|
||||
return run.stdout.trim();
|
||||
}
|
||||
|
||||
describe('/health GBrain score has one rule, computed in code (#3351)', () => {
|
||||
test('the rubric row defers to the computation instead of scoring bands', () => {
|
||||
const row = healthTmpl.split('\n').find(line => line.startsWith('| GBrain |')) ?? '';
|
||||
expect(row).toContain('GBrain sub-score computation');
|
||||
expect(row).not.toMatch(/queue\s*</);
|
||||
expect(row).not.toContain('doctor=');
|
||||
});
|
||||
|
||||
test('the issue inputs score by the formula, not the rubric column', () => {
|
||||
expect(gbrainScript).not.toBe('');
|
||||
expect(gbrainScore(10, 50, 2)).toBe('GBRAIN_SCORE: 9.1');
|
||||
expect(gbrainScore(7, 3, 2)).toBe('GBRAIN_SCORE: 8.5');
|
||||
});
|
||||
|
||||
test('bands are half-open at 10/100 queue lines and 24/72 hours', () => {
|
||||
expect(gbrainScore(10, 9, 23.9)).toBe('GBRAIN_SCORE: 10.0');
|
||||
expect(gbrainScore(10, 10, 24)).toBe('GBRAIN_SCORE: 8.5');
|
||||
expect(gbrainScore(10, 99, 71.9)).toBe('GBRAIN_SCORE: 8.5');
|
||||
expect(gbrainScore(10, 100, 72)).toBe('GBRAIN_SCORE: 5.0');
|
||||
expect(gbrainScore(0, 100, 72)).toBe('GBRAIN_SCORE: 0.0');
|
||||
expect(gbrainScore(null, 0, 1)).toBe('GBRAIN_SCORE: 5.0');
|
||||
});
|
||||
|
||||
test('sync off gives the doctor component the whole weight', () => {
|
||||
expect(gbrainScore(10, null, null)).toBe('GBRAIN_SCORE: 10.0');
|
||||
expect(gbrainScore(7, null, null)).toBe('GBRAIN_SCORE: 7.0');
|
||||
expect(gbrainScore(0, null, null)).toBe('GBRAIN_SCORE: 0.0');
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import * as path from 'path';
|
||||
import { classify, collectPins, evaluatePins, renderPinLines, satisfies } from '../lib/runtime-pins';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BUN_DEFAULT = fs.readFileSync(path.join(ROOT, 'bin/gstack-bun-version.sh'), 'utf8').match(/^GSTACK_BUN_DEFAULT="([^"]+)"$/m)![1];
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => { for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }); });
|
||||
|
||||
@@ -142,15 +143,15 @@ describe('CI exact pins are alternatives', () => {
|
||||
const lane = (name: string, version: string, os = 'ubuntu-latest') => [` ${name}:`, ` runs-on: ${os}`, ' steps:', ' - uses: oven-sh/setup-bun@v2', ' with:', ` bun-version: ${version}`];
|
||||
const workflow = (...jobs: string[][]) => ['jobs:', ...jobs.flat(), ''].join('\n');
|
||||
|
||||
test('the real gstack checkout (CI 1.4.2 lanes plus the 1.4.3 install-default job) passes on 1.4.2 and on 1.4.3, and names the alternatives on 1.4.1', () => {
|
||||
test('the real gstack checkout (CI 1.4.2 lanes plus the install-default job) passes on 1.4.2 and on the install default, and names the alternatives on 1.4.1', () => {
|
||||
const pins = collectPins(ROOT);
|
||||
const ci = [...new Set(pins.filter(p => p.tool === 'bun' && p.field === 'CI' && p.kind === 'exact' && p.lane !== 'windows' && p.lane !== 'macos').map(p => p.constraint))].sort();
|
||||
expect(ci).toEqual(['1.4.2', '1.4.3']);
|
||||
for (const bun of ['1.4.2', '1.4.3']) expect(evaluatePins(pins, { bun }, 'linux').failures.filter(f => f.startsWith('bun'))).toEqual([]);
|
||||
expect(ci).toEqual(['1.4.2', BUN_DEFAULT]);
|
||||
for (const bun of ['1.4.2', BUN_DEFAULT]) expect(evaluatePins(pins, { bun }, 'linux').failures.filter(f => f.startsWith('bun'))).toEqual([]);
|
||||
const low = evaluatePins(pins, { bun: '1.4.1' }, 'linux').failures.find(f => f.startsWith('bun '))!;
|
||||
expect(low).toStartWith('bun 1.4.1 outside engines.bun >=1.4.2 (package.json:');
|
||||
expect(low).toContain('CI exact pins are alternatives, any one of 1.4.2, 1.4.3 satisfies CI');
|
||||
expect(renderPinLines(evaluatePins(pins, { bun: '1.4.2' }, 'linux'), 'linux')).toContain('PIN_CI_ALTERNATIVES: bun 1.4.2 1.4.3');
|
||||
expect(low).toContain(`CI exact pins are alternatives, any one of 1.4.2, ${BUN_DEFAULT} satisfies CI`);
|
||||
expect(renderPinLines(evaluatePins(pins, { bun: '1.4.2' }, 'linux'), 'linux')).toContain(`PIN_CI_ALTERNATIVES: bun 1.4.2 ${BUN_DEFAULT}`);
|
||||
});
|
||||
|
||||
test('gbrain-like (every CI lane 1.4.3) still fails a local 1.4.2 exactly as before', () => {
|
||||
|
||||
@@ -8,13 +8,15 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { DISPATCH_INPUTS, dedupeKey, dispatchPlan, findDuplicateRun, makeNonce, nonceScope, type RunSummary } from '../scripts/eval-bg';
|
||||
import { DEFAULT_POLICY, TRAIN_KEYS_VERSION, validatePolicy } from '../lib/ship-policy';
|
||||
import { fillDue, orderSlots, parsePins } from '../lib/ship-queue-train';
|
||||
import { fillDue, orderSlots, parseOrder, parsePins, prsArgs } from '../lib/ship-queue-train';
|
||||
import { gstackPaidSelection, skillOwnCases, touchedSkills } from '../lib/ship-queue-train-ready';
|
||||
import type { TrainSlot } from '../lib/ship-queue-train-record';
|
||||
import { normalizeRemoteUrl, repoIdentity, type TrainSlot } from '../lib/ship-queue-train-record';
|
||||
import { INFRA_REDISPATCH, REBUILD_ONCE, classifyBisect, classifyGate } from '../lib/ship-queue-train-verdict';
|
||||
import { EVAL_POLICY } from './helpers/periodic-exclude-data';
|
||||
import { COMPLETE, FAIL, PASS, TRAIN_BLOCK } from './helpers/ship-queue-train-fixture';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
@@ -54,6 +56,54 @@ describe('ordering and fill', () => {
|
||||
expect(parsePins('12')).toMatch(/entries are <pr>@<sha>/);
|
||||
expect(parsePins(`12@${SHA},12@${SHA}`)).toBe('a PR appears twice in --prs');
|
||||
});
|
||||
|
||||
test('--order (GSTA-67, #3337 docs slotted ahead of the #3343 catalog it describes): an explicit order replaces screened-first and READY order; absent, the default is unchanged', () => {
|
||||
const docs = slot('3337', false, '2026-10-22T18:00:00Z');
|
||||
const catalog = slot('3343', false, '2026-10-22T19:00:00Z');
|
||||
expect(orderSlots([docs, catalog], 5).train.map(s => s.pr)).toEqual(['3337', '3343']);
|
||||
expect(orderSlots([docs, catalog], 5, undefined).train.map(s => s.pr)).toEqual(['3337', '3343']);
|
||||
const pins = [{ pr: '3337' }, { pr: '3343' }];
|
||||
const order = parseOrder('3343,#3337', pins);
|
||||
expect(order).toEqual(['3343', '3337']);
|
||||
expect(orderSlots([docs, catalog], 5, order as string[]).train.map(s => s.pr)).toEqual(['3343', '3337']);
|
||||
const screened = slot('9', true, '2026-10-22T20:00:00Z');
|
||||
const over = orderSlots([docs, catalog, screened], 2, ['3343', '3337', '9']);
|
||||
expect([over.train.map(s => s.pr), over.held.map(s => s.pr), over.split]).toEqual([['3343', '3337'], ['9'], false]);
|
||||
expect(parseOrder(undefined, pins)).toBeUndefined();
|
||||
for (const bad of ['3343', '3343,3337,3337', '3343,3337,1', '3343,x']) expect(parseOrder(bad, pins)).toEqual({ error: `--order must list every PR in --prs exactly once (--prs has #3337,#3343; got ${bad})` });
|
||||
expect(prsArgs([{ pr: '3343', head: SHA }, { pr: '3337', head: SHA }], true)).toBe(`--prs 3343@${SHA},3337@${SHA} --order 3343,3337`);
|
||||
expect(prsArgs([{ pr: '3343', head: SHA }], false)).toBe(`--prs 3343@${SHA}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('repo identity (GSTA-67, #3331 on 2026-10-21)', () => {
|
||||
test("every URL form of one GitHub repository gives one key, the github.com key gstack's records already use", () => {
|
||||
const canonical = 'https://github.com/garrytan/gstack';
|
||||
for (const url of ['https://github.com/garrytan/gstack', 'https://github.com/garrytan/gstack.git', 'https://git.capy.ai/garrytan/gstack', 'https://git.capy.ai/garrytan/gstack.git/',
|
||||
'git@github.com:garrytan/gstack.git', 'ssh://git@github.com/garrytan/gstack.git', 'ssh://git@ssh.github.com:443/garrytan/gstack.git', 'https://x-access-token:PASSWORD@github.com/garrytan/gstack.git',
|
||||
'http://www.github.com/GarryTan/GStack']) expect(normalizeRemoteUrl(url)).toBe(canonical);
|
||||
expect(normalizeRemoteUrl('https://gitlab.com/acme/widget.git')).toBe('https://gitlab.com/acme/widget');
|
||||
expect(normalizeRemoteUrl('https://user:PASSWORD@gitlab.com/acme/widget.git')).toBe('https://gitlab.com/acme/widget');
|
||||
expect(normalizeRemoteUrl('/tmp/fixture/origin.git')).toBe('/tmp/fixture/origin');
|
||||
});
|
||||
|
||||
test("#3331's lane (origin https://git.capy.ai/garrytan/gstack) now writes b321d228efd2bc14, and readers still accept the 1fb5dd69740a8cf8 receipt it wrote", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'repo-identity-'));
|
||||
try {
|
||||
const git = (...args: string[]) => spawnSync('git', args, { cwd: dir, encoding: 'utf8', timeout: 30_000 });
|
||||
git('init', '-q');
|
||||
git('remote', 'add', 'origin', 'https://git.capy.ai/garrytan/gstack');
|
||||
const lane = repoIdentity(dir);
|
||||
expect(lane.key).toBe('b321d228efd2bc14');
|
||||
expect(lane.url).toBe('https://github.com/garrytan/gstack');
|
||||
expect(lane.keys).toContain('1fb5dd69740a8cf8');
|
||||
git('remote', 'set-url', 'origin', 'https://github.com/garrytan/gstack.git');
|
||||
const coordinator = repoIdentity(dir);
|
||||
expect(coordinator.key).toBe('b321d228efd2bc14');
|
||||
expect(coordinator.keys).toEqual(expect.arrayContaining(['b321d228efd2bc14', '1fb5dd69740a8cf8']));
|
||||
expect(coordinator.keys[0]).toBe('b321d228efd2bc14');
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
describe('verdict class mapping', () => {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { TRAIN_HELP } from '../lib/ship-queue-train-cli';
|
||||
import { BASE_POLICY, cleanup, runBin } from './helpers/restamp-fixture';
|
||||
import { coordinatorClone } from './helpers/ship-queue-fixture';
|
||||
import {
|
||||
COMPLETE, FAIL, GREEN_GATE, PASS, RED_GATE, TRAIN_BLOCK, TRAIN_POLICY, dispatches, line, nonceOf, openPr, queue, readyReceipt, repoKey, trainFixture, trainJson,
|
||||
COMPLETE, FAIL, GREEN_GATE, PASS, RED_GATE, TRAIN_BLOCK, TRAIN_POLICY, dispatches, githubOrigin, line, nonceOf, openPr, queue, readyReceipt, repoKey, trainFixture, trainJson, urlKey,
|
||||
type Rule, type TrainFixture,
|
||||
} from './helpers/ship-queue-train-fixture';
|
||||
|
||||
@@ -127,6 +127,30 @@ describe('ready under a train policy: the screen choice', () => {
|
||||
expect(dispatches(fx)).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
test("GSTA-67: a lane whose origin is another URL form of the repository (#3331's git.capy.ai) writes its receipt under the owner/repo key", () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
githubOrigin(fx, 'https://git.capy.ai/fixture/repo.git');
|
||||
const ok = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']);
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
const canonical = urlKey('https://github.com/fixture/repo');
|
||||
expect(canonical).not.toBe(urlKey('https://git.capy.ai/fixture/repo'));
|
||||
expect(line(ok.out, 'READY_RECEIPT:')).toContain(path.join(fx.state, 'ship-queue', 'ready', canonical, `1-${head}.json`));
|
||||
}, 120_000);
|
||||
|
||||
test("GSTA-67: ready refuses a head the Version Gate refuses (#3345's CHANGELOG replaced a released heading)", () => {
|
||||
const fx = make();
|
||||
const head = openPr(fx, '1', { changelog: cl => cl.replace('## [1.2.3.0] - 2026-10-01', '## [Unreleased]\n\n- #1 change') });
|
||||
fx.write(d => { d.ci[head] = [{ status: 'completed', conclusion: 'success' }]; });
|
||||
const tree = runBin(fx.repo, 'gstack-wtree', []).out.trim();
|
||||
const pregate = path.join(fx.repo.root, 'pregate.json');
|
||||
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
|
||||
const r = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']);
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
expect(r.err).toContain(`the Version Gate refuses ${head.slice(0, 12)}: CHANGELOG.md drops released heading 1.2.3.0`);
|
||||
expect(r.err).toContain('(QUEUE_NOT_READY)');
|
||||
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'ready'))).toBe(false);
|
||||
}, 120_000);
|
||||
|
||||
test('--screen dispatches one screen with the PR, its merge SHA and the policy; a repeat adopts it; a broad screen never adopts the narrow one', () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
const dry = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen', '--dry-run']);
|
||||
@@ -207,6 +231,57 @@ describe('train build', () => {
|
||||
expect(dispatches(fx)).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
test("GSTA-67: a PR whose CHANGELOG drops a released heading (#3345's pin 1eb7092) is a CHANGELOG conflict, never merged with the damage", () => {
|
||||
const fx = make();
|
||||
const h1 = openPr(fx, '1');
|
||||
const h2 = openPr(fx, '2', { changelog: cl => cl.replace('## [1.2.3.0] - 2026-10-01', '## [Unreleased]\n\n- #2 change') });
|
||||
readyReceipt(fx, '1', h1);
|
||||
readyReceipt(fx, '2', h2);
|
||||
const r = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`, '--dry-run']);
|
||||
expect(r.status).toBe(EXIT.fail);
|
||||
expect(line(r.out, 'QUEUE_TRAIN_CONFLICT:')).toContain('stopped at #2 (conflicts: CHANGELOG.md (ours drops released heading 1.2.3.0 that the merge base carries)) after merging #1');
|
||||
expect(dispatches(fx)).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
test("GSTA-67: READY receipts under any URL form of the repository admit (#3331's receipt was keyed on git.capy.ai, the coordinator's origin on github.com)", () => {
|
||||
const fx = make();
|
||||
githubOrigin(fx, 'https://github.com/fixture/repo.git');
|
||||
const h1 = openPr(fx, '1');
|
||||
const h2 = openPr(fx, '2');
|
||||
readyReceipt(fx, '1', h1, { key: urlKey('https://git.capy.ai/fixture/repo') });
|
||||
readyReceipt(fx, '2', h2, { key: urlKey('https://github.com/fixture/repo') });
|
||||
const r = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`, '--dry-run']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(line(r.out, 'ORDER:')).toBe('ORDER: 1:#1(unscreened) 2:#2(unscreened)');
|
||||
const h3 = openPr(fx, '3');
|
||||
readyReceipt(fx, '3', h3, { key: urlKey('https://gitlab.com/fixture/repo') });
|
||||
expect(queue(fx, ['train', 'build', '--prs', `3@${h3}`, '--dry-run']).err).toMatch(/no READY receipt for #3 .*\(QUEUE_NOT_READY\)/);
|
||||
}, 120_000);
|
||||
|
||||
test('GSTA-67 --order: an explicit slot order (#3343 catalog before the #3337 docs that describe it) replaces READY order and carries into NEXT; the default is unchanged', () => {
|
||||
const fx = make();
|
||||
const docs = openPr(fx, '3337');
|
||||
const catalog = openPr(fx, '3343');
|
||||
readyReceipt(fx, '3337', docs, { at: '2026-10-18T07:00:00.000Z' });
|
||||
readyReceipt(fx, '3343', catalog, { at: '2026-10-18T08:00:00.000Z' });
|
||||
const pins = `3337@${docs},3343@${catalog}`;
|
||||
expect(line(queue(fx, ['train', 'build', '--prs', pins, '--dry-run']).out, 'ORDER:')).toBe('ORDER: 1:#3337(unscreened) 2:#3343(unscreened)');
|
||||
const dry = queue(fx, ['train', 'build', '--prs', pins, '--order', '3343,3337', '--dry-run']);
|
||||
expect(dry.status).toBe(EXIT.ok);
|
||||
expect(line(dry.out, 'ORDER:')).toBe('ORDER: 1:#3343(unscreened) 2:#3337(unscreened) (explicit --order)');
|
||||
expect(dry.out).toMatch(new RegExp(`^SLOT: 1 #3343 head=${catalog} `, 'm'));
|
||||
expect(next(dry.out)).toBe(`NEXT: gstack-ship-queue train build --prs 3343@${catalog},3337@${docs} --order 3343,3337`);
|
||||
const partial = queue(fx, ['train', 'build', '--prs', pins, '--order', '3343']);
|
||||
expect(partial.status).toBe(EXIT.refused);
|
||||
expect(partial.err).toContain('--order must list every PR in --prs exactly once (--prs has #3337,#3343; got 3343)');
|
||||
expect(queue(fx, ['train', 'build', '--train', 'x', '--order', '3343']).status).toBe(EXIT.usage);
|
||||
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
|
||||
const real = queue(fx, ['train', 'build', '--prs', pins, '--order', '3343,3337']);
|
||||
expect(real.status).toBe(EXIT.ok);
|
||||
const t = trainJson(fx, nonceOf(real.out));
|
||||
expect([t.slots.map((s: any) => s.pr), t.explicit_order, t.prefixes[1].pr]).toEqual([['3343', '3337'], true, '3343']);
|
||||
}, 180_000);
|
||||
|
||||
test('refusals: a fork PR, a moved head, a PR without READY, and a train still filling (until --urgent)', () => {
|
||||
const fx = make();
|
||||
const h1 = openPr(fx, '1');
|
||||
@@ -557,4 +632,50 @@ describe('verify, freeze, repair and clear', () => {
|
||||
expect(moved.status).toBe(EXIT.fail);
|
||||
expect(moved.err).toMatch(new RegExp(`#2 moved past its pinned head ${h2.slice(0, 12)} and is not T2 modulo stamps \\(${stray.slice(0, 12)}: .*\\(QUEUE_TRAIN_MOVED\\)`));
|
||||
}, 300_000);
|
||||
|
||||
test("GSTA-67 (#3345 at 593e61c): a merged-in head whose CHANGELOG was resolved by hand verifies even where T<i>'s mechanical CHANGELOG differs; an invented line or a head without main is still refused", () => {
|
||||
const fx = make();
|
||||
const h1 = openPr(fx, '1', { changelog: false });
|
||||
const h2 = openPr(fx, '2', { changelog: cl => cl.replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n\n- #2 change\n').replace('## [1.2.2.0] - 2026-09-01\n\n', '') });
|
||||
readyReceipt(fx, '1', h1);
|
||||
readyReceipt(fx, '2', h2);
|
||||
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
|
||||
const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`]).out);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
const t = trainJson(fx, nonce);
|
||||
expect(fx.repo.git('show', `${t.prefixes[2].commit}:CHANGELOG.md`)).not.toContain('## [1.2.2.0]');
|
||||
const coord = coordinatorClone(fx.repo);
|
||||
const v = () => queue(fx, ['verify', '--pr', '2', '--train', nonce], {}, coord);
|
||||
const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
|
||||
const { repo } = fx;
|
||||
const laneHead = (entry: string, parents: string[]) => {
|
||||
repo.git('reset', '-q', '--hard');
|
||||
repo.git('checkout', '-q', '-B', 'lane2', h2);
|
||||
repo.git('rm', '-rq', '.');
|
||||
repo.git('checkout', t.prefixes[2].commit, '--', '.');
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n');
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', `# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n${entry}\n`) + '\n');
|
||||
repo.git('add', '-A');
|
||||
const head = repo.git('commit-tree', repo.git('write-tree'), ...parents.flatMap(p => ['-p', p]), '-m', 'merge main into #2 (CHANGELOG by hand), stamp 1.2.5.0');
|
||||
repo.git('push', '-q', '-f', 'origin', `${head}:refs/pull/2/head`);
|
||||
return head;
|
||||
};
|
||||
|
||||
const byHand = laneHead('- #2 change', [h2, main1]);
|
||||
const ok = v();
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
expect(ok.out).toContain(`head: ${byHand} (T2 with main merged in, same modulo stamps (1 stamp file(s)) except CHANGELOG.md, resolved as main's CHANGELOG.md plus #2's own entry)`);
|
||||
expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${byHand}`);
|
||||
|
||||
const invented = laneHead('- #2 change\n- added after the gate', [h2, main1]);
|
||||
const refused = v();
|
||||
expect(refused.status).toBe(EXIT.fail);
|
||||
expect(refused.err).toContain(`#2 moved past its pinned head ${h2.slice(0, 12)} and is not T2 modulo stamps (${invented.slice(0, 12)}: differs from T2 in CHANGELOG.md, which is not main's plus #2's own entry: the PR's entry holds a line its pin never added: "- added after the gate"; against the pin it differs: `);
|
||||
expect(refused.err).toContain('(QUEUE_TRAIN_MOVED)');
|
||||
|
||||
const orphan = laneHead('- #2 change', [h2]);
|
||||
const noMain = v();
|
||||
expect(noMain.status).toBe(EXIT.fail);
|
||||
expect(noMain.err).toContain(`(${orphan.slice(0, 12)}: does not contain main ${main1.slice(0, 12)}; against the pin it differs: `);
|
||||
}, 300_000);
|
||||
});
|
||||
@@ -308,3 +308,44 @@ test("rendered /ship handles every version-source outcome the classifier emits",
|
||||
expect(step18).toMatch(/NO_VERSION[^.]*replaces items 1-3/);
|
||||
expect(step18).toContain("no version prefix");
|
||||
});
|
||||
|
||||
// GSTA-70: next-version marks a pick provisional with `complete:false` when
|
||||
// open PRs' VERSION claims could not be read. Step 12 used to print the
|
||||
// warning and write the pick as if it were a verified slot.
|
||||
test("rendered Step 12 reruns, then asks, on an incomplete queue instead of treating it as verified", () => {
|
||||
const ship = readFileSync(join(import.meta.dir, "../ship/SKILL.md"), "utf8");
|
||||
const nextVersion = readFileSync(join(import.meta.dir, "../bin/gstack-next-version"), "utf8");
|
||||
expect(nextVersion).toMatch(/unknown_claims: unknownClaims,\s+complete,/);
|
||||
const qualifyAt = ship.indexOf("**Qualify first:**");
|
||||
const incompleteAt = ship.indexOf("**Incomplete** (`complete:false`)");
|
||||
const usableAt = ship.indexOf("**Usable candidate:**");
|
||||
expect(incompleteAt).toBeGreaterThan(qualifyAt);
|
||||
expect(usableAt).toBeGreaterThan(incompleteAt);
|
||||
const incomplete = ship.slice(incompleteAt, usableAt).replace(/\s+/g, " ");
|
||||
expect(incomplete).toContain("`unknown_claims`");
|
||||
expect(incomplete).toContain("may collide");
|
||||
expect(incomplete).toContain("Rerun item 3 once");
|
||||
expect(incomplete).toMatch(/still incomplete, AskUserQuestion: use it \(recommended[^)]*\) or stop/);
|
||||
});
|
||||
|
||||
// GSTA-70: the rendered Step 12 write command, run verbatim against the real
|
||||
// CLI, re-heads the branch's [Unreleased] entry under NEW_VERSION.
|
||||
test("rendered Step 12 write re-heads an existing [Unreleased] entry and leaves released entries alone", () => {
|
||||
const ship = readFileSync(join(import.meta.dir, "../ship/SKILL.md"), "utf8");
|
||||
const step12 = ship.slice(ship.indexOf("## Step 12:"), ship.indexOf("## Step 14:"));
|
||||
const cmd = step12.match(/bun --no-env-file --config=\/dev\/null run \S+\/bin\/gstack-version-bump write [^\n]+/)?.[0];
|
||||
expect(cmd).toContain("--changelog CHANGELOG.md");
|
||||
const bin = join(import.meta.dir, "../bin");
|
||||
const runnable = cmd!.replace(/\S+\/bin\/gstack-version-bump/, join(bin, "gstack-version-bump"));
|
||||
const released = "## [1.0.0.0] - 2026-10-01\n\n- first\n";
|
||||
writeFiles({ VERSION: "1.0.0.0\n", "CHANGELOG.md": `# Changelog\n\n## [Unreleased]\n\n- branch change\n\n${released}` });
|
||||
execSync(runnable, { cwd: dir, shell: "/bin/bash", env: { ...process.env, NEW_VERSION: "1.0.1.0" }, stdio: "pipe", timeout: 30_000 });
|
||||
const changelog = readFileSync(join(dir, "CHANGELOG.md"), "utf8");
|
||||
expect(changelog).toMatch(/^# Changelog\n\n## \[1\.0\.1\.0\] - \d{4}-\d{2}-\d{2}\n\n- branch change\n\n/);
|
||||
expect(changelog.endsWith(released)).toBe(true);
|
||||
expect(changelog).not.toContain("[Unreleased]");
|
||||
writeFiles({ VERSION: "1.0.1.0\n", "CHANGELOG.md": `# Changelog\n\n${released}` });
|
||||
execSync(runnable, { cwd: dir, shell: "/bin/bash", env: { ...process.env, NEW_VERSION: "1.0.2.0" }, stdio: "pipe", timeout: 30_000 });
|
||||
expect(readFileSync(join(dir, "CHANGELOG.md"), "utf8")).toBe(`# Changelog\n\n${released}`);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user