mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-11 07:29:00 +02:00
v1.77.0.0 feat: test-infrastructure overhaul wave 1 — matrix deletion, flake telemetry, sync-spawn wedge class extinct (#2746)
* fix: pin the claude CLI to an exact version in the CI image + tripwire The image installed @anthropic-ai/claude-code UNPINNED and rebuilt weekly 'to pick up CLI updates' — while bun sat carefully pinned at 1.3.13 two RUN lines above. The PTY harness screen-scrapes this CLI's TUI, and that drift broke it three separate times (welcome-screen wedge on 2.1.233, skillify HOME discovery on 2.1.237, guard/freeze hooks on 2.1.162), each debugged as a flake first. Pin 2.1.251 (current latest), bump deliberately via a PR that runs the PTY gate, and enforce with test/ci-image-cli-pin.test.ts: any global npm install in Dockerfile.ci without an exact @X.Y.Z pin fails the free suite. The weekly ci-image cron stays as a cheap tag self-heal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: stamp the claude CLI version into every eval-store run record Three harness breakages were traced to claude-CLI TUI drift only after long flake hunts, because no run record said which CLI it actually exercised. EvalCollector now stamps claude_cli_version (claude --version, cached once per process, 'unknown' when the binary is absent) into both partial and finalized records — schema-additive optional field, no SCHEMA_VERSION bump. Correlating a flake wave with a CLI release becomes a grep over ~/.gstack/projects/<slug>/evals/ instead of archaeology. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: give the spinning-shard kill test load headroom (30s -> 90s) The test spawns and group-kills three real children (one a busy-loop burning a full core) while five sibling shard processes compete for eight vCPUs. Under full-suite load it blew bun's default 30s per-test ceiling at 30,009ms — while passing in isolation in 1.4s — and red the only required lane. Every assertion in it is event-based (statuses, group-kill proof, heartbeat lines); the sole latency claim is the <30s kill-deadline sanity bound, which stays. Explicit 90s headroom, not a weakened oracle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: green-by-skip census — skip counts in the classifier, all-skipped labeling in the paid runner bun's 'Ran N tests' line COUNTS skipped tests, so a codex/gemini shard whose every test self-skipped (binary absent on the runner — true of every CI runner today) exits 0, dodges the hollow-shard guard, and reads as coverage in the weekly census. The classifier now parses bun's ' N skip' / ' N pass' recap lines; ShardOutcome carries skippedTests; formatSummary and the fail-closed slices report label an all-skipped pass explicitly: 'all N tests SKIPPED — verified nothing'. Status stays 'passed' (external service availability is host state, not a repo regression) but the census can no longer mistake absence for coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: extract composite actions for eval-lane setup; surviving lanes gain the fail-fast registry verification 'Fix bun temp' x3, 'Restore deps' x5, 'Seed claude interactive config' x3, and 'Register gstack skills' x3 were byte-near-identical copies across the legacy matrix, the sliced lane, and the periodic lane — and only the MATRIX copy of register-skills carried the 19-line dangling-symlink + frontmatter fail-fast loop written after a silent 'Unknown command' + 35-min-timeout incident. Extract all four into .github/actions/ composites; the register composite carries the verification loop (generalized over the skill list), so the sliced and periodic lanes — the lanes that SURVIVE the matrix deletion — now inherit the check they had silently dropped. Matrix-job inline copies are left untouched: that job is deleted next. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: delete the legacy 17-row eval matrix — the sliced lane is the only paid lane Every PR paid twice: the hand-enumerated matrix (18 test files, 22.6 min, ~$21 API measured on run 33263204465) ran serialized AHEAD of the strictly superior sliced lane via 'needs: evals' — 35.5 min wall and ~2x paid spend for the same diff. 14 of 17 rows carried no tier:, so periodic Opus benchmarks leaked into every PR (the e2e-plan row alone: 12/12 tests, 21.7 min, $7.28 — the wall-clock bound of ALL of CI). Parity receipt (static, pre-deletion): the sliced lane's gate census (49 files, derived from the runner itself) strictly contains all 18 matrix test files, plus 31 files the matrix never ran. Pure deletion — one revert restores it. The PR comment moved into slices-report (same '## E2E Evals' upsert marker, now sourced from slice artifacts + carrying the fail-closed reconciliation verdict). plan-slices loses the needs edge; the dead workflow-level EVALS_TIER env goes with it. test/evals-workflow-matrix.test.ts (and its KNOWN_MATRIX_GAPS / KNOWN_TIER_UNSET burn-down ratchets — retired: the sliced census makes 'every gate file runs' true by construction) is rewritten as test/evals-workflow-wiring.test.ts: matrix stays deleted, planner/executor/ report tier + slice-count agreement, both surviving lanes on the shared register-skills composite with its fail-fast verification loop, PR comment survival. Expected: PR eval wall 35.5 -> ~13 min, per-PR paid spend ~halved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: provider-runner timeouts kill the whole process GROUP; codex/gemini inherit the orphan-drain hardening All three provider runners (claude/codex/gemini) killed only the direct child on timeout: tool subprocesses the CLI spawned survived as orphans holding our pipes open and burning shared API rate (observed: a 600s timeout stretching past 1400s; a stalled run once burned a core for 15 hours). gstack-detach's watchdog had the same shape one level up — killpg SIGTERM, 5s grace, then a direct-child proc.kill() that orphaned grandchildren. Fix: spawn provider children via node:child_process with detached (own process group) and killProcessGroup(SIGKILL) in the timeout handler — runShardChild's proven pattern, EPERM/ESRCH fallbacks included. The codex and gemini copies also gain the reader.cancel() + stderr Promise.race hardening only the claude copy had (they still carried the blocked-drain hang it fixed). gstack-detach's watchdog now group-SIGKILLs after the grace. Regression net: test/session-runner-groupkill.test.ts drives the REAL runSkillTest against a fake claude shim (PATH override) that spawns a grandchild and wedges — the run must classify timeout within budget and leave neither shim nor grandchild alive — plus source pins on all three runners (detached + killProcessGroup, no bare timeout kill, no Bun.spawn reversion). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: skill-e2e-opus-47 renders SKILL.md fixtures into a mkdtemp — never the live tree mkEvalRoot ran gen-skill-docs with cwd=ROOT, regenerating every in-repo SKILL.md mid-run while concurrent paid shards copyFileSync those same files in their beforeAll (EVALS_JOBS>=4 locally, 2 per CI slice) — a sibling could capture a half-regenerated or opus-rendered SKILL.md, and a timeout before afterAll stranded the whole tree at the wrong model for every later shard. A cross-shard race that could flake ANY concurrent paid test. Render via the --out-dir flag gen-skill-docs grew for exactly this reason (mirrors the repo layout, which is all the fixture reads), read the skill heads from the render dir, delete it, and drop the afterAll restore-regen entirely. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: claude CLI version resolves in the runner parent, never on a test thread Eng-review finding: getClaudeCliVersion's fallback is a SYNCHRONOUS spawnSync on the same thread that polls concurrent PTY/session tests — the judgePtyState blocking class this overhaul kills elsewhere. The paid runner parent now resolves it once (cached) and stamps GSTACK_CLAUDE_CLI_VERSION into every shard's env; eval-store short-circuits on the env var, and the fallback spawn's budget tightens 10s -> 3s (bounded one-time stall, records 'unknown' on a slow CLI). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: wire skippedTests end-to-end through runPaidShard The census unit tests hand-built outcomes and the classifier tests parsed strings; nothing proved a real child's ' N skip' recap flows into outcome.skippedTests and the formatSummary label. A commandFor fake now prints the recap shape and the test asserts the parsed counts, the all-skipped predicate, and the 'verified nothing' label. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: make the setup composites rerun-safe (codex diff-review hardenings) restore-deps: 'cp -r SRC node_modules' with an existing node_modules NESTS the copy and leaves stale deps active — rm first. register-gstack-skills: 'ln -snf' hard-errors under set -eu when a REAL directory occupies the gstack slot — clear a non-symlink leftover first. CI workspaces are fresh today; a reusable composite must survive dirty reruns. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: sweep — every sync spawn in the test trees carries a timeout (436 sites, 157 files) spawnSync/execSync/Bun.spawnSync BLOCK the main thread, so bun's in-process per-test timeout can never fire while one waits — a hung child (stdin read, network probe, dead daemon) wedges the whole shard until the runner's external wall-clock SIGKILL. This exact class reached main: free-tests run 33262077256, test/gstack-memory-ingest.test.ts (normally 2.3s) held shard 2 at the 360s wall while its five siblings finished in ~65s. Mechanical sweep in two waves (12 + 4 fan-out agents, every edit verified against its call site): default timeout: 30_000 (matches the free runner's per-test budget), 120_000 for genuinely slow ops (installs, builds, playwright, provider CLIs), helper wrappers fixed ONCE where call sites route through them. Sites that only LOOK like calls (string fixtures, grep needles, comments) were skipped with reasons — the enforcement commit that follows marks them exempt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: sync-spawn timeout tripwire — the wedge class stays extinct Free scanner over all test trees (test/, browse/test/, design/test/, make-pdf/test/, ios-qa, browser-skills): every spawnSync/execSync/ Bun.spawnSync call site must carry a timeout within a 30-line options window, or an explicit '// tripwire-exempt: <reason>' marker. Comment lines are skipped; exemptions are counted and ratcheted shrink-only (ceiling 6 = the 6 string-fixture/grep-needle sites where the pattern is CONTENT, not a call — marked in this commit). A scan-sanity test pins that the scanner still sees >100 real call sites so it can never rot to a vacuous green. Companion to the 436-site sweep in the previous commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: paid-lane flake telemetry — record-level attempts, flaky_retries, report surfacing bun --retry leaves a retried pass INVISIBLE in its output: a fail-then-pass prints the error detail but no (fail) result line and recaps as a clean pass (probed live on 1.3.10). So attempts are recorded where they cannot lie: EvalCollector.addTest stamps a 1-based attempt on same-name re-records (a retried test runs its body again and re-records), finalized runs carry flaky_retries, printSummary warns loudly, and the fail-closed slices report lists every passed-only-on-retry test — recorded and ranked, never blocking and never silent. Cross-model confirmed (codex reached the same don't-parse -the-stream conclusion independently). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: free-lane flake ledger — retry ON in CI, flaky-passes recorded and uploaded The runner's attribution-gated flaky-retry pass (cap 5, truncation veto) was OFF in the required lane and its FLAKY-PASS evidence was console-only — so a single timing flake red the merge gate while repeat offenders stayed unenumerable. free-tests.yml now sets GSTACK_FREE_RETRY_FLAKY=1 and points GSTACK_FLAKE_LEDGER at runner.temp; every flaky-pass appends a JSONL entry (SINGLE writer: the parent runner — no concurrent-append hazard by construction; fail-open with a loud warning so a broken ledger can never red the lane) and the artifact uploads UNCONDITIONALLY — a flaky-pass run is green, which is exactly when the evidence matters. Wiring pinned by free-tests-workflow-wiring; ledger behavior unit-tested incl. the fail-open path. Matches 2026 industry practice (retry for data, quarantine out of merge-blocking but never out of logging) with the repo's own receipts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: eval:flake-rank — the flake-telemetry dial Aggregates per-test series across every finalized eval-store run (shard dirs included) plus the free flake ledger: runs, fails, RETRIED PASSES (the flake signature), avg duration — ranked retries-first. This is the readable dial behind two policies: a flaky pass never blocks a merge but is always ranked here, and the WS16 required-check promotion needs weeks of clean flake-rank, not vibes. --json for machines, --dir for downloaded CI artifacts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: two-phase session timeout — silent APIs die at the startup grace, named The single spawn-armed timer charged API queue latency to the work budget: the recurring '0 turns / $0.00 / x3 attempts' failure with four budget-bump receipts (180->300s, 240->360s, 300->420s, 90->300s). Split: startup phase (no NDJSON byte yet) kills EARLY at min(grace, timeout) with the distinct exitReason 'timeout_startup' — an availability verdict, not transcript archaeology — and the work phase arms on the first byte for the REMAINING budget, so total wall never exceeds the timeout (tier envelopes are margin-free: tests pass timeout: CAPTURE_MS and bun-budget the same tier). Local grace 90s (observed queue latency 60-90s), CI floor 300s (TODOS-filed; shared runners queue harder), both pinned by the new grace tests with fake -claude shims covering the late-first-byte and silent-API paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: census integrity — 17 phantom selection keys deleted, reverse invariant added, gitignored dep patterns replaced, local map forks derived The merge-blocking gate census counted tests that could not run. Deleted (critic-verified against both quoted-occurrence and dep-registration liveness): 7 *-prosons-format keys with no declaring test, ship-plan- completion/-verification, review-plan-completion, design-shotgun-path/ session/full, autoplan-core (dead ~10 months), e2e-harness-audit (its namesake is a FREE-suite file), plus 2 dead LLM-judge keys and 2 free-file keys (budget-regression-pty, global-discover) misplaced in the PAID maps. Census: 191 -> 174 keys, gate 86 -> 78 honest. The new reverse invariant in touchfiles.test.ts makes the class structurally impossible: every key must be quoted in a living paid test file OR registered to an existing paid test file via its dep list (the constructed- name binding the 2026-08 self-registration sweep established) — zero exceptions needed today, with a live-file check on any future exception. Also: '.agents/skills/**' dep patterns replaced with the generator (scripts/gen-skill-docs.ts) — .agents/ is gitignored, so those patterns could NEVER match a git diff and review-template edits silently stopped selecting codex/gemini tests; the codex/gemini local touchfile maps are now DERIVED from the canonical map (loud throw if a key vanishes) instead of hand-forked copies that had already drifted. ios-qa-e2e demoted gate -> periodic: its gate declaration was never executable in CI (hardware exclusion only applies at tier=periodic), so every Linux PR planned a hollow shard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: routing journeys lose their answer key and end at the routing decision The journey tests exist to catch skill-DESCRIPTION regressions (touchfiles: */SKILL.md.tmpl), but the fixture CLAUDE.md shipped an explicit prompt->skill lookup table — with the answer key in context, a badly regressed frontmatter description still routed correctly, so the tests could not fail on the exact class they select for. The fixture now carries only the generic invoke-skills nudge; the frontmatter carries the routing load. Also capped all 10 journeys at maxTurns 2 / tools [Skill, Read]: only the FIRST Skill call is asserted, so 5 turns of Read/Bash/Glob/Grep was pure spend — roughly halves each journey's cost. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: retire decided A/B experiments; vendor the pre-cut fixture; ban raw-SHA fixtures Three one-shot decision experiments kept re-running weekly as N=1 stochastic comparisons — flaky by construction with near-zero remaining information: skill-e2e-auq-repetition-cut-ab (its own header: gate "passed pre-landing, approved 2026-08-25"), skill-e2e-preamble-script-ab ("demoted post-Phase-3"), and opus-47's fanout arm-vs-arm (parA >= parB across two SINGLE stochastic runs — a coin flip). Deleted, with their selection keys; the SDK overlay-harness stays as the maintained instrument for the next experiment, and opus-47 keeps its routing-precision cases. verboseSkill() now reads the VENDORED test/fixtures/auq-pre-cut-...-SKILL.md instead of `git show ab66193e^:...` — a branch-local ref that dies on branch prune and already failed on shallow clones. New free tripwire (test/git-ref-fixture-tripwire.test.ts) bans the raw-SHA fixture class outright: quoted SHA:path rev-specs and gitRef-style hex defaults in the test trees fail the suite with the vendor-instead instruction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: demote plan-ceo-review-expansion-energy to periodic Opus generator + a subjective 2-axis >=4/5 LLM-judge threshold sat in the MERGE-BLOCKING gate — the exact class its sibling posture tests were demoted for, with a receipt (a +21-line preamble change once flipped the score). CLAUDE.md's own tiering rule: Opus model test -> periodic. The weekly lane keeps the regression signal; merges stop paying a judge- temperament tax. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: paid shards get per-shard TMPDIR + CHROMIUM_PROFILE isolation and a kill-path cleanup backstop The free runner treats this isolation as MANDATORY (two concurrent shards on one Chromium profile kill each other's browser; shared tmp cross-contaminates) — the paid lane had none of it. Doubly load-bearing here: a shard that hits its 30-min wall is group-SIGKILLed, so per-test afterAll cleanup never runs; the rmSync backstop is the only thing keeping wedged runs from accumulating full git-repo workspaces in the shared tmpdir forever. This is the DAG prerequisite for raising EVALS_JOBS (next commit) — more concurrency on shared state amplifies exactly the shared-tree race class opus-47 exhibited. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: paid-runner defaults 4x4 -> 8x2 — halve the local gate worst case 39 of 75 skill-e2e files hold exactly ONE test, so within-shard concurrency was dead weight for most shards: 4 jobs x 4 concurrency yielded only ~4-6 real in-flight sessions and a 13-wave local gate worst case (~6.5h). 8 jobs x 2 gives ~10-13 in-flight — under the documented-safe ~15 — and ~7 waves (~3.3h worst case). CI lanes keep their explicit EVALS_JOBS env (2 per slice; 4 for gate-census); this changes local defaults. Rollback trigger: sustained 429 storms in the WS1 telemetry across 2 PR cycles. test/eval-detach-timeout-floor.test.ts recomputed green (the raise LOWERS the worst-case floor). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: SHA-pin every action in the secrets-bearing eval lanes evals.yml and evals-periodic.yml execute PR-authored code with three provider API keys in env, yet rode mutable action tags (@v7/@v8/@v2/@v4) — while quality-gate.yml, osv-scanner.yml, and dependency-review.yml already model the SHA-pin pattern. All 30 uses sites across both lanes now pin the exact commit (tag noted in a trailing comment); dependabot's github-actions ecosystem keeps them fresh via PRs instead of silent tag moves. Pulled forward from the plan's endgame on the CEO-review + outside- voice agreement: supply-chain pins on secret lanes go first, not last. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: sweep wave 3 — the execFileSync family gets timeouts (90 sites, 17 files) The tripwire's regex covered spawnSync/execSync/Bun.spawnSync but not execFileSync — an entire blocking sync-spawn API family that could reintroduce the shard-wedge class undetected (ship review army). Same mechanical recipe as waves 1-2: timeout: 30_000 default, 120_000 for slow ops, shared wrappers fixed once, string-needle sites skipped with reasons. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: review-army + adversarial test hardening - Tripwire scans execFileSync too (ceiling 8: two more grep-needle string exemptions); merge-introduced timeout-less spawnSync in question-preference-hook fixed — the tripwire caught a site that landed on main AFTER the sweep, on its first day. - gstack-detach gains TWO watchdog kill regression tests: TERM-immune grandchild (the killpg-after-grace escalation) and the leader-dies variant (the pgid-at-spawn fix — the case the first test cannot see). - eval-flake-rank gets its unit suite (final-attempt accounting, artifact exclusion, shard recursion, recency bound). - Groupkill/startup-grace shim markers are per-run unique (pid-suffixed sleep durations): sibling Conductor worktrees run free suites with no machine lock, and fixed markers let one run pgrep/pkill the other's shims — a cross-run flake inside the anti-flake tests. - flake-ledger test pins the project-scoped local default; stale empty section headers in touchfiles-data deleted (they invited entries under deliberately retired categories). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: adversarial-review runtime fixes across the telemetry + kill paths - session-runner: exit-labeling keys off 'exit', not 'close' — an orphan holding the pipes could relabel a REAL exit (auth failure) as 'timeout_startup' availability noise; the kill path still always group-kills and cancels the reader (labeling and unblocking are separate concerns). Work phase arms on a flag, not firstResponseMs===0 (a same-ms first byte left the startup timer live all run). The CI startup grace is now a real FLOOR (Math.max), matching its name and pinning test. - gstack-detach: pgid captured AT SPAWN (== child pid under start_new_session) — resolving it after the grace raised ESRCH once the leader died on SIGTERM, orphaning TERM-immune grandchildren forever. - test-free-shards: ledger entries carry branch + git_sha (rev-parse split: '--abbrev-ref HEAD HEAD' printed the branch twice and recorded it as the sha); local ledger default is per-PROJECT, not the machine-global tmpdir. - eval-flake-rank: per-LINE ledger parse (one torn JSONL line vanished the whole series), 60-day recency bound (transcript-bearing files are MBs), shared isFinalizedEvalResultFile predicate (the artifact-taxonomy rule lived in three places); eval-store exports the predicate and finalize stops computing flakyRetries twice; paid-shards cleanup uses async rm (a SIGKILLed shard's git-workspace teardown blocked every sibling's stream classification on the parent event loop). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: CI trust-boundary + fail-closed repairs (adversarial findings) - Token/exec separation restored: slices-report (runs PR-authored code: bun install + the reconcile runner) drops to contents:read; the PR comment moves to a NEW slices-comment job holding the write token with ZERO repo code — no checkout, no bun, only downloaded artifacts + jq/gh. $GITHUB_ENV/BASH_ENV persistence is job-scoped, so the split is the boundary. The matrix-era report job had this property; the consolidation had regressed it. Pinned by the wiring test. - Reconcile exit captured via PIPESTATUS[0] in BOTH lanes: GitHub's default run-step shell has no pipefail, so `$?` after `| tee` was tee's exit — the fail-closed gate was silently fail-open. Wiring test pins it. - PR comment: final-attempt accounting restored the dropped COST accumulation (the dial read $0 forever), flaky passes render as the warning they are (never as failures), and a malformed tests[] artifact skips that file instead of aborting the whole comment under bash -e. - Remaining mutable action tags pinned (free-tests upload-artifact, ci-image checkout/docker trio — the image publisher holds packages:write and feeds the secret-bearing lanes). restore-deps fallback installs --frozen-lockfile; register-gstack-skills validates skill names before its rm -rf. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: bump version and changelog (v1.77.0.0) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: update project documentation for v1.77.0.0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: cross-model doc-review fixes — flake-ledger env knobs, CI retry-on note, stale version comment Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: correct CHANGELOG receipt numbers to measured values Gate census keys: 78 -> 77 (bun-imported E2E_TIERS count). Sweep receipt: 586 sites/176 files -> 499 sites/146 files, measured by running this branch's spawnsync-timeout-tripwire against origin/main (exit 1, 499 violations across 146 unique files; green on this branch). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: slices-comment creates the PR comment via REST — the write-token job has no git context The token/exec split gives slices-comment NO checkout by design, and gh's pr-comment subcommand resolves the repo FROM git — it died with 'not a git repository' on PR #2746's first run (the update-existing PATCH path was already explicit-repo REST and worked). Create now posts through gh api repos/.../issues/N/comments, and the wiring test pins that no git-context-requiring comment call can creep back into the job. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: startup-grace probes clear CI for local semantics; new probe pins the floor clamp The two shim probes pass explicit 2s/4s graces, but in CI the runner clamps any explicit grace up to the 300s floor (deliberate adversarial-review fix), so 'silent API killed at the grace' died at the 30s work cap instead of 2s — a deterministic red on every CI run, green locally. The probes now pin LOCAL semantics with CI cleared (same save/restore pattern as their PATH shim), and a fourth probe pins the clamp itself: CI=1 + 2s grace + 6s timeout must kill at the 6s cap, still in the startup phase — proof an explicit low grace cannot bypass the floor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
253d1dfe26
commit
e76f65a8da
@@ -22,6 +22,7 @@ function runMigration(fakeHome: string): { code: number; stdout: string; stderr:
|
||||
env: { ...process.env, HOME: fakeHome },
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
timeout: 30_000,
|
||||
});
|
||||
return {
|
||||
code: proc.exitCode ?? -1,
|
||||
@@ -217,6 +218,7 @@ function runMigrationV140(fakeHome: string): { code: number; stdout: string; std
|
||||
env: { ...process.env, HOME: fakeHome },
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
timeout: 30_000,
|
||||
});
|
||||
return {
|
||||
code: proc.exitCode ?? -1,
|
||||
|
||||
@@ -113,6 +113,7 @@ function runHook(stdin: object, env: Record<string, string>): { additionalContex
|
||||
input: JSON.stringify(stdin),
|
||||
encoding: 'utf-8',
|
||||
env: { PATH: process.env.PATH ?? '/usr/bin:/bin', ...env },
|
||||
timeout: 30_000,
|
||||
});
|
||||
const parsed = JSON.parse(res.stdout || '{}');
|
||||
return parsed.hookSpecificOutput ?? {};
|
||||
|
||||
@@ -69,8 +69,8 @@ describe("native slug fallback mirrors bin/gstack-slug", () => {
|
||||
["https://gitlab.com/acme/Widget", "acme-Widget"],
|
||||
] as const) {
|
||||
const cwd = fs.mkdtempSync(path.join(tmp, "repo-"));
|
||||
spawnSync("git", ["init", "-q"], { cwd });
|
||||
spawnSync("git", ["remote", "add", "origin", url], { cwd });
|
||||
spawnSync("git", ["init", "-q"], { cwd, timeout: 30_000 });
|
||||
spawnSync("git", ["remote", "add", "origin", url], { cwd, timeout: 30_000 });
|
||||
expect(slugFromEnvironment(path.join(tmp, "home2"), cwd)).toBe(want);
|
||||
}
|
||||
});
|
||||
@@ -231,8 +231,8 @@ describe("walk-up parity with bin/gstack-slug (outermost project root)", () => {
|
||||
const projectRoot = path.join(tmp, "realgit");
|
||||
const subdir = path.join(projectRoot, "src", "deep");
|
||||
fs.mkdirSync(subdir, { recursive: true });
|
||||
spawnSync("git", ["init", "-q", projectRoot]);
|
||||
spawnSync("git", ["-C", projectRoot, "remote", "add", "origin", "https://github.com/foo/bar.git"]);
|
||||
spawnSync("git", ["init", "-q", projectRoot], { timeout: 30_000 });
|
||||
spawnSync("git", ["-C", projectRoot, "remote", "add", "origin", "https://github.com/foo/bar.git"], { timeout: 30_000 });
|
||||
expectBoth(subdir, "foo-bar");
|
||||
});
|
||||
|
||||
@@ -269,10 +269,10 @@ describe("walk-up parity with bin/gstack-slug (outermost project root)", () => {
|
||||
const outer = path.join(tmp, "outer-project");
|
||||
const inner = path.join(outer, "vendor", "inner-lib");
|
||||
fs.mkdirSync(inner, { recursive: true });
|
||||
spawnSync("git", ["init", "-q", outer]);
|
||||
spawnSync("git", ["-C", outer, "remote", "add", "origin", "git@github.com:acme/outer.git"]);
|
||||
spawnSync("git", ["init", "-q", inner]);
|
||||
spawnSync("git", ["-C", inner, "remote", "add", "origin", "git@github.com:vendor/inner.git"]);
|
||||
spawnSync("git", ["init", "-q", outer], { timeout: 30_000 });
|
||||
spawnSync("git", ["-C", outer, "remote", "add", "origin", "git@github.com:acme/outer.git"], { timeout: 30_000 });
|
||||
spawnSync("git", ["init", "-q", inner], { timeout: 30_000 });
|
||||
spawnSync("git", ["-C", inner, "remote", "add", "origin", "git@github.com:vendor/inner.git"], { timeout: 30_000 });
|
||||
expectBoth(inner, "acme-outer");
|
||||
});
|
||||
|
||||
@@ -286,8 +286,8 @@ describe("walk-up parity with bin/gstack-slug (outermost project root)", () => {
|
||||
fs.mkdirSync(path.join(strayHome, ".git"), { recursive: true }); // empty — invalid repo
|
||||
const repo = path.join(strayHome, "work", "repo");
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
spawnSync("git", ["init", "-q", repo]);
|
||||
spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/garrytan/gstack"]);
|
||||
spawnSync("git", ["init", "-q", repo], { timeout: 30_000 });
|
||||
spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/garrytan/gstack"], { timeout: 30_000 });
|
||||
expectBoth(repo, "garrytan-gstack");
|
||||
expect(slugFromEnvironment(nativeHome(), repo)).not.toBe("strayhome");
|
||||
});
|
||||
@@ -299,9 +299,9 @@ describe("walk-up parity with bin/gstack-slug (outermost project root)", () => {
|
||||
const outer = path.join(tmp, "outer-plain");
|
||||
const inner = path.join(outer, "vendor", "inner-lib");
|
||||
fs.mkdirSync(inner, { recursive: true });
|
||||
spawnSync("git", ["init", "-q", outer]); // no origin — marker-only repo
|
||||
spawnSync("git", ["init", "-q", inner]);
|
||||
spawnSync("git", ["-C", inner, "remote", "add", "origin", "git@github.com:vendor/inner.git"]);
|
||||
spawnSync("git", ["init", "-q", outer], { timeout: 30_000 }); // no origin — marker-only repo
|
||||
spawnSync("git", ["init", "-q", inner], { timeout: 30_000 });
|
||||
spawnSync("git", ["-C", inner, "remote", "add", "origin", "git@github.com:vendor/inner.git"], { timeout: 30_000 });
|
||||
expectBoth(inner, "vendor-inner");
|
||||
});
|
||||
|
||||
@@ -313,8 +313,8 @@ describe("walk-up parity with bin/gstack-slug (outermost project root)", () => {
|
||||
fs.mkdirSync(path.join(strayHome, ".git"), { recursive: true });
|
||||
const repo = path.join(strayHome, "git", "proj");
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
spawnSync("git", ["init", "-q", repo]);
|
||||
spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/garrytan/gstack"]);
|
||||
spawnSync("git", ["init", "-q", repo], { timeout: 30_000 });
|
||||
spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/garrytan/gstack"], { timeout: 30_000 });
|
||||
|
||||
const cacheDir = path.join(nativeHome(), "slug-cache");
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
@@ -335,8 +335,8 @@ describe("walk-up parity with bin/gstack-slug (outermost project root)", () => {
|
||||
const inner = path.join(wrapper, "apps", "web");
|
||||
fs.mkdirSync(inner, { recursive: true });
|
||||
fs.writeFileSync(path.join(wrapper, "package.json"), '{"name":"wrapper"}\n');
|
||||
spawnSync("git", ["init", "-q", inner]);
|
||||
spawnSync("git", ["-C", inner, "remote", "add", "origin", "https://github.com/acme/web.git"]);
|
||||
spawnSync("git", ["init", "-q", inner], { timeout: 30_000 });
|
||||
spawnSync("git", ["-C", inner, "remote", "add", "origin", "https://github.com/acme/web.git"], { timeout: 30_000 });
|
||||
|
||||
const cacheDir = path.join(nativeHome(), "slug-cache");
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
@@ -361,8 +361,8 @@ describe("walk-up parity with bin/gstack-slug (outermost project root)", () => {
|
||||
// fire even though cached == basename(project root).
|
||||
const repo = path.join(tmp, "stickyproj");
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
spawnSync("git", ["init", "-q", repo]);
|
||||
spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/x/y.git"]);
|
||||
spawnSync("git", ["init", "-q", repo], { timeout: 30_000 });
|
||||
spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/x/y.git"], { timeout: 30_000 });
|
||||
|
||||
const cacheDir = path.join(nativeHome(), "slug-cache");
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
@@ -379,8 +379,8 @@ describe("walk-up parity with bin/gstack-slug (outermost project root)", () => {
|
||||
// implementations must reject it and fall through to the basename.
|
||||
const repo = path.join(tmp, "dotty");
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
spawnSync("git", ["init", "-q", repo]);
|
||||
spawnSync("git", ["-C", repo, "remote", "add", "origin", ".."]);
|
||||
spawnSync("git", ["init", "-q", repo], { timeout: 30_000 });
|
||||
spawnSync("git", ["-C", repo, "remote", "add", "origin", ".."], { timeout: 30_000 });
|
||||
expectBoth(repo, "dotty");
|
||||
});
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('content-binding template drift', () => {
|
||||
const abort = spawnSync('bash', ['-c', scriptFor(
|
||||
'clean body\n',
|
||||
'body with UNTRUSTED TRACKER CONTENT banner leak\n',
|
||||
)], { encoding: 'utf-8' });
|
||||
)], { encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(abort.stderr).toContain('ABORT: envelope banner leaked');
|
||||
expect(abort.stdout).not.toContain('banner tripwire clean');
|
||||
|
||||
@@ -110,7 +110,7 @@ describe('content-binding template drift', () => {
|
||||
const clean = spawnSync('bash', ['-c', scriptFor(
|
||||
'clean body\n',
|
||||
'also clean body\n',
|
||||
)], { encoding: 'utf-8' });
|
||||
)], { encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(clean.stdout).toContain('banner tripwire clean');
|
||||
expect(clean.stderr).not.toContain('ABORT');
|
||||
} finally {
|
||||
|
||||
+13
-11
@@ -43,12 +43,13 @@ function run(argv: string[], opts: { env?: Record<string, string>; input?: strin
|
||||
encoding: 'utf-8',
|
||||
input: opts.input,
|
||||
cwd: ROOT,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return { stdout: res.stdout || '', stderr: res.stderr || '', status: res.status ?? -1 };
|
||||
}
|
||||
|
||||
function git(args: string[], cwd?: string) {
|
||||
const res = spawnSync('git', args, { cwd: cwd || tmpHome, encoding: 'utf-8' });
|
||||
const res = spawnSync('git', args, { cwd: cwd || tmpHome, encoding: 'utf-8', timeout: 30_000 });
|
||||
return { stdout: res.stdout || '', stderr: res.stderr || '', status: res.status ?? -1 };
|
||||
}
|
||||
|
||||
@@ -77,7 +78,7 @@ function seedSpool(record: string): string {
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-sync-home-'));
|
||||
bareRemote = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-sync-remote-'));
|
||||
spawnSync('git', ['init', '--bare', '-q', '-b', 'main', bareRemote]);
|
||||
spawnSync('git', ['init', '--bare', '-q', '-b', 'main', bareRemote], { timeout: 30_000 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -198,6 +199,7 @@ describe('gstack-brain-enqueue', () => {
|
||||
const r = spawnSync(path.join(BIN, 'gstack-brain-enqueue'), [`file-${i}.jsonl`], {
|
||||
env: { ...process.env, GSTACK_HOME: tmpHome },
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
resolve();
|
||||
}));
|
||||
@@ -245,7 +247,7 @@ describe('gstack-jsonl-merge', () => {
|
||||
const lines = fs.readFileSync(ours, 'utf-8').trim().split('\n');
|
||||
expect(lines.length).toBe(3);
|
||||
// Order is deterministic (sha256 of each line).
|
||||
const again = spawnSync(path.join(BIN, 'gstack-jsonl-merge'), [base, ours, theirs]);
|
||||
const again = spawnSync(path.join(BIN, 'gstack-jsonl-merge'), [base, ours, theirs], { timeout: 30_000 });
|
||||
// (re-running doesn't change the order since same input → same output)
|
||||
});
|
||||
});
|
||||
@@ -271,7 +273,7 @@ describe('init + sync + restore round-trip', () => {
|
||||
test('refuses init on different remote', () => {
|
||||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||||
const otherRemote = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-other-'));
|
||||
spawnSync('git', ['init', '--bare', '-q', '-b', 'main', otherRemote]);
|
||||
spawnSync('git', ['init', '--bare', '-q', '-b', 'main', otherRemote], { timeout: 30_000 });
|
||||
const r = run(['gstack-artifacts-init', '--remote', otherRemote]);
|
||||
expect(r.status).not.toBe(0);
|
||||
expect(r.stderr).toContain('already a git repo pointing at');
|
||||
@@ -288,7 +290,7 @@ describe('init + sync + restore round-trip', () => {
|
||||
const r = run(['gstack-brain-sync', '--once']);
|
||||
expect(r.status).toBe(0);
|
||||
// Check the remote got the commit.
|
||||
const log = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8' });
|
||||
const log = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(log.stdout).toMatch(/sync: 1 file/);
|
||||
});
|
||||
|
||||
@@ -311,7 +313,7 @@ describe('init + sync + restore round-trip', () => {
|
||||
const restored = fs.readFileSync(path.join(machineB, 'projects/myproj/learnings.jsonl'), 'utf-8');
|
||||
expect(restored).toContain('machine A wisdom');
|
||||
// Merge drivers re-registered on B.
|
||||
const cfg = spawnSync('git', ['-C', machineB, 'config', '--get', 'merge.jsonl-append.driver'], { encoding: 'utf-8' });
|
||||
const cfg = spawnSync('git', ['-C', machineB, 'config', '--get', 'merge.jsonl-append.driver'], { encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(cfg.stdout).toContain('gstack-jsonl-merge');
|
||||
fs.rmSync(machineB, { recursive: true, force: true });
|
||||
});
|
||||
@@ -399,7 +401,7 @@ describe('gstack-brain-sync egress receipt gate', () => {
|
||||
// No local commit was created.
|
||||
expect(git(['rev-list', '--count', 'HEAD']).stdout.trim()).toBe(commitsBefore);
|
||||
// Nothing reached the remote.
|
||||
const remoteLog = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8' });
|
||||
const remoteLog = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(remoteLog.stdout).not.toMatch(/sync: 1 file/);
|
||||
const status = JSON.parse(fs.readFileSync(path.join(tmpHome, '.brain-sync-status.json'), 'utf-8'));
|
||||
expect(status.status).toBe('push_failed');
|
||||
@@ -411,7 +413,7 @@ describe('gstack-brain-sync egress receipt gate', () => {
|
||||
// Next run (ledger writable again) drains the intact queue and pushes.
|
||||
const retry = run(['gstack-brain-sync', '--once']);
|
||||
expect(retry.status).toBe(0);
|
||||
const log = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8' });
|
||||
const log = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(log.stdout).toMatch(/sync: 1 file/);
|
||||
});
|
||||
|
||||
@@ -583,7 +585,7 @@ describe('#2549 queue integrity', () => {
|
||||
expect(r.status).toBe(0);
|
||||
expect(spoolText()).not.toContain('learnings.jsonl'); // synced, removed
|
||||
expect(spoolText()).toContain('timeline.jsonl'); // held, retained
|
||||
const log = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8' });
|
||||
const log = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(log.stdout).toMatch(/sync: 1 file/);
|
||||
});
|
||||
|
||||
@@ -619,7 +621,7 @@ describe('#2549 queue integrity', () => {
|
||||
fs.rmSync(hook);
|
||||
const retry = run(['gstack-brain-sync', '--once']);
|
||||
expect(retry.status).toBe(0);
|
||||
const log = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8' });
|
||||
const log = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(log.stdout).toMatch(/sync: 1 file/);
|
||||
expect(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim()).toBe('0');
|
||||
});
|
||||
@@ -751,7 +753,7 @@ describe('C12 spool queue', () => {
|
||||
run(['gstack-config', 'set', 'artifacts_sync_mode', mode]);
|
||||
}
|
||||
const remoteLog = () =>
|
||||
spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8' }).stdout;
|
||||
spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8', timeout: 30_000 }).stdout;
|
||||
|
||||
test('two rapid enqueues of different paths create two spool files; one drain syncs both', () => {
|
||||
initWithMode('full');
|
||||
|
||||
@@ -37,7 +37,7 @@ const FILENAME_PREFIX = /\$\{?_BRANCH\}?[A-Za-z0-9._-]*\.(?:jsonl|json|md|txt|lo
|
||||
function renderedSkillFiles(): string[] {
|
||||
const out = execSync(
|
||||
`find "${ROOT}" -name 'SKILL.md' -not -path '*/node_modules/*' -not -path '*/.claude/*' ; find "${ROOT}" -path '*/sections/*.md' -not -path '*/node_modules/*' -not -path '*/.claude/*'`,
|
||||
{ encoding: 'utf-8' },
|
||||
{ encoding: 'utf-8', timeout: 30_000 },
|
||||
);
|
||||
return out.split('\n').filter(Boolean);
|
||||
}
|
||||
@@ -91,18 +91,18 @@ describe('branch slug hygiene (#2550, #1851)', () => {
|
||||
const env = { ...process.env, GSTACK_HOME: home };
|
||||
execSync(
|
||||
'git init -q && git -c user.email=t@t -c user.name=t commit -q --allow-empty -m init && git checkout -q -b feat/slug-hygiene',
|
||||
{ cwd: repo, encoding: 'utf-8' },
|
||||
{ cwd: repo, encoding: 'utf-8', timeout: 30_000 },
|
||||
);
|
||||
|
||||
// Writer: the real gstack-review-log (canonicalizes via gstack-slug).
|
||||
execSync(
|
||||
`"${path.join(ROOT, 'bin', 'gstack-review-log')}" '{"skill":"ship","status":"ok"}'`,
|
||||
{ cwd: repo, env, encoding: 'utf-8' },
|
||||
{ cwd: repo, env, encoding: 'utf-8', timeout: 30_000 },
|
||||
);
|
||||
|
||||
// The slug-canonical filename must exist; the raw form must not.
|
||||
const slugVars = execSync(`"${path.join(ROOT, 'bin', 'gstack-slug')}"`, {
|
||||
cwd: repo, env, encoding: 'utf-8',
|
||||
cwd: repo, env, encoding: 'utf-8', timeout: 30_000,
|
||||
});
|
||||
const slug = slugVars.match(/^SLUG=(.*)$/m)![1];
|
||||
const branch = slugVars.match(/^BRANCH=(.*)$/m)![1];
|
||||
@@ -120,7 +120,7 @@ describe('branch slug hygiene (#2550, #1851)', () => {
|
||||
.find((l) => l.includes('-reviews.jsonl'))!;
|
||||
const script = `_PROJ="${proj}"\nBRANCH="${branch}"\n${probeLine.trim()}`;
|
||||
const out = execSync(`bash -c '${script.replace(/'/g, `'\\''`)}'`, {
|
||||
cwd: repo, encoding: 'utf-8',
|
||||
cwd: repo, encoding: 'utf-8', timeout: 30_000,
|
||||
});
|
||||
expect(out).toContain('REVIEWS: 1 entries');
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ function runProfile(): Record<string, string> {
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
};
|
||||
const stdout = execSync(`${BIN}/gstack-builder-profile`, execOpts).trim();
|
||||
const stdout = execSync(`${BIN}/gstack-builder-profile`, execOpts).trim(); // timeout via execOpts
|
||||
const result: Record<string, string> = {};
|
||||
for (const line of stdout.split('\n')) {
|
||||
const idx = line.indexOf(':');
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Provider CLIs baked into the CI image must be pinned to EXACT versions.
|
||||
*
|
||||
* The PTY harness (test/helpers/claude-pty-runner.ts) screen-scrapes the
|
||||
* claude CLI's TUI — trust dialog, input-prompt ready marker, spinner glyphs.
|
||||
* The image used to install `npm i -g @anthropic-ai/claude-code` UNPINNED and
|
||||
* rebuild weekly "to pick up CLI updates", while bun sat carefully pinned at
|
||||
* 1.3.13 two RUN lines above — the exact drift class the bun pin exists for.
|
||||
* Receipts: TUI drift broke the harness three separate times (welcome-screen
|
||||
* wedge vs CLI 2.1.233, skillify HOME discovery on 2.1.237, guard/freeze
|
||||
* hooks on 2.1.162), each debugged as a "flake" before being traced to an
|
||||
* unpinned weekly-latest CLI.
|
||||
*
|
||||
* This tripwire fails the free suite when any globally-installed npm package
|
||||
* in Dockerfile.ci lacks an exact `@X.Y.Z` pin. Bumps are deliberate: edit
|
||||
* the pin in a PR and run the PTY gate against the new TUI before merging.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const DOCKERFILE = path.join(ROOT, '.github', 'docker', 'Dockerfile.ci');
|
||||
|
||||
/** Package specs from every `npm i -g` / `npm install -g` in the Dockerfile. */
|
||||
export function globalNpmInstallSpecs(source: string): string[] {
|
||||
const specs: string[] = [];
|
||||
for (const match of source.matchAll(/npm\s+(?:i|install)\s+(?:-g|--global)\s+([^\n\\&|;]+)/g)) {
|
||||
for (const spec of match[1].trim().split(/\s+/)) {
|
||||
if (spec.startsWith('-')) continue; // flags like --no-fund
|
||||
specs.push(spec);
|
||||
}
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
/** Exact pin = a trailing @<semver> with no range operator (no ^ ~ x *). */
|
||||
export function isExactlyPinned(spec: string): boolean {
|
||||
// Scoped (@scope/name@1.2.3) or bare (name@1.2.3); version must be exact.
|
||||
const at = spec.lastIndexOf('@');
|
||||
if (at <= 0) return false; // no version at all (or a bare scope)
|
||||
const version = spec.slice(at + 1);
|
||||
return /^\d+\.\d+\.\d+(?:-[\w.]+)?$/.test(version);
|
||||
}
|
||||
|
||||
describe('ci image provider-CLI pins', () => {
|
||||
const source = fs.readFileSync(DOCKERFILE, 'utf-8');
|
||||
const specs = globalNpmInstallSpecs(source);
|
||||
|
||||
test('the image installs at least the claude CLI globally (scan must not rot)', () => {
|
||||
expect(
|
||||
specs.some((s) => s.startsWith('@anthropic-ai/claude-code@')),
|
||||
`expected a pinned @anthropic-ai/claude-code install in ${path.relative(ROOT, DOCKERFILE)}; found: ${specs.join(', ') || '(none)'}`,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('every global npm install carries an exact @X.Y.Z pin', () => {
|
||||
const unpinned = specs.filter((s) => !isExactlyPinned(s));
|
||||
expect(
|
||||
unpinned,
|
||||
`unpinned global npm installs in Dockerfile.ci: ${unpinned.join(', ')}\n`
|
||||
+ 'Pin the exact version (name@X.Y.Z) and bump via a PR that runs the '
|
||||
+ 'PTY gate against the new TUI — weekly-latest CLI drift broke the '
|
||||
+ 'harness three times before this tripwire existed.',
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -153,9 +153,9 @@ describe("session-start indexing offer (suggest)", () => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-home-"));
|
||||
repo = fs.mkdtempSync(path.join(os.tmpdir(), "ci-repo-"));
|
||||
env = { ...process.env, GSTACK_HOME: home };
|
||||
Bun.spawnSync(["git", "init", "-q", repo]);
|
||||
Bun.spawnSync(["git", "init", "-q", repo], { timeout: 30_000 });
|
||||
for (const name of ["a.ts", "b.ts", "c.ts"]) fs.writeFileSync(path.join(repo, name), "x\n");
|
||||
Bun.spawnSync(["git", "-C", repo, "add", "-A"]);
|
||||
Bun.spawnSync(["git", "-C", repo, "add", "-A"], { timeout: 30_000 });
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
@@ -399,7 +399,7 @@ describe("consent unification — deny tier wins (R1)", () => {
|
||||
function makeRepo(dir: string, url: string): string {
|
||||
const repo = path.join(dir, "repo");
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
const git = (...a: string[]) => execFileSync("git", a, { cwd: repo });
|
||||
const git = (...a: string[]) => execFileSync("git", a, { cwd: repo, timeout: 30_000 });
|
||||
git("init", "-q", ".");
|
||||
git("remote", "add", "origin", url);
|
||||
return repo;
|
||||
@@ -423,11 +423,11 @@ describe("consent unification — deny tier wins (R1)", () => {
|
||||
const env = { ...process.env, GSTACK_HOME: home };
|
||||
const repo = makeRepo(home, URL);
|
||||
setConsent(repo, true, env);
|
||||
execFileSync(POLICY_BIN, ["set", URL, "deny"], { env, encoding: "utf-8" });
|
||||
execFileSync(POLICY_BIN, ["set", URL, "deny"], { env, encoding: "utf-8", timeout: 30_000 });
|
||||
expect(hasConsent(repo, env)).toBe(false);
|
||||
// Flipping the tier back restores the recorded consent — the veto is
|
||||
// live policy, not a destructive rewrite of the consent store.
|
||||
execFileSync(POLICY_BIN, ["set", URL, "read-write"], { env, encoding: "utf-8" });
|
||||
execFileSync(POLICY_BIN, ["set", URL, "read-write"], { env, encoding: "utf-8", timeout: 30_000 });
|
||||
expect(hasConsent(repo, env)).toBe(true);
|
||||
} finally { fs.rmSync(home, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -439,7 +439,7 @@ describe("consent unification — deny tier wins (R1)", () => {
|
||||
const env = { ...process.env, GSTACK_HOME: home };
|
||||
const repo = makeRepo(home, URL);
|
||||
setConsent(repo, true, env);
|
||||
execFileSync(POLICY_BIN, ["set", URL, "read-write"], { env, encoding: "utf-8" });
|
||||
execFileSync(POLICY_BIN, ["set", URL, "read-write"], { env, encoding: "utf-8", timeout: 30_000 });
|
||||
fs.chmodSync(path.join(home, "gbrain-repo-policy.json"), 0o000);
|
||||
try {
|
||||
expect(hasConsent(repo, env)).toBe(false);
|
||||
@@ -460,7 +460,7 @@ describe("consent unification — deny tier wins (R1)", () => {
|
||||
const env = { ...process.env, GSTACK_HOME: home };
|
||||
const repo = makeRepo(home, URL);
|
||||
setConsent(repo, true, env);
|
||||
execFileSync(POLICY_BIN, ["set", URL, "read-only"], { env, encoding: "utf-8" });
|
||||
execFileSync(POLICY_BIN, ["set", URL, "read-only"], { env, encoding: "utf-8", timeout: 30_000 });
|
||||
// Default op class is write — a caller that doesn't say gets fail-closed.
|
||||
expect(hasConsent(repo, env)).toBe(false);
|
||||
expect(hasConsent(repo, env, "write")).toBe(false);
|
||||
@@ -475,7 +475,7 @@ describe("consent unification — deny tier wins (R1)", () => {
|
||||
const env = { ...process.env, GSTACK_HOME: home };
|
||||
const repo = makeRepo(home, URL);
|
||||
setConsent(repo, true, env);
|
||||
execFileSync(POLICY_BIN, ["set", URL, "deny"], { env, encoding: "utf-8" });
|
||||
execFileSync(POLICY_BIN, ["set", URL, "deny"], { env, encoding: "utf-8", timeout: 30_000 });
|
||||
expect(hasConsent(repo, env, "write")).toBe(false);
|
||||
expect(hasConsent(repo, env, "read")).toBe(false);
|
||||
} finally { fs.rmSync(home, { recursive: true, force: true }); }
|
||||
@@ -971,9 +971,9 @@ exit 1
|
||||
|
||||
function makeRepoWithFiles(count: number): string {
|
||||
const repo = fs.mkdtempSync(path.join(os.tmpdir(), "ci-cli-suggest-"));
|
||||
Bun.spawnSync(["git", "init", "-q", repo]);
|
||||
Bun.spawnSync(["git", "init", "-q", repo], { timeout: 30_000 });
|
||||
for (let i = 0; i < count; i++) fs.writeFileSync(path.join(repo, `f${i}.ts`), "x\n");
|
||||
Bun.spawnSync(["git", "-C", repo, "add", "-A"]);
|
||||
Bun.spawnSync(["git", "-C", repo, "add", "-A"], { timeout: 30_000 });
|
||||
return repo;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ const ROOT = path.resolve(import.meta.dir, '..');
|
||||
|
||||
const CODEX_AVAILABLE = (() => {
|
||||
try {
|
||||
const result = Bun.spawnSync(['which', 'codex']);
|
||||
const result = Bun.spawnSync(['which', 'codex'], { timeout: 30_000 });
|
||||
return result.exitCode === 0;
|
||||
} catch { return false; }
|
||||
})();
|
||||
|
||||
@@ -31,7 +31,7 @@ const ROOT = path.resolve(import.meta.dir, '..');
|
||||
|
||||
const CODEX_AVAILABLE = (() => {
|
||||
try {
|
||||
return Bun.spawnSync(['which', 'codex']).exitCode === 0;
|
||||
return Bun.spawnSync(['which', 'codex'], { timeout: 30_000 }).exitCode === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -21,12 +21,12 @@ import { EvalCollector } from './helpers/eval-store';
|
||||
import { selectTests, detectBaseBranch, getChangedFiles, GLOBAL_TOUCHFILES } from './helpers/touchfiles';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const CODEX_AVAILABLE = spawnSync('which', ['codex']).status === 0;
|
||||
const CODEX_AVAILABLE = spawnSync('which', ['codex'], { timeout: 30_000 }).status === 0;
|
||||
// The run pins the model with --ignore-user-config; older codex CLIs reject
|
||||
// the flag with an argv error indistinguishable from a Sol regression, so
|
||||
// probe support and skip (not fail) on old CLIs.
|
||||
const IGNORE_USER_CONFIG_SUPPORTED = CODEX_AVAILABLE
|
||||
&& (spawnSync('codex', ['exec', '--help'], { encoding: 'utf8' }).stdout ?? '').includes('--ignore-user-config');
|
||||
&& (spawnSync('codex', ['exec', '--help'], { encoding: 'utf8', timeout: 120_000 }).stdout ?? '').includes('--ignore-user-config');
|
||||
const evalsEnabled = !!process.env.EVALS;
|
||||
// External-service test — periodic tier only (CLAUDE.md tiering rule 3). The
|
||||
// positive guard shape below is what classifyPaidTestFile greps to exclude
|
||||
|
||||
+13
-6
@@ -32,7 +32,7 @@ const ROOT = path.resolve(import.meta.dir, '..');
|
||||
|
||||
const CODEX_AVAILABLE = (() => {
|
||||
try {
|
||||
const result = Bun.spawnSync(['which', 'codex']);
|
||||
const result = Bun.spawnSync(['which', 'codex'], { timeout: 30_000 });
|
||||
return result.exitCode === 0;
|
||||
} catch { return false; }
|
||||
})();
|
||||
@@ -63,11 +63,18 @@ if (!evalsEnabled) {
|
||||
|
||||
// --- Diff-based test selection ---
|
||||
|
||||
// Codex E2E touchfiles — keyed by test name, same pattern as E2E_TOUCHFILES
|
||||
const CODEX_E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'codex-discover-skill': ['codex/**', '.agents/skills/**', 'test/helpers/codex-session-runner.ts'],
|
||||
'codex-review-findings': ['review/**', '.agents/skills/gstack-review/**', 'codex/**', 'test/helpers/codex-session-runner.ts'],
|
||||
};
|
||||
// Codex E2E touchfiles — DERIVED from the canonical map, never a local fork.
|
||||
// The old hand-copy drifted (it kept gitignored '.agents/skills/**' patterns
|
||||
// that can never match a git diff, and missed deps the canonical map gained
|
||||
// like lib/worktree.ts and this test file itself), so review-template edits
|
||||
// silently stopped selecting these tests. Deriving keeps one source of truth
|
||||
// and puts these keys under the tier-alignment + dep-existence invariants.
|
||||
const CODEX_E2E_TOUCHFILES: Record<string, string[]> = Object.fromEntries(
|
||||
(['codex-discover-skill', 'codex-review-findings'] as const).map((key) => {
|
||||
if (!E2E_TOUCHFILES[key]) throw new Error(`canonical E2E_TOUCHFILES lost key '${key}' — fix the map, not this file`);
|
||||
return [key, E2E_TOUCHFILES[key]];
|
||||
}),
|
||||
);
|
||||
|
||||
let selectedTests: string[] | null = null; // null = run all
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ model = "gpt-5.6-terra"
|
||||
cwd: ROOT,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, CODEX_HOME: home },
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(ok.status).toBe(0);
|
||||
expect(ok.stdout).toBe(`gpt-5.6-sol\t${path.join(home, 'config.toml')}\n`);
|
||||
@@ -123,6 +124,7 @@ model = "gpt-5.6-terra"
|
||||
const bad = spawnSync('bun', ['run', 'scripts/resolve-codex-generation-model.ts', '--explicit', 'llama-local'], {
|
||||
cwd: ROOT,
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(bad.status).not.toBe(0);
|
||||
expect(bad.stderr).toContain('Unknown model');
|
||||
|
||||
@@ -297,7 +297,7 @@ describe('gstack-codex-probe: timeout wrapper + namespace hygiene', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-watchdog-'));
|
||||
try {
|
||||
const which = (tool: string) =>
|
||||
spawnSync('bash', ['-c', `command -v ${tool}`]).stdout.toString().trim() || `/bin/${tool}`;
|
||||
spawnSync('bash', ['-c', `command -v ${tool}`], { timeout: 30_000 }).stdout.toString().trim() || `/bin/${tool}`;
|
||||
fs.symlinkSync(which('bash'), path.join(dir, 'bash'));
|
||||
fs.symlinkSync(which('sleep'), path.join(dir, 'sleep'));
|
||||
const r = runProbe({
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const codexPath = spawnSync('which', ['codex'], { encoding: 'utf-8' }).stdout.trim();
|
||||
const codexPath = spawnSync('which', ['codex'], { encoding: 'utf-8', timeout: 30_000 }).stdout.trim();
|
||||
const codexAvailable = codexPath.length > 0;
|
||||
|
||||
describe.skipIf(!codexAvailable)(
|
||||
|
||||
@@ -23,7 +23,7 @@ function grepRepo(pattern: string, includes: string[]): string[] {
|
||||
const includeArgs = includes.map((i) => `--include='${i}'`).join(' ');
|
||||
const out = execSync(
|
||||
`grep -rln ${includeArgs} -e '${pattern}' "${ROOT}" || true`,
|
||||
{ encoding: 'utf-8' },
|
||||
{ encoding: 'utf-8', timeout: 30_000 },
|
||||
);
|
||||
return out
|
||||
.split('\n')
|
||||
|
||||
@@ -718,7 +718,7 @@ describe("CLI plumbing", () => {
|
||||
});
|
||||
|
||||
it("bin/gstack-context-bill runs standalone", () => {
|
||||
const result = Bun.spawnSync([path.join(ROOT, "bin", "gstack-context-bill"), TREE_A]);
|
||||
const result = Bun.spawnSync([path.join(ROOT, "bin", "gstack-context-bill"), TREE_A], { timeout: 30_000 });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout.toString()).toContain("ALWAYS-ON");
|
||||
expect(result.stdout.toString()).toContain("EAGER");
|
||||
|
||||
@@ -99,7 +99,7 @@ describe("normalizeIntFlag CLI wrapper (exit-1 semantics)", () => {
|
||||
const v = normalizeIntFlag(${rawExpr}, ${specExpr});
|
||||
console.log("VALUE:" + v);
|
||||
`;
|
||||
const res = spawnSync("bun", ["-e", script], { encoding: "utf-8", cwd: ROOT });
|
||||
const res = spawnSync("bun", ["-e", script], { encoding: "utf-8", cwd: ROOT, timeout: 30_000 });
|
||||
return { status: res.status ?? -1, stderr: res.stderr ?? "" };
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ describe("diagram-render bundle drift", () => {
|
||||
"deep: fresh build reproduces committed dist",
|
||||
async () => {
|
||||
const before = await Bun.file(BUILD_INFO).json();
|
||||
const proc = Bun.spawnSync(["bun", "run", "scripts/build.ts"], { cwd: ROOT });
|
||||
const proc = Bun.spawnSync(["bun", "run", "scripts/build.ts"], { cwd: ROOT, timeout: 120_000 });
|
||||
expect(proc.exitCode).toBe(0);
|
||||
const after = await Bun.file(BUILD_INFO).json();
|
||||
expect(after.sha256).toBe(before.sha256);
|
||||
|
||||
@@ -60,7 +60,7 @@ function run(args: string[]): { stdout: string; stderr: string; status: number }
|
||||
env.GSTACK_STATE_ROOT = stateRoot;
|
||||
env.GSTACK_QUESTION_LOG_NO_DERIVE = '1';
|
||||
delete env.GSTACK_HOME;
|
||||
const res = spawnSync(BIN, args, { env, encoding: 'utf-8', cwd: fixtureCwd });
|
||||
const res = spawnSync(BIN, args, { env, encoding: 'utf-8', cwd: fixtureCwd, timeout: 30_000 });
|
||||
return {
|
||||
stdout: res.stdout ?? '',
|
||||
stderr: res.stderr ?? '',
|
||||
|
||||
@@ -47,6 +47,7 @@ function run(args: string[]): { stdout: string; stderr: string; status: number }
|
||||
env: makeEnv(),
|
||||
encoding: 'utf-8',
|
||||
cwd: fixtureCwd,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return {
|
||||
stdout: res.stdout ?? '',
|
||||
@@ -75,6 +76,7 @@ function writeAuqOtherEvent(text: string): void {
|
||||
env: makeEnv(),
|
||||
cwd: fixtureCwd,
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -150,7 +152,7 @@ describe('no-event paths', () => {
|
||||
tool_use_id: 'tu-x',
|
||||
}),
|
||||
],
|
||||
{ env: makeEnv(), cwd: fixtureCwd, encoding: 'utf-8' },
|
||||
{ env: makeEnv(), cwd: fixtureCwd, encoding: 'utf-8', timeout: 30_000 },
|
||||
);
|
||||
const r = run([]);
|
||||
expect(r.status).toBe(0);
|
||||
@@ -169,7 +171,7 @@ describe('--dry-run', () => {
|
||||
// Strip ANTHROPIC_API_KEY to prove no API call happens.
|
||||
const env = makeEnv();
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
const res = spawnSync(BIN, ['--dry-run'], { env, cwd: fixtureCwd, encoding: 'utf-8' });
|
||||
const res = spawnSync(BIN, ['--dry-run'], { env, cwd: fixtureCwd, encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(res.status).toBe(0);
|
||||
expect(res.stdout).toContain('DISTILL PROMPT');
|
||||
expect(res.stdout).toContain('always include tests');
|
||||
@@ -185,7 +187,7 @@ describe('API auth', () => {
|
||||
writeAuqOtherEvent('Some free text response that needs distilling');
|
||||
const env = makeEnv();
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
const res = spawnSync(BIN, [], { env, cwd: fixtureCwd, encoding: 'utf-8' });
|
||||
const res = spawnSync(BIN, [], { env, cwd: fixtureCwd, encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(res.status).not.toBe(0);
|
||||
expect(res.stderr).toMatch(/ANTHROPIC_API_KEY/);
|
||||
expect(res.stderr).toMatch(/separate billing/);
|
||||
|
||||
@@ -42,8 +42,8 @@ const SELF_GATE_RE = /EVALS_TIER\s*===\s*['"](gate|periodic)['"]/g;
|
||||
const HELPER_GATE_RE = /\b(?:describeE2ETier|e2eTierEnabled)\(\s*['"](gate|periodic)['"]/g;
|
||||
|
||||
/**
|
||||
* Ratchet, not amnesty (same contract as KNOWN_MATRIX_GAPS in
|
||||
* test/evals-workflow-matrix.test.ts): skill-e2e files that are named in NO
|
||||
* Ratchet, not amnesty (the contract KNOWN_MATRIX_GAPS pioneered before the
|
||||
* legacy eval matrix and its test retired): skill-e2e files that are named in NO
|
||||
* touchfiles dep list because no E2E map key exists for them. Every entry
|
||||
* carries a one-line reason. Do NOT add new files here — give the test an
|
||||
* E2E map key (touchfiles + tier) and register the file in its dep list.
|
||||
@@ -141,8 +141,8 @@ describe('E2E tier alignment (touchfiles declaration vs test self-gate)', () =>
|
||||
expect(misaligned).toEqual([]);
|
||||
});
|
||||
|
||||
// Ratchet cleanup enforcement (same contract as evals-workflow-matrix's
|
||||
// burn-down test): a KNOWN_UNREGISTERED entry whose file was deleted, or
|
||||
// Ratchet cleanup enforcement (same shrink-only contract as the retired
|
||||
// matrix test's burn-down): a KNOWN_UNREGISTERED entry whose file was deleted, or
|
||||
// whose file is now named in a dep list, is stale — delete the entry so
|
||||
// the set can only shrink.
|
||||
test('KNOWN_UNREGISTERED holds only live, still-unregistered files', () => {
|
||||
|
||||
@@ -222,12 +222,12 @@ describe('gstack-egress-receipt shell bridge', () => {
|
||||
fs.writeFileSync(payload, '[{"v":1}]');
|
||||
const write = spawnSync(bin, ['write', '--sink', 'telemetry-sync', '--host', '127.0.0.1:8399',
|
||||
'--class', 'telemetry-events', '--payload-file', payload, '--consent', 'telemetry=community'],
|
||||
{ encoding: 'utf-8', env: { ...process.env, GSTACK_HOME: home } });
|
||||
{ encoding: 'utf-8', timeout: 30_000, env: { ...process.env, GSTACK_HOME: home } });
|
||||
expect(write.status).toBe(0);
|
||||
const id = write.stdout.trim();
|
||||
expect(id).toMatch(/^[0-9a-f]{64}$/);
|
||||
const outcome = spawnSync(bin, ['outcome', id, '204'],
|
||||
{ encoding: 'utf-8', env: { ...process.env, GSTACK_HOME: home } });
|
||||
{ encoding: 'utf-8', timeout: 30_000, env: { ...process.env, GSTACK_HOME: home } });
|
||||
expect(outcome.status).toBe(0);
|
||||
const receipts = listReceipts(home);
|
||||
expect(receipts.length).toBe(1);
|
||||
@@ -239,7 +239,7 @@ describe('gstack-egress-receipt shell bridge', () => {
|
||||
test('--no-payload records sha256:null (git-class: a subprocess owns the bytes)', () => {
|
||||
const write = spawnSync(bin, ['write', '--sink', 'brain-sync', '--host', 'github.com',
|
||||
'--class', 'git-push', '--no-payload', '--consent', 'artifacts_sync_mode=auto'],
|
||||
{ encoding: 'utf-8', env: { ...process.env, GSTACK_HOME: home } });
|
||||
{ encoding: 'utf-8', timeout: 30_000, env: { ...process.env, GSTACK_HOME: home } });
|
||||
expect(write.status).toBe(0);
|
||||
const receipts = listReceipts(home);
|
||||
expect(receipts.length).toBe(1);
|
||||
@@ -251,7 +251,7 @@ describe('gstack-egress-receipt shell bridge', () => {
|
||||
if (!canRevokeWrites()) return; // chmod is advisory here (win32, root, DAC-override containers)
|
||||
fs.mkdirSync(path.join(home, 'security'), { recursive: true, mode: 0o500 });
|
||||
const write = spawnSync(bin, ['write', '--sink', 's', '--host', 'h', '--class', 'c', '--no-payload'],
|
||||
{ encoding: 'utf-8', env: { ...process.env, GSTACK_HOME: home } });
|
||||
{ encoding: 'utf-8', timeout: 30_000, env: { ...process.env, GSTACK_HOME: home } });
|
||||
expect(write.status).toBe(3);
|
||||
expect(write.stderr).toContain('EGRESS_RECEIPT_FAILED');
|
||||
fs.chmodSync(path.join(home, 'security'), 0o700);
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('empty find must not fall through to cwd (#2483)', () => {
|
||||
test('no resolver emits a bare `xargs ls -t` (must be `xargs -r ls -t`)', () => {
|
||||
const out = execSync(
|
||||
`grep -rn "xargs ls -t" "${path.join(ROOT, 'scripts')}" "${path.join(ROOT, 'bin')}" || true`,
|
||||
{ encoding: 'utf-8' },
|
||||
{ encoding: 'utf-8', timeout: 30_000 },
|
||||
);
|
||||
expect(out.trim()).toBe('');
|
||||
});
|
||||
@@ -67,6 +67,7 @@ describe('empty find must not fall through to cwd (#2483)', () => {
|
||||
const out = execSync(`bash -c '${script.replace(/'/g, `'\\''`)}'`, {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(out).toContain('LATEST_CP=[]');
|
||||
expect(out).not.toContain('DECOY.md');
|
||||
@@ -79,7 +80,7 @@ describe('empty find must not fall through to cwd (#2483)', () => {
|
||||
test('no generated SKILL.md carries the unguarded form', () => {
|
||||
const out = execSync(
|
||||
`grep -rln "xargs ls -t" --include=SKILL.md "${ROOT}" || true`,
|
||||
{ encoding: 'utf-8' },
|
||||
{ encoding: 'utf-8', timeout: 30_000 },
|
||||
);
|
||||
// node_modules and vendored trees are not generated output; nothing in
|
||||
// the repo's generated skills may carry the unguarded form.
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('eval budget tiers', () => {
|
||||
});
|
||||
|
||||
test('no paid-test timeout literal exceeds the ceiling tier', () => {
|
||||
const out = spawnSync('git', ['ls-files', 'test/*.test.ts'], { cwd: ROOT, encoding: 'utf-8' });
|
||||
const out = spawnSync('git', ['ls-files', 'test/*.test.ts'], { cwd: ROOT, encoding: 'utf-8', timeout: 30_000 });
|
||||
const files = out.stdout.split('\n').filter((f) => f && isPaidTestFile(f));
|
||||
expect(files.length).toBeGreaterThan(50); // scan-rot guard
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Unit tests for the flake-rank aggregator (WS1's dial). The CLI ranks tests
|
||||
* by retried passes (the flake signature) across finalized eval-store runs —
|
||||
* these pin the accounting: N attempt records = 1 run of that test, the
|
||||
* FINAL attempt decides pass/fail, retried passes count separately, partials
|
||||
* and runner artifacts are excluded, shard dirs recurse, and the recency
|
||||
* bound drops stale files.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { aggregate, collectEvalFiles } from '../scripts/eval-flake-rank';
|
||||
|
||||
const entry = (name: string, passed: boolean, attempt: number) => ({
|
||||
name, suite: 's', tier: 'e2e', passed, attempt, duration_ms: 1000, cost_usd: 0.1,
|
||||
});
|
||||
|
||||
const run = (tests: object[], extra: object = {}) => JSON.stringify({
|
||||
schema_version: 2, version: '1.0.0', branch: 'b', git_sha: 'x', hostname: 'h',
|
||||
timestamp: '2026-08-31T00:00:00Z', tier: 'e2e',
|
||||
total_tests: tests.length, passed: 0, failed: 0, total_cost_usd: 0, total_duration_ms: 0,
|
||||
tests, ...extra,
|
||||
});
|
||||
|
||||
describe('eval-flake-rank aggregate', () => {
|
||||
test('final attempt decides; retried pass counts as retriedPass, not a fail', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'flakerank-'));
|
||||
fs.writeFileSync(path.join(dir, 'run1.json'), run([
|
||||
entry('flaky', false, 1), entry('flaky', true, 2), // pass on retry
|
||||
entry('steady', true, 1),
|
||||
entry('broken', false, 1), entry('broken', false, 2), // fails even retried
|
||||
]));
|
||||
fs.writeFileSync(path.join(dir, 'run2.json'), run([
|
||||
entry('flaky', true, 1), entry('steady', true, 1),
|
||||
]));
|
||||
const series = aggregate(collectEvalFiles(dir));
|
||||
expect(series.get('flaky')).toMatchObject({ runs: 2, passes: 2, fails: 0, retriedPasses: 1, totalAttempts: 3 });
|
||||
expect(series.get('steady')).toMatchObject({ runs: 2, passes: 2, fails: 0, retriedPasses: 0 });
|
||||
expect(series.get('broken')).toMatchObject({ runs: 1, passes: 0, fails: 1, retriedPasses: 0, totalAttempts: 2 });
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('partials and runner artifacts are excluded; shard dirs recurse', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'flakerank-'));
|
||||
fs.mkdirSync(path.join(dir, 'shards', 'slug-a'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'shards', 'slug-a', 'run.json'), run([entry('sharded', true, 1)]));
|
||||
fs.writeFileSync(path.join(dir, '_partial-e2e.json'), run([entry('inflight', false, 1)], { _partial: true }));
|
||||
fs.writeFileSync(path.join(dir, 'manifest.json'), '{"version":1}');
|
||||
fs.writeFileSync(path.join(dir, 'slice-3.json'), '{"version":1}');
|
||||
const files = collectEvalFiles(dir);
|
||||
expect(files).toHaveLength(1);
|
||||
const series = aggregate(files);
|
||||
expect(series.has('sharded')).toBe(true);
|
||||
expect(series.has('inflight')).toBe(false);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('recency bound drops files older than sinceDays', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'flakerank-'));
|
||||
const stale = path.join(dir, 'old.json');
|
||||
fs.writeFileSync(stale, run([entry('ancient', true, 1)]));
|
||||
const old = new Date(Date.now() - 90 * 86_400_000);
|
||||
fs.utimesSync(stale, old, old);
|
||||
fs.writeFileSync(path.join(dir, 'new.json'), run([entry('recent', true, 1)]));
|
||||
const files = collectEvalFiles(dir, 60);
|
||||
expect(files.map((f) => path.basename(f))).toEqual(['new.json']);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,7 @@ function runEvalList(...args: string[]): { stdout: string; stderr: string; statu
|
||||
GSTACK_HOME: path.join(tmpHome, '.gstack'),
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
return {
|
||||
stdout: result.stdout ?? '',
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* CI eval-matrix completeness tripwire — kills the silent-skip class where a
|
||||
* gate-tier test exists in the repo but the hand-enumerated matrix in
|
||||
* .github/workflows/evals.yml never runs it, so "gate tier blocks merge" is
|
||||
* quietly false in CI. This has happened before (see the "rehomed from the
|
||||
* deleted pre-split monolith" comment in evals.yml) and was found again on
|
||||
* PR #2700: nine gate-hosting files absent from the matrix, plus matrix rows
|
||||
* whose whole-file tier guards can never fire because the Run step exported
|
||||
* no EVALS_TIER.
|
||||
*
|
||||
* Ratchet, not amnesty: the KNOWN_* lists below enumerate the PRE-EXISTING
|
||||
* gaps with reasons, so no NEW gap can land while the backlog burns down
|
||||
* (same pattern as SCANNER_EXEMPT in egress-receipt-wiring). If you fix a
|
||||
* listed gap (add its matrix row / tier property), this test FAILS until you
|
||||
* remove the entry — stale exemptions are enforced, not decorative.
|
||||
*
|
||||
* Wiring pinned:
|
||||
* - every matrix `file:` path exists on disk (no stale rows),
|
||||
* - every gate-hosting paid file (whole-file gate self-gate, or named in the
|
||||
* dep list of a gate-tier E2E_TOUCHFILES key) appears in the matrix or in
|
||||
* KNOWN_MATRIX_GAPS,
|
||||
* - every matrix file with a whole-file tier guard has a matching row-level
|
||||
* `tier:` property (else the suite self-skips and the job is hollow-green)
|
||||
* or sits in KNOWN_TIER_UNSET.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { E2E_TOUCHFILES, E2E_TIERS } from './helpers/touchfiles-data';
|
||||
import { isPaidTestFile } from './helpers/paid-test-set';
|
||||
|
||||
const ROOT = path.join(import.meta.dir, '..');
|
||||
const WORKFLOW = path.join(ROOT, '.github', 'workflows', 'evals.yml');
|
||||
|
||||
/**
|
||||
* Pre-existing gate-hosting files with no matrix row (found 2026-08-26,
|
||||
* PR #2700). Adding a row activates real paid runs on every PR — a cost and
|
||||
* flake-surface decision per file, tracked in TODOS.md ("CI gate-lane
|
||||
* hollow-coverage burn-down"). Fix = add a matrix row (plus `tier: gate` when
|
||||
* the file is whole-file gated), then DELETE the entry here.
|
||||
*/
|
||||
const KNOWN_MATRIX_GAPS = new Set([
|
||||
'test/skill-e2e-ask-user-question-format-compliance.test.ts',
|
||||
'test/skill-e2e-hermetic-canary.test.ts',
|
||||
'test/skill-e2e-ios.test.ts',
|
||||
'test/skill-e2e-plan-ceo-finding-floor.test.ts',
|
||||
'test/skill-e2e-plan-ceo-plan-mode.test.ts',
|
||||
'test/skill-e2e-plan-design-with-ui.test.ts',
|
||||
'test/skill-e2e-plan-devex-finding-floor.test.ts',
|
||||
'test/skill-e2e-plan-devex-plan-mode.test.ts',
|
||||
// Exposed by the 2026-08 dep-list self-registration sweep: these eight had
|
||||
// zero gate-key dep-list membership before it, so the census never saw
|
||||
// them as gate-hosting. Their gate tests run in NO CI lane today. The
|
||||
// paid-lane re-platform (test-paid-shards.ts as the CI engine) runs every
|
||||
// gate-tier file by construction and retires this whole ratchet.
|
||||
'test/skill-e2e-cso.test.ts',
|
||||
'test/skill-e2e-diagram.test.ts',
|
||||
'test/skill-e2e-learnings.test.ts',
|
||||
'test/skill-e2e-plan-tune.test.ts',
|
||||
'test/skill-e2e-plan-tune-cathedral.test.ts',
|
||||
'test/skill-e2e-review-army.test.ts',
|
||||
'test/skill-e2e-session-intelligence.test.ts',
|
||||
'test/skill-e2e-skillify.test.ts',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Matrix files whose whole-file tier guard has no matching row `tier:`
|
||||
* property. Burned down to empty 2026-08-29: the vestigial codex/gemini rows
|
||||
* were deleted (periodic-tier files, zero tests per PR) and
|
||||
* e2e-pty-plan-smoke gained its `tier: gate`. The ratchet stays so a future
|
||||
* row/file tier mismatch fails the suite instead of shipping hollow green.
|
||||
*/
|
||||
const KNOWN_TIER_UNSET = new Map<string, string>([]);
|
||||
|
||||
interface MatrixRow {
|
||||
name: string;
|
||||
files: string[];
|
||||
tier?: string;
|
||||
}
|
||||
|
||||
/** Parse the `matrix: suite:` rows (name / file / optional tier) from evals.yml. */
|
||||
function parseMatrixRows(source: string): MatrixRow[] {
|
||||
const rows: MatrixRow[] = [];
|
||||
let current: MatrixRow | null = null;
|
||||
for (const line of source.split('\n')) {
|
||||
const name = line.match(/^\s+- name: (\S+)\s*$/);
|
||||
if (name) {
|
||||
if (current) rows.push(current);
|
||||
current = { name: name[1], files: [] };
|
||||
continue;
|
||||
}
|
||||
if (!current) continue;
|
||||
const file = line.match(/^\s+file: (.+?)\s*$/);
|
||||
if (file) current.files.push(...file[1].trim().split(/\s+/));
|
||||
const tier = line.match(/^\s+tier: (\S+)\s*$/);
|
||||
if (tier) current.tier = tier[1];
|
||||
// `steps:` ends the strategy block — stop before step-level keys leak in.
|
||||
if (/^\s{4}steps:\s*$/.test(line)) break;
|
||||
}
|
||||
if (current) rows.push(current);
|
||||
return rows.filter((r) => r.files.length > 0);
|
||||
}
|
||||
|
||||
const wholeFileTier = (source: string): string | null => {
|
||||
const m =
|
||||
/\b(?:describeE2ETier|e2eTierEnabled)\(\s*['"`](gate|periodic)['"`]/.exec(source) ||
|
||||
/EVALS_TIER\s*===\s*['"`](gate|periodic)['"`]/.exec(source);
|
||||
return m ? m[1] : null;
|
||||
};
|
||||
|
||||
const workflowSource = fs.readFileSync(WORKFLOW, 'utf-8');
|
||||
const rows = parseMatrixRows(workflowSource);
|
||||
const matrixFiles = new Map<string, MatrixRow>();
|
||||
for (const row of rows) for (const f of row.files) matrixFiles.set(f, row);
|
||||
|
||||
const paidFiles = fs
|
||||
.readdirSync(path.join(ROOT, 'test'))
|
||||
.filter((f) => f.endsWith('.test.ts'))
|
||||
.map((f) => `test/${f}`)
|
||||
.filter(isPaidTestFile);
|
||||
|
||||
describe('evals.yml matrix completeness (gate-lane silent-skip tripwire)', () => {
|
||||
test('matrix parse sanity: rows and known suites present', () => {
|
||||
expect(rows.length).toBeGreaterThanOrEqual(15);
|
||||
expect(matrixFiles.has('test/skill-e2e-workflow.test.ts')).toBe(true);
|
||||
expect(matrixFiles.has('test/skill-e2e-ship-docsync.test.ts')).toBe(true);
|
||||
});
|
||||
|
||||
test('every matrix file exists on disk', () => {
|
||||
const missing = [...matrixFiles.keys()].filter(
|
||||
(f) => !fs.existsSync(path.join(ROOT, f))
|
||||
);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
test('every gate-hosting paid file is in the matrix (or the documented backlog)', () => {
|
||||
const gaps: string[] = [];
|
||||
for (const file of paidFiles) {
|
||||
const source = fs.readFileSync(path.join(ROOT, file), 'utf-8');
|
||||
const declaresGate = wholeFileTier(source) === 'gate';
|
||||
const inGateDeps = Object.entries(E2E_TOUCHFILES).some(
|
||||
([key, deps]) =>
|
||||
(E2E_TIERS as Record<string, string>)[key] === 'gate' &&
|
||||
(deps as string[]).includes(file)
|
||||
);
|
||||
if (!declaresGate && !inGateDeps) continue;
|
||||
if (matrixFiles.has(file) || KNOWN_MATRIX_GAPS.has(file)) continue;
|
||||
gaps.push(file);
|
||||
}
|
||||
expect(
|
||||
gaps,
|
||||
`Gate-hosting test file(s) missing from the evals.yml matrix — CI will ` +
|
||||
`never run them and "gate tier blocks merge" becomes silently false. ` +
|
||||
`Add a matrix row (with tier: gate when the file is whole-file gated). ` +
|
||||
`Do NOT extend KNOWN_MATRIX_GAPS for new files.`
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test('matrix rows for whole-file-gated files carry a matching tier property', () => {
|
||||
const mismatches: string[] = [];
|
||||
for (const [file, row] of matrixFiles) {
|
||||
if (!fs.existsSync(path.join(ROOT, file))) continue;
|
||||
const declared = wholeFileTier(fs.readFileSync(path.join(ROOT, file), 'utf-8'));
|
||||
if (!declared) continue;
|
||||
if (row.tier === declared) continue;
|
||||
if (KNOWN_TIER_UNSET.get(file) === declared && row.tier === undefined) continue;
|
||||
mismatches.push(`${file} declares '${declared}' but row '${row.name}' has tier: ${row.tier ?? 'unset'}`);
|
||||
}
|
||||
expect(
|
||||
mismatches,
|
||||
`A whole-file tier guard with no matching row tier means the suite ` +
|
||||
`self-skips and the CI job reports a hollow green. Set tier: <declared> ` +
|
||||
`on the row (the Run step exports it as EVALS_TIER).`
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test('burn-down lists hold only live gaps (ratchet cleanup enforcement)', () => {
|
||||
const staleGaps = [...KNOWN_MATRIX_GAPS].filter(
|
||||
(f) => matrixFiles.has(f) || !fs.existsSync(path.join(ROOT, f))
|
||||
);
|
||||
expect(
|
||||
staleGaps,
|
||||
'Entry fixed or file removed — delete it from KNOWN_MATRIX_GAPS.'
|
||||
).toEqual([]);
|
||||
const staleTiers = [...KNOWN_TIER_UNSET.entries()].filter(([f, declared]) => {
|
||||
const row = matrixFiles.get(f);
|
||||
if (!row) return true; // row deleted — entry no longer applies
|
||||
if (row.tier === declared) return true; // fixed — entry must go
|
||||
if (!fs.existsSync(path.join(ROOT, f))) return true;
|
||||
return wholeFileTier(fs.readFileSync(path.join(ROOT, f), 'utf-8')) !== declared;
|
||||
});
|
||||
expect(
|
||||
staleTiers.map(([f]) => f),
|
||||
'Entry fixed, row removed, or guard changed — delete it from KNOWN_TIER_UNSET.'
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Sliced-lane wiring pins for the paid CI workflows — the successor to
|
||||
* evals-workflow-matrix.test.ts, which enforced completeness of a
|
||||
* hand-enumerated 17-row matrix (and carried KNOWN_MATRIX_GAPS /
|
||||
* KNOWN_TIER_UNSET burn-down ratchets for the files that matrix missed).
|
||||
* The matrix is deleted: the sliced lane's planner derives the gate census
|
||||
* from the runner itself (collectPaidTestFiles + tier selection), so "every
|
||||
* gate-hosting file is in the census" is true BY CONSTRUCTION and the
|
||||
* burn-down ratchets retired with the rows.
|
||||
*
|
||||
* What still needs pinning is the WIRING — the yml plumbing that free tests
|
||||
* are the only guard for:
|
||||
* - the legacy matrix (and its `needs: evals` serialization) stays deleted,
|
||||
* - planner/executor/report all run tier=gate and agree on the slice count,
|
||||
* - both surviving lanes register skills through the SHARED composite that
|
||||
* carries the fail-fast dangling-symlink/frontmatter verification loop
|
||||
* (the sliced + periodic copies had silently dropped it — the loop was
|
||||
* written after a silent "Unknown command" + 35-min-timeout incident),
|
||||
* - the PR comment survives the matrix-report deletion (it moved into
|
||||
* slices-report, keyed on the same "## E2E Evals" upsert marker).
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = path.join(import.meta.dir, '..');
|
||||
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf-8');
|
||||
|
||||
const evalsYml = read('.github/workflows/evals.yml');
|
||||
const periodicYml = read('.github/workflows/evals-periodic.yml');
|
||||
const registerAction = read('.github/actions/register-gstack-skills/action.yml');
|
||||
|
||||
/** Slice count the planner emits (`--slices N`) in a workflow source. */
|
||||
function plannedSlices(source: string): number[] {
|
||||
return [...source.matchAll(/--emit-plan\s+\S+\s+--slices\s+(\d+)/g)].map((m) => Number(m[1]));
|
||||
}
|
||||
|
||||
/** The executor matrix's slice list (`slice: [1, 2, ...]`). */
|
||||
function matrixSlices(source: string): number[][] {
|
||||
return [...source.matchAll(/^\s+slice: \[([\d,\s]+)\]\s*$/gm)].map((m) =>
|
||||
m[1].split(',').map((n) => Number(n.trim())),
|
||||
);
|
||||
}
|
||||
|
||||
describe('evals.yml sliced-lane wiring (post-matrix)', () => {
|
||||
test('the legacy matrix job stays deleted', () => {
|
||||
// Row-enumeration shapes from the deleted matrix. Any reappearance means
|
||||
// someone is re-growing a hand-maintained enumeration next to a lane
|
||||
// whose census is derived — the drift class the deletion killed.
|
||||
expect(evalsYml).not.toMatch(/^\s+suite:\s*$/m);
|
||||
expect(evalsYml).not.toMatch(/^\s+file: test\//m);
|
||||
expect(evalsYml).not.toContain('needs: [build-image, evals]');
|
||||
expect(evalsYml).not.toMatch(/^\s+needs: evals\s*$/m);
|
||||
});
|
||||
|
||||
test('no workflow-level EVALS_TIER env (each command sets its own)', () => {
|
||||
// The workflow-level `EVALS_TIER: gate` was dead config once every
|
||||
// consumer set its own; a resurrected copy would silently leak gate
|
||||
// semantics into steps that must choose explicitly.
|
||||
expect(evalsYml).not.toMatch(/^env:[\s\S]{0,120}^\s+EVALS_TIER:/m);
|
||||
});
|
||||
|
||||
test('planner, executors, and report all run tier=gate on the shared runner', () => {
|
||||
expect(evalsYml).toMatch(/EVALS_TIER=gate bun run scripts\/test-paid-shards\.ts --tier gate --emit-plan/);
|
||||
expect(evalsYml).toMatch(/EVALS_TIER=gate bun run scripts\/test-paid-shards\.ts --tier gate --plan .* --slice /);
|
||||
expect(evalsYml).toMatch(/EVALS_TIER=gate bun run scripts\/test-paid-shards\.ts --tier gate --report /);
|
||||
});
|
||||
|
||||
test('executor matrix slice list matches the planner --slices count', () => {
|
||||
const planned = plannedSlices(evalsYml);
|
||||
const matrices = matrixSlices(evalsYml);
|
||||
expect(planned, 'expected exactly one --emit-plan site in evals.yml').toHaveLength(1);
|
||||
expect(matrices, 'expected exactly one slice matrix in evals.yml').toHaveLength(1);
|
||||
const n = planned[0];
|
||||
expect(matrices[0]).toEqual(Array.from({ length: n }, (_, i) => i + 1));
|
||||
});
|
||||
|
||||
test('reconcile exit is captured via PIPESTATUS, never $? after a pipe', () => {
|
||||
// GitHub's default run-step shell is `bash -e {0}` with NO pipefail, so
|
||||
// `$?` after `... | tee` is tee's exit — always 0. That made the
|
||||
// fail-closed reconcile gate silently fail-open (ship review army,
|
||||
// 2026-08-31). Both lanes must read PIPESTATUS[0].
|
||||
for (const [name, source] of [['evals.yml', evalsYml], ['evals-periodic.yml', periodicYml]] as const) {
|
||||
const reconcileBlocks = [...source.matchAll(/--report[^\n]*\| tee[^\n]*\n([\s\S]{0,400}?)GITHUB_OUTPUT/g)];
|
||||
expect(reconcileBlocks.length, `${name}: expected a tee'd reconcile step`).toBeGreaterThanOrEqual(1);
|
||||
for (const block of reconcileBlocks) {
|
||||
expect(block[1], `${name} reconcile captures tee's exit, not the runner's`).toContain('PIPESTATUS[0]');
|
||||
expect(block[1]).not.toMatch(/exit=\$\?/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the PR comment survived the matrix-report deletion (moved to slices-comment)', () => {
|
||||
// Keyed on the upsert marker so the migration keeps updating the SAME
|
||||
// comment; and the job holding it needs the issues permission (#1802).
|
||||
expect(evalsYml).toContain('## E2E Evals');
|
||||
expect(evalsYml).toMatch(/pull-requests: write/);
|
||||
expect(evalsYml).toMatch(/issues: write/);
|
||||
});
|
||||
|
||||
test('the write-token job runs ZERO repo code (token/exec separation)', () => {
|
||||
// slices-report executes PR-authored code (bun install + the reconcile
|
||||
// runner), so it must hold contents:read ONLY; the write token lives in
|
||||
// slices-comment, which may only download artifacts and run jq/gh —
|
||||
// $GITHUB_ENV persistence is job-scoped, so this split IS the trust
|
||||
// boundary (codex adversarial, 2026-08-31; the matrix-era report job had
|
||||
// this property and the consolidation briefly regressed it).
|
||||
const commentJob = evalsYml.slice(evalsYml.indexOf(' slices-comment:'));
|
||||
expect(commentJob.length).toBeGreaterThan(100);
|
||||
expect(commentJob).not.toContain('actions/checkout');
|
||||
expect(commentJob).not.toContain('bun install');
|
||||
expect(commentJob).not.toMatch(/run: .*bun run/);
|
||||
expect(commentJob).not.toContain('uses: ./');
|
||||
// No checkout also means no git context: `gh pr comment` resolves the
|
||||
// repo FROM git and dies with "not a git repository" here (PR #2746's
|
||||
// first run). Every comment call must be explicit-repo REST (gh api).
|
||||
expect(commentJob).not.toContain('gh pr comment');
|
||||
// And the code-executing report job must NOT hold write scopes.
|
||||
const reportJob = evalsYml.slice(evalsYml.indexOf(' slices-report:'), evalsYml.indexOf(' slices-comment:'));
|
||||
expect(reportJob).not.toMatch(/pull-requests: write/);
|
||||
expect(reportJob).not.toMatch(/issues: write/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evals-periodic.yml sliced-lane wiring', () => {
|
||||
test('planner/executor/report tier=periodic and slice counts agree', () => {
|
||||
expect(periodicYml).toMatch(/EVALS_TIER=periodic bun run scripts\/test-paid-shards\.ts --tier periodic --emit-plan/);
|
||||
expect(periodicYml).toMatch(/EVALS_TIER=periodic bun run scripts\/test-paid-shards\.ts --tier periodic --plan .* --slice /);
|
||||
expect(periodicYml).toMatch(/EVALS_TIER=periodic bun run scripts\/test-paid-shards\.ts --tier periodic --report /);
|
||||
const planned = plannedSlices(periodicYml);
|
||||
const matrices = matrixSlices(periodicYml);
|
||||
expect(planned).toHaveLength(1);
|
||||
expect(matrices).toHaveLength(1);
|
||||
expect(matrices[0]).toEqual(Array.from({ length: planned[0] }, (_, i) => i + 1));
|
||||
});
|
||||
});
|
||||
|
||||
describe('shared setup composites (both surviving lanes)', () => {
|
||||
test('both lanes register skills through the shared composite', () => {
|
||||
for (const [name, source] of [['evals.yml', evalsYml], ['evals-periodic.yml', periodicYml]] as const) {
|
||||
expect(source, `${name} must use the register-gstack-skills composite`)
|
||||
.toContain('uses: ./.github/actions/register-gstack-skills');
|
||||
// No inline re-implementation creeping back beside the composite.
|
||||
expect(source, `${name} re-inlines the skill registry instead of using the composite`)
|
||||
.not.toContain('ln -snf "$REPO" "$SKILLS_DIR/gstack"');
|
||||
}
|
||||
});
|
||||
|
||||
test('the register composite carries the fail-fast verification loop', () => {
|
||||
// The loop is the POINT of the composite: a dangling symlink or renamed
|
||||
// committed target fails in seconds with a named path, never as a wedged
|
||||
// PTY session at the shard wall. Pin its load-bearing markers.
|
||||
expect(registerAction).toContain('skill registry OK');
|
||||
expect(registerAction).toContain('skill-registry target missing');
|
||||
expect(registerAction).toContain('gstack root symlink dangles');
|
||||
expect(registerAction).toMatch(/grep -m1 "\^name: \$s\\\$"/);
|
||||
});
|
||||
|
||||
test('seed/deps/temp composites exist and both lanes use them', () => {
|
||||
for (const action of ['seed-claude-config', 'restore-deps', 'fix-bun-temp']) {
|
||||
expect(fs.existsSync(path.join(ROOT, '.github', 'actions', action, 'action.yml')), `missing composite: ${action}`).toBe(true);
|
||||
}
|
||||
for (const [name, source] of [['evals.yml', evalsYml], ['evals-periodic.yml', periodicYml]] as const) {
|
||||
expect(source, `${name} must use seed-claude-config`).toContain('uses: ./.github/actions/seed-claude-config');
|
||||
expect(source, `${name} must use restore-deps`).toContain('uses: ./.github/actions/restore-deps');
|
||||
expect(source, `${name} must use fix-bun-temp`).toContain('uses: ./.github/actions/fix-bun-temp');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* WS1 flake-ledger unit tests: the free runner's FLAKY-PASS events become a
|
||||
* durable JSONL series (single writer: the parent runner). Fail-open is the
|
||||
* contract — a broken ledger warns loudly but must never turn a real verdict
|
||||
* into a failure on the only required lane.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { appendFlakeLedger, flakeLedgerPath, type FlakeLedgerEntry } from '../scripts/test-free-shards';
|
||||
|
||||
const entry = (file: string): FlakeLedgerEntry => ({
|
||||
ts: '2026-08-31T00:00:00.000Z',
|
||||
runner: 'free',
|
||||
kind: 'flaky-pass',
|
||||
file,
|
||||
shard: 2,
|
||||
});
|
||||
|
||||
describe('flake ledger', () => {
|
||||
test('appends one JSONL line per entry, creating parent dirs', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'flake-ledger-'));
|
||||
const ledger = path.join(dir, 'nested', 'ledger.jsonl');
|
||||
expect(appendFlakeLedger([entry('test/a.test.ts')], ledger)).toBe(true);
|
||||
expect(appendFlakeLedger([entry('test/b.test.ts'), entry('test/c.test.ts')], ledger)).toBe(true);
|
||||
const lines = fs.readFileSync(ledger, 'utf-8').trim().split('\n');
|
||||
expect(lines).toHaveLength(3);
|
||||
expect(JSON.parse(lines[0])).toMatchObject({ runner: 'free', kind: 'flaky-pass', file: 'test/a.test.ts', shard: 2 });
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('empty entry list is a no-op success (no file created)', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'flake-ledger-'));
|
||||
const ledger = path.join(dir, 'ledger.jsonl');
|
||||
expect(appendFlakeLedger([], ledger)).toBe(true);
|
||||
expect(fs.existsSync(ledger)).toBe(false);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('FAIL-OPEN: an unwritable path warns and returns false, never throws', () => {
|
||||
const warnings: string[] = [];
|
||||
// A path whose parent is a FILE cannot be mkdir'd — deterministic EEXIST/ENOTDIR.
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'flake-ledger-'));
|
||||
const blocker = path.join(dir, 'blocker');
|
||||
fs.writeFileSync(blocker, 'not a dir');
|
||||
const ledger = path.join(blocker, 'ledger.jsonl');
|
||||
const ok = appendFlakeLedger([entry('test/a.test.ts')], ledger, (l) => warnings.push(l));
|
||||
expect(ok).toBe(false);
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('verdict unaffected');
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('env override wins; local default is project-scoped, never machine-global', () => {
|
||||
expect(flakeLedgerPath({ GSTACK_FLAKE_LEDGER: '/x/y.jsonl' } as NodeJS.ProcessEnv)).toBe('/x/y.jsonl');
|
||||
// Without the env override, the default lives under the PROJECT dir
|
||||
// (sibling worktrees of different repos must not interleave one series);
|
||||
// tmpdir is only the last-resort fallback when slug detection fails.
|
||||
const local = flakeLedgerPath({} as NodeJS.ProcessEnv);
|
||||
expect(local).toMatch(/flake-ledger\.jsonl$/);
|
||||
expect(local.includes(path.join('.gstack', 'projects')) || local.startsWith(os.tmpdir())).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ afterEach(() => { fs.rmSync(tmpHome, { recursive: true, force: true }); });
|
||||
|
||||
function cfg(args: string[]): string {
|
||||
return execFileSync(CONFIG_BIN, args, {
|
||||
timeout: 30_000,
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env, GSTACK_HOME: tmpHome },
|
||||
}).trim();
|
||||
@@ -47,6 +48,7 @@ describe("founder_resources config key (#538)", () => {
|
||||
|
||||
test("invalid values are rejected to the default, never persisted as-is", () => {
|
||||
execFileSync(CONFIG_BIN, ["set", "founder_resources", "banana"], {
|
||||
timeout: 30_000,
|
||||
encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"],
|
||||
env: { ...process.env, GSTACK_HOME: tmpHome },
|
||||
});
|
||||
|
||||
@@ -54,6 +54,17 @@ describe('free-tests workflow wiring', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('flake telemetry stays wired: retry flag, single-writer ledger, unconditional artifact', () => {
|
||||
// WS1: a timing flake must not red the required lane, but every
|
||||
// flaky-pass must be recorded and uploaded — a green run is exactly when
|
||||
// the evidence matters. Removing any of these silently returns flakes to
|
||||
// either merge-blocking (flag off) or invisibility (ledger/artifact off).
|
||||
expect(source).toMatch(/GSTACK_FREE_RETRY_FLAKY:\s*"1"/);
|
||||
expect(source).toMatch(/GSTACK_FLAKE_LEDGER:\s*\$\{\{ runner\.temp \}\}\/flake-ledger\.jsonl/);
|
||||
expect(source).toContain('name: flake-ledger');
|
||||
expect(source).toMatch(/name: Upload flake ledger\s*\n\s*if: always\(\)/);
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
@@ -74,11 +74,11 @@ describe("swept mkdirp sites under bun-on-Windows EEXIST semantics (#2635)", ()
|
||||
fs.mkdirSync(work, { recursive: true });
|
||||
const payload = '{"decision":"eexist probe","rationale":"r","scope":"repo","source":"user"}';
|
||||
const env = { ...process.env, HOME: base };
|
||||
const first = spawnSync("bun", [DECISION_LOG, payload], { cwd: work, encoding: "utf8", env });
|
||||
const first = spawnSync("bun", [DECISION_LOG, payload], { cwd: work, encoding: "utf8", env, timeout: 30_000 });
|
||||
expect(first.status).toBe(0);
|
||||
const second = spawnSync(
|
||||
"bun", ["--preload", EEXIST_PRELOAD, DECISION_LOG, payload],
|
||||
{ cwd: work, encoding: "utf8", env },
|
||||
{ cwd: work, encoding: "utf8", env, timeout: 30_000 },
|
||||
);
|
||||
expect(second.status).toBe(0);
|
||||
expect(second.stderr ?? "").not.toContain("EEXIST");
|
||||
@@ -93,7 +93,7 @@ describe("install-prepush-hook under bun-on-Windows EEXIST semantics (#2635)", (
|
||||
const base = tmpdir();
|
||||
try {
|
||||
const repo = path.join(base, "repo");
|
||||
spawnSync("git", ["init", "-q", repo]);
|
||||
spawnSync("git", ["init", "-q", repo], { timeout: 30_000 });
|
||||
const hookDir = path.join(repo, ".git", "hooks");
|
||||
fs.mkdirSync(hookDir, { recursive: true });
|
||||
const hookPath = path.join(hookDir, "pre-push");
|
||||
@@ -105,6 +105,7 @@ describe("install-prepush-hook under bun-on-Windows EEXIST semantics (#2635)", (
|
||||
const r = spawnSync("bun", ["--preload", EEXIST_PRELOAD, REDACT, "install-prepush-hook"], {
|
||||
cwd: repo,
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr ?? "").not.toContain("EEXIST");
|
||||
|
||||
@@ -51,6 +51,7 @@ function run(bin: string, args: string[], opts: RunOpts = {}) {
|
||||
env,
|
||||
cwd: opts.cwd,
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
return {
|
||||
stdout: (res.stdout || '').trim(),
|
||||
|
||||
@@ -35,9 +35,12 @@ const GUARDED_FILES = [
|
||||
// Patterns that would bypass lib/gbrain-exec.ts. Match the literal `"gbrain"`
|
||||
// as the first argument since these helpers are the failure mode.
|
||||
const BANNED_PATTERNS: Array<{ name: string; regex: RegExp }> = [
|
||||
// tripwire-exempt: grep-needle STRING for this invariant, not a process spawn
|
||||
{ name: 'spawnSync("gbrain", ...)', regex: /spawnSync\s*\(\s*["']gbrain["']/g },
|
||||
{ name: 'spawn("gbrain", ...)', regex: /\bspawn\s*\(\s*["']gbrain["']/g },
|
||||
// tripwire-exempt: grep-needle STRING for this invariant, not a process spawn
|
||||
{ name: 'execFileSync("gbrain", ...)', regex: /execFileSync\s*\(\s*["']gbrain["']/g },
|
||||
// tripwire-exempt: grep-needle STRING for this invariant, not a process spawn
|
||||
{ name: 'execSync("...gbrain...")', regex: /execSync\s*\(\s*["'`][^"'`]*\bgbrain\b/g },
|
||||
];
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ echo "ok"
|
||||
HOME: env.home,
|
||||
PATH: `${env.bindir}:/usr/bin:/bin`,
|
||||
},
|
||||
timeout: 30_000,
|
||||
});
|
||||
return {
|
||||
exitCode: result.status ?? 1,
|
||||
|
||||
@@ -121,6 +121,7 @@ gbrain init --pglite --json "$@"
|
||||
const result = spawnSync(shell, ["-c", script], {
|
||||
encoding: "utf-8",
|
||||
env: baseEnv,
|
||||
timeout: 30_000,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`init script exited ${result.status}: ${result.stderr}`);
|
||||
@@ -133,7 +134,7 @@ function lastArgc(env: FakeEnv): number {
|
||||
return parseInt(lines[lines.length - 1], 10);
|
||||
}
|
||||
|
||||
const HAVE_ZSH = spawnSync("zsh", ["-c", "true"]).status === 0;
|
||||
const HAVE_ZSH = spawnSync("zsh", ["-c", "true"], { timeout: 30_000 }).status === 0;
|
||||
|
||||
describe("voyage-code-3 default for gstack-driven PGLite init", () => {
|
||||
it("passes voyage-code-3 flags when VOYAGE_API_KEY is set", () => {
|
||||
@@ -193,6 +194,7 @@ gbrain init --pglite --json $GBRAIN_EMBED_FLAGS
|
||||
const result = spawnSync("zsh", ["-c", brokenShape], {
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env, HOME: env.home, PATH: `${env.bindir}:/usr/bin:/bin` },
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(lastArgc(env)).toBe(4); // init, --pglite, --json, "<entire flag string>"
|
||||
|
||||
@@ -28,6 +28,7 @@ function runVerify(arg: string, stdin?: string) {
|
||||
const res = spawnSync(VERIFY, arg === '' ? [] : [arg], {
|
||||
input: stdin,
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
return {
|
||||
stdout: (res.stdout || '').trim(),
|
||||
@@ -43,6 +44,7 @@ function runLibSnippet(snippet: string, stdin: string = '') {
|
||||
const res = spawnSync('bash', ['-c', script], {
|
||||
input: stdin,
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
return {
|
||||
stdout: (res.stdout || '').trim(),
|
||||
|
||||
@@ -210,6 +210,7 @@ describe("lib/gbrain-local-status — status classification", () => {
|
||||
);
|
||||
|
||||
expect(source).not.toContain('command -v gbrain');
|
||||
// tripwire-exempt: string assertion on lib source text, not a call
|
||||
expect(source).toContain('execFileSync("gbrain", ["--version"]');
|
||||
});
|
||||
|
||||
@@ -538,6 +539,7 @@ describe("lib/gbrain-local-status — thin-client (#2051)", () => {
|
||||
GSTACK_HOME: env.gstackHome,
|
||||
GSTACK_DETECT_NO_CACHE: "1",
|
||||
},
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
});
|
||||
@@ -554,6 +556,7 @@ describe("lib/gbrain-local-status — thin-client (#2051)", () => {
|
||||
GSTACK_HOME: env.gstackHome,
|
||||
GSTACK_DETECT_NO_CACHE: "1",
|
||||
},
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(r.status).toBe(1);
|
||||
});
|
||||
@@ -790,6 +793,7 @@ describe("lib/gbrain-local-status — bearer-token thin-client (#2520)", () => {
|
||||
GSTACK_HOME: env.gstackHome,
|
||||
GSTACK_DETECT_NO_CACHE: "1",
|
||||
},
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ function env(): NodeJS.ProcessEnv {
|
||||
}
|
||||
|
||||
function run(args: string[], input?: string) {
|
||||
const res = spawnSync(BIN, args, { env: env(), encoding: "utf-8", input });
|
||||
const res = spawnSync(BIN, args, { env: env(), encoding: "utf-8", input, timeout: 30_000 });
|
||||
return {
|
||||
stdout: res.stdout || "",
|
||||
stderr: res.stderr || "",
|
||||
|
||||
@@ -29,6 +29,7 @@ function run(args: string[], opts: { env?: Record<string, string> } = {}) {
|
||||
const res = spawnSync(BIN, args, {
|
||||
env: { ...process.env, GSTACK_HOME: tmpHome, ...(opts.env || {}) },
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
return {
|
||||
stdout: (res.stdout || '').trim(),
|
||||
@@ -263,6 +264,7 @@ describe('get without arg (auto-detect from current dir)', () => {
|
||||
env: { ...process.env, GSTACK_HOME: tmpHome },
|
||||
cwd: cwdTmp,
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect((res.stdout || '').trim()).toBe('unset');
|
||||
} finally {
|
||||
@@ -288,7 +290,7 @@ describe('gstack-gbrain-sync code stage honors the repo policy (#2140 sync path)
|
||||
function makeRepo(): void {
|
||||
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-policy-repo-'));
|
||||
const git = (...args: string[]) =>
|
||||
spawnSync('git', args, { cwd: repoDir, encoding: 'utf-8' });
|
||||
spawnSync('git', args, { cwd: repoDir, encoding: 'utf-8', timeout: 30_000 });
|
||||
git('init', '-q', '.');
|
||||
git('remote', 'add', 'origin', REPO_URL);
|
||||
fs.writeFileSync(path.join(repoDir, 'README.md'), 'fixture\n');
|
||||
|
||||
@@ -23,12 +23,13 @@ function run(argv: string[], env: Record<string, string> = {}) {
|
||||
env: { ...process.env, HOME: tmpHome, GSTACK_HOME: tmpHome, ...env },
|
||||
encoding: 'utf-8',
|
||||
cwd: ROOT,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return { stdout: res.stdout || '', stderr: res.stderr || '', status: res.status ?? -1 };
|
||||
}
|
||||
|
||||
function git(args: string[], cwd: string) {
|
||||
const res = spawnSync('git', args, { cwd, encoding: 'utf-8' });
|
||||
const res = spawnSync('git', args, { cwd, encoding: 'utf-8', timeout: 30_000 });
|
||||
return { stdout: (res.stdout || '').trim(), status: res.status ?? -1 };
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import { join } from "path";
|
||||
import { execFileSync, spawnSync } from "child_process";
|
||||
|
||||
const SCRIPT = join(import.meta.dir, "..", "bin", "gstack-gbrain-sync.ts");
|
||||
const BUN_BIN = execFileSync("sh", ["-c", "command -v bun"], { encoding: "utf-8" }).trim();
|
||||
const BUN_BIN = execFileSync("sh", ["-c", "command -v bun"], { encoding: "utf-8", timeout: 30_000 }).trim();
|
||||
|
||||
interface FakeEnv {
|
||||
tmp: string;
|
||||
@@ -112,9 +112,10 @@ function runOrchestrator(
|
||||
): { stdout: string; stderr: string; exitCode: number } {
|
||||
// Initialize a git repo in the sandbox so repoRoot() finds it (otherwise
|
||||
// code stage skips with "not in git repo" before our check ever fires).
|
||||
spawnSync("git", ["init", "-q", env.home], { encoding: "utf-8" });
|
||||
spawnSync("git", ["init", "-q", env.home], { encoding: "utf-8", timeout: 30_000 });
|
||||
spawnSync("git", ["-C", env.home, "commit", "--allow-empty", "-m", "init", "-q"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
env: { ...process.env, GIT_AUTHOR_NAME: "T", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "T", GIT_COMMITTER_EMAIL: "t@t" },
|
||||
});
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { spawnSync } from "child_process";
|
||||
|
||||
const gbrainPath = spawnSync("which", ["gbrain"], { encoding: "utf-8" }).stdout.trim();
|
||||
const gbrainPath = spawnSync("which", ["gbrain"], { encoding: "utf-8", timeout: 30_000 }).stdout.trim();
|
||||
const gbrainAvailable = gbrainPath.length > 0;
|
||||
const voyageKey = process.env.VOYAGE_API_KEY?.trim() ?? "";
|
||||
const voyageKeyPresent = voyageKey.length > 0;
|
||||
@@ -114,14 +114,14 @@ The queue module is a simple FIFO job queue.
|
||||
);
|
||||
|
||||
// Make it a git repo because gbrain's code-sync strategy expects one.
|
||||
const gitInit = spawnSync("git", ["init", "-q"], { cwd: fixtureDir, encoding: "utf-8" });
|
||||
const gitInit = spawnSync("git", ["init", "-q"], { cwd: fixtureDir, encoding: "utf-8", timeout: 30_000 });
|
||||
if (gitInit.status !== 0) {
|
||||
throw new Error(`git init failed: ${gitInit.stderr}`);
|
||||
}
|
||||
spawnSync("git", ["config", "user.email", "test@example.invalid"], { cwd: fixtureDir });
|
||||
spawnSync("git", ["config", "user.name", "test"], { cwd: fixtureDir });
|
||||
spawnSync("git", ["add", "."], { cwd: fixtureDir });
|
||||
spawnSync("git", ["commit", "-q", "-m", "fixture"], { cwd: fixtureDir });
|
||||
spawnSync("git", ["config", "user.email", "test@example.invalid"], { cwd: fixtureDir, timeout: 30_000 });
|
||||
spawnSync("git", ["config", "user.name", "test"], { cwd: fixtureDir, timeout: 30_000 });
|
||||
spawnSync("git", ["add", "."], { cwd: fixtureDir, timeout: 30_000 });
|
||||
spawnSync("git", ["commit", "-q", "-m", "fixture"], { cwd: fixtureDir, timeout: 30_000 });
|
||||
|
||||
return {
|
||||
root,
|
||||
|
||||
+12
-6
@@ -19,7 +19,7 @@ import { JUDGE_MS } from './helpers/eval-budgets';
|
||||
import { runGeminiSkill } from './helpers/gemini-session-runner';
|
||||
import type { GeminiResult } from './helpers/gemini-session-runner';
|
||||
import { EvalCollector } from './helpers/eval-store';
|
||||
import { selectTests, detectBaseBranch, getChangedFiles, GLOBAL_TOUCHFILES } from './helpers/touchfiles';
|
||||
import { selectTests, detectBaseBranch, getChangedFiles, E2E_TOUCHFILES, GLOBAL_TOUCHFILES } from './helpers/touchfiles';
|
||||
import { createTestWorktree, harvestAndCleanup } from './helpers/e2e-helpers';
|
||||
import * as path from 'path';
|
||||
|
||||
@@ -29,7 +29,7 @@ const ROOT = path.resolve(import.meta.dir, '..');
|
||||
|
||||
const GEMINI_AVAILABLE = (() => {
|
||||
try {
|
||||
const result = Bun.spawnSync(['which', 'gemini']);
|
||||
const result = Bun.spawnSync(['which', 'gemini'], { timeout: 30_000 });
|
||||
return result.exitCode === 0;
|
||||
} catch { return false; }
|
||||
})();
|
||||
@@ -74,10 +74,16 @@ if (!evalsEnabled) {
|
||||
|
||||
// --- Diff-based test selection ---
|
||||
|
||||
// Gemini E2E touchfiles — keyed by test name
|
||||
const GEMINI_E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'gemini-smoke': ['.agents/skills/**', 'test/helpers/gemini-session-runner.ts'],
|
||||
};
|
||||
// Gemini E2E touchfiles — DERIVED from the canonical map, never a local fork
|
||||
// (the old hand-copy kept a gitignored '.agents/skills/**' pattern that can
|
||||
// never match a git diff and missed canonical deps — same drift class as the
|
||||
// codex copy).
|
||||
const GEMINI_E2E_TOUCHFILES: Record<string, string[]> = Object.fromEntries(
|
||||
(['gemini-smoke'] as const).map((key) => {
|
||||
if (!E2E_TOUCHFILES[key]) throw new Error(`canonical E2E_TOUCHFILES lost key '${key}' — fix the map, not this file`);
|
||||
return [key, E2E_TOUCHFILES[key]];
|
||||
}),
|
||||
);
|
||||
|
||||
let selectedTests: string[] | null = null; // null = run all
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('gen-skill-docs import purity', () => {
|
||||
}
|
||||
console.log('IMPORT_PURE');
|
||||
`;
|
||||
const out = Bun.spawnSync(['bun', '-e', probe], { cwd: ROOT });
|
||||
const out = Bun.spawnSync(['bun', '-e', probe], { cwd: ROOT, timeout: 120_000 });
|
||||
const stdout = out.stdout.toString();
|
||||
const stderr = out.stderr.toString();
|
||||
expect(stderr, stderr).not.toContain('import mutated');
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('gen-skill-docs --out-dir (B2 render isolation)', () => {
|
||||
}
|
||||
|
||||
function porcelain(): string {
|
||||
const r = spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf-8' });
|
||||
const r = spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf-8', timeout: 30_000 });
|
||||
return r.status === 0 ? r.stdout : '';
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ const EXTERNAL_OUT = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gen-docs-out-
|
||||
{
|
||||
const render = Bun.spawnSync(
|
||||
['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'all', '--out-dir', EXTERNAL_OUT],
|
||||
{ cwd: ROOT, stdout: 'pipe', stderr: 'pipe' },
|
||||
{ cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 120_000 },
|
||||
);
|
||||
if (render.exitCode !== 0) {
|
||||
throw new Error(
|
||||
@@ -315,6 +315,7 @@ describe('gen-skill-docs', () => {
|
||||
cwd: ROOT,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
timeout: 120_000,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
const output = result.stdout.toString();
|
||||
@@ -1965,7 +1966,7 @@ describe('Codex generation (--host codex)', () => {
|
||||
'/tmp/gstack-claude-error-XXXXXX',
|
||||
'/tmp/gstack-claude-diff-XXXXXX',
|
||||
]) {
|
||||
const result = spawnSync('mktemp', [template], { encoding: 'utf-8' });
|
||||
const result = spawnSync('mktemp', [template], { encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(result.status).toBe(0);
|
||||
const created = result.stdout.trim();
|
||||
expect(created.startsWith(template.replace('XXXXXX', ''))).toBe(true);
|
||||
@@ -1990,6 +1991,7 @@ describe('Codex generation (--host codex)', () => {
|
||||
cwd: ROOT,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
timeout: 120_000,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
const output = result.stdout.toString();
|
||||
@@ -2005,11 +2007,13 @@ describe('Codex generation (--host codex)', () => {
|
||||
cwd: ROOT,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
timeout: 120_000,
|
||||
});
|
||||
const agentsResult = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'agents', '--dry-run', '--out-dir', EXTERNAL_OUT], {
|
||||
cwd: ROOT,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
timeout: 120_000,
|
||||
});
|
||||
expect(codexResult.exitCode).toBe(0);
|
||||
expect(agentsResult.exitCode).toBe(0);
|
||||
@@ -2224,6 +2228,7 @@ describe('Codex generation (--host codex)', () => {
|
||||
cwd: ROOT,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
timeout: 120_000,
|
||||
});
|
||||
expect(override.exitCode).toBe(0);
|
||||
const content = fs.readFileSync(path.join(overrideOut, '.agents', 'skills', 'gstack-ship', 'SKILL.md'), 'utf-8');
|
||||
@@ -2342,10 +2347,10 @@ describe('Factory generation (--host factory)', () => {
|
||||
|
||||
test('--host droid alias works', () => {
|
||||
const factoryResult = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'factory', '--dry-run', '--out-dir', EXTERNAL_OUT], {
|
||||
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
|
||||
cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 120_000,
|
||||
});
|
||||
const droidResult = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'droid', '--dry-run', '--out-dir', EXTERNAL_OUT], {
|
||||
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
|
||||
cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 120_000,
|
||||
});
|
||||
expect(factoryResult.exitCode).toBe(0);
|
||||
expect(droidResult.exitCode).toBe(0);
|
||||
@@ -2354,7 +2359,7 @@ describe('Factory generation (--host factory)', () => {
|
||||
|
||||
test('--host factory --dry-run freshness', () => {
|
||||
const result = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'factory', '--dry-run', '--out-dir', EXTERNAL_OUT], {
|
||||
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
|
||||
cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 120_000,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
const output = result.stdout.toString();
|
||||
@@ -2432,7 +2437,7 @@ describe('Parameterized host smoke tests', () => {
|
||||
test('--dry-run freshness check passes', () => {
|
||||
const result = Bun.spawnSync(
|
||||
['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', hostConfig.name, '--dry-run', '--out-dir', EXTERNAL_OUT],
|
||||
{ cwd: ROOT, stdout: 'pipe', stderr: 'pipe' }
|
||||
{ cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 120_000 }
|
||||
);
|
||||
expect(result.exitCode).toBe(0);
|
||||
const output = result.stdout.toString();
|
||||
@@ -2457,7 +2462,7 @@ describe('--host all', () => {
|
||||
// claude host plus every external host regenerate deterministically.
|
||||
test('--host all generates for all registered hosts', () => {
|
||||
const result = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'all', '--dry-run', '--out-dir', EXTERNAL_OUT], {
|
||||
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
|
||||
cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 120_000,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
const output = result.stdout.toString();
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Git-ref fixture tripwire: no test or helper may pin repo content to a raw
|
||||
* commit SHA (the `git show <sha>:path` fixture pattern).
|
||||
*
|
||||
* The class: test/helpers/auq-sdk-capture.ts defaulted verboseSkill() to
|
||||
* `git show ab66193e^:plan-ceo-review/SKILL.md` — a BRANCH-LOCAL ref. That
|
||||
* fixture dies the day the branch is pruned, and already failed on shallow
|
||||
* clones (CI executors fetch-depth-0 exists precisely because self-derived
|
||||
* selection crashed on shallow checkouts). The v1.75 precedent is to VENDOR
|
||||
* the frozen content under test/fixtures/ instead — content-addressed by the
|
||||
* repo itself, immune to ref pruning and clone depth.
|
||||
*
|
||||
* Scans test trees + helpers for two shapes:
|
||||
* - a quoted `<hex>{7,40}[^]?:` rev-path (the `git show SHA:path` form)
|
||||
* - a gitRef-style default parameter carrying a raw hex SHA
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const SCAN_ROOTS = ['test', 'browse/test', 'design/test', 'make-pdf/test'];
|
||||
const SELF = path.join('test', 'git-ref-fixture-tripwire.test.ts');
|
||||
|
||||
// Quoted `SHA:` rev-path (7-40 hex chars, optional ^/~ suffix, then colon) —
|
||||
// requires >= 2 digits among the hex so ordinary words ('deadbeef' aside)
|
||||
// and pure-alpha identifiers don't false-positive.
|
||||
const REV_PATH = /['"`]([0-9a-f]{7,40})[\^~]?:/g;
|
||||
const GIT_REF_DEFAULT = /gitRef\s*=\s*['"`][0-9a-f]{7,40}/;
|
||||
|
||||
const looksLikeSha = (s: string): boolean => /[0-9]/.test(s) && /[a-f]/.test(s);
|
||||
|
||||
describe('git-ref fixture tripwire', () => {
|
||||
test('no raw-SHA fixture refs in the test trees (vendor the content instead)', () => {
|
||||
const hits: string[] = [];
|
||||
for (const root of SCAN_ROOTS) {
|
||||
const abs = path.join(ROOT, root);
|
||||
if (!fs.existsSync(abs)) continue;
|
||||
const stack = [abs];
|
||||
while (stack.length > 0) {
|
||||
const dir = stack.pop()!;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) { stack.push(full); continue; }
|
||||
if (!/\.(?:[cm]?[jt]s|tsx)$/.test(entry.name)) continue;
|
||||
const rel = path.relative(ROOT, full);
|
||||
if (rel === SELF) continue;
|
||||
const src = fs.readFileSync(full, 'utf-8');
|
||||
src.split('\n').forEach((line, i) => {
|
||||
for (const m of line.matchAll(REV_PATH)) {
|
||||
if (looksLikeSha(m[1])) hits.push(`${rel}:${i + 1} ${line.trim().slice(0, 100)}`);
|
||||
}
|
||||
if (GIT_REF_DEFAULT.test(line)) hits.push(`${rel}:${i + 1} ${line.trim().slice(0, 100)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(
|
||||
hits,
|
||||
`raw-SHA fixture reference(s) — these die on branch prune and fail on shallow clones. `
|
||||
+ `Vendor the frozen content under test/fixtures/ instead (v1.75 precedent):\n ${hits.join('\n ')}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -180,10 +180,11 @@ describe("gstack-global-discover", () => {
|
||||
// Create a git repo as the session target
|
||||
const repoDir = join(tmpDir, "fake-repo");
|
||||
mkdirSync(repoDir);
|
||||
spawnSync("git", ["init"], { cwd: repoDir, stdio: "pipe" });
|
||||
spawnSync("git", ["init"], { cwd: repoDir, stdio: "pipe", timeout: 30_000 });
|
||||
spawnSync("git", ["commit", "--allow-empty", "-m", "init"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Write a session with a 20KB first line (simulates Codex v0.117+)
|
||||
|
||||
@@ -119,7 +119,7 @@ exit 0
|
||||
* test focused on artifacts-init's branching logic, not git plumbing.
|
||||
*/
|
||||
function makeFakeGit() {
|
||||
const realGit = spawnSync('which', ['git'], { encoding: 'utf-8' }).stdout.trim();
|
||||
const realGit = spawnSync('which', ['git'], { encoding: 'utf-8', timeout: 30_000 }).stdout.trim();
|
||||
const script = `#!/bin/bash
|
||||
# Walk argv past leading -C <dir> and similar flags to find the real subcommand.
|
||||
args=("$@")
|
||||
@@ -157,6 +157,7 @@ function run(argv: string[], opts: { env?: Record<string, string>; input?: strin
|
||||
encoding: 'utf-8',
|
||||
input: opts.input,
|
||||
cwd: ROOT,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return {
|
||||
stdout: res.stdout || '',
|
||||
@@ -176,7 +177,7 @@ beforeEach(() => {
|
||||
fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'artifacts-fake-bin-'));
|
||||
ghCallLog = path.join(fakeBinDir, 'gh-calls.log');
|
||||
glabCallLog = path.join(fakeBinDir, 'glab-calls.log');
|
||||
spawnSync('git', ['init', '--bare', '-q', '-b', 'main', bareRemote]);
|
||||
spawnSync('git', ['init', '--bare', '-q', '-b', 'main', bareRemote], { timeout: 30_000 });
|
||||
makeFakeGit();
|
||||
});
|
||||
|
||||
@@ -277,7 +278,7 @@ describe('gstack-artifacts-init canonical URL storage (codex Finding #10)', () =
|
||||
makeFakeGh({ webUrl: 'https://github.com/testuser/gstack-artifacts-testuser' });
|
||||
const r = run(['--host', 'github']);
|
||||
expect(r.status).toBe(0);
|
||||
const remote = spawnSync('git', ['-C', tmpHome, 'remote', 'get-url', 'origin'], { encoding: 'utf-8' });
|
||||
const remote = spawnSync('git', ['-C', tmpHome, 'remote', 'get-url', 'origin'], { encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(remote.stdout.trim()).toBe('https://github.com/testuser/gstack-artifacts-testuser');
|
||||
});
|
||||
|
||||
@@ -288,7 +289,7 @@ describe('gstack-artifacts-init canonical URL storage (codex Finding #10)', () =
|
||||
});
|
||||
const r = run(['--host', 'github']);
|
||||
expect(r.status).toBe(0);
|
||||
const remote = spawnSync('git', ['-C', tmpHome, 'remote', 'get-url', 'origin'], { encoding: 'utf-8' });
|
||||
const remote = spawnSync('git', ['-C', tmpHome, 'remote', 'get-url', 'origin'], { encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(remote.stdout.trim()).toBe('git@github.com:testuser/gstack-artifacts-testuser.git');
|
||||
});
|
||||
|
||||
@@ -296,7 +297,7 @@ describe('gstack-artifacts-init canonical URL storage (codex Finding #10)', () =
|
||||
makeFakeGh({ gitProtocol: 'unset' });
|
||||
const r = run(['--host', 'github']);
|
||||
expect(r.status).toBe(0);
|
||||
const remote = spawnSync('git', ['-C', tmpHome, 'remote', 'get-url', 'origin'], { encoding: 'utf-8' });
|
||||
const remote = spawnSync('git', ['-C', tmpHome, 'remote', 'get-url', 'origin'], { encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(remote.stdout.trim()).toBe('https://github.com/testuser/gstack-artifacts-testuser');
|
||||
});
|
||||
|
||||
@@ -304,7 +305,7 @@ describe('gstack-artifacts-init canonical URL storage (codex Finding #10)', () =
|
||||
makeFakeGlab({ gitProtocol: 'ssh' });
|
||||
const r = run(['--host', 'gitlab']);
|
||||
expect(r.status).toBe(0);
|
||||
const remote = spawnSync('git', ['-C', tmpHome, 'remote', 'get-url', 'origin'], { encoding: 'utf-8' });
|
||||
const remote = spawnSync('git', ['-C', tmpHome, 'remote', 'get-url', 'origin'], { encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(remote.stdout.trim()).toBe('git@gitlab.com:testuser/gstack-artifacts-testuser.git');
|
||||
});
|
||||
});
|
||||
@@ -359,7 +360,7 @@ describe('gstack-artifacts-init idempotency', () => {
|
||||
makeFakeGh({ gitProtocol: 'ssh' });
|
||||
const r = run(['--remote', 'https://github.com/testuser/gstack-artifacts-testuser']);
|
||||
expect(r.status).toBe(0);
|
||||
const remote = spawnSync('git', ['-C', tmpHome, 'remote', 'get-url', 'origin'], { encoding: 'utf-8' });
|
||||
const remote = spawnSync('git', ['-C', tmpHome, 'remote', 'get-url', 'origin'], { encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(remote.stdout.trim()).toBe('https://github.com/testuser/gstack-artifacts-testuser');
|
||||
});
|
||||
|
||||
@@ -372,7 +373,7 @@ describe('gstack-artifacts-init idempotency', () => {
|
||||
'ssh',
|
||||
]);
|
||||
expect(r.status).toBe(0);
|
||||
const remote = spawnSync('git', ['-C', tmpHome, 'remote', 'get-url', 'origin'], { encoding: 'utf-8' });
|
||||
const remote = spawnSync('git', ['-C', tmpHome, 'remote', 'get-url', 'origin'], { encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(remote.stdout.trim()).toBe('git@github.com:testuser/gstack-artifacts-testuser.git');
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const URL_BIN = path.join(ROOT, 'bin', 'gstack-artifacts-url');
|
||||
|
||||
function run(args: string[]): { code: number; stdout: string; stderr: string } {
|
||||
const r = spawnSync(URL_BIN, args, { encoding: 'utf-8' });
|
||||
const r = spawnSync(URL_BIN, args, { encoding: 'utf-8', timeout: 30_000 });
|
||||
return {
|
||||
code: r.status ?? -1,
|
||||
stdout: (r.stdout || '').trim(),
|
||||
|
||||
@@ -68,7 +68,7 @@ function runImport(sessionPath: string): { stdout: string; stderr: string; statu
|
||||
env.GSTACK_STATE_ROOT = stateRoot;
|
||||
env.GSTACK_QUESTION_LOG_NO_DERIVE = '1';
|
||||
delete env.GSTACK_HOME;
|
||||
const res = spawnSync(BIN, [sessionPath], { env, encoding: 'utf-8', cwd: ROOT });
|
||||
const res = spawnSync(BIN, [sessionPath], { env, encoding: 'utf-8', cwd: ROOT, timeout: 30_000 });
|
||||
return {
|
||||
stdout: res.stdout ?? '',
|
||||
stderr: res.stderr ?? '',
|
||||
@@ -196,7 +196,7 @@ describe('default mode (no args → latest)', () => {
|
||||
}
|
||||
env.GSTACK_STATE_ROOT = stateRoot;
|
||||
env.CODEX_SESSIONS_ROOT = emptyDir;
|
||||
const res = spawnSync(BIN, [], { env, encoding: 'utf-8', cwd: ROOT });
|
||||
const res = spawnSync(BIN, [], { env, encoding: 'utf-8', cwd: ROOT, timeout: 30_000 });
|
||||
expect(res.status).toBe(0);
|
||||
expect(res.stdout).toMatch(/NO_SESSIONS/);
|
||||
} finally {
|
||||
|
||||
@@ -45,6 +45,7 @@ const STATE = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-config-test-'));
|
||||
function get(key: string): { out: string; code: number } {
|
||||
const r = spawnSync('bash', [CONFIG_BIN, 'get', key], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
env: { ...process.env, GSTACK_STATE_ROOT: STATE },
|
||||
});
|
||||
return { out: r.stdout ?? '', code: r.status ?? -1 };
|
||||
|
||||
@@ -28,6 +28,7 @@ function run(args: string[]) {
|
||||
// live tree (observed in the free-tests CI job). Relink behavior itself is
|
||||
// covered in isolation by test/relink.test.ts's mock install.
|
||||
env: { ...process.env, GSTACK_STATE_ROOT: stateRoot, GSTACK_SETUP_RUNNING: "1" },
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -16,6 +16,7 @@ function cfg(args: string[]): { code: number; out: string; err: string } {
|
||||
const r = spawnSync(CONFIG, args, {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, GSTACK_HOME: home },
|
||||
timeout: 30_000,
|
||||
});
|
||||
return { code: r.status ?? 0, out: r.stdout ?? "", err: r.stderr ?? "" };
|
||||
}
|
||||
|
||||
@@ -20,18 +20,18 @@ function opts(): ExecSyncOptionsWithStringEncoding {
|
||||
}
|
||||
function log(arg: string, expectFail = false): { out: string; code: number } {
|
||||
try {
|
||||
return { out: execSync(`${LOG} '${arg.replace(/'/g, "'\\''")}'`, opts()).trim(), code: 0 };
|
||||
return { out: execSync(`${LOG} '${arg.replace(/'/g, "'\\''")}'`, opts()).trim(), code: 0 }; // timeout via opts()
|
||||
} catch (e: any) {
|
||||
if (expectFail) return { out: (e.stderr?.toString() || "").trim(), code: e.status || 1 };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
function logFlag(flag: string): string {
|
||||
return execSync(`${LOG} ${flag}`, opts()).trim();
|
||||
return execSync(`${LOG} ${flag}`, opts()).trim(); // timeout via opts()
|
||||
}
|
||||
function search(args = ""): string {
|
||||
try {
|
||||
return execSync(`${SEARCH} ${args}`, opts()).trim();
|
||||
return execSync(`${SEARCH} ${args}`, opts()).trim(); // timeout via opts()
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -15,13 +15,13 @@ const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const DETACH = path.join(ROOT, 'bin', 'gstack-detach');
|
||||
|
||||
function ownPgid(): string {
|
||||
return (spawnSync('ps', ['-o', 'pgid=', '-p', String(process.pid)], { encoding: 'utf-8' }).stdout || '').trim();
|
||||
return (spawnSync('ps', ['-o', 'pgid=', '-p', String(process.pid)], { encoding: 'utf-8', timeout: 30_000 }).stdout || '').trim();
|
||||
}
|
||||
function waitFor(pred: () => boolean, ms: number): boolean {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
if (pred()) return true;
|
||||
spawnSync('sleep', ['0.2']);
|
||||
spawnSync('sleep', ['0.2'], { timeout: 30_000 });
|
||||
}
|
||||
return pred();
|
||||
}
|
||||
@@ -69,6 +69,59 @@ describe('gstack-detach', () => {
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
}, 16000);
|
||||
|
||||
test('watchdog group-SIGKILLs TERM-immune grandchildren (no orphan survives)', () => {
|
||||
// Regression pin for the 2026-08 escalation change: the watchdog used to
|
||||
// follow its killpg(SIGTERM) + 5s grace with a DIRECT proc.kill() — a
|
||||
// grandchild that ignores TERM survived and burned cores/API for hours
|
||||
// (the observed 15-hour-orphan class). Now the grace escalates to
|
||||
// killpg(SIGKILL). The child here traps TERM and spawns a TERM-immune
|
||||
// grandchild; only a GROUP SIGKILL clears both. Markers are per-run
|
||||
// unique (pid) so concurrent worktree suites can't cross-kill.
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gd-'));
|
||||
const log = path.join(dir, 'run.log');
|
||||
const g1 = `6091.${process.pid}`;
|
||||
const g2 = `6092.${process.pid}`;
|
||||
const alive = (m: string) => spawnSync('pgrep', ['-f', `sleep ${m.replace('.', '\\.')}`], { stdio: 'pipe', timeout: 5_000 }).status === 0;
|
||||
try {
|
||||
spawnSync(DETACH, ['--log', log, '--timeout', '1', '--', 'bash', '-c',
|
||||
`trap '' TERM; (trap '' TERM; sleep ${g1}) & exec sleep ${g2}`],
|
||||
{ encoding: 'utf-8', timeout: 10000 });
|
||||
expect(waitFor(() => logHas(log, '### gstack-detach EXIT=timeout ###'), 15000)).toBe(true);
|
||||
// Grace is 5s after the TERM that both processes ignore — the SIGKILL
|
||||
// escalation must clear the whole group shortly after the sentinel.
|
||||
expect(waitFor(() => !alive(g1) && !alive(g2), 10000),
|
||||
'TERM-immune child/grandchild survived the watchdog — killpg(SIGKILL) escalation regressed').toBe(true);
|
||||
} finally {
|
||||
spawnSync('pkill', ['-9', '-f', `sleep 609[12]\\.${process.pid}`], { stdio: 'ignore', timeout: 5_000 });
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test('watchdog kills the grandchild even when the LEADER dies on the SIGTERM', () => {
|
||||
// The pgid-after-grace bug: killpg(getpgid(proc.pid), SIGKILL) raised
|
||||
// ESRCH once the leader had honored the TERM, and the except fell back
|
||||
// to proc.kill() on a corpse — the TERM-immune grandchild lived forever.
|
||||
// The fix captures the pgid AT SPAWN. This variant is the one the
|
||||
// TERM-immune-leader test above cannot see.
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gd-'));
|
||||
const log = path.join(dir, 'run.log');
|
||||
const g = `6093.${process.pid}`;
|
||||
const alive = () => spawnSync('pgrep', ['-f', `sleep ${g.replace('.', '\\.')}`], { stdio: 'pipe', timeout: 5_000 }).status === 0;
|
||||
try {
|
||||
// Leader: no trap — dies on the watchdog's SIGTERM. Grandchild:
|
||||
// TERM-immune, same group — only a saved-pgid SIGKILL reaches it.
|
||||
spawnSync(DETACH, ['--log', log, '--timeout', '1', '--', 'bash', '-c',
|
||||
`(trap '' TERM; sleep ${g}) & sleep 60`],
|
||||
{ encoding: 'utf-8', timeout: 10000 });
|
||||
expect(waitFor(() => logHas(log, '### gstack-detach EXIT=timeout ###'), 15000)).toBe(true);
|
||||
expect(waitFor(() => !alive(), 10000),
|
||||
'grandchild survived a dead leader — the pgid must be captured at spawn, not resolved after the grace').toBe(true);
|
||||
} finally {
|
||||
spawnSync('pkill', ['-9', '-f', `sleep 6093\\.${process.pid}`], { stdio: 'ignore', timeout: 5_000 });
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test('machine --lock serializes concurrent runs (second WAITS for the first)', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gd-'));
|
||||
const lock = `gstack-detach-test-${process.pid}`;
|
||||
@@ -90,7 +143,7 @@ describe('gstack-detach', () => {
|
||||
}, 20000);
|
||||
|
||||
test('rejects missing command (exit 2)', () => {
|
||||
const r = spawnSync(DETACH, ['--label', 'x'], { encoding: 'utf-8' });
|
||||
const r = spawnSync(DETACH, ['--label', 'x'], { encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(r.status).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ function runDev(...args: string[]): { stdout: string; stderr: string; status: nu
|
||||
env: { ...process.env, GSTACK_HOME: tmpHome },
|
||||
encoding: 'utf-8',
|
||||
cwd: ROOT,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return {
|
||||
stdout: res.stdout ?? '',
|
||||
@@ -49,6 +50,7 @@ function logQuestion(payload: Record<string, unknown>): number {
|
||||
env: { ...process.env, GSTACK_HOME: tmpHome },
|
||||
encoding: 'utf-8',
|
||||
cwd: ROOT,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return res.status ?? -1;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ function run(args: string[]) {
|
||||
const result = spawnSync(BIN, args, {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, GSTACK_HOME: home },
|
||||
timeout: 30_000,
|
||||
});
|
||||
return { code: result.status ?? -1, stdout: result.stdout || '', stderr: result.stderr || '' };
|
||||
}
|
||||
@@ -122,11 +123,13 @@ describe('gstack-egress grants', () => {
|
||||
const config = spawnSync(path.join(ROOT, 'bin', 'gstack-config'), ['set', 'telemetry', 'community'], {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, GSTACK_HOME: home },
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(config.status).toBe(0);
|
||||
spawnSync(path.join(ROOT, 'bin', 'gstack-config'), ['set', 'artifacts_sync_mode', 'full'], {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, GSTACK_HOME: home },
|
||||
timeout: 30_000,
|
||||
});
|
||||
const r = run(['grants', '--json']);
|
||||
expect(r.code).toBe(0);
|
||||
|
||||
@@ -66,6 +66,7 @@ function runDetect(extraEnv: Record<string, string> = {}): { code: number; json:
|
||||
...extraEnv,
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
let json: any = null;
|
||||
try {
|
||||
|
||||
@@ -94,6 +94,7 @@ function runVerify(token: string, url: string): { code: number; stdout: string;
|
||||
GSTACK_HOME: tmpDir,
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
return {
|
||||
code: result.status ?? -1,
|
||||
@@ -249,6 +250,7 @@ describe('gstack-gbrain-mcp-verify', () => {
|
||||
const r = spawnSync(VERIFY_BIN, ['https://example.com/mcp'], {
|
||||
env: { ...process.env, PATH: `${fakeBinDir}:${process.env.PATH}`, GBRAIN_MCP_TOKEN: '' },
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('GBRAIN_MCP_TOKEN');
|
||||
@@ -259,6 +261,7 @@ describe('gstack-gbrain-mcp-verify', () => {
|
||||
const r = spawnSync(VERIFY_BIN, [], {
|
||||
env: { ...process.env, PATH: `${fakeBinDir}:${process.env.PATH}`, GBRAIN_MCP_TOKEN: 'x' },
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(r.status).toBe(2);
|
||||
});
|
||||
|
||||
@@ -145,6 +145,7 @@ function run(
|
||||
env,
|
||||
encoding: 'utf-8',
|
||||
cwd: ROOT,
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -163,13 +164,13 @@ function gbrainCalls(): string[] {
|
||||
function setupGstackRepo(remoteUrl: string) {
|
||||
// Real git repo at gstackHome with at least one commit + an origin remote.
|
||||
fs.mkdirSync(gstackHome, { recursive: true });
|
||||
spawnSync('git', ['-C', gstackHome, 'init', '-q', '-b', 'main'], { stdio: 'pipe' });
|
||||
spawnSync('git', ['-C', gstackHome, 'config', 'user.email', 'test@example.com'], { stdio: 'pipe' });
|
||||
spawnSync('git', ['-C', gstackHome, 'config', 'user.name', 'test'], { stdio: 'pipe' });
|
||||
spawnSync('git', ['-C', gstackHome, 'init', '-q', '-b', 'main'], { stdio: 'pipe', timeout: 30_000 });
|
||||
spawnSync('git', ['-C', gstackHome, 'config', 'user.email', 'test@example.com'], { stdio: 'pipe', timeout: 30_000 });
|
||||
spawnSync('git', ['-C', gstackHome, 'config', 'user.name', 'test'], { stdio: 'pipe', timeout: 30_000 });
|
||||
fs.writeFileSync(path.join(gstackHome, '.brain-allowlist'), '# allowlist\n');
|
||||
spawnSync('git', ['-C', gstackHome, 'add', '.'], { stdio: 'pipe' });
|
||||
spawnSync('git', ['-C', gstackHome, 'commit', '-q', '-m', 'init'], { stdio: 'pipe' });
|
||||
spawnSync('git', ['-C', gstackHome, 'remote', 'add', 'origin', remoteUrl], { stdio: 'pipe' });
|
||||
spawnSync('git', ['-C', gstackHome, 'add', '.'], { stdio: 'pipe', timeout: 30_000 });
|
||||
spawnSync('git', ['-C', gstackHome, 'commit', '-q', '-m', 'init'], { stdio: 'pipe', timeout: 30_000 });
|
||||
spawnSync('git', ['-C', gstackHome, 'remote', 'add', 'origin', remoteUrl], { stdio: 'pipe', timeout: 30_000 });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -316,6 +317,7 @@ describe('gstack-gbrain-source-wireup — wireup mode', () => {
|
||||
const check = spawnSync('bash', ['-c', `command -v gbrain && gbrain --version`], {
|
||||
env: { PATH: `${hostLikeDir}:${process.env.PATH || '/usr/bin:/bin'}` },
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(check.status).toBe(0);
|
||||
expect(check.stdout).toContain('gbrain 0.18.2');
|
||||
@@ -526,13 +528,15 @@ describe('gstack-gbrain-source-wireup — defensive paths', () => {
|
||||
run([], { env: { GSTACK_BRAIN_NO_SYNC: '1' } });
|
||||
// Make a new commit on parent so worktree HEAD is "behind"
|
||||
fs.writeFileSync(path.join(gstackHome, 'newfile.md'), 'new');
|
||||
spawnSync('git', ['-C', gstackHome, 'add', '.'], { stdio: 'pipe' });
|
||||
spawnSync('git', ['-C', gstackHome, 'commit', '-q', '-m', 'second commit'], { stdio: 'pipe' });
|
||||
spawnSync('git', ['-C', gstackHome, 'add', '.'], { stdio: 'pipe', timeout: 30_000 });
|
||||
spawnSync('git', ['-C', gstackHome, 'commit', '-q', '-m', 'second commit'], { stdio: 'pipe', timeout: 30_000 });
|
||||
const parentHeadAfter = spawnSync('git', ['-C', gstackHome, 'rev-parse', 'HEAD'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
}).stdout.trim();
|
||||
const worktreeHeadBefore = spawnSync('git', ['-C', worktreeDir, 'rev-parse', 'HEAD'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
}).stdout.trim();
|
||||
expect(parentHeadAfter).not.toBe(worktreeHeadBefore); // sanity: parent advanced
|
||||
// --no-pull should leave worktree HEAD where it was
|
||||
@@ -540,6 +544,7 @@ describe('gstack-gbrain-source-wireup — defensive paths', () => {
|
||||
expect(r.status).toBe(0);
|
||||
const worktreeHeadAfter = spawnSync('git', ['-C', worktreeDir, 'rev-parse', 'HEAD'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
}).stdout.trim();
|
||||
expect(worktreeHeadAfter).toBe(worktreeHeadBefore);
|
||||
expect(worktreeHeadAfter).not.toBe(parentHeadAfter);
|
||||
|
||||
@@ -137,7 +137,7 @@ describe("gstack-gbrain-sync CLI", () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), "gstack-pinned-source-repo-"));
|
||||
const commandLog = join(home, "gbrain-commands.log");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo });
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo, timeout: 30_000 });
|
||||
writeFileSync(join(repo, ".gbrain-source"), "client-acme-app\n");
|
||||
writeFileSync(join(bindir, "gbrain"), `#!/bin/sh
|
||||
printf '%s\\n' "$*" >> "$GSTACK_TEST_GBRAIN_LOG"
|
||||
@@ -179,7 +179,7 @@ exit 99
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
mkdirSync(join(home, ".gbrain"), { recursive: true });
|
||||
writeFileSync(join(home, ".gbrain", "config.json"), JSON.stringify({ engine: "pglite", database_url: "pglite:///test" }));
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo });
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo, timeout: 30_000 });
|
||||
writeFileSync(join(repo, ".gbrain-source"), "client-acme-app\n");
|
||||
symlinkSync(repo, link, "dir");
|
||||
writeFileSync(join(bindir, "gbrain"), `#!/bin/sh
|
||||
@@ -223,7 +223,7 @@ esac
|
||||
const bindir = mkdtempSync(join(tmpdir(), "gstack-pinned-dream-bin-"));
|
||||
const repo = mkdtempSync(join(tmpdir(), "gstack-pinned-dream-repo-"));
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo });
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo, timeout: 30_000 });
|
||||
writeFileSync(join(repo, ".gbrain-source"), "client-acme-app\n");
|
||||
writeFileSync(join(bindir, "gbrain"), "#!/bin/sh\nexit 99\n");
|
||||
chmodSync(join(bindir, "gbrain"), 0o755);
|
||||
@@ -247,7 +247,7 @@ esac
|
||||
const gstackHome = join(home, ".gstack");
|
||||
const repo = mkdtempSync(join(tmpdir(), "gstack-unreadable-pin-repo-"));
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo });
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo, timeout: 30_000 });
|
||||
mkdirSync(join(repo, ".gbrain-source"));
|
||||
|
||||
const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], {
|
||||
@@ -282,8 +282,8 @@ esac
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const repo = mkdtempSync(join(tmpdir(), "gstack-source-id-repo-"));
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo });
|
||||
spawnSync("git", ["remote", "add", "origin", remote], { cwd: repo });
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo, timeout: 30_000 });
|
||||
spawnSync("git", ["remote", "add", "origin", remote], { cwd: repo, timeout: 30_000 });
|
||||
|
||||
const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], {
|
||||
encoding: "utf-8",
|
||||
@@ -312,7 +312,7 @@ esac
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const repo = mkdtempSync(join(tmpdir(), "gstack-no-origin-"));
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo });
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo, timeout: 30_000 });
|
||||
// No `git remote add origin` — this is the no-remote case.
|
||||
|
||||
const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], {
|
||||
@@ -347,7 +347,7 @@ esac
|
||||
const parent = mkdtempSync(join(tmpdir(), "gstack-empty-base-"));
|
||||
const repo = join(parent, "___");
|
||||
mkdirSync(repo);
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo });
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo, timeout: 30_000 });
|
||||
// No `origin` remote — forces the basename-fallback path.
|
||||
|
||||
const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], {
|
||||
@@ -381,8 +381,8 @@ esac
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const repo = mkdtempSync(join(tmpdir(), "gstack-host-collide-"));
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo });
|
||||
spawnSync("git", ["remote", "add", "origin", "https://github.com/example/multihost.git"], { cwd: repo });
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo, timeout: 30_000 });
|
||||
spawnSync("git", ["remote", "add", "origin", "https://github.com/example/multihost.git"], { cwd: repo, timeout: 30_000 });
|
||||
|
||||
// Dry-run still gates the code stage on `command -v gbrain`. Drop a no-op
|
||||
// shim on PATH so the stage runs (we only assert the preview line, never
|
||||
@@ -569,8 +569,8 @@ esac
|
||||
const repoA = mkdtempSync(join(tmpdir(), "gstack-worktree-a-"));
|
||||
const repoB = mkdtempSync(join(tmpdir(), "gstack-worktree-b-"));
|
||||
for (const repo of [repoA, repoB]) {
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo });
|
||||
spawnSync("git", ["remote", "add", "origin", remote], { cwd: repo });
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo, timeout: 30_000 });
|
||||
spawnSync("git", ["remote", "add", "origin", remote], { cwd: repo, timeout: 30_000 });
|
||||
}
|
||||
|
||||
const idOf = (cwd: string): string => {
|
||||
@@ -606,8 +606,8 @@ esac
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const repo = mkdtempSync(join(tmpdir(), "gstack-worktree-stable-"));
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo });
|
||||
spawnSync("git", ["remote", "add", "origin", remote], { cwd: repo });
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo, timeout: 30_000 });
|
||||
spawnSync("git", ["remote", "add", "origin", remote], { cwd: repo, timeout: 30_000 });
|
||||
|
||||
const idOf = (): string => {
|
||||
const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], {
|
||||
@@ -635,8 +635,8 @@ esac
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const repo = mkdtempSync(join(tmpdir(), "gstack-legacy-cleanup-"));
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo });
|
||||
spawnSync("git", ["remote", "add", "origin", "https://github.com/garrytan/gstack.git"], { cwd: repo });
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo, timeout: 30_000 });
|
||||
spawnSync("git", ["remote", "add", "origin", "https://github.com/garrytan/gstack.git"], { cwd: repo, timeout: 30_000 });
|
||||
|
||||
const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], {
|
||||
encoding: "utf-8",
|
||||
@@ -671,8 +671,8 @@ esac
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const repo = mkdtempSync(join(tmpdir(), "gstack-attach-preview-"));
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo });
|
||||
spawnSync("git", ["remote", "add", "origin", "https://github.com/garrytan/gstack.git"], { cwd: repo });
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo, timeout: 30_000 });
|
||||
spawnSync("git", ["remote", "add", "origin", "https://github.com/garrytan/gstack.git"], { cwd: repo, timeout: 30_000 });
|
||||
|
||||
const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], {
|
||||
encoding: "utf-8",
|
||||
@@ -727,8 +727,8 @@ describe("derivePathOnlyHashLegacyId", () => {
|
||||
// legacy id regardless of $GSTACK_HOSTNAME, because the pre-#1468 hash
|
||||
// didn't include hostname.
|
||||
const repo = mkdtempSync(join(tmpdir(), "gstack-legacy-id-"));
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo });
|
||||
spawnSync("git", ["remote", "add", "origin", "https://github.com/example/legacy-test.git"], { cwd: repo });
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo, timeout: 30_000 });
|
||||
spawnSync("git", ["remote", "add", "origin", "https://github.com/example/legacy-test.git"], { cwd: repo, timeout: 30_000 });
|
||||
|
||||
const cwd = process.cwd();
|
||||
try {
|
||||
@@ -754,8 +754,8 @@ describe("derivePathOnlyHashLegacyId", () => {
|
||||
// host-fold id must differ for any non-empty hostname, so the migration
|
||||
// can detect + clean up the orphan.
|
||||
const repo = mkdtempSync(join(tmpdir(), "gstack-legacy-id-distinct-"));
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo });
|
||||
spawnSync("git", ["remote", "add", "origin", "https://github.com/example/distinct.git"], { cwd: repo });
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo, timeout: 30_000 });
|
||||
spawnSync("git", ["remote", "add", "origin", "https://github.com/example/distinct.git"], { cwd: repo, timeout: 30_000 });
|
||||
|
||||
const cwd = process.cwd();
|
||||
try {
|
||||
@@ -890,10 +890,10 @@ describe("constrainSourceId truncation (hyphen-boundary cut)", () => {
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const repo = mkdtempSync(join(tmpdir(), "gstack-hyphen-cut-"));
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo });
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo, timeout: 30_000 });
|
||||
// Remote chosen to be long enough that constrainSourceId truncates and
|
||||
// the boundary lands inside the word `skill`.
|
||||
spawnSync("git", ["remote", "add", "origin", "https://github.com/drummerms-av-sow-wiz/skill-270c0001.git"], { cwd: repo });
|
||||
spawnSync("git", ["remote", "add", "origin", "https://github.com/drummerms-av-sow-wiz/skill-270c0001.git"], { cwd: repo, timeout: 30_000 });
|
||||
|
||||
const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], {
|
||||
encoding: "utf-8",
|
||||
@@ -924,8 +924,8 @@ describe("constrainSourceId truncation (hyphen-boundary cut)", () => {
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const repo = mkdtempSync(join(tmpdir(), "gstack-https-period-"));
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo });
|
||||
spawnSync("git", ["remote", "add", "origin", "https://github.com/foo/bar.git"], { cwd: repo });
|
||||
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo, timeout: 30_000 });
|
||||
spawnSync("git", ["remote", "add", "origin", "https://github.com/foo/bar.git"], { cwd: repo, timeout: 30_000 });
|
||||
|
||||
const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], {
|
||||
encoding: "utf-8",
|
||||
|
||||
@@ -26,7 +26,7 @@ const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
function trackedTestFiles(): string[] {
|
||||
const out = spawnSync('git', ['ls-files', '*.test.ts'], {
|
||||
cwd: ROOT, encoding: 'utf-8',
|
||||
cwd: ROOT, encoding: 'utf-8', timeout: 30_000,
|
||||
});
|
||||
if (out.status !== 0) throw new Error(`git ls-files failed: ${out.stderr}`);
|
||||
return out.stdout.split('\n').filter(Boolean);
|
||||
|
||||
@@ -16,6 +16,7 @@ const otherProjDir = path.join(tmpHome, 'projects', 'other-project');
|
||||
|
||||
function run(args: string[]): string {
|
||||
return execFileSync(BIN, args, {
|
||||
timeout: 30_000,
|
||||
env: { ...process.env, GSTACK_HOME: tmpHome },
|
||||
cwd: tmpCwd,
|
||||
encoding: 'utf-8',
|
||||
|
||||
@@ -500,6 +500,7 @@ describe("gstack-memory-ingest writer (gbrain v0.20+ batch `import` interface)",
|
||||
const POLICY = join(import.meta.dir, "..", "bin", "gstack-gbrain-repo-policy");
|
||||
const seeded = spawnSync("bash", [POLICY, "set", "_unattributed", "deny"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome },
|
||||
});
|
||||
expect(seeded.status).toBe(0);
|
||||
@@ -622,6 +623,7 @@ esac
|
||||
expect(existsSync(stagingCopy)).toBe(true);
|
||||
const findMd = spawnSync("find", [stagingCopy, "-name", "*.md", "-type", "f"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
});
|
||||
const mdPaths = (findMd.stdout || "").trim().split("\n").filter(Boolean);
|
||||
expect(mdPaths.length).toBeGreaterThan(0);
|
||||
@@ -685,6 +687,7 @@ esac
|
||||
// walk to find a .md and read its head.)
|
||||
const findMd = spawnSync("find", [stagingCopy, "-name", "*.md", "-type", "f"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
});
|
||||
const mdPaths = (findMd.stdout || "").trim().split("\n").filter(Boolean);
|
||||
expect(mdPaths.length).toBeGreaterThan(0);
|
||||
@@ -913,8 +916,8 @@ describe("#2394: probe applies the same attribution gate as prepare", () => {
|
||||
function makeAttributableCwd(home: string): string {
|
||||
const repo = join(home, "work", "attributable-repo");
|
||||
mkdirSync(repo, { recursive: true });
|
||||
spawnSync("git", ["-C", repo, "init", "-q"], { encoding: "utf-8" });
|
||||
spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/foo/bar.git"], { encoding: "utf-8" });
|
||||
spawnSync("git", ["-C", repo, "init", "-q"], { encoding: "utf-8", timeout: 30_000 });
|
||||
spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/foo/bar.git"], { encoding: "utf-8", timeout: 30_000 });
|
||||
return repo;
|
||||
}
|
||||
|
||||
@@ -993,8 +996,8 @@ describe("#2394: probe applies the same attribution gate as prepare", () => {
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const attributableCwd = join(home, "work", "attributable-repo");
|
||||
mkdirSync(attributableCwd, { recursive: true });
|
||||
spawnSync("git", ["-C", attributableCwd, "init", "-q"], { encoding: "utf-8" });
|
||||
spawnSync("git", ["-C", attributableCwd, "remote", "add", "origin", "https://github.com/foo/bar.git"], { encoding: "utf-8" });
|
||||
spawnSync("git", ["-C", attributableCwd, "init", "-q"], { encoding: "utf-8", timeout: 30_000 });
|
||||
spawnSync("git", ["-C", attributableCwd, "remote", "add", "origin", "https://github.com/foo/bar.git"], { encoding: "utf-8", timeout: 30_000 });
|
||||
|
||||
const ts = new Date().toISOString();
|
||||
const cwdLine = `{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"${ts}","cwd":"${attributableCwd.replace(/\\/g, "\\\\")}"}\n`;
|
||||
@@ -1077,8 +1080,8 @@ describe("#2392: transcript ingest honors per-remote trust policy", () => {
|
||||
function makeRepoWithRemote(home: string, name: string, remoteUrl: string): string {
|
||||
const repo = join(home, "work", name);
|
||||
mkdirSync(repo, { recursive: true });
|
||||
spawnSync("git", ["-C", repo, "init", "-q"], { encoding: "utf-8" });
|
||||
spawnSync("git", ["-C", repo, "remote", "add", "origin", remoteUrl], { encoding: "utf-8" });
|
||||
spawnSync("git", ["-C", repo, "init", "-q"], { encoding: "utf-8", timeout: 30_000 });
|
||||
spawnSync("git", ["-C", repo, "remote", "add", "origin", remoteUrl], { encoding: "utf-8", timeout: 30_000 });
|
||||
return repo;
|
||||
}
|
||||
|
||||
@@ -1095,6 +1098,7 @@ describe("#2392: transcript ingest honors per-remote trust policy", () => {
|
||||
function setPolicy(gstackHome: string, url: string, tier: string): void {
|
||||
const r = spawnSync(POLICY_BIN, ["set", url, tier], {
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
env: { ...process.env, GSTACK_HOME: gstackHome },
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
|
||||
@@ -326,7 +326,7 @@ describe("default-base detection (no --base)", () => {
|
||||
function runWithoutBase(cwd: string): { exitCode: number; parsed: any } {
|
||||
const proc = Bun.spawnSync(
|
||||
["bun", "run", SCRIPT, "--bump", "patch", "--workspace-root", "null"],
|
||||
{ cwd },
|
||||
{ cwd, timeout: 30_000 },
|
||||
);
|
||||
const out = new TextDecoder().decode(proc.stdout);
|
||||
return { exitCode: proc.exitCode, parsed: JSON.parse(out) };
|
||||
@@ -403,11 +403,11 @@ describe("offline output contract (what /ship branches on, #2545)", () => {
|
||||
// host:"unknown" on CI) while keeping ls-remote/fetch fully local.
|
||||
const bare = join(root, "github.com", "origin.git");
|
||||
mkdirSync(bare, { recursive: true });
|
||||
Bun.spawnSync(["git", "init", "-q", "--bare", "-b", "main", bare]);
|
||||
Bun.spawnSync(["git", "init", "-q", "--bare", "-b", "main", bare], { timeout: 30_000 });
|
||||
const work = join(root, "work");
|
||||
mkdirSync(work);
|
||||
const git = (...args: string[]) =>
|
||||
Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd: work });
|
||||
Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd: work, timeout: 30_000 });
|
||||
git("init", "-q", "-b", "main");
|
||||
writeFileSync(join(work, "VERSION"), "1.0.0.0\n");
|
||||
git("add", "-A");
|
||||
@@ -427,7 +427,7 @@ describe("offline output contract (what /ship branches on, #2545)", () => {
|
||||
const proc = Bun.spawnSync(
|
||||
["bun", "run", NEXTVER, "--base", "main",
|
||||
"--bump", "patch", "--current-version", "1.0.0.0", "--workspace-root", "null"],
|
||||
{ cwd: work, env: { ...process.env, PATH: `${stubDir}:${process.env.PATH}` } },
|
||||
{ cwd: work, env: { ...process.env, PATH: `${stubDir}:${process.env.PATH}` }, timeout: 30_000 },
|
||||
);
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
@@ -454,7 +454,7 @@ describe("offline output contract (what /ship branches on, #2545)", () => {
|
||||
const proc = Bun.spawnSync(
|
||||
["bun", "run", NEXTVER, "--base", "main",
|
||||
"--bump", "patch", "--current-version", "1.0.0.0", "--workspace-root", "null"],
|
||||
{ cwd: work, env: { ...process.env, PATH: `${stubDir}:${process.env.PATH}` } },
|
||||
{ cwd: work, env: { ...process.env, PATH: `${stubDir}:${process.env.PATH}` }, timeout: 30_000 },
|
||||
);
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
@@ -475,7 +475,7 @@ describe("fetchGitClaimed (offline allocation — the anti-duplicate fallback, #
|
||||
// (plus three earlier pairs found in the same audit). Git knows what the API
|
||||
// was asked for, so offline now degrades the QUEUE VIEW, not the ALLOCATION.
|
||||
function git(cwd: string, ...args: string[]) {
|
||||
return Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd });
|
||||
return Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd, timeout: 30_000 });
|
||||
}
|
||||
|
||||
function fixture(): string {
|
||||
@@ -600,7 +600,7 @@ describe("fetchGitClaimed — non-mutating live remote query (ls-remote first)",
|
||||
// remote's LIVE branch list with zero local mutation — a path/file remote
|
||||
// answers it offline, which is exactly what these fixtures use.
|
||||
function git(cwd: string, ...args: string[]) {
|
||||
return Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd });
|
||||
return Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd, timeout: 30_000 });
|
||||
}
|
||||
|
||||
// Local origin with: main (0.1.66.0), sibling (0.1.67.0, live claim), and
|
||||
@@ -718,7 +718,7 @@ describe("fetchGitClaimed — unfetched live claims (G2: ls-remote advertises SH
|
||||
// VERSION reads fail. The old `continue` silently dropped that LIVE claim —
|
||||
// the exact duplicate-allocation this fallback exists to prevent.
|
||||
function git(cwd: string, ...args: string[]) {
|
||||
return Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd });
|
||||
return Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd, timeout: 30_000 });
|
||||
}
|
||||
|
||||
function cloneFixture(): { root: string; origin: string; clone: string } {
|
||||
@@ -908,14 +908,14 @@ describe("width pinned on failed base read (3-digit repos)", () => {
|
||||
// the base read fails too, which is the path under test.
|
||||
writeFileSync(join(stubDir, "gh"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
writeFileSync(join(stubDir, "glab"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
Bun.spawnSync(["git", "init", "-q", "-b", "main"], { cwd: dir });
|
||||
Bun.spawnSync(["git", "init", "-q", "-b", "main"], { cwd: dir, timeout: 30_000 });
|
||||
writeFileSync(join(dir, "VERSION"), "0.99.2\n");
|
||||
Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: dir });
|
||||
Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "init"], { cwd: dir });
|
||||
Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: dir, timeout: 30_000 });
|
||||
Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "init"], { cwd: dir, timeout: 30_000 });
|
||||
|
||||
const proc = Bun.spawnSync(
|
||||
["bun", "run", SCRIPT, "--base", "main", "--bump", "patch", "--workspace-root", "null"],
|
||||
{ cwd: dir, env: { ...process.env, PATH: `${stubDir}:${process.env.PATH}` } },
|
||||
{ cwd: dir, env: { ...process.env, PATH: `${stubDir}:${process.env.PATH}` }, timeout: 30_000 },
|
||||
);
|
||||
const out = JSON.parse(new TextDecoder().decode(proc.stdout));
|
||||
// Zero base at the repo's OWN width — never "0.0.0.0" in a 3-digit repo.
|
||||
@@ -946,7 +946,7 @@ describe("integration (smoke)", () => {
|
||||
"1.6.3.0",
|
||||
"--workspace-root",
|
||||
"null", // skip sibling scan in CI
|
||||
]);
|
||||
], { timeout: 30_000 });
|
||||
const out = new TextDecoder().decode(proc.stdout);
|
||||
const parsed = JSON.parse(out);
|
||||
expect(parsed).toHaveProperty("version");
|
||||
@@ -976,7 +976,7 @@ describe("integration (smoke)", () => {
|
||||
"null",
|
||||
"--version-path",
|
||||
"Tinas Second Brain/health-tracker/VERSION",
|
||||
]);
|
||||
], { timeout: 30_000 });
|
||||
const out = new TextDecoder().decode(proc.stdout);
|
||||
const parsed = JSON.parse(out);
|
||||
expect(parsed).toHaveProperty("version_path", "Tinas Second Brain/health-tracker/VERSION");
|
||||
@@ -1003,7 +1003,7 @@ describe("fetchGitClaimed — laundered ls-remote (exit 0, empty output) is neve
|
||||
chmodSync(join(stubDir, "git"), 0o755);
|
||||
|
||||
const git = (cwd: string, ...args: string[]) =>
|
||||
Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd });
|
||||
Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd, timeout: 30_000 });
|
||||
|
||||
const cwd = process.cwd();
|
||||
const oldPath = process.env.PATH;
|
||||
@@ -1052,7 +1052,7 @@ describe("fetchGitClaimed — laundered ls-remote (exit 0, empty output) is neve
|
||||
chmodSync(join(stubDir, "git"), 0o755);
|
||||
|
||||
const git = (cwd: string, ...args: string[]) =>
|
||||
Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd });
|
||||
Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd, timeout: 30_000 });
|
||||
|
||||
const cwd = process.cwd();
|
||||
const oldPath = process.env.PATH;
|
||||
|
||||
@@ -19,6 +19,7 @@ function run(env: Record<string, string | undefined>): Record<string, string> {
|
||||
const result = spawnSync('bash', [BIN], {
|
||||
env: { PATH: process.env.PATH, USERPROFILE: '', ...env } as Record<string, string>,
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`gstack-paths failed (status ${result.status}): ${result.stderr}`);
|
||||
@@ -117,6 +118,7 @@ describe('gstack-paths', () => {
|
||||
{
|
||||
env: { PATH: process.env.PATH, USERPROFILE: '', ...env } as Record<string, string>,
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
@@ -159,6 +161,7 @@ describe('gstack-paths', () => {
|
||||
const result = spawnSync('bash', [BIN], {
|
||||
env: { PATH: process.env.PATH, USERPROFILE: '', HOME: '/tmp/h' } as Record<string, string>,
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
const lines = result.stdout.split('\n').filter(Boolean);
|
||||
for (const line of lines) {
|
||||
|
||||
@@ -26,6 +26,7 @@ function run(payload: string): { stdout: string; stderr: string; status: number
|
||||
env: { ...process.env, GSTACK_HOME: tmpHome },
|
||||
encoding: 'utf-8',
|
||||
cwd: ROOT,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return {
|
||||
stdout: res.stdout ?? '',
|
||||
|
||||
@@ -31,6 +31,7 @@ function run(...args: string[]): { stdout: string; stderr: string; status: numbe
|
||||
env: { ...process.env, GSTACK_HOME: tmpHome },
|
||||
encoding: 'utf-8',
|
||||
cwd: ROOT,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return {
|
||||
stdout: res.stdout ?? '',
|
||||
@@ -45,6 +46,7 @@ function runWithStdin(input: string, ...args: string[]): { stdout: string; stder
|
||||
encoding: 'utf-8',
|
||||
cwd: ROOT,
|
||||
input,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return {
|
||||
stdout: res.stdout ?? '',
|
||||
|
||||
@@ -22,6 +22,7 @@ function run(
|
||||
): { code: number; stdout: string; stderr: string } {
|
||||
const proc = Bun.spawnSync(["bun", BIN, ...args], {
|
||||
stdin: Buffer.from(stdin),
|
||||
timeout: 30_000,
|
||||
});
|
||||
return {
|
||||
code: proc.exitCode,
|
||||
@@ -88,7 +89,7 @@ describe("gstack-redact --from-file", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "redact-file-"));
|
||||
const f = path.join(dir, "spec.md");
|
||||
fs.writeFileSync(f, "leaked ghp_" + "a".repeat(36));
|
||||
const proc = Bun.spawnSync(["bun", BIN, "--from-file", f, "--json"]);
|
||||
const proc = Bun.spawnSync(["bun", BIN, "--from-file", f, "--json"], { timeout: 30_000 });
|
||||
const parsed = JSON.parse(proc.stdout.toString());
|
||||
expect(parsed.findings[0].id).toBe("github.pat");
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
|
||||
@@ -25,6 +25,7 @@ function run(command: string, args: string[], cwd: string, home: string): Comman
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, HOME: home, GSTACK_HOME: path.join(home, '.gstack') },
|
||||
timeout: 30_000,
|
||||
});
|
||||
return {
|
||||
stdout: result.stdout ?? '',
|
||||
|
||||
@@ -22,6 +22,7 @@ function kind(env: Record<string, string>): string {
|
||||
return execFileSync(BIN, [], {
|
||||
env: { PATH: process.env.PATH ?? '/usr/bin:/bin', ...env },
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -919,6 +919,7 @@ describe('prune-stale', () => {
|
||||
const live = mkCanon(tmpDir, 'live-worktree');
|
||||
execSync(`'${path.join(ROOT, 'bin', 'gstack-config')}' set plan_tune_hooks no`, {
|
||||
env: { ...process.env, GSTACK_STATE_ROOT: tmpDir },
|
||||
timeout: 30_000,
|
||||
});
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: {
|
||||
|
||||
@@ -31,6 +31,7 @@ let tmpGstackHome: string;
|
||||
|
||||
function runStart(args: string[] = [], env: Record<string, string> = {}): string {
|
||||
return execFileSync(START, ['--skill', 'testskill', ...args], {
|
||||
timeout: 30_000,
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpHome, // no CLAUDE.md/AGENTS.md, not the repo — routing detection stays cold
|
||||
env: {
|
||||
@@ -150,6 +151,7 @@ describe('gstack-skill-start behavior', () => {
|
||||
fs.copyFileSync(START, path.join(fakeBin, 'gstack-skill-start'));
|
||||
fs.chmodSync(path.join(fakeBin, 'gstack-skill-start'), 0o755);
|
||||
const out = execFileSync(path.join(fakeBin, 'gstack-skill-start'), ['--skill', 't'], {
|
||||
timeout: 30_000,
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpHome,
|
||||
env: { PATH: process.env.PATH!, HOME: tmpHome, GSTACK_HOME: tmpGstackHome },
|
||||
@@ -317,7 +319,7 @@ describe('gstack-skill-end', () => {
|
||||
const out = execFileSync(
|
||||
END,
|
||||
['--skill', 't', '--outcome', 'success', '--session-id', 'sid-1', '--tel-start', String(start)],
|
||||
{ encoding: 'utf-8', cwd: tmpHome, env: { PATH: process.env.PATH!, HOME: tmpHome, GSTACK_HOME: tmpGstackHome } },
|
||||
{ timeout: 30_000, encoding: 'utf-8', cwd: tmpHome, env: { PATH: process.env.PATH!, HOME: tmpHome, GSTACK_HOME: tmpGstackHome } },
|
||||
);
|
||||
const m = out.match(/SKILL_END: recorded outcome=success duration_s=(\d+)/);
|
||||
expect(m).not.toBeNull();
|
||||
@@ -338,6 +340,7 @@ describe('gstack-skill-end', () => {
|
||||
const pending = path.join(tmpGstackHome, 'analytics', '.pending-sid-2');
|
||||
fs.writeFileSync(pending, 'x');
|
||||
execFileSync(END, ['--skill', 't', '--outcome', 'abort', '--session-id', 'sid-2', '--tel-start', 'bogus'], {
|
||||
timeout: 30_000,
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpHome,
|
||||
env: { PATH: process.env.PATH!, HOME: tmpHome, GSTACK_HOME: tmpGstackHome },
|
||||
|
||||
@@ -172,12 +172,13 @@ describe('gstack-slug — outermost project-root resolution', () => {
|
||||
// succeeds. (The script's step 2 reads the remote when there's no cache.)
|
||||
const gitInit = spawnSync('git', ['init', '-q', '-b', 'main', projectRoot], {
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(gitInit.status).toBe(0);
|
||||
const gitRemote = spawnSync(
|
||||
'git',
|
||||
['-C', projectRoot, 'remote', 'add', 'origin', 'https://github.com/foo/bar.git'],
|
||||
{ encoding: 'utf8' },
|
||||
{ encoding: 'utf8', timeout: 30_000 },
|
||||
);
|
||||
expect(gitRemote.status).toBe(0);
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ function runSlug(cwd: string, home: string) {
|
||||
return spawnSync([SLUG_BIN], {
|
||||
cwd,
|
||||
env: { ...process.env, HOME: home },
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,6 +78,7 @@ describe('slug cache hygiene', () => {
|
||||
const r = spawnSync(['bash', SLUG_BIN], {
|
||||
cwd: os.tmpdir(),
|
||||
env: { ...process.env, GSTACK_HOME: home, GSTACK_PROJECT_SLUG: 'override-slug' },
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(r.stdout.toString()).toContain('SLUG=override-slug');
|
||||
expect(fs.existsSync(path.join(home, 'slug-cache'))).toBe(false);
|
||||
@@ -95,6 +97,7 @@ describe('slug cache hygiene', () => {
|
||||
const r = spawnSync(['bash', SLUG_BIN], {
|
||||
cwd: os.tmpdir(),
|
||||
env: { ...ambient, GSTACK_HOME: home },
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(r.exitCode).toBe(0);
|
||||
const entries = fs.readdirSync(path.join(home, 'slug-cache'));
|
||||
|
||||
@@ -54,6 +54,7 @@ function runBin(
|
||||
env: cleaned,
|
||||
encoding: 'utf-8',
|
||||
cwd: ROOT,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return {
|
||||
stdout: res.stdout ?? '',
|
||||
|
||||
@@ -42,6 +42,7 @@ describe('gstack-team-init required: PreToolUse hook schema (#2413)', () => {
|
||||
const stdout = execSync(`bash "${hookPath}"`, {
|
||||
env: { ...process.env, HOME: home },
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
return { status: 0, stdout, stderr: '' };
|
||||
} catch (err) {
|
||||
|
||||
@@ -64,6 +64,7 @@ function run(opts: { env?: Record<string, string> } = {}) {
|
||||
env,
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpHome,
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -136,6 +136,7 @@ function run(opts: { path?: string } = {}) {
|
||||
env,
|
||||
encoding: "utf-8",
|
||||
cwd: tmpHome,
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('write (FRESH bump)', () => {
|
||||
test('writes VERSION + package.json.version, preserving other pkg fields', () => {
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '1.0.0.0\n');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0.0', scripts: { t: 'y' } }, null, 2) + '\n');
|
||||
const out = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: dir }).toString();
|
||||
const out = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: dir, timeout: 30_000 }).toString();
|
||||
expect(JSON.parse(out)).toEqual({
|
||||
wrote: '1.1.0.0', packageJson: true, packageJsonPath: 'package.json',
|
||||
packageJsonVersion: '1.1.0', packageLock: false, agentsDigest: null,
|
||||
@@ -76,7 +76,7 @@ describe('write (FRESH bump)', () => {
|
||||
|
||||
test('rejects a malformed version with exit 2', () => {
|
||||
let code = 0;
|
||||
try { execFileSync('bun', [BIN, 'write', '--version', '1.2.3.4.5'], { cwd: dir, stdio: 'pipe' }); }
|
||||
try { execFileSync('bun', [BIN, 'write', '--version', '1.2.3.4.5'], { cwd: dir, stdio: 'pipe', timeout: 30_000 }); }
|
||||
catch (e: any) { code = e.status; }
|
||||
expect(code).toBe(2);
|
||||
});
|
||||
@@ -84,7 +84,7 @@ describe('write (FRESH bump)', () => {
|
||||
test('VERSION-only repo (no package.json) writes just VERSION', () => {
|
||||
const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-noPkg-'));
|
||||
fs.writeFileSync(path.join(d2, 'VERSION'), '0.1.0.0\n');
|
||||
const out = execFileSync('bun', [BIN, 'write', '--version', '0.2.0.0'], { cwd: d2 }).toString();
|
||||
const out = execFileSync('bun', [BIN, 'write', '--version', '0.2.0.0'], { cwd: d2, timeout: 30_000 }).toString();
|
||||
expect(JSON.parse(out)).toEqual({
|
||||
wrote: '0.2.0.0', packageJson: false, packageJsonPath: null,
|
||||
packageJsonVersion: null, packageLock: false, agentsDigest: null,
|
||||
@@ -101,7 +101,7 @@ describe('repair (DRIFT_STALE_PKG)', () => {
|
||||
test('syncs package.json.version up to VERSION, no re-bump', () => {
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '2.0.0.0\n');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.9.0.0' }, null, 2) + '\n');
|
||||
const out = execFileSync('bun', [BIN, 'repair'], { cwd: dir }).toString();
|
||||
const out = execFileSync('bun', [BIN, 'repair'], { cwd: dir, timeout: 30_000 }).toString();
|
||||
expect(JSON.parse(out)).toEqual({
|
||||
repaired: '2.0.0.0', packageJsonPath: 'package.json', packageJsonVersion: '2.0.0',
|
||||
});
|
||||
@@ -112,7 +112,7 @@ describe('repair (DRIFT_STALE_PKG)', () => {
|
||||
test('refuses to propagate an invalid VERSION (exit 2)', () => {
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), 'not-a-version\n');
|
||||
let code = 0;
|
||||
try { execFileSync('bun', [BIN, 'repair'], { cwd: dir, stdio: 'pipe' }); }
|
||||
try { execFileSync('bun', [BIN, 'repair'], { cwd: dir, stdio: 'pipe', timeout: 30_000 }); }
|
||||
catch (e: any) { code = e.status; }
|
||||
expect(code).toBe(2);
|
||||
});
|
||||
@@ -131,7 +131,7 @@ describe('write/repair sync npm lockfiles (both version fields, #2567)', () => {
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '1.0.0.0\n');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0' }, null, 2) + '\n');
|
||||
fs.writeFileSync(path.join(dir, 'package-lock.json'), lock('1.0.0'));
|
||||
const out = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: dir }).toString();
|
||||
const out = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: dir, timeout: 30_000 }).toString();
|
||||
expect(JSON.parse(out)).toEqual({
|
||||
wrote: '1.1.0.0', packageJson: true, packageJsonPath: 'package.json',
|
||||
packageJsonVersion: '1.1.0', packageLock: true, agentsDigest: null,
|
||||
@@ -146,7 +146,7 @@ describe('write/repair sync npm lockfiles (both version fields, #2567)', () => {
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '2.0.0.0\n');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.9.0' }, null, 2) + '\n');
|
||||
fs.writeFileSync(path.join(dir, 'package-lock.json'), lock('1.9.0'));
|
||||
execFileSync('bun', [BIN, 'repair'], { cwd: dir });
|
||||
execFileSync('bun', [BIN, 'repair'], { cwd: dir, timeout: 30_000 });
|
||||
const l = JSON.parse(fs.readFileSync(path.join(dir, 'package-lock.json'), 'utf-8'));
|
||||
expect(l.version).toBe('2.0.0');
|
||||
expect(l.packages[''].version).toBe('2.0.0');
|
||||
@@ -156,7 +156,7 @@ describe('write/repair sync npm lockfiles (both version fields, #2567)', () => {
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '3.0.0.0\n');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '2.9.0' }, null, 2) + '\n');
|
||||
fs.writeFileSync(path.join(dir, 'package-lock.json'), JSON.stringify({ name: 'x', version: '2.9.0', lockfileVersion: 1 }, null, 2) + '\n');
|
||||
execFileSync('bun', [BIN, 'repair'], { cwd: dir });
|
||||
execFileSync('bun', [BIN, 'repair'], { cwd: dir, timeout: 30_000 });
|
||||
const l = JSON.parse(fs.readFileSync(path.join(dir, 'package-lock.json'), 'utf-8'));
|
||||
expect(l.version).toBe('3.0.0');
|
||||
expect(l.packages).toBeUndefined();
|
||||
@@ -167,7 +167,7 @@ describe('write/repair sync npm lockfiles (both version fields, #2567)', () => {
|
||||
fs.writeFileSync(path.join(d2, 'VERSION'), '1.0.0.0\n');
|
||||
fs.writeFileSync(path.join(d2, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0' }, null, 2) + '\n');
|
||||
fs.writeFileSync(path.join(d2, 'npm-shrinkwrap.json'), lock('1.0.0').replace('package-lock', 'npm-shrinkwrap'));
|
||||
const out = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: d2 }).toString();
|
||||
const out = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: d2, timeout: 30_000 }).toString();
|
||||
expect(JSON.parse(out).packageLock).toBe(true);
|
||||
const l = JSON.parse(fs.readFileSync(path.join(d2, 'npm-shrinkwrap.json'), 'utf-8'));
|
||||
expect(l.version).toBe('1.1.0');
|
||||
@@ -183,7 +183,7 @@ describe('write/repair sync npm lockfiles (both version fields, #2567)', () => {
|
||||
fs.writeFileSync(path.join(d3, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0.0' }, null, 2) + '\n');
|
||||
fs.writeFileSync(path.join(d3, 'package-lock.json'), '{ not json');
|
||||
let code = 0;
|
||||
try { execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: d3, stdio: 'pipe' }); }
|
||||
try { execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: d3, stdio: 'pipe', timeout: 30_000 }); }
|
||||
catch (e: any) { code = e.status; }
|
||||
expect(code).toBe(3);
|
||||
// VERSION was written before the failure — exactly the half-write the
|
||||
@@ -198,26 +198,26 @@ describe('classify (idempotency over a real git base)', () => {
|
||||
afterAll(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } });
|
||||
|
||||
// Build a tiny repo with an "origin/main" carrying VERSION=1.0.0.0.
|
||||
const git = (...a: string[]) => execFileSync('git', a, { cwd: dir, stdio: 'pipe' });
|
||||
const git = (...a: string[]) => execFileSync('git', a, { cwd: dir, stdio: 'pipe', timeout: 30_000 });
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '1.0.0.0\n');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0.0' }, null, 2) + '\n');
|
||||
git('init', '-q', '-b', 'main');
|
||||
git('config', 'user.email', 't@t'); git('config', 'user.name', 't');
|
||||
git('add', '-A'); git('commit', '-q', '-m', 'base');
|
||||
// Fake an "origin/main" remote-tracking ref pointing at this commit.
|
||||
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir }).toString().trim();
|
||||
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, timeout: 30_000 }).toString().trim();
|
||||
fs.mkdirSync(path.join(dir, '.git', 'refs', 'remotes', 'origin'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.git', 'refs', 'remotes', 'origin', 'main'), head + '\n');
|
||||
|
||||
test('reports FRESH before any bump', () => {
|
||||
const out = execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: dir }).toString();
|
||||
const out = execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: dir, timeout: 30_000 }).toString();
|
||||
expect(JSON.parse(out).state).toBe('FRESH');
|
||||
});
|
||||
|
||||
test('reports ALREADY_BUMPED after VERSION+pkg move together', () => {
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '1.1.0.0\n');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.1.0.0' }, null, 2) + '\n');
|
||||
const out = execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: dir }).toString();
|
||||
const out = execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: dir, timeout: 30_000 }).toString();
|
||||
const parsed = JSON.parse(out);
|
||||
expect(parsed.state).toBe('ALREADY_BUMPED');
|
||||
expect(parsed.baseVersion).toBe('1.0.0.0');
|
||||
@@ -246,17 +246,17 @@ describe('package.json as the version source (monorepo, 3-digit, #2501)', () =>
|
||||
fs.mkdirSync(path.join(dir, 'frontend'), { recursive: true });
|
||||
fs.writeFileSync(pkgAbs, JSON.stringify({ name: 'frontend', version: '0.99.2', private: true, scripts: { dev: 'next dev' } }, null, 2) + '\n');
|
||||
|
||||
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir });
|
||||
execFileSync('git', ['config', 'user.email', 't@e.com'], { cwd: dir });
|
||||
execFileSync('git', ['config', 'user.name', 't'], { cwd: dir });
|
||||
execFileSync('git', ['add', '-A'], { cwd: dir });
|
||||
execFileSync('git', ['commit', '-qm', 'v0.99.2 base'], { cwd: dir });
|
||||
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir }).toString().trim();
|
||||
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir, timeout: 30_000 });
|
||||
execFileSync('git', ['config', 'user.email', 't@e.com'], { cwd: dir, timeout: 30_000 });
|
||||
execFileSync('git', ['config', 'user.name', 't'], { cwd: dir, timeout: 30_000 });
|
||||
execFileSync('git', ['add', '-A'], { cwd: dir, timeout: 30_000 });
|
||||
execFileSync('git', ['commit', '-qm', 'v0.99.2 base'], { cwd: dir, timeout: 30_000 });
|
||||
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, timeout: 30_000 }).toString().trim();
|
||||
fs.mkdirSync(path.join(dir, '.git', 'refs', 'remotes', 'origin'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.git', 'refs', 'remotes', 'origin', 'main'), head + '\n');
|
||||
|
||||
test('classify reads the real version from the package.json version-path', () => {
|
||||
const out = execFileSync('bun', [BIN, 'classify', '--base', 'main', '--version-path', pkgRel], { cwd: dir }).toString();
|
||||
const out = execFileSync('bun', [BIN, 'classify', '--base', 'main', '--version-path', pkgRel], { cwd: dir, timeout: 30_000 }).toString();
|
||||
const parsed = JSON.parse(out);
|
||||
expect(parsed.state).toBe('FRESH');
|
||||
expect(parsed.baseVersion).toBe('0.99.2'); // was "0.0.0.0"
|
||||
@@ -265,7 +265,7 @@ describe('package.json as the version source (monorepo, 3-digit, #2501)', () =>
|
||||
});
|
||||
|
||||
test('write updates the package.json in place and creates no VERSION file', () => {
|
||||
const out = execFileSync('bun', [BIN, 'write', '--version', '0.99.3', '--version-path', pkgRel], { cwd: dir }).toString();
|
||||
const out = execFileSync('bun', [BIN, 'write', '--version', '0.99.3', '--version-path', pkgRel], { cwd: dir, timeout: 30_000 }).toString();
|
||||
expect(JSON.parse(out)).toEqual({ wrote: '0.99.3', versionPath: pkgRel, packageJson: true, packageLock: false, agentsDigest: null });
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgAbs, 'utf-8'));
|
||||
expect(pkg.version).toBe('0.99.3');
|
||||
@@ -275,7 +275,7 @@ describe('package.json as the version source (monorepo, 3-digit, #2501)', () =>
|
||||
});
|
||||
|
||||
test('classify reports ALREADY_BUMPED after that write, not a drift state', () => {
|
||||
const out = execFileSync('bun', [BIN, 'classify', '--base', 'main', '--version-path', pkgRel], { cwd: dir }).toString();
|
||||
const out = execFileSync('bun', [BIN, 'classify', '--base', 'main', '--version-path', pkgRel], { cwd: dir, timeout: 30_000 }).toString();
|
||||
const parsed = JSON.parse(out);
|
||||
expect(parsed.state).toBe('ALREADY_BUMPED');
|
||||
expect(parsed.baseVersion).toBe('0.99.2');
|
||||
@@ -283,14 +283,14 @@ describe('package.json as the version source (monorepo, 3-digit, #2501)', () =>
|
||||
});
|
||||
|
||||
test('repair is a no-op: there is no second file to drift from', () => {
|
||||
const out = execFileSync('bun', [BIN, 'repair', '--version-path', pkgRel], { cwd: dir }).toString();
|
||||
const out = execFileSync('bun', [BIN, 'repair', '--version-path', pkgRel], { cwd: dir, timeout: 30_000 }).toString();
|
||||
expect(JSON.parse(out).repaired).toBeNull();
|
||||
});
|
||||
|
||||
test('write refuses a version-path that does not exist', () => {
|
||||
let code = 0;
|
||||
try {
|
||||
execFileSync('bun', [BIN, 'write', '--version', '1.0.0', '--version-path', 'nope/package.json'], { cwd: dir, stdio: 'pipe' });
|
||||
execFileSync('bun', [BIN, 'write', '--version', '1.0.0', '--version-path', 'nope/package.json'], { cwd: dir, stdio: 'pipe', timeout: 30_000 });
|
||||
} catch (e: any) { code = e.status; }
|
||||
expect(code).toBe(2);
|
||||
});
|
||||
@@ -315,12 +315,12 @@ describe('.gstack/version-path pin, no --version-path flag (#2462)', () => {
|
||||
};
|
||||
|
||||
const commitBase = (d: string): void => {
|
||||
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: d });
|
||||
execFileSync('git', ['config', 'user.email', 't@e.com'], { cwd: d });
|
||||
execFileSync('git', ['config', 'user.name', 't'], { cwd: d });
|
||||
execFileSync('git', ['add', '-A'], { cwd: d });
|
||||
execFileSync('git', ['commit', '-qm', 'base'], { cwd: d });
|
||||
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: d }).toString().trim();
|
||||
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: d, timeout: 30_000 });
|
||||
execFileSync('git', ['config', 'user.email', 't@e.com'], { cwd: d, timeout: 30_000 });
|
||||
execFileSync('git', ['config', 'user.name', 't'], { cwd: d, timeout: 30_000 });
|
||||
execFileSync('git', ['add', '-A'], { cwd: d, timeout: 30_000 });
|
||||
execFileSync('git', ['commit', '-qm', 'base'], { cwd: d, timeout: 30_000 });
|
||||
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: d, timeout: 30_000 }).toString().trim();
|
||||
fs.mkdirSync(path.join(d, '.git', 'refs', 'remotes', 'origin'), { recursive: true });
|
||||
fs.writeFileSync(path.join(d, '.git', 'refs', 'remotes', 'origin', 'main'), head + '\n');
|
||||
};
|
||||
@@ -332,7 +332,7 @@ describe('.gstack/version-path pin, no --version-path flag (#2462)', () => {
|
||||
commitBase(d);
|
||||
// Move the pinned file past base — NO root VERSION file exists at all.
|
||||
fs.writeFileSync(path.join(d, pinRel), '1.5.0.0\n');
|
||||
const out = JSON.parse(execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: d }).toString());
|
||||
const out = JSON.parse(execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: d, timeout: 30_000 }).toString());
|
||||
// Before the fix: baseVersion read root VERSION → "0.0.0.0" and the
|
||||
// branch misclassified as... current 1.5.0.0 vs base 0.0.0.0. The REAL
|
||||
// base is the pinned file's committed value.
|
||||
@@ -347,7 +347,7 @@ describe('.gstack/version-path pin, no --version-path flag (#2462)', () => {
|
||||
const d = mkPinned(pinRel);
|
||||
fs.writeFileSync(path.join(d, pinRel), JSON.stringify({ name: 'f', version: '0.99.2' }, null, 2) + '\n');
|
||||
commitBase(d);
|
||||
const out = JSON.parse(execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: d }).toString());
|
||||
const out = JSON.parse(execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: d, timeout: 30_000 }).toString());
|
||||
// Before the fix: versionRel="VERSION" → the pinned JSON was read as raw
|
||||
// text → currentVersion "0.0.0.0", pkgExists false, base from a
|
||||
// nonexistent root VERSION.
|
||||
@@ -362,7 +362,7 @@ describe('.gstack/version-path pin, no --version-path flag (#2462)', () => {
|
||||
const pinRel = 'frontend/package.json';
|
||||
const d = mkPinned(pinRel);
|
||||
fs.writeFileSync(path.join(d, pinRel), JSON.stringify({ name: 'f', version: '0.99.2' }, null, 2) + '\n');
|
||||
const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '0.99.3'], { cwd: d }).toString());
|
||||
const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '0.99.3'], { cwd: d, timeout: 30_000 }).toString());
|
||||
expect(out).toEqual({ wrote: '0.99.3', versionPath: pinRel, packageJson: true, packageLock: false, agentsDigest: null });
|
||||
expect(JSON.parse(fs.readFileSync(path.join(d, pinRel), 'utf-8')).version).toBe('0.99.3');
|
||||
// Before the fix, write treated versionRel as "VERSION" and overwrote the
|
||||
@@ -375,7 +375,7 @@ describe('.gstack/version-path pin, no --version-path flag (#2462)', () => {
|
||||
const pinRel = 'frontend/package.json';
|
||||
const d = mkPinned(pinRel);
|
||||
fs.writeFileSync(path.join(d, pinRel), JSON.stringify({ name: 'f', version: '0.99.2' }, null, 2) + '\n');
|
||||
const out = JSON.parse(execFileSync('bun', [BIN, 'repair'], { cwd: d }).toString());
|
||||
const out = JSON.parse(execFileSync('bun', [BIN, 'repair'], { cwd: d, timeout: 30_000 }).toString());
|
||||
expect(out.repaired).toBeNull();
|
||||
fs.rmSync(d, { recursive: true, force: true });
|
||||
});
|
||||
@@ -386,7 +386,7 @@ describe('.gstack/version-path pin, no --version-path flag (#2462)', () => {
|
||||
fs.writeFileSync(path.join(d, 'OTHER_VERSION'), '2.0.0.0\n');
|
||||
commitBase(d);
|
||||
const out = JSON.parse(
|
||||
execFileSync('bun', [BIN, 'classify', '--base', 'main', '--version-path', 'OTHER_VERSION'], { cwd: d }).toString(),
|
||||
execFileSync('bun', [BIN, 'classify', '--base', 'main', '--version-path', 'OTHER_VERSION'], { cwd: d, timeout: 30_000 }).toString(),
|
||||
);
|
||||
expect(out.currentVersion).toBe('2.0.0.0');
|
||||
expect(out.baseVersion).toBe('2.0.0.0');
|
||||
@@ -414,7 +414,7 @@ describe('subdirectory manifest (no root package.json, #2531)', () => {
|
||||
const d = mk();
|
||||
fs.writeFileSync(path.join(d, 'web', 'package.json'),
|
||||
JSON.stringify({ name: 'w', version: '0.1.0' }, null, 2) + '\n');
|
||||
const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '0.2.0.0'], { cwd: d }).toString());
|
||||
const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '0.2.0.0'], { cwd: d, timeout: 30_000 }).toString());
|
||||
expect(out.packageJson).toBe(true);
|
||||
expect(out.packageJsonPath).toBe('web/package.json');
|
||||
expect(out.packageJsonVersion).toBe('0.2.0');
|
||||
@@ -428,7 +428,7 @@ describe('subdirectory manifest (no root package.json, #2531)', () => {
|
||||
fs.writeFileSync(path.join(d, 'web', 'package.json'), JSON.stringify({ version: '0.1.0' }, null, 2) + '\n');
|
||||
fs.writeFileSync(path.join(d, 'app', 'package.json'), JSON.stringify({ version: '0.1.0' }, null, 2) + '\n');
|
||||
const out = JSON.parse(execFileSync('bun',
|
||||
[BIN, 'write', '--version', '0.3.0.0', '--package-json-path', 'app/package.json'], { cwd: d }).toString());
|
||||
[BIN, 'write', '--version', '0.3.0.0', '--package-json-path', 'app/package.json'], { cwd: d, timeout: 30_000 }).toString());
|
||||
expect(out.packageJsonPath).toBe('app/package.json');
|
||||
expect(JSON.parse(fs.readFileSync(path.join(d, 'app', 'package.json'), 'utf-8')).version).toBe('0.3.0');
|
||||
// the pinned one is untouched
|
||||
@@ -440,16 +440,16 @@ describe('subdirectory manifest (no root package.json, #2531)', () => {
|
||||
const d = mk();
|
||||
fs.writeFileSync(path.join(d, 'web', 'package.json'),
|
||||
JSON.stringify({ name: 'w', version: '0.1.0' }, null, 2) + '\n');
|
||||
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: d });
|
||||
execFileSync('git', ['config', 'user.email', 't@e.com'], { cwd: d });
|
||||
execFileSync('git', ['config', 'user.name', 't'], { cwd: d });
|
||||
execFileSync('git', ['add', '-A'], { cwd: d });
|
||||
execFileSync('git', ['commit', '-qm', 'base'], { cwd: d });
|
||||
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: d }).toString().trim();
|
||||
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: d, timeout: 30_000 });
|
||||
execFileSync('git', ['config', 'user.email', 't@e.com'], { cwd: d, timeout: 30_000 });
|
||||
execFileSync('git', ['config', 'user.name', 't'], { cwd: d, timeout: 30_000 });
|
||||
execFileSync('git', ['add', '-A'], { cwd: d, timeout: 30_000 });
|
||||
execFileSync('git', ['commit', '-qm', 'base'], { cwd: d, timeout: 30_000 });
|
||||
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: d, timeout: 30_000 }).toString().trim();
|
||||
fs.mkdirSync(path.join(d, '.git', 'refs', 'remotes', 'origin'), { recursive: true });
|
||||
fs.writeFileSync(path.join(d, '.git', 'refs', 'remotes', 'origin', 'main'), head + '\n');
|
||||
|
||||
const out = JSON.parse(execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: d }).toString());
|
||||
const out = JSON.parse(execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: d, timeout: 30_000 }).toString());
|
||||
// 0.1.0 IS the npm-valid translation of 0.1.0.0 — in sync, no drift.
|
||||
expect(out.state).toBe('FRESH');
|
||||
expect(out.pkgExists).toBe(true);
|
||||
@@ -462,7 +462,7 @@ describe('subdirectory manifest (no root package.json, #2531)', () => {
|
||||
const d = mk();
|
||||
fs.writeFileSync(path.join(d, 'web', 'package.json'),
|
||||
JSON.stringify({ name: 'w', version: '0.0.9' }, null, 2) + '\n');
|
||||
const out = JSON.parse(execFileSync('bun', [BIN, 'repair'], { cwd: d }).toString());
|
||||
const out = JSON.parse(execFileSync('bun', [BIN, 'repair'], { cwd: d, timeout: 30_000 }).toString());
|
||||
expect(out).toEqual({ repaired: '0.1.0.0', packageJsonPath: 'web/package.json', packageJsonVersion: '0.1.0' });
|
||||
expect(JSON.parse(fs.readFileSync(path.join(d, 'web', 'package.json'), 'utf-8')).version).toBe('0.1.0');
|
||||
fs.rmSync(d, { recursive: true, force: true });
|
||||
@@ -504,7 +504,7 @@ describe('path containment: pins and flags cannot escape the repo', () => {
|
||||
|
||||
function runFail(args: string[]): { code: number; stderr: string } {
|
||||
try {
|
||||
execFileSync('bun', [BIN, ...args], { cwd: dir, stdio: 'pipe' });
|
||||
execFileSync('bun', [BIN, ...args], { cwd: dir, stdio: 'pipe', timeout: 30_000 });
|
||||
return { code: 0, stderr: '' };
|
||||
} catch (e: any) {
|
||||
return { code: e.status, stderr: (e.stderr || '').toString() };
|
||||
@@ -562,7 +562,7 @@ describe('path containment: pins and flags cannot escape the repo', () => {
|
||||
fs.writeFileSync(outerLock, JSON.stringify({ version: '1.0.0', packages: { '': { version: '1.0.0' } } }, null, 2) + '\n');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0' }, null, 2) + '\n');
|
||||
fs.symlinkSync(outerLock, path.join(dir, 'package-lock.json'));
|
||||
const res = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: dir, stdio: 'pipe' });
|
||||
const res = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: dir, stdio: 'pipe', timeout: 30_000 });
|
||||
expect(JSON.parse(res.toString()).packageLock).toBe(false);
|
||||
expect(JSON.parse(fs.readFileSync(outerLock, 'utf-8')).version).toBe('1.0.0');
|
||||
});
|
||||
@@ -572,7 +572,7 @@ describe('path containment: pins and flags cannot escape the repo', () => {
|
||||
fs.mkdirSync(path.join(dir, 'frontend'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'frontend', 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0' }, null, 2) + '\n');
|
||||
fs.writeFileSync(path.join(dir, '.gstack', 'version-path'), 'frontend/package.json\n');
|
||||
const out = execFileSync('bun', [BIN, 'write', '--version', '1.1.0'], { cwd: dir }).toString();
|
||||
const out = execFileSync('bun', [BIN, 'write', '--version', '1.1.0'], { cwd: dir, timeout: 30_000 }).toString();
|
||||
expect(JSON.parse(out).wrote).toBe('1.1.0');
|
||||
expect(JSON.parse(fs.readFileSync(path.join(dir, 'frontend', 'package.json'), 'utf-8')).version).toBe('1.1.0');
|
||||
});
|
||||
@@ -602,7 +602,7 @@ describe('#2600: repair must not write fabricated 0.0.0.0 when VERSION is missin
|
||||
let code = 0;
|
||||
let stderr = '';
|
||||
try {
|
||||
execFileSync('bun', [BIN, 'repair'], { cwd: dir, stdio: 'pipe' });
|
||||
execFileSync('bun', [BIN, 'repair'], { cwd: dir, stdio: 'pipe', timeout: 30_000 });
|
||||
} catch (e: any) {
|
||||
code = e.status;
|
||||
stderr = (e.stderr || '').toString();
|
||||
@@ -621,7 +621,7 @@ describe('#2600: repair must not write fabricated 0.0.0.0 when VERSION is missin
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '2.0.0.0\n');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.9.0' }, null, 2) + '\n');
|
||||
|
||||
const out = execFileSync('bun', [BIN, 'repair'], { cwd: dir }).toString();
|
||||
const out = execFileSync('bun', [BIN, 'repair'], { cwd: dir, timeout: 30_000 }).toString();
|
||||
const result = JSON.parse(out);
|
||||
|
||||
expect(result.repaired).toBe('2.0.0.0');
|
||||
@@ -639,7 +639,7 @@ describe('#2600: repair must not write fabricated 0.0.0.0 when VERSION is missin
|
||||
let code = 0;
|
||||
let stderr = '';
|
||||
try {
|
||||
execFileSync('bun', [BIN, 'repair'], { cwd: dir, stdio: 'pipe' });
|
||||
execFileSync('bun', [BIN, 'repair'], { cwd: dir, stdio: 'pipe', timeout: 30_000 });
|
||||
} catch (e: any) {
|
||||
code = e.status;
|
||||
stderr = (e.stderr || '').toString();
|
||||
@@ -660,7 +660,7 @@ describe('#2600: repair must not write fabricated 0.0.0.0 when VERSION is missin
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '0.0.0.0\n');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '0.5.0' }, null, 2) + '\n');
|
||||
|
||||
const out = execFileSync('bun', [BIN, 'repair'], { cwd: dir }).toString();
|
||||
const out = execFileSync('bun', [BIN, 'repair'], { cwd: dir, timeout: 30_000 }).toString();
|
||||
const result = JSON.parse(out);
|
||||
expect(result.repaired).toBe('0.0.0.0');
|
||||
expect(result.packageJsonVersion).toBe('0.0.0');
|
||||
@@ -673,7 +673,7 @@ describe('#2600: repair must not write fabricated 0.0.0.0 when VERSION is missin
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '0.5.0' }, null, 2) + '\n');
|
||||
|
||||
let code = 0;
|
||||
try { execFileSync('bun', [BIN, 'repair'], { cwd: dir, stdio: 'pipe' }); }
|
||||
try { execFileSync('bun', [BIN, 'repair'], { cwd: dir, stdio: 'pipe', timeout: 30_000 }); }
|
||||
catch (e: any) { code = e.status; }
|
||||
expect(code).toBe(2);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('0.5.0');
|
||||
@@ -692,7 +692,7 @@ describe('#2600: repair must not write fabricated 0.0.0.0 when VERSION is missin
|
||||
let code = 0;
|
||||
let stderr = '';
|
||||
try {
|
||||
execFileSync('bun', [BIN, 'repair'], { cwd: path.join(rootDir, 'app'), stdio: 'pipe' });
|
||||
execFileSync('bun', [BIN, 'repair'], { cwd: path.join(rootDir, 'app'), stdio: 'pipe', timeout: 30_000 });
|
||||
} catch (e: any) {
|
||||
code = e.status;
|
||||
stderr = (e.stderr || '').toString();
|
||||
@@ -717,13 +717,13 @@ describe('#2600: classify must surface versionFileExists=false when VERSION is m
|
||||
function makeRepoDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-2600-classify-'));
|
||||
dirs.push(dir);
|
||||
const git = (...a: string[]) => execFileSync('git', a, { cwd: dir, stdio: 'pipe' });
|
||||
const git = (...a: string[]) => execFileSync('git', a, { cwd: dir, stdio: 'pipe', timeout: 30_000 });
|
||||
git('init', '-q', '-b', 'main');
|
||||
git('config', 'user.email', 't@t'); git('config', 'user.name', 't');
|
||||
// Commit with no VERSION file
|
||||
fs.writeFileSync(path.join(dir, 'README.md'), 'test\n');
|
||||
git('add', '-A'); git('commit', '-q', '-m', 'base');
|
||||
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir }).toString().trim();
|
||||
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, timeout: 30_000 }).toString().trim();
|
||||
fs.mkdirSync(path.join(dir, '.git', 'refs', 'remotes', 'origin'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.git', 'refs', 'remotes', 'origin', 'main'), head + '\n');
|
||||
return dir;
|
||||
@@ -734,7 +734,7 @@ describe('#2600: classify must surface versionFileExists=false when VERSION is m
|
||||
// No package.json: pkgExists=false, pkgAgrees=true, current===base → FRESH.
|
||||
// (A package.json with a non-zero version would cause DRIFT_UNEXPECTED.)
|
||||
|
||||
const out = execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: dir }).toString();
|
||||
const out = execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: dir, timeout: 30_000 }).toString();
|
||||
const result = JSON.parse(out);
|
||||
|
||||
expect(result.versionFileExists).toBe(false);
|
||||
@@ -748,7 +748,7 @@ describe('#2600: classify must surface versionFileExists=false when VERSION is m
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '0.2.0.0\n');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '0.2.0.0' }, null, 2) + '\n');
|
||||
|
||||
const out = execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: dir }).toString();
|
||||
const out = execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: dir, timeout: 30_000 }).toString();
|
||||
const result = JSON.parse(out);
|
||||
|
||||
expect(result.versionFileExists).toBe(true);
|
||||
@@ -784,7 +784,7 @@ describe('write --regen-digest regenerates the gstack agents digest (explicit op
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-digest-'));
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '1.0.0.0\n');
|
||||
stubGenerator(dir);
|
||||
const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0', '--regen-digest'], { cwd: dir }).toString());
|
||||
const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0', '--regen-digest'], { cwd: dir, timeout: 30_000 }).toString());
|
||||
expect(out.agentsDigest).toBe(true);
|
||||
expect(fs.readFileSync(path.join(dir, 'agents-digest', 'gstack-AGENTS.md'), 'utf-8'))
|
||||
.toBe('# gstack digest v1.1.0.0\n');
|
||||
@@ -795,7 +795,7 @@ describe('write --regen-digest regenerates the gstack agents digest (explicit op
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-digest-noflag-'));
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '1.0.0.0\n');
|
||||
stubGenerator(dir);
|
||||
const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: dir }).toString());
|
||||
const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: dir, timeout: 30_000 }).toString());
|
||||
expect(out.agentsDigest).toBe(null);
|
||||
// Digest untouched — the stub would have stamped v1.1.0.0 had it run.
|
||||
expect(fs.readFileSync(path.join(dir, 'agents-digest', 'gstack-AGENTS.md'), 'utf-8'))
|
||||
@@ -811,7 +811,7 @@ describe('write --regen-digest regenerates the gstack agents digest (explicit op
|
||||
fs.writeFileSync(path.join(d2, 'scripts', 'gen-agents-digest.ts'), 'process.exit(1);\n');
|
||||
fs.writeFileSync(path.join(d2, 'agents-digest', 'gstack-AGENTS.md'), '# gstack digest v1.0.0.0\n');
|
||||
|
||||
const res = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0', '--regen-digest'], { cwd: d2, stdio: 'pipe' });
|
||||
const res = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0', '--regen-digest'], { cwd: d2, stdio: 'pipe', timeout: 30_000 });
|
||||
const out = JSON.parse(res.toString());
|
||||
expect(out.wrote).toBe('1.1.0.0'); // the bump itself still lands
|
||||
expect(out.agentsDigest).toBe(false);
|
||||
@@ -834,7 +834,7 @@ describe('write --regen-digest regenerates the gstack agents digest (explicit op
|
||||
path.join(dir, 'agents-digest', 'gstack-AGENTS.md'),
|
||||
);
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '9.9.9.9\n');
|
||||
const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '9.9.10.0', '--regen-digest'], { cwd: dir }).toString());
|
||||
const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '9.9.10.0', '--regen-digest'], { cwd: dir, timeout: 30_000 }).toString());
|
||||
expect(out.agentsDigest).toBe(true);
|
||||
const first = fs.readFileSync(path.join(dir, 'agents-digest', 'gstack-AGENTS.md'), 'utf-8').split('\n')[0];
|
||||
expect(first).toContain('v9.9.10.0');
|
||||
|
||||
@@ -281,13 +281,21 @@ export function carvedSkill(): { skillMd: string; sectionsFrom: string | null }
|
||||
};
|
||||
}
|
||||
|
||||
/** Read the pre-carve verbose monolith plan-ceo SKILL.md from git. */
|
||||
export function verboseSkill(gitRef = 'ab66193e^'): string {
|
||||
return execGit(['show', `${gitRef}:plan-ceo-review/SKILL.md`]);
|
||||
/** Read the pre-carve verbose monolith plan-ceo SKILL.md.
|
||||
* VENDORED fixture (v1.75 precedent), not a git ref: the old default
|
||||
* `git show ab66193e^:...` pinned a BRANCH-LOCAL commit — it dies the day
|
||||
* that branch is pruned and already fails on shallow clones. The fixture
|
||||
* is the frozen pre-cut render; test/git-ref-fixture-tripwire.test.ts
|
||||
* keeps this class from coming back. */
|
||||
export function verboseSkill(): string {
|
||||
return fs.readFileSync(
|
||||
path.join(ROOT, 'test', 'fixtures', 'auq-pre-cut-plan-ceo-review-SKILL.md'),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
function execGit(args: string[]): string {
|
||||
const r = spawnSync('git', args, { cwd: ROOT, encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024 });
|
||||
const r = spawnSync('git', args, { cwd: ROOT, encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024, timeout: 30_000 });
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`);
|
||||
return r.stdout;
|
||||
}
|
||||
|
||||
@@ -141,8 +141,8 @@ function discoverEvalCoverage(repoRoot: string, skills: string[]): {
|
||||
|
||||
function getGitInfo(repoRoot: string): { commit: string; branch: string } {
|
||||
try {
|
||||
const commit = execSync('git rev-parse --short HEAD', { cwd: repoRoot, encoding: 'utf-8' }).trim();
|
||||
const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: repoRoot, encoding: 'utf-8' }).trim();
|
||||
const commit = execSync('git rev-parse --short HEAD', { cwd: repoRoot, encoding: 'utf-8', timeout: 30_000 }).trim();
|
||||
const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: repoRoot, encoding: 'utf-8', timeout: 30_000 }).trim();
|
||||
return { commit, branch };
|
||||
} catch {
|
||||
return { commit: 'unknown', branch: 'unknown' };
|
||||
|
||||
@@ -15,8 +15,11 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { spawn } from 'child_process';
|
||||
import { Readable } from 'node:stream';
|
||||
import { hermeticChildEnv } from './hermetic-env';
|
||||
import { extractSkillSections } from './skill-fixture';
|
||||
import { killProcessGroup } from '../../scripts/test-strict-output';
|
||||
|
||||
// --- Interfaces ---
|
||||
|
||||
@@ -180,7 +183,7 @@ export async function runCodexSkill(opts: {
|
||||
const name = skillName || path.basename(skillDir) || 'gstack';
|
||||
|
||||
// Check if codex binary exists
|
||||
const whichResult = Bun.spawnSync(['which', 'codex']);
|
||||
const whichResult = Bun.spawnSync(['which', 'codex'], { timeout: 30_000 });
|
||||
if (whichResult.exitCode !== 0) {
|
||||
return {
|
||||
output: 'SKIP: codex binary not found',
|
||||
@@ -232,28 +235,42 @@ export async function runCodexSkill(opts: {
|
||||
// Hermetic scrub (test/helpers/hermetic-env.ts) with codex's auth surface
|
||||
// re-admitted: codex auths from $HOME/.codex (copied into tempHome above)
|
||||
// plus OPENAI_API_KEY/CODEX_* when present. HOME override merges last.
|
||||
const proc = Bun.spawn(['codex', ...args], {
|
||||
// node:child_process spawn with `detached` (own process group) — mirrors
|
||||
// session-runner.ts. Bun.spawn's bare proc.kill() signalled only codex
|
||||
// itself; command subprocesses codex spawned survived as orphans holding
|
||||
// our pipes open (the same blocked-drain hang the claude runner fixed —
|
||||
// this copy never inherited that fix until now).
|
||||
const proc = spawn('codex', args, {
|
||||
cwd: cwd || skillDir,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: process.platform !== 'win32',
|
||||
env: hermeticChildEnv(
|
||||
{ HOME: tempHome, CODEX_HOME: tempCodexDir },
|
||||
{ extraAllow: ['OPENAI_API_KEY', 'CODEX_*'] },
|
||||
),
|
||||
});
|
||||
const stdoutWeb = Readable.toWeb(proc.stdout!) as ReadableStream<Uint8Array>;
|
||||
const stderrWeb = Readable.toWeb(proc.stderr!) as ReadableStream<Uint8Array>;
|
||||
const procExited: Promise<number> = new Promise((resolve) => {
|
||||
proc.on('close', (code) => resolve(code ?? 1));
|
||||
proc.on('error', () => resolve(1));
|
||||
});
|
||||
|
||||
// Race against timeout
|
||||
let timedOut = false;
|
||||
const timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
proc.kill();
|
||||
// Group SIGKILL + reader cancel: kill the whole tree AND unblock the
|
||||
// read loop even if a stray grandchild survives the group kill.
|
||||
killProcessGroup(proc, 'SIGKILL');
|
||||
reader.cancel().catch(() => { /* stream already closed */ });
|
||||
}, timeoutMs);
|
||||
|
||||
// Stream and collect JSONL from stdout
|
||||
const collectedLines: string[] = [];
|
||||
const stderrPromise = new Response(proc.stderr).text();
|
||||
const stderrPromise = new Response(stderrWeb).text();
|
||||
|
||||
const reader = proc.stdout.getReader();
|
||||
const reader = stdoutWeb.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
|
||||
@@ -291,8 +308,18 @@ export async function runCodexSkill(opts: {
|
||||
collectedLines.push(buf);
|
||||
}
|
||||
|
||||
const stderr = await stderrPromise;
|
||||
const exitCode = await proc.exited;
|
||||
// Same orphan hazard as stdout: a grandchild holding stderr open would
|
||||
// block this drain forever. Race it against child exit + a short grace
|
||||
// window (ported from session-runner.ts — the codex copy lacked it).
|
||||
const stderr = await Promise.race([
|
||||
stderrPromise,
|
||||
(async () => {
|
||||
await procExited;
|
||||
await new Promise((r) => setTimeout(r, 5_000));
|
||||
return '';
|
||||
})(),
|
||||
]);
|
||||
const exitCode = await procExited;
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
@@ -114,6 +114,36 @@ describe('EvalCollector', () => {
|
||||
expect(data.total_duration_ms).toBe(3000);
|
||||
expect(data.timestamp).toBeTruthy();
|
||||
expect(data.hostname).toBeTruthy();
|
||||
// CLI version stamping: always a non-empty string ('unknown' when the
|
||||
// claude binary is absent — the field must exist either way so flake
|
||||
// investigations can correlate runs with the TUI they exercised).
|
||||
expect(typeof data.claude_cli_version).toBe('string');
|
||||
expect(data.claude_cli_version!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('a same-name re-record stamps attempts and surfaces flaky_retries', async () => {
|
||||
// bun --retry re-runs the test BODY, so recordE2E fires again under the
|
||||
// same name — the only reliable retry signal (bun's own output hides
|
||||
// retried passes: fail→pass recaps as a clean pass, probed on 1.3.10).
|
||||
const collector = new EvalCollector('e2e', tmpDir);
|
||||
collector.addTest(makeEntry({ name: 'flaky-one', passed: false }));
|
||||
collector.addTest(makeEntry({ name: 'flaky-one', passed: true }));
|
||||
collector.addTest(makeEntry({ name: 'steady', passed: true }));
|
||||
const filepath = await collector.finalize();
|
||||
|
||||
const data: EvalResult = JSON.parse(fs.readFileSync(filepath, 'utf-8'));
|
||||
const attempts = data.tests.filter((t) => t.name === 'flaky-one').map((t) => t.attempt);
|
||||
expect(attempts).toEqual([1, 2]);
|
||||
expect(data.tests.find((t) => t.name === 'steady')?.attempt).toBe(1);
|
||||
expect(data.flaky_retries).toEqual([{ name: 'flaky-one', attempts: 2 }]);
|
||||
});
|
||||
|
||||
test('no retries → no flaky_retries field (absent, not empty)', async () => {
|
||||
const collector = new EvalCollector('e2e', tmpDir);
|
||||
collector.addTest(makeEntry({ name: 'only-once' }));
|
||||
const filepath = await collector.finalize();
|
||||
const data: EvalResult = JSON.parse(fs.readFileSync(filepath, 'utf-8'));
|
||||
expect('flaky_retries' in data).toBe(false);
|
||||
});
|
||||
|
||||
test('finalize creates directory if missing', async () => {
|
||||
|
||||
@@ -63,6 +63,12 @@ export interface EvalTestEntry {
|
||||
passed: boolean;
|
||||
duration_ms: number;
|
||||
cost_usd: number;
|
||||
/** 1-based record attempt for this name in this run. bun's --retry leaves
|
||||
* retried passes INVISIBLE in its text output (a fail→pass prints no
|
||||
* (fail) line and recaps as a clean pass — probed on 1.3.10), so the ONLY
|
||||
* reliable attempt signal is this in-process record: a retried test runs
|
||||
* its body again and re-records under the same name. Set by addTest. */
|
||||
attempt?: number;
|
||||
|
||||
// E2E
|
||||
transcript?: any[];
|
||||
@@ -118,6 +124,10 @@ export interface EvalResult {
|
||||
git_sha: string;
|
||||
timestamp: string;
|
||||
hostname: string;
|
||||
/** `claude --version` first line at run time (schema-additive, optional).
|
||||
* TUI drift broke the PTY harness three times before runs recorded which
|
||||
* CLI they actually exercised. */
|
||||
claude_cli_version?: string;
|
||||
tier: 'e2e' | 'llm-judge';
|
||||
total_tests: number;
|
||||
passed: number;
|
||||
@@ -128,6 +138,11 @@ export interface EvalResult {
|
||||
tests: EvalTestEntry[];
|
||||
/** Shard slug when the run was collected under <evalDir>/shards/<slug>/. */
|
||||
shard?: string;
|
||||
/** Tests recorded more than once this run — the flake ledger for the paid
|
||||
* lane. A test passing on attempt 2 every week used to read permanently
|
||||
* green (the retry's entry was indistinguishable and bun's output hides
|
||||
* retries entirely). Present only when non-empty. */
|
||||
flaky_retries?: Array<{ name: string; attempts: number }>;
|
||||
_partial?: boolean; // true for incremental saves, absent in final
|
||||
}
|
||||
|
||||
@@ -177,6 +192,21 @@ export function isPartialEval(data: unknown, filename: string): boolean {
|
||||
return Boolean((data as { _partial?: unknown } | null)?._partial);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this path a FINALIZED eval-store result file? Single owner of the
|
||||
* filename taxonomy (manifest.json / slice-N.json are runner artifacts,
|
||||
* _partial* are in-progress accumulators) — the paid runner's report mode
|
||||
* and eval-flake-rank both consume this instead of re-encoding the rule
|
||||
* (review finding: the rule lived in three places).
|
||||
*/
|
||||
export function isFinalizedEvalResultFile(relPath: string): boolean {
|
||||
const base = path.basename(relPath);
|
||||
if (!base.endsWith('.json')) return false;
|
||||
if (base === 'manifest.json' || /^slice-\d+\.json$/.test(base)) return false;
|
||||
if (base.startsWith('_partial')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* List eval JSON files in `evalDir` plus one level of `<evalDir>/shards/<slug>/`
|
||||
* subdirectories (where the sharded paid runner points each shard's collector).
|
||||
@@ -777,6 +807,35 @@ function getVersion(): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Cached per process: savePartial runs after EVERY test and must not pay a
|
||||
// CLI spawn each time. Three separate harness breakages were traced to
|
||||
// claude-CLI TUI drift only after long flake hunts — stamping the version
|
||||
// into every run record makes that correlation a grep instead of an
|
||||
// archaeology dig.
|
||||
//
|
||||
// GSTACK_CLAUDE_CLI_VERSION short-circuits the spawn entirely: the paid
|
||||
// runner's parent resolves the version once and passes it to every shard,
|
||||
// so test processes never block on it. The fallback spawn is SYNCHRONOUS on
|
||||
// the same thread that polls PTY sessions — the judgePtyState blocking
|
||||
// class — so its budget is a tight 3s, not a generous one: a slow/hung CLI
|
||||
// costs one bounded stall per process and records 'unknown'.
|
||||
let claudeCliVersionCache: string | null = null;
|
||||
export function getClaudeCliVersion(): string {
|
||||
if (claudeCliVersionCache !== null) return claudeCliVersionCache;
|
||||
const fromEnv = process.env.GSTACK_CLAUDE_CLI_VERSION;
|
||||
if (fromEnv) {
|
||||
claudeCliVersionCache = fromEnv;
|
||||
return claudeCliVersionCache;
|
||||
}
|
||||
try {
|
||||
const result = spawnSync('claude', ['--version'], { stdio: 'pipe', timeout: 3_000 });
|
||||
claudeCliVersionCache = result.stdout?.toString().split('\n')[0].trim() || 'unknown';
|
||||
} catch {
|
||||
claudeCliVersionCache = 'unknown';
|
||||
}
|
||||
return claudeCliVersionCache;
|
||||
}
|
||||
|
||||
export class EvalCollector {
|
||||
private tier: 'e2e' | 'llm-judge';
|
||||
private tests: EvalTestEntry[] = [];
|
||||
@@ -792,10 +851,23 @@ export class EvalCollector {
|
||||
}
|
||||
|
||||
addTest(entry: EvalTestEntry): void {
|
||||
this.tests.push(entry);
|
||||
// Same-name re-record = the test body ran again = bun retried it (test
|
||||
// names are unique by convention). Stamp the 1-based attempt so a
|
||||
// pass-on-attempt-2 stays visible forever — the stream hides it.
|
||||
const prior = this.tests.filter((t) => t.name === entry.name).length;
|
||||
this.tests.push({ ...entry, attempt: prior + 1 });
|
||||
this.savePartial();
|
||||
}
|
||||
|
||||
/** Names recorded more than once this run, with their attempt counts. */
|
||||
private flakyRetries(): Array<{ name: string; attempts: number }> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const t of this.tests) counts.set(t.name, (counts.get(t.name) ?? 0) + 1);
|
||||
return [...counts.entries()]
|
||||
.filter(([, n]) => n > 1)
|
||||
.map(([name, attempts]) => ({ name, attempts }));
|
||||
}
|
||||
|
||||
/** Write incremental results after each test. Atomic write, non-fatal. */
|
||||
savePartial(): void {
|
||||
try {
|
||||
@@ -812,6 +884,7 @@ export class EvalCollector {
|
||||
git_sha: git.sha,
|
||||
timestamp: new Date().toISOString(),
|
||||
hostname: os.hostname(),
|
||||
claude_cli_version: getClaudeCliVersion(),
|
||||
tier: this.tier,
|
||||
total_tests: this.tests.length,
|
||||
passed,
|
||||
@@ -842,6 +915,7 @@ export class EvalCollector {
|
||||
const totalDuration = this.tests.reduce((s, t) => s + t.duration_ms, 0);
|
||||
const passed = this.tests.filter(t => t.passed).length;
|
||||
|
||||
const flaky = this.flakyRetries();
|
||||
const result: EvalResult = {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
version,
|
||||
@@ -849,6 +923,7 @@ export class EvalCollector {
|
||||
git_sha: git.sha,
|
||||
timestamp,
|
||||
hostname: os.hostname(),
|
||||
claude_cli_version: getClaudeCliVersion(),
|
||||
tier: this.tier,
|
||||
total_tests: this.tests.length,
|
||||
passed,
|
||||
@@ -858,6 +933,7 @@ export class EvalCollector {
|
||||
wall_clock_ms: Date.now() - this.createdAt,
|
||||
tests: this.tests,
|
||||
...(this.shard ? { shard: this.shard } : {}),
|
||||
...(flaky.length > 0 ? { flaky_retries: flaky } : {}),
|
||||
};
|
||||
|
||||
// Write eval file
|
||||
@@ -920,6 +996,12 @@ export class EvalCollector {
|
||||
const totalCost = `$${result.total_cost_usd.toFixed(2)}`;
|
||||
const totalDur = `${Math.round(result.total_duration_ms / 1000)}s`;
|
||||
lines.push(` Total: ${result.passed}/${result.total_tests} passed${' '.repeat(20)}${totalCost.padStart(6)} ${totalDur}`);
|
||||
if (result.flaky_retries && result.flaky_retries.length > 0) {
|
||||
// Loud, never fatal: a flaky pass must not block anyone, but it must
|
||||
// never be silent either — that invisibility is how flakes calcified.
|
||||
lines.push(` ⚠ FLAKY: ${result.flaky_retries.length} test(s) recorded multiple attempts this run: `
|
||||
+ result.flaky_retries.map((f) => `${f.name} (x${f.attempts})`).join(', '));
|
||||
}
|
||||
lines.push(`Saved: ${filepath}`);
|
||||
|
||||
process.stderr.write(lines.join('\n') + '\n');
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
import { Readable } from 'node:stream';
|
||||
import { hermeticChildEnv } from './hermetic-env';
|
||||
import { killProcessGroup } from '../../scripts/test-strict-output';
|
||||
|
||||
// --- Interfaces ---
|
||||
|
||||
@@ -108,7 +111,7 @@ export async function runGeminiSkill(opts: {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Check if gemini binary exists
|
||||
const whichResult = Bun.spawnSync(['which', 'gemini']);
|
||||
const whichResult = Bun.spawnSync(['which', 'gemini'], { timeout: 30_000 });
|
||||
if (whichResult.exitCode !== 0) {
|
||||
return {
|
||||
output: 'SKIP: gemini binary not found',
|
||||
@@ -130,27 +133,40 @@ export async function runGeminiSkill(opts: {
|
||||
// Spawn gemini — uses real HOME for auth (~/.gemini; HOME is allowlisted),
|
||||
// cwd for skill discovery. Hermetic scrub with gemini's auth surface
|
||||
// re-admitted (previously this spawn inherited the full operator env).
|
||||
const proc = Bun.spawn(['gemini', ...args], {
|
||||
// node:child_process spawn with `detached` (own process group) — mirrors
|
||||
// session-runner.ts. A bare kill signalled only gemini itself; tool
|
||||
// subprocesses survived as orphans holding our pipes open (the same
|
||||
// blocked-drain hang the claude runner fixed — this copy lacked it).
|
||||
const proc = spawn('gemini', args, {
|
||||
cwd: cwd || process.cwd(),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: process.platform !== 'win32',
|
||||
env: hermeticChildEnv(undefined, {
|
||||
extraAllow: ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_*', 'GEMINI_*'],
|
||||
}),
|
||||
});
|
||||
const stdoutWeb = Readable.toWeb(proc.stdout!) as ReadableStream<Uint8Array>;
|
||||
const stderrWeb = Readable.toWeb(proc.stderr!) as ReadableStream<Uint8Array>;
|
||||
const procExited: Promise<number> = new Promise((resolve) => {
|
||||
proc.on('close', (code) => resolve(code ?? 1));
|
||||
proc.on('error', () => resolve(1));
|
||||
});
|
||||
|
||||
// Race against timeout
|
||||
let timedOut = false;
|
||||
const timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
proc.kill();
|
||||
// Group SIGKILL + reader cancel: kill the whole tree AND unblock the
|
||||
// read loop even if a stray grandchild survives the group kill.
|
||||
killProcessGroup(proc, 'SIGKILL');
|
||||
reader.cancel().catch(() => { /* stream already closed */ });
|
||||
}, timeoutMs);
|
||||
|
||||
// Stream and collect JSONL from stdout
|
||||
const collectedLines: string[] = [];
|
||||
const stderrPromise = new Response(proc.stderr).text();
|
||||
const stderrPromise = new Response(stderrWeb).text();
|
||||
|
||||
const reader = proc.stdout.getReader();
|
||||
const reader = stdoutWeb.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
|
||||
@@ -185,8 +201,18 @@ export async function runGeminiSkill(opts: {
|
||||
collectedLines.push(buf);
|
||||
}
|
||||
|
||||
const stderr = await stderrPromise;
|
||||
const exitCode = await proc.exited;
|
||||
// Same orphan hazard as stdout: a grandchild holding stderr open would
|
||||
// block this drain forever. Race against child exit + a short grace window
|
||||
// (ported from session-runner.ts — the gemini copy lacked it).
|
||||
const stderr = await Promise.race([
|
||||
stderrPromise,
|
||||
(async () => {
|
||||
await procExited;
|
||||
await new Promise((r) => setTimeout(r, 5_000));
|
||||
return '';
|
||||
})(),
|
||||
]);
|
||||
const exitCode = await procExited;
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
+108
-23
@@ -9,8 +9,11 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { spawn } from 'child_process';
|
||||
import { Readable } from 'node:stream';
|
||||
import { getProjectEvalDir } from './eval-store';
|
||||
import { hermeticChildEnv, isHermeticEnabled } from './hermetic-env';
|
||||
import { killProcessGroup } from '../../scripts/test-strict-output';
|
||||
|
||||
const GSTACK_DEV_DIR = path.join(os.homedir(), '.gstack-dev');
|
||||
const HEARTBEAT_PATH = path.join(GSTACK_DEV_DIR, 'e2e-live.json'); // heartbeat stays global
|
||||
@@ -52,6 +55,14 @@ export interface SkillTestResult {
|
||||
maxInterTurnMs: number;
|
||||
}
|
||||
|
||||
/** Local default startup grace: 90s covers observed API queue latency
|
||||
* (60-90s receipts) without letting a dead API burn a 600s budget. */
|
||||
export const STARTUP_GRACE_MS = 90_000;
|
||||
/** CI floor (TODOS-filed): shared runners queue harder; killing startup
|
||||
* before 300s in CI converts ordinary queueing into false failures.
|
||||
* Pinned by test/session-runner-startup-grace.test.ts. */
|
||||
export const STARTUP_GRACE_CI_FLOOR_MS = 300_000;
|
||||
|
||||
const BROWSE_ERROR_PATTERNS = [
|
||||
/Unknown command: \w+/,
|
||||
/Unknown snapshot flag: .+/,
|
||||
@@ -131,6 +142,15 @@ export async function runSkillTest(options: {
|
||||
* per-test GSTACK_HOME overrides so the test doesn't have to spell out
|
||||
* env setup in the prompt itself. */
|
||||
env?: Record<string, string>;
|
||||
/** Startup-phase deadline: if NO NDJSON byte arrives within this window,
|
||||
* the run is killed EARLY with exitReason 'timeout_startup' instead of
|
||||
* burning the whole work budget waiting on an API that is not answering
|
||||
* (the recurring '0 turns / $0.00' class — four budget-bump receipts).
|
||||
* Defaults to min(STARTUP_GRACE_MS, timeout); the CI floor is higher
|
||||
* because CI queueing is real. Total wall stays <= timeout either way —
|
||||
* bun-level tier budgets are sized to the runner timeout with no margin,
|
||||
* so this phase split must never extend the envelope. */
|
||||
startupGraceMs?: number;
|
||||
}): Promise<SkillTestResult> {
|
||||
const {
|
||||
prompt,
|
||||
@@ -142,6 +162,15 @@ export async function runSkillTest(options: {
|
||||
runId,
|
||||
env: extraEnv,
|
||||
} = options;
|
||||
// The CI floor is a FLOOR, not a default: an explicit startupGraceMs below
|
||||
// 300s in CI would re-open the queueing-becomes-false-red hole the floor
|
||||
// exists for (review finding — the name promised a clamp the code lacked).
|
||||
// Local runs honor the caller verbatim; timeout still caps everything.
|
||||
const requestedGrace = options.startupGraceMs ?? (process.env.CI ? STARTUP_GRACE_CI_FLOOR_MS : STARTUP_GRACE_MS);
|
||||
const startupGraceMs = Math.min(
|
||||
process.env.CI ? Math.max(requestedGrace, STARTUP_GRACE_CI_FLOOR_MS) : requestedGrace,
|
||||
timeout,
|
||||
);
|
||||
const model = options.model ?? process.env.EVALS_MODEL ?? 'claude-sonnet-4-6';
|
||||
|
||||
const startTime = Date.now();
|
||||
@@ -174,8 +203,13 @@ export async function runSkillTest(options: {
|
||||
if (isHermeticEnabled()) args.push('--strict-mcp-config');
|
||||
|
||||
// Spawn claude directly with array-form args (no shell interpolation).
|
||||
// Prompt is piped via stdin using a Blob to avoid temp files and shell escaping.
|
||||
const proc = Bun.spawn(['claude', ...args], {
|
||||
// node:child_process spawn (not Bun.spawn): `detached` puts the child in
|
||||
// its OWN process group, so the timeout handler can killpg the whole tree.
|
||||
// Bun.spawn has no detached option, and its bare proc.kill() signalled only
|
||||
// claude itself — tool subprocesses claude spawned survived as orphans
|
||||
// burning shared API rate for the rest of the shard's lifetime.
|
||||
// Prompt is piped via stdin to avoid temp files and shell escaping.
|
||||
const proc = spawn('claude', args, {
|
||||
cwd: workingDirectory,
|
||||
// Hermetic by default (see test/helpers/hermetic-env.ts): operator
|
||||
// session context (CONDUCTOR_*, CLAUDECODE, ~/.claude config, ~/.gstack)
|
||||
@@ -185,38 +219,78 @@ export async function runSkillTest(options: {
|
||||
// suite exercising the INTERACTIVE prose-fallback path opts out by passing
|
||||
// `env: { GSTACK_HEADLESS: '' }` — extraEnv wins because it spreads last.
|
||||
env: hermeticChildEnv({ GSTACK_HEADLESS: '1', ...extraEnv }),
|
||||
stdin: new Blob([prompt]),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
detached: process.platform !== 'win32',
|
||||
});
|
||||
proc.stdin!.on('error', () => { /* child died before reading the prompt — exit handling reports it */ });
|
||||
proc.stdin!.write(prompt);
|
||||
proc.stdin!.end();
|
||||
const stdoutWeb = Readable.toWeb(proc.stdout!) as ReadableStream<Uint8Array>;
|
||||
const stderrWeb = Readable.toWeb(proc.stderr!) as ReadableStream<Uint8Array>;
|
||||
// 'exit' vs 'close' matters here: 'close' waits for stdout/stderr to
|
||||
// drain, which an orphaned grandchild can hold open long after claude
|
||||
// itself died with a REAL exit code — labeling must key off 'exit' or an
|
||||
// auth failure gets triaged as 'timeout_startup' availability noise
|
||||
// (claude adversarial finding). procExited stays 'close'-based (streams
|
||||
// complete) for the drain race below.
|
||||
let childExited = false;
|
||||
const procExited: Promise<number> = new Promise((resolve) => {
|
||||
proc.on('exit', () => { childExited = true; });
|
||||
proc.on('close', (code) => { childExited = true; resolve(code ?? 1); });
|
||||
proc.on('error', () => { childExited = true; resolve(1); });
|
||||
});
|
||||
|
||||
// Race against timeout
|
||||
// Two-phase timeout. Phase 1 (startup): no NDJSON byte yet — a shorter
|
||||
// deadline kills a non-answering API run EARLY and names it, instead of
|
||||
// the old single timer burning the full work budget to produce an opaque
|
||||
// '0 turns / $0.00' failure. Phase 2 (work): armed by the read loop when
|
||||
// the FIRST byte arrives, for the REMAINING budget — total wall is always
|
||||
// <= timeout (tier envelopes are margin-free by convention).
|
||||
let stderr = '';
|
||||
let exitReason = 'unknown';
|
||||
let timedOut = false;
|
||||
let timedOutInStartup = false;
|
||||
let phaseTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
proc.kill();
|
||||
// proc.kill() signals claude itself (direct spawn, no shell wrapper),
|
||||
// but tool subprocesses claude spawned can survive as orphans that
|
||||
// inherited our stdout/stderr pipes, so without cancel() the read loop
|
||||
// below blocks until the orphan finally exits (observed: a 600s timeout
|
||||
// stretching past 1400s and tripping bun's per-test timeout instead of
|
||||
// returning a result).
|
||||
const killRun = (startupPhase: boolean): void => {
|
||||
// Labeling and unblocking are SEPARATE concerns: a timer firing after
|
||||
// the child already exited must not relabel a real exit (auth error,
|
||||
// crash) as a timeout — but it must STILL group-kill and cancel the
|
||||
// reader, or an orphan holding the pipes re-creates the exact
|
||||
// blocked-drain hang this runner fixed (an early `return` here was the
|
||||
// bug the adversarial pass caught in the first version of this guard).
|
||||
if (!childExited) {
|
||||
timedOut = true;
|
||||
timedOutInStartup = startupPhase;
|
||||
}
|
||||
// Group SIGKILL (mirrors runShardChild): claude AND every tool
|
||||
// subprocess it spawned die together — a bare proc.kill() left orphans
|
||||
// that inherited our stdout/stderr pipes and kept the API burning
|
||||
// (observed: a 600s timeout stretching past 1400s while an orphan held
|
||||
// the pipes open).
|
||||
killProcessGroup(proc, 'SIGKILL');
|
||||
// Belt and braces with the group kill: even if an orphan survives (EPERM
|
||||
// fallback path), cancel() unblocks the read loop below.
|
||||
reader.cancel().catch(() => { /* stream already closed */ });
|
||||
}, timeout);
|
||||
};
|
||||
phaseTimer = setTimeout(() => killRun(true), startupGraceMs);
|
||||
/** Called once by the read loop on the first NDJSON byte. */
|
||||
const armWorkPhase = (elapsedMs: number): void => {
|
||||
clearTimeout(phaseTimer);
|
||||
phaseTimer = setTimeout(() => killRun(false), Math.max(0, timeout - elapsedMs));
|
||||
};
|
||||
|
||||
// Stream NDJSON from stdout for real-time progress
|
||||
const collectedLines: string[] = [];
|
||||
let liveTurnCount = 0;
|
||||
let liveToolCount = 0;
|
||||
let firstResponseMs = 0;
|
||||
let workPhaseArmed = false;
|
||||
let lastToolTime = 0;
|
||||
let maxInterTurnMs = 0;
|
||||
const stderrPromise = new Response(proc.stderr).text();
|
||||
const stderrPromise = new Response(stderrWeb).text();
|
||||
|
||||
const reader = proc.stdout.getReader();
|
||||
const reader = stdoutWeb.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
|
||||
@@ -232,8 +306,15 @@ export async function runSkillTest(options: {
|
||||
collectedLines.push(line);
|
||||
|
||||
// Track time to first NDJSON line (measures latency from spawn to first Claude response)
|
||||
if (firstResponseMs === 0) {
|
||||
if (!workPhaseArmed) {
|
||||
// Flag, not `firstResponseMs === 0`: a first line landing in the
|
||||
// same millisecond as spawn would read as "not yet seen" and leave
|
||||
// the startup timer live for the whole run (claude adversarial).
|
||||
workPhaseArmed = true;
|
||||
firstResponseMs = Date.now() - startTime;
|
||||
// First byte: startup phase over — arm the work phase for the
|
||||
// REMAINING budget (total wall stays <= timeout).
|
||||
armWorkPhase(firstResponseMs);
|
||||
}
|
||||
|
||||
// Real-time progress to stderr + persistent logs
|
||||
@@ -304,16 +385,20 @@ export async function runSkillTest(options: {
|
||||
stderr = await Promise.race([
|
||||
stderrPromise,
|
||||
(async () => {
|
||||
await proc.exited;
|
||||
await procExited;
|
||||
await new Promise((r) => setTimeout(r, 5_000));
|
||||
return '';
|
||||
})(),
|
||||
]);
|
||||
const exitCode = await proc.exited;
|
||||
clearTimeout(timeoutId);
|
||||
const exitCode = await procExited;
|
||||
clearTimeout(phaseTimer);
|
||||
|
||||
if (timedOut) {
|
||||
exitReason = 'timeout';
|
||||
// 'timeout_startup' = the API never sent a byte inside the grace — an
|
||||
// availability problem, not a test failure worth reading transcripts
|
||||
// for. Distinct so triage (and WS10's inconclusive classification) can
|
||||
// key off it without receipts archaeology.
|
||||
exitReason = timedOutInStartup ? 'timeout_startup' : 'timeout';
|
||||
} else if (exitCode === 0) {
|
||||
exitReason = 'success';
|
||||
} else {
|
||||
|
||||
@@ -129,12 +129,9 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
// Real-PTY E2E batch (#6 new tests on the harness).
|
||||
// Each one tests behavior the SDK harness can't observe (rendered TTY,
|
||||
// numbered-option lists, multi-phase ordering, idempotency state echo).
|
||||
'preamble-script-ab': ['bin/gstack-skill-start', 'bin/gstack-skill-end', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-brain-sync-block.ts', 'scripts/resolvers/preamble.ts', 'plan-ceo-review/**', 'test/helpers/auq-sdk-capture.ts', 'test/skill-e2e-preamble-script-ab.test.ts'],
|
||||
'auq-format-gate': ['plan-ceo-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completeness-section.ts', 'scripts/resolvers/preamble.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'test/helpers/llm-judge.ts', 'test/skill-e2e-ask-user-question-format-compliance.test.ts'],
|
||||
'auq-repetition-cut-ab': ['scripts/resolvers/preamble/generate-ask-user-format.ts', 'plan-ceo-review/**', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/llm-judge.ts', 'test/fixtures/auq-pre-cut-plan-ceo-review-SKILL.md', 'test/skill-e2e-auq-repetition-cut-ab.test.ts'],
|
||||
'plan-ceo-mode-routing': ['plan-ceo-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'test/helpers/claude-pty-runner.ts', 'test/skill-e2e-plan-ceo-mode-routing.test.ts'],
|
||||
'plan-design-with-ui-scope': ['plan-design-review/**', 'test/fixtures/plans/ui-heavy-feature.md', 'test/helpers/claude-pty-runner.ts', 'test/skill-e2e-plan-design-with-ui.test.ts'],
|
||||
'budget-regression-pty': ['test/helpers/eval-store.ts', 'test/skill-budget-regression.test.ts'],
|
||||
'ship-idempotency-pty': ['ship/**', 'bin/gstack-next-version', 'bin/gstack-version-bump', 'scripts/resolvers/sections.ts', 'lib/worktree.ts', 'test/helpers/claude-pty-runner.ts', 'test/skill-e2e-ship-idempotency.test.ts'],
|
||||
'tpa-present': ['scripts/resolvers/third-party-actions.ts', 'ship/SKILL.md.tmpl', 'ship/sections/apple-release.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/helpers/session-runner.ts', 'test/skill-e2e-third-party-actions.test.ts'],
|
||||
'tpa-absent-linux': ['scripts/resolvers/third-party-actions.ts', 'ship/SKILL.md.tmpl', 'ship/sections/apple-release.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/helpers/session-runner.ts', 'test/skill-e2e-third-party-actions.test.ts'],
|
||||
@@ -149,7 +146,6 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
// skill (D-CODEX A). Touching the registry/helper or sections.ts runs all.
|
||||
'carve-section-loading': ['design-html/**', 'design-shotgun/**', 'qa/**', 'browse/**', 'retro/**', 'autoplan/**', 'spec/**', 'setup-gbrain/**', 'review/**', 'codex/**', 'land-and-deploy/**', 'plan-eng-review/**', 'plan-design-review/**', 'plan-devex-review/**', 'office-hours/**', 'document-release/**', 'design-consultation/**', 'cso/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'test/carve-section-loading.test.ts'],
|
||||
'autoplan-chain-pty': ['autoplan/**', 'plan-ceo-review/**', 'plan-design-review/**', 'plan-eng-review/**', 'plan-devex-review/**', 'test/fixtures/plans/ui-heavy-feature.md', 'test/helpers/claude-pty-runner.ts', 'test/skill-e2e-autoplan-chain.test.ts'],
|
||||
'e2e-harness-audit': ['bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-ceo-review/**', 'plan-eng-review/**', 'plan-design-review/**', 'plan-devex-review/**', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/claude-pty-runner.ts'],
|
||||
|
||||
// Per-finding AskUserQuestion count + review-report-at-bottom assertion.
|
||||
// Each test drives its skill end-to-end; touchfiles include preamble +
|
||||
@@ -207,13 +203,6 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'plan-review-prosons-neutral-neg': ['plan-ceo-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'model-overlays/opus-4-7.md', 'test/skill-e2e-plan-prosons.test.ts'],
|
||||
|
||||
// Expanded coverage (CT3) — 6 non-plan-review skills inherit Pros/Cons via preamble
|
||||
'ship-prosons-format': ['ship/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'model-overlays/opus-4-7.md'],
|
||||
'office-hours-prosons-format': ['office-hours/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'model-overlays/opus-4-7.md'],
|
||||
'investigate-prosons-format': ['investigate/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'model-overlays/opus-4-7.md'],
|
||||
'qa-prosons-format': ['qa/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'model-overlays/opus-4-7.md'],
|
||||
'review-prosons-format': ['review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'model-overlays/opus-4-7.md'],
|
||||
'design-review-prosons-format': ['design-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'model-overlays/opus-4-7.md'],
|
||||
'document-release-prosons-format': ['document-release/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'model-overlays/opus-4-7.md'],
|
||||
|
||||
// /plan-tune (v1 observational)
|
||||
'plan-tune-inspect': ['plan-tune/**', 'scripts/question-registry.ts', 'scripts/psychographic-signals.ts', 'scripts/one-way-doors.ts', 'bin/gstack-question-log', 'bin/gstack-question-preference', 'bin/gstack-developer-profile', 'test/skill-e2e-plan-tune.test.ts'],
|
||||
@@ -240,9 +229,6 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'retro': ['bin/gstack-retro-metrics', 'retro/**', 'test/skill-e2e-retro.test.ts'],
|
||||
'retro-base-branch': ['bin/gstack-retro-metrics', 'retro/**', 'test/skill-e2e-retro.test.ts'],
|
||||
|
||||
// Global discover
|
||||
'global-discover': ['bin/gstack-global-discover.ts', 'test/global-discover.test.ts'],
|
||||
|
||||
// CSO
|
||||
'cso-full-audit': ['cso/**', 'test/skill-e2e-cso.test.ts'],
|
||||
'cso-diff-mode': ['cso/**', 'test/skill-e2e-cso.test.ts'],
|
||||
@@ -277,14 +263,14 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'codex-review': ['codex/**', 'test/skill-e2e-workflow.test.ts'],
|
||||
|
||||
// Codex E2E (tests skills via Codex CLI + worktree)
|
||||
'codex-discover-skill': ['codex/**', '.agents/skills/**', 'test/helpers/codex-session-runner.ts', 'lib/worktree.ts', 'test/codex-e2e.test.ts'],
|
||||
'codex-review-findings': ['review/**', '.agents/skills/gstack-review/**', 'codex/**', 'test/helpers/codex-session-runner.ts', 'lib/worktree.ts', 'test/codex-e2e.test.ts'],
|
||||
'codex-discover-skill': ['codex/**', 'scripts/gen-skill-docs.ts', 'test/helpers/codex-session-runner.ts', 'lib/worktree.ts', 'test/codex-e2e.test.ts'],
|
||||
'codex-review-findings': ['review/**', 'scripts/gen-skill-docs.ts', 'codex/**', 'test/helpers/codex-session-runner.ts', 'lib/worktree.ts', 'test/codex-e2e.test.ts'],
|
||||
|
||||
// GPT-5.6 Sol scope-termination E2E (Codex CLI, full generated investigate skill)
|
||||
'codex-sol-scope-termination': ['model-overlays/gpt-5.6-sol.md', 'scripts/models.ts', 'scripts/resolvers/model-overlay.ts', 'scripts/resolvers/preamble/**', 'investigate/**', 'test/helpers/codex-session-runner.ts', 'test/codex-e2e-sol-scope.test.ts'],
|
||||
|
||||
// Gemini E2E — smoke test only (Gemini gets lost in worktrees on complex tasks)
|
||||
'gemini-smoke': ['.agents/skills/**', 'test/helpers/gemini-session-runner.ts', 'lib/worktree.ts', 'test/gemini-e2e.test.ts'],
|
||||
'gemini-smoke': ['scripts/gen-skill-docs.ts', 'test/helpers/gemini-session-runner.ts', 'lib/worktree.ts', 'test/gemini-e2e.test.ts'],
|
||||
|
||||
|
||||
// Coverage audit (shared fixture) + triage + gates
|
||||
@@ -309,11 +295,6 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'test/skill-e2e-docsync-spawned.test.ts',
|
||||
],
|
||||
|
||||
// Plan completion audit + verification
|
||||
'ship-plan-completion': ['ship/**', 'scripts/gen-skill-docs.ts'],
|
||||
'ship-plan-verification': ['ship/**', 'qa-only/**', 'scripts/gen-skill-docs.ts'],
|
||||
'review-plan-completion': ['review/**', 'scripts/gen-skill-docs.ts'],
|
||||
|
||||
// Design
|
||||
'design-consultation-core': ['design-consultation/**', 'scripts/gen-skill-docs.ts', 'test/helpers/llm-judge.ts', 'test/skill-e2e-design.test.ts'],
|
||||
'design-consultation-existing': ['design-consultation/**', 'scripts/gen-skill-docs.ts', 'test/skill-e2e-design.test.ts'],
|
||||
@@ -322,11 +303,6 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'plan-design-review-no-ui-scope': ['plan-design-review/**', 'scripts/gen-skill-docs.ts', 'test/skill-e2e-design.test.ts'],
|
||||
'design-review-fix': ['design-review/**', 'browse/src/**', 'scripts/gen-skill-docs.ts', 'test/skill-e2e-design.test.ts'],
|
||||
|
||||
// Design Shotgun
|
||||
'design-shotgun-path': ['design-shotgun/**', 'design/src/**', 'scripts/resolvers/design.ts'],
|
||||
'design-shotgun-session': ['design-shotgun/**', 'scripts/resolvers/design.ts'],
|
||||
'design-shotgun-full': ['design-shotgun/**', 'design/src/**', 'browse/src/**'],
|
||||
|
||||
// /diagram (diagram-render bundle consumers). Triplet = deterministic
|
||||
// functional (gate); authoring quality = LLM-judged benchmark (periodic).
|
||||
'diagram-triplet': ['diagram/**', 'lib/diagram-render/**', 'browse/src/write-commands.ts', 'browse/src/read-commands.ts', 'test/skill-e2e-diagram.test.ts'],
|
||||
@@ -345,7 +321,6 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
|
||||
|
||||
// Autoplan
|
||||
'autoplan-core': ['autoplan/**', 'plan-ceo-review/**', 'plan-eng-review/**', 'plan-design-review/**'],
|
||||
'autoplan-dual-voice': ['autoplan/**', 'codex/**', 'bin/gstack-codex-probe', 'scripts/resolvers/review.ts', 'scripts/resolvers/design.ts', 'test/skill-e2e-autoplan-dual-voice.test.ts'],
|
||||
|
||||
// Multi-provider benchmark adapters — live API smoke against real claude/codex/gemini CLIs
|
||||
@@ -538,7 +513,6 @@ export const E2E_TIERS: Record<string, 'gate' | 'periodic'> = {
|
||||
'review-base-branch': 'gate',
|
||||
'review-design-lite': 'periodic', // 4/7 threshold is subjective
|
||||
'review-coverage-audit': 'gate',
|
||||
'review-plan-completion': 'gate',
|
||||
'review-dashboard-via': 'gate',
|
||||
|
||||
// Review Army — gate for core functionality, periodic for multi-specialist
|
||||
@@ -576,7 +550,7 @@ export const E2E_TIERS: Record<string, 'gate' | 'periodic'> = {
|
||||
'plan-ceo-review': 'periodic',
|
||||
'plan-ceo-review-selective': 'periodic',
|
||||
'plan-ceo-review-benefits': 'gate',
|
||||
'plan-ceo-review-expansion-energy': 'gate', // V1.1 mode-posture regression gate (Opus generator, Sonnet judge)
|
||||
'plan-ceo-review-expansion-energy': 'periodic', // Demoted from gate (2026-08 audit): Opus generator + subjective 2-axis >=4/5 LLM-judge threshold in the merge lane — the exact class siblings were demoted for (a +21-line preamble change once flipped the score). CLAUDE.md's own rule: Opus model test -> periodic.
|
||||
'plan-eng-review': 'periodic',
|
||||
'plan-eng-review-artifact': 'periodic',
|
||||
'plan-eng-coverage-audit': 'gate',
|
||||
@@ -598,17 +572,13 @@ export const E2E_TIERS: Record<string, 'gate' | 'periodic'> = {
|
||||
'office-hours-auto-mode': 'gate',
|
||||
'auto-decide-preserved': 'periodic',
|
||||
'conductor-prose': 'periodic',
|
||||
'e2e-harness-audit': 'gate',
|
||||
|
||||
// Real-PTY E2E batch — tier classification:
|
||||
// gate: cheap, deterministic, run on every PR
|
||||
// periodic: long-running or expensive (>$3/run), run weekly
|
||||
'preamble-script-ab': 'periodic', // Phase 1-3 A/B: script vs inline preamble; demoted post-Phase-3 (OV7)
|
||||
'auq-repetition-cut-ab': 'periodic', // AUQ repetition-cut NOT-WORSE gate (passed pre-landing). Periodic runs force EVALS_ALL, so the dep list cannot auto-trigger it — an AUQ format edit carries a MANUAL re-run obligation (bun test test/skill-e2e-auq-repetition-cut-ab.test.ts with EVALS=1 EVALS_TIER=periodic)
|
||||
'auq-format-gate': 'gate', // ~$0.50/run, SDK capture, single skill probe
|
||||
'plan-ceo-mode-routing': 'periodic', // ~$3/run, deep navigation through 8-12 prior AskUserQuestions
|
||||
'plan-design-with-ui-scope': 'gate', // ~$0.80/run
|
||||
'budget-regression-pty': 'gate', // free, library-only assertion
|
||||
'ship-idempotency-pty': 'periodic', // ~$3/run, real /ship in plan mode
|
||||
'tpa-present': 'gate', // consent/credential safety guardrail; deterministic shims + grep asserts
|
||||
'tpa-absent-linux': 'gate', // consent/credential safety guardrail; deterministic shims + grep asserts
|
||||
@@ -671,13 +641,6 @@ export const E2E_TIERS: Record<string, 'gate' | 'periodic'> = {
|
||||
'plan-review-prosons-neutral-neg': 'periodic',
|
||||
|
||||
// CT3 expanded coverage — non-plan-review skills inheriting Pros/Cons (all periodic)
|
||||
'ship-prosons-format': 'periodic',
|
||||
'office-hours-prosons-format': 'periodic',
|
||||
'investigate-prosons-format': 'periodic',
|
||||
'qa-prosons-format': 'periodic',
|
||||
'review-prosons-format': 'periodic',
|
||||
'design-review-prosons-format': 'periodic',
|
||||
'document-release-prosons-format': 'periodic',
|
||||
|
||||
// /plan-tune — gate (core v1 DX promise: plain-English intent routing)
|
||||
'plan-tune-inspect': 'gate',
|
||||
@@ -718,16 +681,15 @@ export const E2E_TIERS: Record<string, 'gate' | 'periodic'> = {
|
||||
'ship-triage': 'gate',
|
||||
'ship-docsync': 'gate',
|
||||
'docsync-spawned': 'gate', // #2733 JSON-contract-through-a-firing-gate proof (deterministic safety)
|
||||
'ship-plan-completion': 'gate',
|
||||
'ship-plan-verification': 'gate',
|
||||
// (merge note: main's side also re-added ship-plan-completion /
|
||||
// ship-plan-verification here — phantom keys with no declaring test,
|
||||
// deleted by the census-integrity commit; the reverse invariant in
|
||||
// test/touchfiles.test.ts now fails the suite if they come back.)
|
||||
|
||||
// Retro — gate for cheap branch detection, periodic for full Opus retro
|
||||
'retro': 'periodic',
|
||||
'retro-base-branch': 'gate',
|
||||
|
||||
// Global discover
|
||||
'global-discover': 'gate',
|
||||
|
||||
// CSO — gate for security guardrails, periodic for quality
|
||||
'cso-full-audit': 'periodic', // D2a demotion 2026-08: 250s/$0.57 full audit; cso targeted tests stay gate
|
||||
'cso-diff-mode': 'gate',
|
||||
@@ -755,9 +717,6 @@ export const E2E_TIERS: Record<string, 'gate' | 'periodic'> = {
|
||||
'design-consultation-preview': 'periodic', // D2a demotion 2026-08 ($0.89/481s)
|
||||
'plan-design-review-no-ui-scope': 'gate',
|
||||
'design-review-fix': 'periodic',
|
||||
'design-shotgun-path': 'gate',
|
||||
'design-shotgun-session': 'gate',
|
||||
'design-shotgun-full': 'periodic',
|
||||
|
||||
// /diagram — triplet is deterministic functional, judge is a quality benchmark
|
||||
'diagram-triplet': 'gate',
|
||||
@@ -776,7 +735,6 @@ export const E2E_TIERS: Record<string, 'gate' | 'periodic'> = {
|
||||
|
||||
|
||||
// Autoplan — periodic (not yet implemented)
|
||||
'autoplan-core': 'periodic',
|
||||
'autoplan-dual-voice': 'periodic',
|
||||
|
||||
// Multi-provider benchmark — periodic (requires external CLIs + auth, paid)
|
||||
@@ -809,9 +767,14 @@ export const E2E_TIERS: Record<string, 'gate' | 'periodic'> = {
|
||||
'overlay-harness-opus-4-7-fanout-toy': 'periodic',
|
||||
'overlay-harness-opus-4-7-fanout-realistic': 'periodic',
|
||||
|
||||
// /ios-qa daemon + codegen — no-device path runs every PR (no hardware
|
||||
// dependency, deterministic). with-device path requires GSTACK_HAS_IOS_DEVICE.
|
||||
'ios-qa-e2e': 'gate',
|
||||
// /ios-qa daemon + codegen. Demoted gate -> periodic (2026-08 audit): the
|
||||
// gate declaration was never executable in CI — the file sits in
|
||||
// PERIODIC_CI_EXCLUDE ("not a CI runner capability"), but that exclusion
|
||||
// only applies at tier=periodic, so the gate lane planned a HOLLOW shard
|
||||
// on every Linux PR. Periodic keeps it in the weekly census on capable
|
||||
// hosts; re-promote if a macOS runner lands (flagged decision in the
|
||||
// test-infra overhaul plan).
|
||||
'ios-qa-e2e': 'periodic',
|
||||
// Swift toolchain only, no device required, but heavier than TS unit tests.
|
||||
'ios-qa-swift-build': 'periodic',
|
||||
// Requires a real connected + paired iPhone. Manual-trigger only.
|
||||
@@ -856,10 +819,6 @@ export const LLM_JUDGE_TOUCHFILES: Record<string, string[]> = {
|
||||
'design-review/SKILL.md fix loop': ['design-review/SKILL.md', 'design-review/SKILL.md.tmpl', 'test/skill-llm-eval.test.ts'],
|
||||
'design-consultation/SKILL.md research': ['design-consultation/SKILL.md', 'design-consultation/SKILL.md.tmpl', 'test/skill-llm-eval.test.ts'],
|
||||
|
||||
// Office Hours
|
||||
'office-hours/SKILL.md spec review': ['office-hours/SKILL.md', 'office-hours/SKILL.md.tmpl', 'scripts/gen-skill-docs.ts'],
|
||||
'office-hours/SKILL.md design sketch': ['office-hours/SKILL.md', 'office-hours/SKILL.md.tmpl', 'scripts/gen-skill-docs.ts'],
|
||||
|
||||
// Deploy skills
|
||||
'land-and-deploy/SKILL.md workflow': ['land-and-deploy/SKILL.md', 'land-and-deploy/SKILL.md.tmpl', 'land-and-deploy/sections/**', 'test/skill-llm-eval.test.ts'],
|
||||
'canary/SKILL.md monitoring loop': ['canary/SKILL.md', 'canary/SKILL.md.tmpl', 'test/skill-llm-eval.test.ts'],
|
||||
|
||||
@@ -31,7 +31,7 @@ const MAX_BODY = 64 * 1024;
|
||||
const GUARD_RE = /^\s*(?::\s*"\$\{)?BASH_COMPAT(?:[:=]|\}")/m;
|
||||
|
||||
function trackedShellScripts(): string[] {
|
||||
const out = execSync('git ls-files', { cwd: ROOT, encoding: 'utf-8', maxBuffer: 32 * 1024 * 1024 });
|
||||
const out = execSync('git ls-files', { cwd: ROOT, encoding: 'utf-8', maxBuffer: 32 * 1024 * 1024, timeout: 30_000 });
|
||||
return out
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
@@ -88,6 +88,7 @@ describe('heredoc pipe-deadlock guard', () => {
|
||||
test('the guard actually moves the body off the pipe', () => {
|
||||
const bash = spawnSync('bash', ['-c', 'echo "${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}"'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
const version = (bash.stdout ?? '').trim();
|
||||
const [maj, min] = version.split('.').map((n) => parseInt(n, 10));
|
||||
@@ -102,6 +103,7 @@ describe('heredoc pipe-deadlock guard', () => {
|
||||
// probe would answer OTHER for an unobservable fd. Skip rather than fail.
|
||||
const devStdin = spawnSync('bash', ['-c', '[ -e /dev/stdin ] && echo yes || echo no'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
if ((devStdin.stdout ?? '').trim() !== 'yes') return;
|
||||
|
||||
@@ -114,7 +116,7 @@ $body
|
||||
EOF
|
||||
`;
|
||||
const run = (guard: string) =>
|
||||
(spawnSync('bash', ['-c', probe(guard)], { encoding: 'utf-8' }).stdout ?? '').trim();
|
||||
(spawnSync('bash', ['-c', probe(guard)], { encoding: 'utf-8', timeout: 30_000 }).stdout ?? '').trim();
|
||||
|
||||
expect(run('')).toBe('PIPE');
|
||||
expect(run('BASH_COMPAT=50')).toBe('TEMPFILE');
|
||||
|
||||
@@ -330,7 +330,7 @@ describe('host-config-export.ts CLI', () => {
|
||||
|
||||
function run(...args: string[]): { stdout: string; stderr: string; exitCode: number } {
|
||||
const result = Bun.spawnSync(['bun', 'run', EXPORT_SCRIPT, ...args], {
|
||||
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
|
||||
cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 30_000,
|
||||
});
|
||||
return {
|
||||
stdout: result.stdout.toString().trim(),
|
||||
@@ -445,7 +445,7 @@ describe('golden-file regression', () => {
|
||||
for (const host of ['codex', 'factory']) {
|
||||
const result = Bun.spawnSync(
|
||||
['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', host, '--out-dir', GOLDEN_OUT],
|
||||
{ cwd: ROOT },
|
||||
{ cwd: ROOT, timeout: 120_000 },
|
||||
);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('gstack-ios-qa-regen', () => {
|
||||
const workDir = mkdtempSync(join(tmpdir(), 'ios-qa-regen-'));
|
||||
workDirs.push(workDir);
|
||||
const { launcher } = copyIntoFakeInstall(workDir);
|
||||
const result = spawnSync('bash', [launcher, '--app-source', workDir], { encoding: 'utf8' });
|
||||
const result = spawnSync('bash', [launcher, '--app-source', workDir], { encoding: 'utf8', timeout: 30_000 });
|
||||
|
||||
expect(result.status).toBe(2);
|
||||
expect(result.stderr).toContain('both --app-source and --bridge-dir are required');
|
||||
@@ -134,6 +134,7 @@ describe('gstack-ios-qa-regen', () => {
|
||||
], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH ?? ''}` },
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(17);
|
||||
@@ -184,7 +185,7 @@ final class AppState {
|
||||
GEN_ACCESSORS_REV: 'regen-test',
|
||||
};
|
||||
const args = [launcher, '--app-source', appSource, '--bridge-dir', bridgeDir];
|
||||
const first = spawnSync('bash', args, { encoding: 'utf8', env });
|
||||
const first = spawnSync('bash', args, { encoding: 'utf8', env, timeout: 30_000 });
|
||||
expect(first.status).toBe(0);
|
||||
expect(first.stderr).toBe('');
|
||||
|
||||
@@ -232,10 +233,11 @@ final class AppState {
|
||||
expect(installedContents).not.toContain('FORBIDDEN-STATE-SENTINEL');
|
||||
expect(installedContents).not.toContain('OBSOLETE-HARNESS-SENTINEL');
|
||||
|
||||
const swiftAvailable = spawnSync('swift', ['--version'], { encoding: 'utf8' }).status === 0;
|
||||
const swiftAvailable = spawnSync('swift', ['--version'], { encoding: 'utf8', timeout: 30_000 }).status === 0;
|
||||
if (swiftAvailable) {
|
||||
const dump = spawnSync('swift', ['package', 'dump-package', '--package-path', bridgeDir], {
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(dump.status).toBe(0);
|
||||
const manifest = JSON.parse(dump.stdout) as { targets: Array<{ name: string }> };
|
||||
@@ -248,7 +250,7 @@ final class AppState {
|
||||
|
||||
const firstHash = treeHash(bridgeDir, generatedDir);
|
||||
const firstAccessorHash = accessor.match(/accessorHash: "([a-f0-9]+)"/)?.[1];
|
||||
const second = spawnSync('bash', args, { encoding: 'utf8', env });
|
||||
const second = spawnSync('bash', args, { encoding: 'utf8', env, timeout: 30_000 });
|
||||
expect(second.status).toBe(0);
|
||||
expect(second.stderr).toBe('');
|
||||
expect(second.stdout).toContain('gen-accessors: cache hit');
|
||||
|
||||
@@ -37,7 +37,7 @@ function runSearch(args: string = ''): string {
|
||||
timeout: 15000,
|
||||
};
|
||||
try {
|
||||
return execSync(`${BIN}/gstack-learnings-search ${args}`, execOpts).trim();
|
||||
return execSync(`${BIN}/gstack-learnings-search ${args}`, execOpts).trim(); // timeout via execOpts
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ function runHook(stdin: object): { stdout: string; stderr: string; status: numbe
|
||||
input: JSON.stringify({ ...stdin, cwd: fixtureCwd }),
|
||||
encoding: 'utf-8',
|
||||
cwd: ROOT,
|
||||
timeout: 30_000,
|
||||
});
|
||||
let parsed: any = null;
|
||||
try { parsed = JSON.parse(res.stdout || '{}'); } catch {}
|
||||
|
||||
@@ -69,6 +69,7 @@ describe("gstack-memory-ingest: gbrain import must not be filtered by .gitignore
|
||||
try {
|
||||
const git = (args: string[], cwd: string, env?: NodeJS.ProcessEnv) =>
|
||||
execFileSync("git", args, {
|
||||
timeout: 30_000,
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env, ...env },
|
||||
@@ -113,7 +114,7 @@ describe("gstack-memory-ingest: gbrain import must not be filtered by .gitignore
|
||||
const dir = mkdtempSync(join(tmpdir(), "gstack-ingest-gitignore-"));
|
||||
try {
|
||||
const git = (...args: string[]) =>
|
||||
execFileSync("git", args, { cwd: dir, encoding: "utf-8" });
|
||||
execFileSync("git", args, { timeout: 30_000, cwd: dir, encoding: "utf-8" });
|
||||
git("init", "-q", ".");
|
||||
|
||||
const staging = join(dir, ".staging-ingest-12345-1700000000000", "learnings");
|
||||
|
||||
@@ -81,6 +81,7 @@ function run(extraEnv: Record<string, string> = {}, input = ''): { code: number;
|
||||
encoding: 'utf-8',
|
||||
input,
|
||||
cwd: tmpHome,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return { code: r.status ?? -1, stdout: r.stdout || '', stderr: r.stderr || '' };
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ function run(extraEnv: Record<string, string> = {}): {
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpHome,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return { code: r.status ?? -1, stdout: r.stdout || '', stderr: r.stderr || '' };
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user