mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-15 16:52:14 +08:00
* fix(learnings): accept type:"investigation" in gstack-learnings-log The /investigate skill instructed agents to log learnings with type:"investigation", but bin/gstack-learnings-log:22 rejected anything not in [pattern, pitfall, preference, architecture, tool, operational]. Every investigation run exited 1 to stderr and the learning was dropped, silently to the user. Fix: add 'investigation' to ALLOWED_TYPES. Regression test: round-trips a learning with type:"investigation" and asserts exit 0 + file write; second test reads investigate/SKILL.md.tmpl and asserts it emits the literal type:"investigation" string, guarding the template/validator contract at both ends. Fixes #1423. Reported by diogolealassis. * fix(gbrain): engine detection survives gbrain ≥0.25 schema + non-zero doctor exit freshDetectEngineTier() in lib/gstack-memory-helpers.ts returned engine: "unknown" for every Supabase user on gbrain ≥0.25. Two stacking bugs: 1. execSync("gbrain doctor --json --fast 2>/dev/null") threw on non-zero exit. gbrain doctor exits 1 whenever health_score < 100, which is essentially every fresh install due to resolver_health warnings. The JSON output never reached the parser. 2. gbrain ≥0.25 shipped schema_version:2 doctor output that dropped the top-level 'engine' field entirely. Result: every /sync-gbrain on Supabase logged 'engine=unknown' and skipped all sync stages silently. Fix: - Replace execSync with execFileSync (no shell, no bash-specific 2>/dev/null redirect; portable to Windows). - Recover stdout from the thrown error object so non-zero exits still parse. - Fall back to reading gbrain's config.json (respecting GBRAIN_HOME env var, defaulting to ~/.gbrain/config.json) when doctor output doesn't surface an engine field. - Add logGbrainError() helper that appends one-line JSONL to ~/.gstack/.gbrain-errors.jsonl on parse failure, so future regressions leave a forensic trail. The "supabase" tier here means "remote postgres" in practice — gbrain config uses engine:"postgres" for both real Supabase and any other remote postgres (e.g. local-postgres-for-testing). Downstream sync code treats them identically, so the label compression is intentional and documented inline. Regression test: existing detectEngineTier suite now isolates HOME + GBRAIN_HOME + PATH to temp dirs (closes a flake source where the prior tests would read whatever was on the reviewer's machine). New test forces gbrain off PATH, writes a synthetic config.json with engine:"postgres", asserts detectEngineTier() returns engine:"supabase". Fixes #1415. Patch shape contributed by Shiv @shivasymbl (tested on gstack v1.31.0.0 + gbrain v0.31.3 + Supabase). * fix(codex): /codex review works on Codex CLI ≥0.130.0 Codex CLI 0.130.0 made [PROMPT] and --base <BRANCH> mutually exclusive at argv level. Step 2A of codex/SKILL.md.tmpl had always passed both (the filesystem boundary prefix as the prompt argument + the base branch), so every /codex review call died with: error: the argument '[PROMPT]' cannot be used with '--base <BRANCH>' Fix: split Step 2A into two paths. Default (no custom user instructions): bare 'codex review --base <base>'. Codex's review prompt is internally diff-scoped, so the model focuses on the changes against base. The filesystem boundary prefix is dropped here because Codex 0.130 has no documented system-prompt config key (probed -c 'system_prompt="..."' against 0.130 — the flag is silently accepted but the value isn't applied). Skill files under .claude/ and agents/ are public, so this is a token-efficiency concern, not a safety one. Custom instructions (/codex review <focus>): route through codex exec with the diff written to a tempfile, inlined into the prompt between explicit DIFF_START / DIFF_END markers. The boundary is preserved here because codex exec isn't auto-scoped to the diff. The DIFF_START/END delimiters tell the model where data ends and instructions resume, which materially reduces prompt-injection hijack rates when the diff contains adversarial content. Note on bash semantics: codex's earlier review flagged the exec route as "command injection via $_DIFF interpolation." That framing is wrong — bash parameter expansion does not re-evaluate $(...) or backticks inside the expanded value, so a diff containing $(rm -rf /) is plain string data to codex exec. The real risk is prompt injection (model-side, not shell-side), which the DIFF_START/END pattern mitigates. Regression tests in test/codex-hardening.test.ts assert across BOTH codex/SKILL.md.tmpl AND the generated codex/SKILL.md: 1. No 'codex review' invocation line combines a quoted-string OR variable positional argument with --base. 2. Step 2A still contains either bare 'codex review --base' OR 'codex exec' (guards against accidental deletion of both fix paths). Fixes #1428. Reported by Stashub. * test: raise timeouts for slow integration tests Two test files were timing out at the default 5s on developer machines, both pre-existing on origin/main but unrelated to this branch's bug fixes: - test/gstack-artifacts-init.test.ts: 13 tests spawning real subprocesses via fake gh/glab/git shims in PATH. bun's fork+exec overhead pushed these past 5s consistently. Added a local test-wrapper that aliases test() with a 30s timeout (matches the brain-sync.test.ts pattern already in the repo). - test/gstack-next-version.test.ts: one integration smoke test that spawns 'bun run ./bin/gstack-next-version' and parses the resulting JSON. The subprocess does a 'gh pr list' against the live GitHub API to enumerate claimed version slots. Network latency makes 5s tight; raised this single test to 30s. No production code changed. The tests already passed deterministically once given enough wall-clock time. * chore: bump version and changelog (v1.34.2.0) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
186 lines
7.1 KiB
TypeScript
186 lines
7.1 KiB
TypeScript
// Pure-function tests for bin/gstack-next-version.
|
|
// Covers the version arithmetic and slot-picking logic. Subprocess paths
|
|
// (gh/glab/git) are covered by the integration test at the bottom (skipped
|
|
// when the relevant CLI isn't available).
|
|
|
|
import { test, expect, describe } from "bun:test";
|
|
import {
|
|
parseVersion,
|
|
fmtVersion,
|
|
bumpVersion,
|
|
cmpVersion,
|
|
pickNextSlot,
|
|
markActiveSiblings,
|
|
} from "../bin/gstack-next-version";
|
|
|
|
describe("parseVersion", () => {
|
|
test("accepts 4-digit semver", () => {
|
|
expect(parseVersion("1.6.3.0")).toEqual([1, 6, 3, 0]);
|
|
expect(parseVersion("0.0.0.0")).toEqual([0, 0, 0, 0]);
|
|
expect(parseVersion("99.99.99.99")).toEqual([99, 99, 99, 99]);
|
|
});
|
|
|
|
test("trims whitespace", () => {
|
|
expect(parseVersion(" 1.2.3.4 \n")).toEqual([1, 2, 3, 4]);
|
|
});
|
|
|
|
test("rejects malformed", () => {
|
|
expect(parseVersion("1.2.3")).toBeNull();
|
|
expect(parseVersion("1.2.3.4.5")).toBeNull();
|
|
expect(parseVersion("v1.2.3.4")).toBeNull();
|
|
expect(parseVersion("")).toBeNull();
|
|
expect(parseVersion("not-a-version")).toBeNull();
|
|
expect(parseVersion("1.2.3.x")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("bumpVersion", () => {
|
|
test("major zeros everything right", () => {
|
|
expect(bumpVersion([1, 6, 3, 0], "major")).toEqual([2, 0, 0, 0]);
|
|
expect(bumpVersion([1, 6, 3, 7], "major")).toEqual([2, 0, 0, 0]);
|
|
});
|
|
test("minor zeros patch+micro", () => {
|
|
expect(bumpVersion([1, 6, 3, 0], "minor")).toEqual([1, 7, 0, 0]);
|
|
expect(bumpVersion([1, 6, 3, 7], "minor")).toEqual([1, 7, 0, 0]);
|
|
});
|
|
test("patch zeros micro", () => {
|
|
expect(bumpVersion([1, 6, 3, 0], "patch")).toEqual([1, 6, 4, 0]);
|
|
expect(bumpVersion([1, 6, 3, 7], "patch")).toEqual([1, 6, 4, 0]);
|
|
});
|
|
test("micro increments slot 4", () => {
|
|
expect(bumpVersion([1, 6, 3, 0], "micro")).toEqual([1, 6, 3, 1]);
|
|
expect(bumpVersion([1, 6, 3, 7], "micro")).toEqual([1, 6, 3, 8]);
|
|
});
|
|
});
|
|
|
|
describe("cmpVersion", () => {
|
|
test("detects order", () => {
|
|
expect(cmpVersion([1, 6, 3, 0], [1, 6, 3, 0])).toBe(0);
|
|
expect(cmpVersion([1, 6, 4, 0], [1, 6, 3, 0])).toBeGreaterThan(0);
|
|
expect(cmpVersion([1, 6, 3, 0], [1, 6, 4, 0])).toBeLessThan(0);
|
|
expect(cmpVersion([2, 0, 0, 0], [1, 99, 99, 99])).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe("pickNextSlot (the heart of queue-aware allocation)", () => {
|
|
const base: [number, number, number, number] = [1, 6, 3, 0];
|
|
|
|
test("happy path — no claims, clean bump", () => {
|
|
const r = pickNextSlot(base, [], "minor");
|
|
expect(fmtVersion(r.version)).toBe("1.7.0.0");
|
|
expect(r.reason).toMatch(/no collision/);
|
|
});
|
|
|
|
test("collision — one PR claims the next slot, bump past", () => {
|
|
const r = pickNextSlot(base, [[1, 7, 0, 0]], "minor");
|
|
expect(fmtVersion(r.version)).toBe("1.8.0.0");
|
|
expect(r.reason).toMatch(/bumped past/);
|
|
});
|
|
|
|
test("multi-collision — two PRs claim sequential slots", () => {
|
|
const r = pickNextSlot(base, [[1, 7, 0, 0], [1, 8, 0, 0]], "minor");
|
|
expect(fmtVersion(r.version)).toBe("1.9.0.0");
|
|
});
|
|
|
|
test("collision cross-level — queued MINOR bumps past my PATCH", () => {
|
|
// Queue has 1.7.0.0 (minor), my bump is patch. I should land at 1.7.1.0
|
|
// (patch relative to the highest claim).
|
|
const r = pickNextSlot(base, [[1, 7, 0, 0]], "patch");
|
|
expect(fmtVersion(r.version)).toBe("1.7.1.0");
|
|
});
|
|
|
|
test("claims below base are ignored", () => {
|
|
const r = pickNextSlot(base, [[1, 5, 0, 0], [1, 6, 2, 0]], "patch");
|
|
expect(fmtVersion(r.version)).toBe("1.6.4.0");
|
|
expect(r.reason).toMatch(/no collision/);
|
|
});
|
|
|
|
test("claims equal to base are treated as no-claim", () => {
|
|
// The caller is expected to pre-filter base-equal claims out, but even if
|
|
// one slipped through, we don't want to inflate past it.
|
|
const r = pickNextSlot(base, [], "micro");
|
|
expect(fmtVersion(r.version)).toBe("1.6.3.1");
|
|
});
|
|
|
|
test("major collision — competing majors", () => {
|
|
const r = pickNextSlot(base, [[2, 0, 0, 0]], "major");
|
|
expect(fmtVersion(r.version)).toBe("3.0.0.0");
|
|
});
|
|
|
|
test("unsorted claims still resolve correctly", () => {
|
|
const r = pickNextSlot(base, [[1, 9, 0, 0], [1, 7, 0, 0], [1, 8, 0, 0]], "minor");
|
|
expect(fmtVersion(r.version)).toBe("1.10.0.0");
|
|
});
|
|
});
|
|
|
|
describe("markActiveSiblings", () => {
|
|
const base: [number, number, number, number] = [1, 6, 3, 0];
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
test("flags siblings that are ahead of base AND recent AND have no PR", () => {
|
|
const siblings = [
|
|
{ path: "/a", branch: "feat/alpha", version: "1.7.0.0", last_commit_ts: now - 60, has_open_pr: false, is_active: false },
|
|
];
|
|
const r = markActiveSiblings(siblings, base);
|
|
expect(r[0].is_active).toBe(true);
|
|
});
|
|
|
|
test("does not flag siblings with open PRs (already in the queue)", () => {
|
|
const siblings = [
|
|
{ path: "/a", branch: "feat/alpha", version: "1.7.0.0", last_commit_ts: now - 60, has_open_pr: true, is_active: false },
|
|
];
|
|
expect(markActiveSiblings(siblings, base)[0].is_active).toBe(false);
|
|
});
|
|
|
|
test("does not flag stale siblings (commit > 24h old)", () => {
|
|
const siblings = [
|
|
{ path: "/a", branch: "feat/alpha", version: "1.7.0.0", last_commit_ts: now - 25 * 3600, has_open_pr: false, is_active: false },
|
|
];
|
|
expect(markActiveSiblings(siblings, base)[0].is_active).toBe(false);
|
|
});
|
|
|
|
test("does not flag siblings at or below base", () => {
|
|
const siblings = [
|
|
{ path: "/a", branch: "feat/alpha", version: "1.6.3.0", last_commit_ts: now - 60, has_open_pr: false, is_active: false },
|
|
{ path: "/b", branch: "feat/beta", version: "1.5.0.0", last_commit_ts: now - 60, has_open_pr: false, is_active: false },
|
|
];
|
|
const r = markActiveSiblings(siblings, base);
|
|
expect(r[0].is_active).toBe(false);
|
|
expect(r[1].is_active).toBe(false);
|
|
});
|
|
});
|
|
|
|
// Integration smoke — only runs if gh is available and authenticated. Confirms
|
|
// the CLI executes end-to-end against real APIs without crashing.
|
|
describe("integration (smoke)", () => {
|
|
// Bumps timeout to 30s — the test spawns a real `bun run` subprocess that
|
|
// does a `gh pr list` against the live GitHub API to inspect claimed slots.
|
|
// Network latency makes 5s tight on developer machines.
|
|
test("CLI runs against real repo and emits parseable JSON", async () => {
|
|
const proc = Bun.spawnSync([
|
|
"bun",
|
|
"run",
|
|
"./bin/gstack-next-version",
|
|
"--base",
|
|
"main",
|
|
"--bump",
|
|
"patch",
|
|
"--current-version",
|
|
"1.6.3.0",
|
|
"--workspace-root",
|
|
"null", // skip sibling scan in CI
|
|
]);
|
|
const out = new TextDecoder().decode(proc.stdout);
|
|
const parsed = JSON.parse(out);
|
|
expect(parsed).toHaveProperty("version");
|
|
expect(parseVersion(parsed.version)).not.toBeNull();
|
|
expect(parsed).toHaveProperty("bump", "patch");
|
|
expect(parsed).toHaveProperty("host");
|
|
expect(["github", "gitlab", "unknown"]).toContain(parsed.host);
|
|
expect(parsed).toHaveProperty("claimed");
|
|
expect(Array.isArray(parsed.claimed)).toBe(true);
|
|
expect(parsed).toHaveProperty("siblings");
|
|
expect(parsed.siblings).toEqual([]); // --workspace-root null disabled scanning
|
|
}, 30000);
|
|
});
|