mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 22:48:57 +02:00
* 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>
2169 lines
90 KiB
TypeScript
2169 lines
90 KiB
TypeScript
import { describe, test, expect, afterAll } from 'bun:test';
|
|
import { validateSkill, extractRemoteSlugPatterns, extractWeightsFromTable } from './helpers/skill-parser';
|
|
import { ALL_COMMANDS, COMMAND_DESCRIPTIONS, READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from '../browse/src/commands';
|
|
import { SNAPSHOT_FLAGS } from '../browse/src/snapshot';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
|
|
// ─── Codex-host render isolation ─────────────────────────────
|
|
// .agents/ is gitignored and regenerated. This file used to regenerate it IN
|
|
// PLACE at three sites (a tree-mutating hazard for concurrent readers).
|
|
// Render the codex host ONCE into a module-level out-dir instead; every
|
|
// codex-artifact assertion reads from here. Out-dir renders are byte-
|
|
// identical to in-place external-host renders (pinned by
|
|
// test/gen-skill-docs-out-dir.test.ts).
|
|
const CODEX_OUT = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-skillval-codex-'));
|
|
{
|
|
const render = Bun.spawnSync(
|
|
['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'codex', '--out-dir', CODEX_OUT],
|
|
{ cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 120_000 },
|
|
);
|
|
if (render.exitCode !== 0) {
|
|
throw new Error(
|
|
`gen-skill-docs --host codex --out-dir failed (exit ${render.exitCode}):\n${render.stderr.toString()}`,
|
|
);
|
|
}
|
|
}
|
|
afterAll(() => {
|
|
fs.rmSync(CODEX_OUT, { recursive: true, force: true });
|
|
});
|
|
|
|
// Carved-skill aware (v2 plan T9 / Phase B): a carved skill is a skeleton SKILL.md
|
|
// plus sections/*.md. Read the union so validations of content that moved into a
|
|
// section still hold. For an uncarved skill (no sections dir) this is just the
|
|
// skeleton, so readSkillUnion is safe to use everywhere.
|
|
function readSkillUnion(skill: string): string {
|
|
let t = fs.readFileSync(path.join(ROOT, skill, 'SKILL.md'), 'utf-8');
|
|
const secDir = path.join(ROOT, skill, 'sections');
|
|
if (fs.existsSync(secDir)) {
|
|
for (const f of fs.readdirSync(secDir).sort()) {
|
|
if (f.endsWith('.md')) t += '\n' + fs.readFileSync(path.join(secDir, f), 'utf-8');
|
|
}
|
|
}
|
|
return t;
|
|
}
|
|
function readShipUnion(): string {
|
|
return readSkillUnion('ship');
|
|
}
|
|
|
|
describe('SKILL.md command validation', () => {
|
|
// P2 (v1.2.0): the top-level gstack skill is a pure ROUTER, not the browse
|
|
// skill. The browse body lives only in browse/SKILL.md now. This regression
|
|
// pins the split: the router carries routing rules and zero browse commands,
|
|
// while browse/SKILL.md still advertises the full QA surface (asserted below).
|
|
test('top-level SKILL.md is a router with no browse body (P2)', () => {
|
|
const md = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
|
|
expect(md).not.toContain('gstack browse: QA Testing'); // browse body removed
|
|
expect(md).toContain('## Route first'); // router head present
|
|
expect(md).toContain('invoke `/investigate`'); // routing rules present
|
|
const result = validateSkill(path.join(ROOT, 'SKILL.md'));
|
|
expect(result.invalid).toHaveLength(0); // no INVALID browse commands
|
|
expect(result.valid.length).toBe(0); // and no browse commands at all — it routes, not browses
|
|
});
|
|
|
|
test('all $B commands in browse/SKILL.md are valid browse commands', () => {
|
|
const result = validateSkill(path.join(ROOT, 'browse', 'SKILL.md'));
|
|
expect(result.invalid).toHaveLength(0);
|
|
expect(result.valid.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('all snapshot flags in browse/SKILL.md are valid', () => {
|
|
const result = validateSkill(path.join(ROOT, 'browse', 'SKILL.md'));
|
|
expect(result.snapshotFlagErrors).toHaveLength(0);
|
|
});
|
|
|
|
test('all $B commands in qa/SKILL.md are valid browse commands', () => {
|
|
const qaSkill = path.join(ROOT, 'qa', 'SKILL.md');
|
|
if (!fs.existsSync(qaSkill)) return; // skip if missing
|
|
const result = validateSkill(qaSkill);
|
|
expect(result.invalid).toHaveLength(0);
|
|
});
|
|
|
|
test('all snapshot flags in qa/SKILL.md are valid', () => {
|
|
const qaSkill = path.join(ROOT, 'qa', 'SKILL.md');
|
|
if (!fs.existsSync(qaSkill)) return;
|
|
const result = validateSkill(qaSkill);
|
|
expect(result.snapshotFlagErrors).toHaveLength(0);
|
|
});
|
|
|
|
// qa carve: the Phases 1-6 methodology (with its $B command examples) moved
|
|
// into qa/sections/*.md — validate the section files too so browse-command
|
|
// coverage doesn't silently shrink with the carve.
|
|
test('all $B commands and snapshot flags in qa/sections/*.md are valid', () => {
|
|
const secDir = path.join(ROOT, 'qa', 'sections');
|
|
if (!fs.existsSync(secDir)) return; // pre-carve checkout
|
|
const sectionMds = fs.readdirSync(secDir).filter(f => f.endsWith('.md') && !f.endsWith('.md.tmpl'));
|
|
expect(sectionMds.length).toBeGreaterThan(0);
|
|
let validTotal = 0;
|
|
for (const f of sectionMds) {
|
|
const result = validateSkill(path.join(secDir, f));
|
|
expect({ file: f, invalid: result.invalid }).toEqual({ file: f, invalid: [] });
|
|
expect({ file: f, snapshotFlagErrors: result.snapshotFlagErrors }).toEqual({ file: f, snapshotFlagErrors: [] });
|
|
validTotal += result.valid.length;
|
|
}
|
|
// Non-empty guard: the carved methodology must still carry $B examples.
|
|
expect(validTotal).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('all $B commands in qa-only/SKILL.md are valid browse commands', () => {
|
|
const qaOnlySkill = path.join(ROOT, 'qa-only', 'SKILL.md');
|
|
if (!fs.existsSync(qaOnlySkill)) return;
|
|
const result = validateSkill(qaOnlySkill);
|
|
expect(result.invalid).toHaveLength(0);
|
|
});
|
|
|
|
test('all snapshot flags in qa-only/SKILL.md are valid', () => {
|
|
const qaOnlySkill = path.join(ROOT, 'qa-only', 'SKILL.md');
|
|
if (!fs.existsSync(qaOnlySkill)) return;
|
|
const result = validateSkill(qaOnlySkill);
|
|
expect(result.snapshotFlagErrors).toHaveLength(0);
|
|
});
|
|
|
|
test('all $B commands in plan-design-review/SKILL.md are valid browse commands', () => {
|
|
const skill = path.join(ROOT, 'plan-design-review', 'SKILL.md');
|
|
if (!fs.existsSync(skill)) return;
|
|
const result = validateSkill(skill);
|
|
expect(result.invalid).toHaveLength(0);
|
|
});
|
|
|
|
test('all snapshot flags in plan-design-review/SKILL.md are valid', () => {
|
|
const skill = path.join(ROOT, 'plan-design-review', 'SKILL.md');
|
|
if (!fs.existsSync(skill)) return;
|
|
const result = validateSkill(skill);
|
|
expect(result.snapshotFlagErrors).toHaveLength(0);
|
|
});
|
|
|
|
test('all $B commands in design-review/SKILL.md are valid browse commands', () => {
|
|
const skill = path.join(ROOT, 'design-review', 'SKILL.md');
|
|
if (!fs.existsSync(skill)) return;
|
|
const result = validateSkill(skill);
|
|
expect(result.invalid).toHaveLength(0);
|
|
});
|
|
|
|
test('all snapshot flags in design-review/SKILL.md are valid', () => {
|
|
const skill = path.join(ROOT, 'design-review', 'SKILL.md');
|
|
if (!fs.existsSync(skill)) return;
|
|
const result = validateSkill(skill);
|
|
expect(result.snapshotFlagErrors).toHaveLength(0);
|
|
});
|
|
|
|
test('all $B commands in design-consultation/SKILL.md are valid browse commands', () => {
|
|
const skill = path.join(ROOT, 'design-consultation', 'SKILL.md');
|
|
if (!fs.existsSync(skill)) return;
|
|
const result = validateSkill(skill);
|
|
expect(result.invalid).toHaveLength(0);
|
|
});
|
|
|
|
test('all snapshot flags in design-consultation/SKILL.md are valid', () => {
|
|
const skill = path.join(ROOT, 'design-consultation', 'SKILL.md');
|
|
if (!fs.existsSync(skill)) return;
|
|
const result = validateSkill(skill);
|
|
expect(result.snapshotFlagErrors).toHaveLength(0);
|
|
});
|
|
|
|
test('all $B commands in autoplan/SKILL.md are valid browse commands', () => {
|
|
const skill = path.join(ROOT, 'autoplan', 'SKILL.md');
|
|
if (!fs.existsSync(skill)) return;
|
|
const result = validateSkill(skill);
|
|
expect(result.invalid).toHaveLength(0);
|
|
});
|
|
|
|
test('all snapshot flags in autoplan/SKILL.md are valid', () => {
|
|
const skill = path.join(ROOT, 'autoplan', 'SKILL.md');
|
|
if (!fs.existsSync(skill)) return;
|
|
const result = validateSkill(skill);
|
|
expect(result.snapshotFlagErrors).toHaveLength(0);
|
|
});
|
|
|
|
test('autoplan section skip list includes the scope gate', () => {
|
|
// autoplan Step 3 reads plan-eng-review / plan-design-review SKILL.md
|
|
// verbatim; without this skip-list entry it ingests their scope gate — a
|
|
// hard-STOP AskUserQuestion that contradicts autoplan's auto-decide
|
|
// contract. Nothing else pins the skip-list contents.
|
|
const md = fs.readFileSync(path.join(ROOT, 'autoplan', 'SKILL.md'), 'utf-8');
|
|
expect(md).toContain('- Scope gate (the plan under review is already the target)');
|
|
});
|
|
});
|
|
|
|
describe('Command registry consistency', () => {
|
|
test('COMMAND_DESCRIPTIONS covers all commands in sets', () => {
|
|
const allCmds = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
|
|
const descKeys = new Set(Object.keys(COMMAND_DESCRIPTIONS));
|
|
for (const cmd of allCmds) {
|
|
expect(descKeys.has(cmd)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('COMMAND_DESCRIPTIONS has no extra commands not in sets', () => {
|
|
const allCmds = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
|
|
for (const key of Object.keys(COMMAND_DESCRIPTIONS)) {
|
|
expect(allCmds.has(key)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('ALL_COMMANDS matches union of all sets', () => {
|
|
const union = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
|
|
expect(ALL_COMMANDS.size).toBe(union.size);
|
|
for (const cmd of union) {
|
|
expect(ALL_COMMANDS.has(cmd)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('SNAPSHOT_FLAGS option keys are valid SnapshotOptions fields', () => {
|
|
const validKeys = new Set([
|
|
'interactive', 'compact', 'depth', 'selector',
|
|
'diff', 'annotate', 'outputPath', 'cursorInteractive',
|
|
'heatmap',
|
|
]);
|
|
for (const flag of SNAPSHOT_FLAGS) {
|
|
expect(validKeys.has(flag.optionKey)).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Usage string consistency', () => {
|
|
// Normalize a usage string to its structural skeleton for comparison.
|
|
// Replaces <param-names> with <>, [optional] with [], strips parenthetical hints.
|
|
// This catches format mismatches (e.g., <name>:<value> vs <name> <value>)
|
|
// without tripping on abbreviation differences (e.g., <sel> vs <selector>).
|
|
function skeleton(usage: string): string {
|
|
return usage
|
|
.replace(/\(.*?\)/g, '') // strip parenthetical hints like (e.g., Enter, Tab)
|
|
.replace(/<[^>]*>/g, '<>') // normalize <param-name> → <>
|
|
.replace(/\[[^\]]*\]/g, '[]') // normalize [optional] → []
|
|
.replace(/\s+/g, ' ') // collapse whitespace
|
|
.trim();
|
|
}
|
|
|
|
// Cross-check Usage: patterns in implementation against COMMAND_DESCRIPTIONS
|
|
test('implementation Usage: structural format matches COMMAND_DESCRIPTIONS', () => {
|
|
const implFiles = [
|
|
path.join(ROOT, 'browse', 'src', 'write-commands.ts'),
|
|
path.join(ROOT, 'browse', 'src', 'read-commands.ts'),
|
|
path.join(ROOT, 'browse', 'src', 'meta-commands.ts'),
|
|
];
|
|
|
|
// Extract "Usage: browse <pattern>" from throw new Error(...) calls
|
|
const usagePattern = /throw new Error\(['"`]Usage:\s*browse\s+(.+?)['"`]\)/g;
|
|
const implUsages = new Map<string, string>();
|
|
|
|
for (const file of implFiles) {
|
|
const content = fs.readFileSync(file, 'utf-8');
|
|
let match;
|
|
while ((match = usagePattern.exec(content)) !== null) {
|
|
const usage = match[1].split('\\n')[0].trim();
|
|
const cmd = usage.split(/\s/)[0];
|
|
implUsages.set(cmd, usage);
|
|
}
|
|
}
|
|
|
|
// Compare structural skeletons
|
|
const mismatches: string[] = [];
|
|
for (const [cmd, implUsage] of implUsages) {
|
|
const desc = COMMAND_DESCRIPTIONS[cmd];
|
|
if (!desc) continue;
|
|
if (!desc.usage) continue;
|
|
const descSkel = skeleton(desc.usage);
|
|
const implSkel = skeleton(implUsage);
|
|
if (descSkel !== implSkel) {
|
|
mismatches.push(`${cmd}: docs "${desc.usage}" (${descSkel}) vs impl "${implUsage}" (${implSkel})`);
|
|
}
|
|
}
|
|
|
|
expect(mismatches).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('Generated SKILL.md freshness', () => {
|
|
test('no unresolved {{placeholders}} in generated SKILL.md', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
|
|
const unresolved = content.match(/\{\{\w+\}\}/g);
|
|
expect(unresolved).toBeNull();
|
|
});
|
|
|
|
test('no unresolved {{placeholders}} in generated browse/SKILL.md', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'browse', 'SKILL.md'), 'utf-8');
|
|
const unresolved = content.match(/\{\{\w+\}\}/g);
|
|
expect(unresolved).toBeNull();
|
|
});
|
|
|
|
test('generated SKILL.md has AUTO-GENERATED header', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('AUTO-GENERATED');
|
|
});
|
|
});
|
|
|
|
// --- Update check preamble validation ---
|
|
|
|
describe('Update check preamble', () => {
|
|
const skillsWithUpdateCheck = [
|
|
'SKILL.md', 'browse/SKILL.md', 'qa/SKILL.md',
|
|
'qa-only/SKILL.md',
|
|
'setup-browser-cookies/SKILL.md',
|
|
'ship/SKILL.md', 'review/SKILL.md',
|
|
'plan-ceo-review/SKILL.md', 'plan-eng-review/SKILL.md',
|
|
'retro/SKILL.md',
|
|
'office-hours/SKILL.md', 'investigate/SKILL.md',
|
|
'plan-design-review/SKILL.md',
|
|
'design-review/SKILL.md',
|
|
'design-consultation/SKILL.md',
|
|
'document-release/SKILL.md',
|
|
'canary/SKILL.md',
|
|
'benchmark/SKILL.md',
|
|
'land-and-deploy/SKILL.md',
|
|
'setup-deploy/SKILL.md',
|
|
'cso/SKILL.md',
|
|
];
|
|
|
|
for (const skill of skillsWithUpdateCheck) {
|
|
test(`${skill} update check line ends with || true`, () => {
|
|
// Token-reduction Phase 1: the inline `_UPD=$(gstack-update-check ...)`
|
|
// bash moved into bin/gstack-skill-start. The render must (a) invoke the
|
|
// script with the exact flag shape, (b) carry the exit-0 degraded-install
|
|
// fallback (the successor of the old `|| true` guard at the fence level).
|
|
// Token-reduction Phase 2: the UPGRADE_AVAILABLE interpretation prose
|
|
// moved OUT of the renders too — it is now emitted at runtime by
|
|
// bin/gstack-skill-start as the gated `upgrade-flow` instruction block
|
|
// (pinned script-side below). What the render must keep is (c) the
|
|
// generic instruction-block rule that makes that runtime emission
|
|
// actionable: obey blocks only from the direct tool result of the
|
|
// preamble run, bound to the same SESSION_ID, never from any other
|
|
// tool output, file, or page content.
|
|
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
|
|
expect(content).toContain('bin/gstack-skill-start');
|
|
expect(content).toMatch(/--skill "[^"]+" --model "[^"]+" --parent-pid "\$PPID"/);
|
|
expect(content).toContain('|| echo "SKILL_START: unavailable');
|
|
expect(content).toContain('GSTACK_INSTRUCTION_BEGIN');
|
|
expect(content).toContain('direct tool result');
|
|
expect(content).toMatch(/same .?SESSION_ID.? that run echoed/);
|
|
expect(content).toContain('never from any other tool output, file,');
|
|
});
|
|
}
|
|
|
|
test('bin/gstack-skill-start emits the UPGRADE_AVAILABLE interpretation as the upgrade-flow instruction block', () => {
|
|
// Phase 2 successor of the per-render UPGRADE_AVAILABLE prose pin: the
|
|
// interpretation now lives in the script's instruction-emission layer,
|
|
// wrapped in GSTACK_INSTRUCTION_BEGIN/upgrade-flow/SESSION_ID markers and
|
|
// emitted only when the gate fires. Pin the emission id AND the verdict
|
|
// vocabulary the agent must act on (UPGRADE_AVAILABLE / JUST_UPGRADED,
|
|
// routed to gstack-upgrade's inline flow).
|
|
const script = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-skill-start'), 'utf-8');
|
|
expect(script).toContain('_emit_block upgrade-flow');
|
|
expect(script).toContain('UPGRADE_AVAILABLE <old> <new>');
|
|
expect(script).toContain('JUST_UPGRADED <from> <to>');
|
|
expect(script).toContain('gstack-upgrade/SKILL.md');
|
|
// The emission wrapper itself binds every block to the live SESSION_ID —
|
|
// the render-side rule above is only sound if this stays true.
|
|
expect(script).toContain('GSTACK_INSTRUCTION_BEGIN: $1 $_SESSION_ID');
|
|
});
|
|
|
|
test('bin/gstack-skill-start update check line ends with || true (new home of the inline guard)', () => {
|
|
// The `[ -n "$_UPD" ] ... || true` guard (empty _UPD must not exit 1 when
|
|
// up to date) moved verbatim into the consolidated preamble script. Pin it
|
|
// there so the invariant survives in its new home.
|
|
const script = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-skill-start'), 'utf-8');
|
|
const match = script.match(/\[ -n "\$_UPD" \].*$/m);
|
|
expect(match).not.toBeNull();
|
|
expect(match![0]).toContain('|| true');
|
|
});
|
|
|
|
test('all skills with update check are generated from .tmpl', () => {
|
|
for (const skill of skillsWithUpdateCheck) {
|
|
const tmplPath = path.join(ROOT, skill + '.tmpl');
|
|
expect(fs.existsSync(tmplPath)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('update check bash block exits 0 when up to date', () => {
|
|
// Simulate the exact update-check lines from bin/gstack-skill-start
|
|
// (per-line `|| true`, sanitize pipe included)
|
|
const result = Bun.spawnSync(['bash', '-c',
|
|
'_sanitize() { sed "s/GSTACK_INSTRUCTION/GSTACK-INSTRUCTION-(stripped)/g"; }; ' +
|
|
'_UPD=$(echo "" || true); [ -n "$_UPD" ] && printf "%s\\n" "$_UPD" | _sanitize || true'
|
|
], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
|
|
expect(result.exitCode).toBe(0);
|
|
});
|
|
|
|
test('update check bash block exits 0 when upgrade available', () => {
|
|
const result = Bun.spawnSync(['bash', '-c',
|
|
'_sanitize() { sed "s/GSTACK_INSTRUCTION/GSTACK-INSTRUCTION-(stripped)/g"; }; ' +
|
|
'_UPD=$(echo "UPGRADE_AVAILABLE 0.3.3 0.4.0" || true); [ -n "$_UPD" ] && printf "%s\\n" "$_UPD" | _sanitize || true'
|
|
], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.stdout.toString().trim()).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0');
|
|
});
|
|
});
|
|
|
|
// --- Part 7: Cross-skill path consistency (A1) ---
|
|
|
|
describe('Cross-skill path consistency', () => {
|
|
test('REMOTE_SLUG derivation pattern is identical across files that use it', () => {
|
|
const patterns = extractRemoteSlugPatterns(ROOT, ['qa', 'review']);
|
|
const allPatterns: string[] = [];
|
|
|
|
for (const [, filePatterns] of patterns) {
|
|
allPatterns.push(...filePatterns);
|
|
}
|
|
|
|
// Should find at least 2 occurrences (qa/SKILL.md + review/greptile-triage.md)
|
|
expect(allPatterns.length).toBeGreaterThanOrEqual(2);
|
|
|
|
// All occurrences must be character-for-character identical
|
|
const unique = new Set(allPatterns);
|
|
if (unique.size > 1) {
|
|
const variants = Array.from(unique);
|
|
throw new Error(
|
|
`REMOTE_SLUG pattern differs across files:\n` +
|
|
variants.map((v, i) => ` ${i + 1}: ${v}`).join('\n')
|
|
);
|
|
}
|
|
});
|
|
|
|
test('all greptile-history write references specify both per-project and global paths', () => {
|
|
const filesToCheck = [
|
|
'review/SKILL.md',
|
|
'ship/SKILL.md',
|
|
'review/greptile-triage.md',
|
|
];
|
|
|
|
for (const file of filesToCheck) {
|
|
const filePath = path.join(ROOT, file);
|
|
if (!fs.existsSync(filePath)) continue;
|
|
// ship's greptile handling moved into sections/greptile.md (T9 carve).
|
|
const content = file === 'ship/SKILL.md' ? readShipUnion() : fs.readFileSync(filePath, 'utf-8');
|
|
|
|
const hasBoth = (content.includes('per-project') && content.includes('global')) ||
|
|
(content.includes('$REMOTE_SLUG/greptile-history') && content.includes('~/.gstack/greptile-history'));
|
|
|
|
expect(hasBoth).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('greptile-triage.md contains both project and global history paths', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'review', 'greptile-triage.md'), 'utf-8');
|
|
expect(content).toContain('$REMOTE_SLUG/greptile-history.md');
|
|
expect(content).toContain('~/.gstack/greptile-history.md');
|
|
});
|
|
|
|
test('retro/SKILL.md reads global greptile-history (not per-project)', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('~/.gstack/greptile-history.md');
|
|
// Should NOT reference per-project path for reads
|
|
expect(content).not.toContain('$REMOTE_SLUG/greptile-history.md');
|
|
});
|
|
});
|
|
|
|
// --- Part 7: QA skill structure validation (A2) ---
|
|
|
|
describe('QA skill structure validation', () => {
|
|
// qa carve: modes, Phases 1-6, and the health rubric moved into
|
|
// qa/sections/qa-patterns.md — validate the skeleton+sections union.
|
|
const qaContent = readSkillUnion('qa');
|
|
|
|
test('qa/SKILL.md has all 11 phases', () => {
|
|
const phases = [
|
|
'Phase 1', 'Initialize',
|
|
'Phase 2', 'Authenticate',
|
|
'Phase 3', 'Orient',
|
|
'Phase 4', 'Explore',
|
|
'Phase 5', 'Document',
|
|
'Phase 6', 'Wrap Up',
|
|
'Phase 7', 'Triage',
|
|
'Phase 8', 'Fix Loop',
|
|
'Phase 9', 'Final QA',
|
|
'Phase 10', 'Report',
|
|
'Phase 11', 'TODOS',
|
|
];
|
|
for (const phase of phases) {
|
|
expect(qaContent).toContain(phase);
|
|
}
|
|
});
|
|
|
|
test('has all four QA modes defined', () => {
|
|
const modes = [
|
|
'Diff-aware',
|
|
'Full',
|
|
'Quick',
|
|
'Regression',
|
|
];
|
|
for (const mode of modes) {
|
|
expect(qaContent).toContain(mode);
|
|
}
|
|
|
|
// Mode triggers/flags
|
|
expect(qaContent).toContain('--quick');
|
|
expect(qaContent).toContain('--regression');
|
|
});
|
|
|
|
test('has all three tiers defined', () => {
|
|
const tiers = ['Quick', 'Standard', 'Exhaustive'];
|
|
for (const tier of tiers) {
|
|
expect(qaContent).toContain(tier);
|
|
}
|
|
});
|
|
|
|
test('health score weights sum to 100%', () => {
|
|
const weights = extractWeightsFromTable(qaContent);
|
|
expect(weights.size).toBeGreaterThan(0);
|
|
|
|
let sum = 0;
|
|
for (const pct of weights.values()) {
|
|
sum += pct;
|
|
}
|
|
expect(sum).toBe(100);
|
|
});
|
|
|
|
test('health score has all 8 categories', () => {
|
|
const weights = extractWeightsFromTable(qaContent);
|
|
const expectedCategories = [
|
|
'Console', 'Links', 'Visual', 'Functional',
|
|
'UX', 'Performance', 'Content', 'Accessibility',
|
|
];
|
|
for (const cat of expectedCategories) {
|
|
expect(weights.has(cat)).toBe(true);
|
|
}
|
|
expect(weights.size).toBe(8);
|
|
});
|
|
|
|
test('has four mode definitions (Diff-aware/Full/Quick/Regression)', () => {
|
|
expect(qaContent).toContain('### Diff-aware');
|
|
expect(qaContent).toContain('### Full');
|
|
expect(qaContent).toContain('### Quick');
|
|
expect(qaContent).toContain('### Regression');
|
|
});
|
|
|
|
test('output structure references report directory layout', () => {
|
|
expect(qaContent).toContain('qa-report-');
|
|
expect(qaContent).toContain('baseline.json');
|
|
expect(qaContent).toContain('screenshots/');
|
|
expect(qaContent).toContain('.gstack/qa-reports/');
|
|
});
|
|
});
|
|
|
|
// --- Part 7: Greptile history format consistency (A3) ---
|
|
|
|
describe('Greptile history format consistency', () => {
|
|
test('greptile-triage.md defines the canonical history format', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'review', 'greptile-triage.md'), 'utf-8');
|
|
expect(content).toContain('<YYYY-MM-DD>');
|
|
expect(content).toContain('<owner/repo>');
|
|
expect(content).toContain('<type');
|
|
expect(content).toContain('<file-pattern>');
|
|
expect(content).toContain('<category>');
|
|
});
|
|
|
|
test('review/SKILL.md and ship/SKILL.md both reference greptile-triage.md for write details', () => {
|
|
const reviewContent = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
|
const shipContent = readShipUnion();
|
|
|
|
expect(reviewContent.toLowerCase()).toContain('greptile-triage.md');
|
|
expect(shipContent.toLowerCase()).toContain('greptile-triage.md');
|
|
});
|
|
|
|
test('greptile-triage.md defines all 9 valid categories', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'review', 'greptile-triage.md'), 'utf-8');
|
|
const categories = [
|
|
'race-condition', 'null-check', 'error-handling', 'style',
|
|
'type-safety', 'security', 'performance', 'correctness', 'other',
|
|
];
|
|
for (const cat of categories) {
|
|
expect(content).toContain(cat);
|
|
}
|
|
});
|
|
});
|
|
|
|
// --- Hardcoded branch name detection in templates ---
|
|
|
|
describe('No hardcoded branch names in SKILL templates', () => {
|
|
const tmplFiles = [
|
|
'ship/SKILL.md.tmpl',
|
|
'review/SKILL.md.tmpl',
|
|
'qa/SKILL.md.tmpl',
|
|
'plan-ceo-review/SKILL.md.tmpl',
|
|
'retro/SKILL.md.tmpl',
|
|
'document-release/SKILL.md.tmpl',
|
|
'plan-eng-review/SKILL.md.tmpl',
|
|
'plan-design-review/SKILL.md.tmpl',
|
|
'codex/SKILL.md.tmpl',
|
|
];
|
|
|
|
// Patterns that indicate hardcoded 'main' in git commands
|
|
const gitMainPatterns = [
|
|
/\bgit\s+diff\s+(?:origin\/)?main\b/,
|
|
/\bgit\s+log\s+(?:origin\/)?main\b/,
|
|
/\bgit\s+fetch\s+origin\s+main\b/,
|
|
/\bgit\s+merge\s+origin\/main\b/,
|
|
/\borigin\/main\b/,
|
|
];
|
|
|
|
// Lines that are allowed to mention 'main' (fallback logic, prose)
|
|
const allowlist = [
|
|
/fall\s*back\s+to\s+`main`/i,
|
|
/fall\s*back\s+to\s+`?main`?/i,
|
|
/typically\s+`?main`?/i,
|
|
/If\s+on\s+`main`/i, // old pattern — should not exist
|
|
];
|
|
|
|
for (const tmplFile of tmplFiles) {
|
|
test(`${tmplFile} has no hardcoded 'main' in git commands`, () => {
|
|
const filePath = path.join(ROOT, tmplFile);
|
|
if (!fs.existsSync(filePath)) return;
|
|
const lines = fs.readFileSync(filePath, 'utf-8').split('\n');
|
|
const violations: string[] = [];
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const isAllowlisted = allowlist.some(p => p.test(line));
|
|
if (isAllowlisted) continue;
|
|
|
|
for (const pattern of gitMainPatterns) {
|
|
if (pattern.test(line)) {
|
|
violations.push(`Line ${i + 1}: ${line.trim()}`);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
throw new Error(
|
|
`${tmplFile} has hardcoded 'main' in git commands:\n` +
|
|
violations.map(v => ` ${v}`).join('\n')
|
|
);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// --- Part 7b: TODOS-format.md reference consistency ---
|
|
|
|
describe('TODOS-format.md reference consistency', () => {
|
|
test('review/TODOS-format.md exists and defines canonical format', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'review', 'TODOS-format.md'), 'utf-8');
|
|
expect(content).toContain('**What:**');
|
|
expect(content).toContain('**Why:**');
|
|
expect(content).toContain('**Priority:**');
|
|
expect(content).toContain('**Effort:**');
|
|
expect(content).toContain('## Completed');
|
|
});
|
|
|
|
test('skills that write TODOs reference TODOS-format.md', () => {
|
|
const shipContent = readShipUnion();
|
|
const ceoPlanContent = readSkillUnion('plan-ceo-review'); // carved: TODOS-format ref moved to section
|
|
const engPlanContent = readSkillUnion('plan-eng-review');
|
|
|
|
expect(shipContent).toContain('TODOS-format.md');
|
|
expect(ceoPlanContent).toContain('TODOS-format.md');
|
|
expect(engPlanContent).toContain('TODOS-format.md');
|
|
});
|
|
});
|
|
|
|
// --- v0.4.1 feature coverage: RECOMMENDATION format, session awareness, enum completeness ---
|
|
|
|
describe('v0.4.1 preamble features', () => {
|
|
// Tier 1 skills have core preamble only (no AskUserQuestion format)
|
|
const tier1Skills = ['SKILL.md', 'browse/SKILL.md', 'setup-browser-cookies/SKILL.md', 'benchmark/SKILL.md'];
|
|
|
|
// Tier 2+ skills have AskUserQuestion format with RECOMMENDATION
|
|
const tier2PlusSkills = [
|
|
'qa/SKILL.md', 'qa-only/SKILL.md',
|
|
'ship/SKILL.md', 'review/SKILL.md',
|
|
'plan-ceo-review/SKILL.md', 'plan-eng-review/SKILL.md',
|
|
'retro/SKILL.md',
|
|
'office-hours/SKILL.md', 'investigate/SKILL.md',
|
|
'plan-design-review/SKILL.md',
|
|
'design-review/SKILL.md',
|
|
'design-consultation/SKILL.md',
|
|
'document-release/SKILL.md',
|
|
'canary/SKILL.md',
|
|
'land-and-deploy/SKILL.md',
|
|
'setup-deploy/SKILL.md',
|
|
'cso/SKILL.md',
|
|
];
|
|
|
|
const skillsWithPreamble = [...tier1Skills, ...tier2PlusSkills];
|
|
|
|
for (const skill of tier2PlusSkills) {
|
|
test(`${skill} contains AskUserQuestion Pros/Cons format`, () => {
|
|
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
|
|
// v1.7.0.0 Pros/Cons format tokens. The preamble resolver
|
|
// (generate-ask-user-format.ts) injects all of these into every
|
|
// tier-2+ skill. Drop any of them and the test catches it on the
|
|
// next `bun test` run.
|
|
expect(content).toContain('AskUserQuestion');
|
|
expect(content).toContain('Pros / cons:');
|
|
expect(content).toContain('Recommendation: <choice>');
|
|
expect(content).toContain('Net:');
|
|
expect(content).toContain('ELI10');
|
|
expect(content).toContain('Stakes if we pick wrong:');
|
|
// Concrete format markers must be documented in the resolver text
|
|
expect(content).toMatch(/✅/);
|
|
expect(content).toMatch(/❌/);
|
|
});
|
|
}
|
|
|
|
for (const skill of skillsWithPreamble) {
|
|
test(`${skill} contains session awareness`, () => {
|
|
// Token-reduction Phase 1: the inline `_SESSIONS=$(find ~/.gstack/sessions ...)`
|
|
// bash moved into bin/gstack-skill-start. The render still carries session
|
|
// identity (--parent-pid feeds the sessions dir with the harness pid) and
|
|
// the SESSION_KIND STATUS-line interpretation prose.
|
|
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
|
|
expect(content).toMatch(/--parent-pid "\$PPID"/);
|
|
expect(content).toContain('SESSION_KIND');
|
|
});
|
|
}
|
|
|
|
test('bin/gstack-skill-start owns the session-tracking machinery (new home of _SESSIONS)', () => {
|
|
// The sessions-dir touch + stale-session cleanup that every preamble used
|
|
// to inline now lives in the consolidated script — pin it there.
|
|
const script = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-skill-start'), 'utf-8');
|
|
expect(script).toContain('mkdir -p "$_GH/sessions"');
|
|
expect(script).toContain('touch "$_GH/sessions/$PARENT_PID"');
|
|
expect(script).toContain('-mmin +120'); // 120-min freshness window survives the move
|
|
});
|
|
|
|
for (const skill of skillsWithPreamble) {
|
|
test(`${skill} contains escalation protocol`, () => {
|
|
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
|
|
expect(content).toContain('DONE_WITH_CONCERNS');
|
|
expect(content).toContain('BLOCKED');
|
|
expect(content).toContain('NEEDS_CONTEXT');
|
|
});
|
|
}
|
|
});
|
|
|
|
// --- Structural tests for new skills ---
|
|
|
|
describe('office-hours skill structure', () => {
|
|
// Carved (v2 plan T9): Phase 5 (Design Doc) + Phase 6 (handoff) moved into
|
|
// sections/design-and-handoff.md, so structural phrases now live there — read
|
|
// the skeleton+sections union.
|
|
const content = readSkillUnion('office-hours');
|
|
|
|
// Original structural assertions
|
|
for (const section of ['Phase 1', 'Phase 2', 'Phase 3', 'Phase 4', 'Phase 5', 'Phase 6',
|
|
'Design Doc', 'Supersedes', 'APPROVED', 'Premise Challenge',
|
|
'Alternatives', 'Smart-skip']) {
|
|
test(`contains ${section}`, () => expect(content).toContain(section));
|
|
}
|
|
|
|
// Dual-mode structure
|
|
for (const section of ['Startup mode', 'Builder mode']) {
|
|
test(`contains ${section}`, () => expect(content).toContain(section));
|
|
}
|
|
|
|
// Mode detection question
|
|
test('contains explicit mode detection question', () => {
|
|
expect(content).toContain("what's your goal");
|
|
});
|
|
|
|
// Six forcing questions (startup mode)
|
|
for (const question of ['Demand Reality', 'Status Quo', 'Desperate Specificity',
|
|
'Narrowest Wedge', 'Observation & Surprise', 'Future-Fit']) {
|
|
test(`contains forcing question: ${question}`, () => expect(content).toContain(question));
|
|
}
|
|
|
|
// Builder mode questions
|
|
test('contains builder brainstorming questions', () => {
|
|
expect(content).toContain('coolest version');
|
|
expect(content).toContain('delightful');
|
|
});
|
|
|
|
// Intrapreneurship adaptation
|
|
test('contains intrapreneurship adaptation', () => {
|
|
expect(content).toContain('Intrapreneurship');
|
|
});
|
|
|
|
// YC founder discovery engine
|
|
test('contains YC apply CTA with ref tracking', () => {
|
|
expect(content).toContain('ycombinator.com/apply?ref=gstack');
|
|
});
|
|
|
|
test('contains "What I noticed" design doc section', () => {
|
|
expect(content).toContain('What I noticed about how you think');
|
|
});
|
|
|
|
test('contains golden age framing', () => {
|
|
expect(content).toContain('golden age');
|
|
});
|
|
|
|
test('contains Garry Tan personal plea', () => {
|
|
expect(content).toContain('Garry Tan, the creator of GStack');
|
|
});
|
|
|
|
test('contains founder signal synthesis phase', () => {
|
|
expect(content).toContain('Founder Signal Synthesis');
|
|
});
|
|
|
|
test('contains three-tier decision rubric', () => {
|
|
expect(content).toContain('Top tier');
|
|
expect(content).toContain('Middle tier');
|
|
expect(content).toContain('Base tier');
|
|
});
|
|
|
|
test('contains anti-slop examples', () => {
|
|
expect(content).toContain('GOOD:');
|
|
expect(content).toContain('BAD:');
|
|
});
|
|
|
|
test('contains "One more thing" transition beat', () => {
|
|
expect(content).toContain('One more thing');
|
|
});
|
|
|
|
// Operating principles per mode
|
|
test('contains startup operating principles', () => {
|
|
expect(content).toContain('Specificity is the only currency');
|
|
});
|
|
|
|
test('contains builder operating principles', () => {
|
|
expect(content).toContain('Delight is the currency');
|
|
});
|
|
|
|
// Spec Review Loop (Phase 5.5)
|
|
test('contains spec review loop', () => {
|
|
expect(content).toContain('Spec Review Loop');
|
|
});
|
|
|
|
test('contains adversarial review dimensions', () => {
|
|
for (const dim of ['Completeness', 'Consistency', 'Clarity', 'Scope', 'Feasibility']) {
|
|
expect(content).toContain(dim);
|
|
}
|
|
});
|
|
|
|
test('contains subagent dispatch instruction', () => {
|
|
expect(content).toMatch(/Agent.*tool|subagent/i);
|
|
});
|
|
|
|
test('contains max 3 iterations', () => {
|
|
expect(content).toMatch(/3.*iteration|maximum.*3/i);
|
|
});
|
|
|
|
test('contains quality score', () => {
|
|
expect(content).toContain('quality score');
|
|
});
|
|
|
|
test('contains spec review metrics path', () => {
|
|
expect(content).toContain('spec-review.jsonl');
|
|
});
|
|
|
|
test('contains convergence guard', () => {
|
|
expect(content).toMatch(/convergence/i);
|
|
});
|
|
|
|
// Visual Sketch (Phase 4.5)
|
|
test('contains visual sketch section', () => {
|
|
expect(content).toContain('Visual Sketch');
|
|
});
|
|
|
|
test('contains wireframe generation', () => {
|
|
expect(content).toMatch(/wireframe|sketch/i);
|
|
});
|
|
|
|
test('contains DESIGN.md awareness', () => {
|
|
expect(content).toContain('DESIGN.md');
|
|
});
|
|
|
|
test('contains browse rendering', () => {
|
|
expect(content).toContain('$B goto');
|
|
expect(content).toContain('$B screenshot');
|
|
});
|
|
|
|
test('contains rough aesthetic instruction', () => {
|
|
expect(content).toMatch(/rough|hand-drawn/i);
|
|
});
|
|
});
|
|
|
|
describe('investigate skill structure', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'investigate', 'SKILL.md'), 'utf-8');
|
|
for (const section of ['Iron Law', 'Root Cause', 'Pattern Analysis', 'Hypothesis',
|
|
'DEBUG REPORT', '3-strike', 'BLOCKED']) {
|
|
test(`contains ${section}`, () => expect(content).toContain(section));
|
|
}
|
|
});
|
|
|
|
// Contributor mode was removed in v0.13.10.0 — replaced by operational self-improvement.
|
|
// Tests for contributor mode preamble structure are no longer applicable.
|
|
|
|
describe('Enum & Value Completeness in review checklist', () => {
|
|
const checklist = fs.readFileSync(path.join(ROOT, 'review', 'checklist.md'), 'utf-8');
|
|
|
|
test('checklist has Enum & Value Completeness section', () => {
|
|
expect(checklist).toContain('Enum & Value Completeness');
|
|
});
|
|
|
|
test('Enum & Value Completeness is classified as CRITICAL', () => {
|
|
// It should appear under Pass 1 — CRITICAL, not Pass 2
|
|
const pass1Start = checklist.indexOf('### Pass 1');
|
|
const pass2Start = checklist.indexOf('### Pass 2');
|
|
const enumStart = checklist.indexOf('Enum & Value Completeness');
|
|
expect(enumStart).toBeGreaterThan(pass1Start);
|
|
expect(enumStart).toBeLessThan(pass2Start);
|
|
});
|
|
|
|
test('Enum & Value Completeness mentions tracing through consumers', () => {
|
|
expect(checklist).toContain('Trace it through every consumer');
|
|
expect(checklist).toContain('case');
|
|
expect(checklist).toContain('allowlist');
|
|
});
|
|
|
|
test('Enum & Value Completeness is in the severity classification as CRITICAL', () => {
|
|
const gateSection = checklist.slice(checklist.indexOf('## Severity Classification'));
|
|
// The ASCII art has CRITICAL on the left and INFORMATIONAL on the right
|
|
// Enum & Value Completeness should appear on a line with the CRITICAL tree (├─ or └─)
|
|
const enumLine = gateSection.split('\n').find(l => l.includes('Enum & Value Completeness'));
|
|
expect(enumLine).toBeDefined();
|
|
// It's on the left (CRITICAL) side — starts with ├─ or └─
|
|
expect(enumLine!.trimStart().startsWith('├─') || enumLine!.trimStart().startsWith('└─')).toBe(true);
|
|
});
|
|
|
|
test('Fix-First Heuristic exists in checklist and is referenced by review + ship', () => {
|
|
expect(checklist).toContain('## Fix-First Heuristic');
|
|
expect(checklist).toContain('AUTO-FIX');
|
|
expect(checklist).toContain('ASK');
|
|
|
|
const reviewSkill = fs.readFileSync(path.join(ROOT, 'review/SKILL.md'), 'utf-8');
|
|
const shipSkill = readShipUnion();
|
|
expect(reviewSkill).toContain('AUTO-FIX');
|
|
expect(reviewSkill).toContain('[AUTO-FIXED]');
|
|
expect(shipSkill).toContain('AUTO-FIX');
|
|
expect(shipSkill).toContain('[AUTO-FIXED]');
|
|
});
|
|
});
|
|
|
|
// --- Completeness Principle spot-check ---
|
|
|
|
describe('Completeness Principle in generated SKILL.md files', () => {
|
|
const skillsWithPreamble = [
|
|
'qa/SKILL.md',
|
|
'qa-only/SKILL.md',
|
|
'ship/SKILL.md', 'review/SKILL.md',
|
|
'plan-ceo-review/SKILL.md', 'plan-eng-review/SKILL.md',
|
|
'retro/SKILL.md',
|
|
'plan-design-review/SKILL.md',
|
|
'design-review/SKILL.md',
|
|
'design-consultation/SKILL.md',
|
|
'document-release/SKILL.md',
|
|
'cso/SKILL.md', ];
|
|
|
|
for (const skill of skillsWithPreamble) {
|
|
test(`${skill} contains Completeness Principle section`, () => {
|
|
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
|
|
expect(content).toContain('Completeness Principle');
|
|
expect(content).toContain('Boil the Ocean');
|
|
});
|
|
}
|
|
|
|
test('Completeness Principle keeps compact scoring guidance in tier 2+ skills', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'cso', 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('Completeness: X/10');
|
|
expect(content).toContain('10 = all edge cases');
|
|
expect(content).toContain('Note: options differ in kind, not coverage');
|
|
expect(content).toContain('Do not fabricate scores');
|
|
});
|
|
});
|
|
|
|
// --- Part 7: Planted-bug fixture validation (A4) ---
|
|
|
|
describe('Planted-bug fixture validation', () => {
|
|
test('qa-eval ground truth has exactly 5 planted bugs', () => {
|
|
const groundTruth = JSON.parse(
|
|
fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'qa-eval-ground-truth.json'), 'utf-8')
|
|
);
|
|
expect(groundTruth.bugs).toHaveLength(5);
|
|
expect(groundTruth.total_bugs).toBe(5);
|
|
});
|
|
|
|
test('qa-eval-spa ground truth has exactly 5 planted bugs', () => {
|
|
const groundTruth = JSON.parse(
|
|
fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'qa-eval-spa-ground-truth.json'), 'utf-8')
|
|
);
|
|
expect(groundTruth.bugs).toHaveLength(5);
|
|
expect(groundTruth.total_bugs).toBe(5);
|
|
});
|
|
|
|
test('qa-eval-checkout ground truth has exactly 5 planted bugs', () => {
|
|
const groundTruth = JSON.parse(
|
|
fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'qa-eval-checkout-ground-truth.json'), 'utf-8')
|
|
);
|
|
expect(groundTruth.bugs).toHaveLength(5);
|
|
expect(groundTruth.total_bugs).toBe(5);
|
|
});
|
|
|
|
test('qa-eval.html contains the planted bugs', () => {
|
|
const html = fs.readFileSync(path.join(ROOT, 'browse', 'test', 'fixtures', 'qa-eval.html'), 'utf-8');
|
|
// BUG 1: broken link
|
|
expect(html).toContain('/nonexistent-404-page');
|
|
// BUG 2: disabled submit
|
|
expect(html).toContain('disabled');
|
|
// BUG 3: overflow
|
|
expect(html).toContain('overflow: hidden');
|
|
// BUG 4: missing alt
|
|
expect(html).toMatch(/<img[^>]*src="\/logo\.png"[^>]*>/);
|
|
expect(html).not.toMatch(/<img[^>]*src="\/logo\.png"[^>]*alt=/);
|
|
// BUG 5: console error
|
|
expect(html).toContain("Cannot read properties of undefined");
|
|
});
|
|
|
|
test('review-eval-vuln.rb contains expected vulnerability patterns', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'review-eval-vuln.rb'), 'utf-8');
|
|
expect(content).toContain('params[:id]');
|
|
expect(content).toContain('update_column');
|
|
});
|
|
});
|
|
|
|
// --- CEO review mode validation ---
|
|
|
|
describe('CEO review mode validation', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'plan-ceo-review', 'SKILL.md'), 'utf-8');
|
|
|
|
test('has all four CEO review modes defined', () => {
|
|
const modes = ['SCOPE EXPANSION', 'SELECTIVE EXPANSION', 'HOLD SCOPE', 'SCOPE REDUCTION'];
|
|
for (const mode of modes) {
|
|
expect(content).toContain(mode);
|
|
}
|
|
});
|
|
|
|
test('has CEO plan persistence step', () => {
|
|
expect(content).toContain('ceo-plans');
|
|
expect(content).toContain('status: ACTIVE');
|
|
});
|
|
|
|
test('has docs/designs promotion section', () => {
|
|
// Carved (v2 plan Phase B): the promotion block moved into the review section.
|
|
const union = readSkillUnion('plan-ceo-review');
|
|
expect(union).toContain('docs/designs');
|
|
expect(union).toContain('PROMOTED');
|
|
});
|
|
|
|
test('mode quick reference has four columns', () => {
|
|
expect(content).toContain('EXPANSION');
|
|
expect(content).toContain('SELECTIVE');
|
|
expect(content).toContain('HOLD SCOPE');
|
|
expect(content).toContain('REDUCTION');
|
|
});
|
|
|
|
// Skill chaining (benefits-from)
|
|
test('contains prerequisite skill offer for office-hours', () => {
|
|
expect(content).toContain('Prerequisite Skill Offer');
|
|
expect(content).toContain('/office-hours');
|
|
});
|
|
|
|
test('contains mid-session detection', () => {
|
|
expect(content).toContain('Mid-session detection');
|
|
expect(content).toMatch(/still figuring out|seems lost/i);
|
|
});
|
|
|
|
// Spec review on CEO plans
|
|
test('contains spec review loop for CEO plan documents', () => {
|
|
expect(content).toContain('Spec Review Loop');
|
|
});
|
|
});
|
|
|
|
// --- gstack-slug helper ---
|
|
|
|
describe('gstack-slug', () => {
|
|
const SLUG_BIN = path.join(ROOT, 'bin', 'gstack-slug');
|
|
|
|
test('binary exists and is executable', () => {
|
|
expect(fs.existsSync(SLUG_BIN)).toBe(true);
|
|
const stat = fs.statSync(SLUG_BIN);
|
|
expect(stat.mode & 0o111).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('outputs SLUG and BRANCH lines in a git repo', () => {
|
|
const result = Bun.spawnSync([SLUG_BIN], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
|
|
expect(result.exitCode).toBe(0);
|
|
const output = result.stdout.toString();
|
|
expect(output).toContain('SLUG=');
|
|
expect(output).toContain('BRANCH=');
|
|
});
|
|
|
|
test('SLUG does not contain forward slashes', () => {
|
|
const result = Bun.spawnSync([SLUG_BIN], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
|
|
const slug = result.stdout.toString().match(/SLUG=(.*)/)?.[1] ?? '';
|
|
expect(slug).not.toContain('/');
|
|
expect(slug.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('BRANCH does not contain forward slashes', () => {
|
|
const result = Bun.spawnSync([SLUG_BIN], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
|
|
const branch = result.stdout.toString().match(/BRANCH=(.*)/)?.[1] ?? '';
|
|
expect(branch).not.toContain('/');
|
|
expect(branch.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('output is eval-compatible (KEY=VALUE format)', () => {
|
|
const result = Bun.spawnSync([SLUG_BIN], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
|
|
const lines = result.stdout.toString().trim().split('\n');
|
|
expect(lines.length).toBe(2);
|
|
expect(lines[0]).toMatch(/^SLUG=.+/);
|
|
expect(lines[1]).toMatch(/^BRANCH=.+/);
|
|
});
|
|
|
|
test('output values contain only safe characters (no shell metacharacters)', () => {
|
|
const result = Bun.spawnSync([SLUG_BIN], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
|
|
const slug = result.stdout.toString().match(/SLUG=(.*)/)?.[1] ?? '';
|
|
const branch = result.stdout.toString().match(/BRANCH=(.*)/)?.[1] ?? '';
|
|
// Only alphanumeric, dot, dash, underscore are allowed (#133)
|
|
expect(slug).toMatch(/^[a-zA-Z0-9._-]+$/);
|
|
expect(branch).toMatch(/^[a-zA-Z0-9._-]+$/);
|
|
});
|
|
test('eval sets variables under bash with set -euo pipefail', () => {
|
|
const result = Bun.spawnSync(
|
|
['bash', '-c', 'set -euo pipefail; eval "$(./bin/gstack-slug 2>/dev/null)"; echo "SLUG=$SLUG"; echo "BRANCH=$BRANCH"'],
|
|
{ cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 }
|
|
);
|
|
expect(result.exitCode).toBe(0);
|
|
const output = result.stdout.toString();
|
|
expect(output).toMatch(/^SLUG=.+/m);
|
|
expect(output).toMatch(/^BRANCH=.+/m);
|
|
});
|
|
|
|
test('no templates or bin scripts use source process substitution for gstack-slug', () => {
|
|
const result = Bun.spawnSync(
|
|
['grep', '-r', 'source <(.*gstack-slug', '--include=*.tmpl', '--include=gstack-review-*', '.'],
|
|
{ cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 }
|
|
);
|
|
// grep returns exit code 1 when no matches found — that's what we want
|
|
expect(result.stdout.toString().trim()).toBe('');
|
|
});
|
|
});
|
|
|
|
// --- Test Bootstrap validation ---
|
|
|
|
describe('Test Bootstrap ({{TEST_BOOTSTRAP}}) integration', () => {
|
|
// qa carve: the rendered TEST_BOOTSTRAP body lives in
|
|
// qa/sections/test-bootstrap.md — read the skeleton+sections union.
|
|
test('TEST_BOOTSTRAP resolver produces valid content', () => {
|
|
const qaContent = readSkillUnion('qa');
|
|
expect(qaContent).toContain('Test Framework Bootstrap');
|
|
expect(qaContent).toContain('RUNTIME:ruby');
|
|
expect(qaContent).toContain('RUNTIME:node');
|
|
expect(qaContent).toContain('RUNTIME:python');
|
|
expect(qaContent).toContain('no-test-bootstrap');
|
|
expect(qaContent).toContain('BOOTSTRAP_DECLINED');
|
|
});
|
|
|
|
test('TEST_BOOTSTRAP appears in qa/SKILL.md', () => {
|
|
const content = readSkillUnion('qa');
|
|
expect(content).toContain('Test Framework Bootstrap');
|
|
expect(content).toContain('TESTING.md');
|
|
expect(content).toContain('CLAUDE.md');
|
|
});
|
|
|
|
test('TEST_BOOTSTRAP appears in ship/SKILL.md', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('Test Framework Bootstrap');
|
|
expect(content).toContain('Step 4');
|
|
});
|
|
|
|
test('TEST_BOOTSTRAP appears in design-review/SKILL.md', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'design-review', 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('Test Framework Bootstrap');
|
|
});
|
|
|
|
test('TEST_BOOTSTRAP does NOT appear in qa-only/SKILL.md', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'qa-only', 'SKILL.md'), 'utf-8');
|
|
expect(content).not.toContain('Test Framework Bootstrap');
|
|
// But should have the recommendation note
|
|
expect(content).toContain('No test framework detected');
|
|
expect(content).toContain('Run `/qa` to bootstrap');
|
|
});
|
|
|
|
test('bootstrap includes framework knowledge table', () => {
|
|
const content = readSkillUnion('qa');
|
|
expect(content).toContain('vitest');
|
|
expect(content).toContain('minitest');
|
|
expect(content).toContain('pytest');
|
|
expect(content).toContain('cargo test');
|
|
expect(content).toContain('phpunit');
|
|
expect(content).toContain('ExUnit');
|
|
});
|
|
|
|
test('bootstrap includes CI/CD pipeline generation', () => {
|
|
const content = readSkillUnion('qa');
|
|
expect(content).toContain('.github/workflows/test.yml');
|
|
expect(content).toContain('GitHub Actions');
|
|
});
|
|
|
|
test('bootstrap includes first real tests step', () => {
|
|
const content = readSkillUnion('qa');
|
|
expect(content).toContain('First real tests');
|
|
expect(content).toContain('git log --since=30.days');
|
|
expect(content).toContain('Prioritize by risk');
|
|
});
|
|
|
|
test('bootstrap includes vibe coding philosophy', () => {
|
|
const content = readSkillUnion('qa');
|
|
expect(content).toContain('vibe coding');
|
|
expect(content).toContain('100% test coverage');
|
|
});
|
|
|
|
test('WebSearch is in allowed-tools for qa, ship, design-review', () => {
|
|
const qa = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
|
|
const ship = readShipUnion();
|
|
const qaDesign = fs.readFileSync(path.join(ROOT, 'design-review', 'SKILL.md'), 'utf-8');
|
|
expect(qa).toContain('WebSearch');
|
|
expect(ship).toContain('WebSearch');
|
|
expect(qaDesign).toContain('WebSearch');
|
|
});
|
|
});
|
|
|
|
// --- Phase 8e.5 regression test validation ---
|
|
|
|
describe('Phase 8e.5 regression test generation', () => {
|
|
test('qa/SKILL.md contains Phase 8e.5', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('8e.5. Regression Test');
|
|
expect(content).toContain('test(qa): regression test');
|
|
expect(content).toContain('WTF-likelihood exclusion');
|
|
});
|
|
|
|
test('qa/SKILL.md Rule 13 is amended for regression tests', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('Only modify tests when generating regression tests in Phase 8e.5');
|
|
expect(content).not.toContain('Never modify tests or CI configuration');
|
|
});
|
|
|
|
test('design-review has CSS-aware Phase 8e.5 variant', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'design-review', 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('8e.5. Regression Test (design-review variant)');
|
|
expect(content).toContain('CSS-only');
|
|
expect(content).toContain('test(design): regression test');
|
|
});
|
|
|
|
test('regression test includes full attribution comment format', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('// Regression: ISSUE-NNN');
|
|
expect(content).toContain('// Found by /qa on');
|
|
expect(content).toContain('// Report: .gstack/qa-reports/');
|
|
});
|
|
|
|
test('regression test uses auto-incrementing names', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('auto-incrementing');
|
|
expect(content).toContain('max number + 1');
|
|
});
|
|
});
|
|
|
|
// --- Step 3.4 coverage audit validation ---
|
|
|
|
describe('Step 3.4 test coverage audit', () => {
|
|
test('ship/SKILL.md contains Step 7', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('Step 7: Test Coverage Audit');
|
|
// The coverage diagram collapses code-path and user-flow counts onto one
|
|
// summary line. Verify that summary is present (labels are stable).
|
|
expect(content).toContain('Code paths:');
|
|
});
|
|
|
|
test('Step 3.4 includes quality scoring rubric', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('★★★');
|
|
expect(content).toContain('★★');
|
|
expect(content).toContain('edge cases AND error paths');
|
|
expect(content).toContain('happy path only');
|
|
});
|
|
|
|
test('Step 3.4 includes before/after test count', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('Count test files before');
|
|
expect(content).toContain('Count test files after');
|
|
});
|
|
|
|
test('ship PR body includes Test Coverage section', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('## Test Coverage');
|
|
});
|
|
|
|
test('ship rules include test generation rule', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('Step 7 generates coverage tests');
|
|
expect(content).toContain('Never commit failing tests');
|
|
});
|
|
|
|
test('Step 3.4 includes vibe coding philosophy', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('vibe coding becomes yolo coding');
|
|
});
|
|
|
|
test('Step 3.4 traces actual codepaths, not just syntax', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('Trace every codepath');
|
|
expect(content).toContain('Trace data flow');
|
|
expect(content).toContain('Diagram the execution');
|
|
});
|
|
|
|
test('Step 3.4 maps user flows and interaction edge cases', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('Map user flows');
|
|
expect(content).toContain('Interaction edge cases');
|
|
expect(content).toContain('Double-click');
|
|
expect(content).toContain('Navigate away');
|
|
expect(content).toContain('Error states the user can see');
|
|
expect(content).toContain('Empty/zero/boundary states');
|
|
});
|
|
|
|
test('Step 3.4 diagram includes user-flow coverage summary', () => {
|
|
const content = readShipUnion();
|
|
// The diagram was compressed from separate CODE PATH COVERAGE / USER FLOW
|
|
// COVERAGE section headers into a single summary line. Assert on the
|
|
// labels that still appear on that summary line.
|
|
expect(content).toContain('Code paths:');
|
|
expect(content).toContain('User flows:');
|
|
});
|
|
});
|
|
|
|
// --- Ship step numbering regression guard ---
|
|
|
|
describe('ship step numbering', () => {
|
|
// Allowed sub-steps that are resolver-generated and intentionally nested:
|
|
// 0.9 (Apple target detection — MUST precede Step 1's branch gate, R2-pinned
|
|
// by test/ship-apple-gate.test.ts), 8.1 (Plan Verification), 8.2 (Scope
|
|
// Drift), 9.1 (Review Army), 9.2 (Findings Merge), 9.3 (Cross-review dedup),
|
|
// 15.0 (WIP squash — continuous checkpoint), 15.1 (Bisectable commits).
|
|
const ALLOWED_SUBSTEPS = new Set(['0.9', '8.1', '8.2', '9.1', '9.2', '9.3', '15.0', '15.1']);
|
|
|
|
test('ship/SKILL.md.tmpl contains no unexpected fractional step numbers', () => {
|
|
const tmpl = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md.tmpl'), 'utf-8');
|
|
// Match "Step X.Y" where X.Y is a decimal step reference (e.g., "Step 3.47", "Step 8.1")
|
|
const matches = Array.from(tmpl.matchAll(/Step (\d+\.\d+)/g));
|
|
const violations = matches
|
|
.map((m) => m[1])
|
|
.filter((n) => !ALLOWED_SUBSTEPS.has(n));
|
|
if (violations.length > 0) {
|
|
const unique = Array.from(new Set(violations)).sort();
|
|
throw new Error(
|
|
`ship/SKILL.md.tmpl contains fractional step numbers that are not in the allowed sub-step list.\n` +
|
|
` Found: ${unique.join(', ')}\n` +
|
|
` Allowed sub-steps: ${Array.from(ALLOWED_SUBSTEPS).sort().join(', ')}\n` +
|
|
` Fix: use clean integer step numbers (1-20), or add to ALLOWED_SUBSTEPS if intentional.`
|
|
);
|
|
}
|
|
});
|
|
|
|
test('ship/SKILL.md main headings use clean integer step numbers', () => {
|
|
const skill = readShipUnion();
|
|
// Headings like "## Step 7: Test Coverage Audit" — NOT sub-steps like "## Step 8.1:"
|
|
const headings = Array.from(skill.matchAll(/^## Step (\d+(?:\.\d+)?):/gm)).map(
|
|
(m) => m[1]
|
|
);
|
|
const fractional = headings.filter((n) => n.includes('.'));
|
|
const unexpected = fractional.filter((n) => !ALLOWED_SUBSTEPS.has(n));
|
|
expect(unexpected).toEqual([]);
|
|
});
|
|
|
|
test('review/SKILL.md step numbers unchanged (regression guard for resolver conditionals)', () => {
|
|
// Carved skill: Step 4.5 lives in sections/review-army.md and Step 5.7 in
|
|
// sections/adversarial.md — read the skeleton+sections union.
|
|
const skill = readSkillUnion('review');
|
|
// /review uses its own fractional numbering: 1.5, 2.5, 4.5, 5.5, 5.6, 5.7, 5.8
|
|
// If the ship-side renumber accidentally touched the review-side of resolver conditionals,
|
|
// these would vanish. This test catches that.
|
|
expect(skill).toContain('## Step 1.5: Scope Drift Detection');
|
|
expect(skill).toContain('## Step 4.5: Review Army');
|
|
expect(skill).toContain('## Step 5.7: Adversarial review');
|
|
});
|
|
});
|
|
|
|
// --- Retro test health validation ---
|
|
|
|
describe('Retro test health tracking', () => {
|
|
// The inline git pipelines moved into bin/gstack-retro-metrics (retro
|
|
// token-reduction wave); the skill interprets its labeled METRIC lines.
|
|
test('gstack-retro-metrics gathers the test health data', () => {
|
|
const script = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-retro-metrics'), 'utf-8');
|
|
expect(script).toContain('TEST_FILES_TOTAL');
|
|
expect(script).toContain('REGRESSION_TEST_COMMITS');
|
|
expect(script).toContain('TEST_FILES_CHANGED');
|
|
// The historical grep targets survive the script absorption.
|
|
expect(script).toContain('test(qa):');
|
|
expect(script).toContain('test(design):');
|
|
expect(script).toContain('git ls-files');
|
|
});
|
|
|
|
test('retro skill interprets the test-health metric lines', () => {
|
|
// Template source, not the generated render: the pin must hold across the
|
|
// regen boundary (the generated file follows the template at gen time).
|
|
const content = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md.tmpl'), 'utf-8');
|
|
expect(content).toContain('TEST_FILES_TOTAL');
|
|
expect(content).toContain('REGRESSION_TEST_COMMITS');
|
|
});
|
|
|
|
test('retro/SKILL.md has Test Health metrics row', () => {
|
|
const content = readSkillUnion('retro');
|
|
expect(content).toContain('Test Health');
|
|
expect(content).toContain('regression tests');
|
|
});
|
|
|
|
test('retro/SKILL.md has Test Health narrative section', () => {
|
|
// Carved: the narrative report format lives in sections/report-format.md.
|
|
const content = readSkillUnion('retro');
|
|
expect(content).toContain('### Test Health');
|
|
expect(content).toContain('Total test files');
|
|
expect(content).toContain('vibe coding safe');
|
|
});
|
|
|
|
test('retro JSON schema includes test_health field', () => {
|
|
const content = readSkillUnion('retro');
|
|
expect(content).toContain('test_health');
|
|
expect(content).toContain('total_test_files');
|
|
expect(content).toContain('regression_test_commits');
|
|
});
|
|
});
|
|
|
|
// --- QA report template regression tests section ---
|
|
|
|
describe('QA report template', () => {
|
|
test('qa-report-template.md has Regression Tests section', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'qa', 'templates', 'qa-report-template.md'), 'utf-8');
|
|
expect(content).toContain('## Regression Tests');
|
|
expect(content).toContain('committed / deferred / skipped');
|
|
expect(content).toContain('### Deferred Tests');
|
|
expect(content).toContain('**Precondition:**');
|
|
});
|
|
});
|
|
|
|
// --- Codex skill validation ---
|
|
|
|
describe('Codex skill', () => {
|
|
test('codex/SKILL.md exists and has correct frontmatter', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'codex', 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('name: codex');
|
|
expect(content).toContain('version: 1.0.0');
|
|
expect(content).toContain('allowed-tools:');
|
|
});
|
|
|
|
// Carved skill (T9): the three mode bodies live in codex/sections/
|
|
// {review,challenge,consult}-mode.md. Content pins read the union; the
|
|
// mode DISPATCH must stay in the always-loaded skeleton.
|
|
test('codex union contains all three modes; skeleton keeps the dispatch', () => {
|
|
const content = readSkillUnion('codex');
|
|
expect(content).toContain('Step 2A: Review Mode');
|
|
expect(content).toContain('Step 2B: Challenge');
|
|
expect(content).toContain('Step 2C: Consult Mode');
|
|
const skeleton = fs.readFileSync(path.join(ROOT, 'codex', 'SKILL.md'), 'utf-8');
|
|
expect(skeleton).toContain('## Step 1: Detect mode');
|
|
expect(skeleton).toContain('MUTUALLY EXCLUSIVE');
|
|
});
|
|
|
|
test('codex union contains gate verdict logic', () => {
|
|
const content = readSkillUnion('codex');
|
|
expect(content).toContain('[P1]');
|
|
expect(content).toContain('GATE: PASS');
|
|
expect(content).toContain('GATE: FAIL');
|
|
});
|
|
|
|
test('codex union contains session continuity', () => {
|
|
const content = readSkillUnion('codex');
|
|
expect(content).toContain('codex-session-id');
|
|
expect(content).toContain('codex exec resume');
|
|
});
|
|
|
|
test('codex resume command only uses resume-supported flags', () => {
|
|
const content = readSkillUnion('codex');
|
|
const match = content.match(/codex exec resume[^\n]+/);
|
|
expect(match).not.toBeNull();
|
|
const resumeCommand = match![0];
|
|
expect(resumeCommand).not.toContain(' -C ');
|
|
expect(resumeCommand).not.toContain(' -s read-only');
|
|
expect(resumeCommand).toContain("-c 'sandbox_mode=\"read-only\"'");
|
|
});
|
|
|
|
test('codex union contains cost tracking', () => {
|
|
const content = readSkillUnion('codex');
|
|
expect(content).toContain('tokens used');
|
|
expect(content).toContain('Est. cost');
|
|
});
|
|
|
|
test('codex union contains cross-model comparison', () => {
|
|
const content = readSkillUnion('codex');
|
|
expect(content).toContain('CROSS-MODEL ANALYSIS');
|
|
expect(content).toContain('Agreement rate');
|
|
});
|
|
|
|
test('codex union contains review log persistence', () => {
|
|
const content = readSkillUnion('codex');
|
|
expect(content).toContain('codex-review');
|
|
expect(content).toContain('gstack-review-log');
|
|
});
|
|
|
|
test('codex uses command -v for binary discovery, not hardcoded path', () => {
|
|
const content = readSkillUnion('codex');
|
|
expect(content).toContain('command -v codex');
|
|
expect(content).not.toContain('/opt/homebrew/bin/codex');
|
|
// Defensive: catch any future regression that reintroduces `which codex`,
|
|
// which fails in environments where `which` isn't on PATH (some Windows
|
|
// shells, BusyBox-only containers). #1197.
|
|
expect(content).not.toContain('which codex');
|
|
});
|
|
|
|
test('codex/SKILL.md contains error handling for missing binary and auth', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'codex', 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('NOT_FOUND');
|
|
expect(content).toContain('codex login');
|
|
});
|
|
|
|
test('codex union uses mktemp for temp files', () => {
|
|
const content = readSkillUnion('codex');
|
|
expect(content).toContain('mktemp');
|
|
});
|
|
|
|
test('codex JSON stream parser uses portable Python discovery', () => {
|
|
// The JSONL parsers live in the challenge + consult mode sections; sweep
|
|
// the tmpl union and the rendered union so neither side drifts.
|
|
const sectionsDir = path.join(ROOT, 'codex', 'sections');
|
|
const tmplUnion = ['codex/SKILL.md.tmpl']
|
|
.map((rel) => fs.readFileSync(path.join(ROOT, rel), 'utf-8'))
|
|
.concat(
|
|
fs.readdirSync(sectionsDir).sort()
|
|
.filter((f) => f.endsWith('.md.tmpl'))
|
|
.map((f) => fs.readFileSync(path.join(sectionsDir, f), 'utf-8')),
|
|
)
|
|
.join('\n');
|
|
|
|
for (const content of [tmplUnion, readSkillUnion('codex')]) {
|
|
expect(content).toContain('PYTHON_CMD=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true)');
|
|
expect(content).toContain('PYTHONUNBUFFERED=1 "$PYTHON_CMD" -u -c');
|
|
expect(content).not.toContain('PYTHONUNBUFFERED=1 python3 -u -c');
|
|
}
|
|
});
|
|
|
|
test('adversarial review in /review always runs both passes', () => {
|
|
// Carved skill: the Step 5.7 adversarial body lives in sections/adversarial.md.
|
|
const content = readSkillUnion('review');
|
|
expect(content).toContain('Adversarial review (always-on)');
|
|
// Always-on: both Claude and Codex adversarial
|
|
expect(content).toContain('Claude adversarial subagent (always runs)');
|
|
expect(content).toContain('Codex adversarial challenge (runs whenever');
|
|
// Claude adversarial subagent dispatch
|
|
expect(content).toContain('Agent tool');
|
|
expect(content).toContain('FIXABLE');
|
|
expect(content).toContain('INVESTIGATE');
|
|
// Probe-based availability via the shared codexPreflight() (install + auth)
|
|
expect(content).toContain('CODEX_MODE');
|
|
expect(content).toContain('command -v codex'); // install check kept literal
|
|
// codex_reviews=disabled gates Codex passes only; Claude adversarial still runs
|
|
expect(content).toContain('skip the Codex passes ONLY');
|
|
// Review log
|
|
expect(content).toContain('adversarial-review');
|
|
expect(content).toContain('reasoning_effort="high"');
|
|
expect(content).toContain('ADVERSARIAL REVIEW SYNTHESIS');
|
|
// Large diff structured review still gated
|
|
expect(content).toContain('Codex structured review (large diffs only');
|
|
expect(content).toContain('200');
|
|
});
|
|
|
|
test('adversarial review in /ship always runs both passes', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('Adversarial review (always-on)');
|
|
expect(content).toContain('adversarial-review');
|
|
expect(content).toContain('reasoning_effort="high"');
|
|
expect(content).toContain('Investigate and fix');
|
|
expect(content).toContain('Claude adversarial subagent (always runs)');
|
|
});
|
|
|
|
test('scope drift detection in /review and /ship', () => {
|
|
const reviewContent = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
|
const shipContent = readShipUnion();
|
|
// Both should contain scope drift from the shared resolver
|
|
for (const content of [reviewContent, shipContent]) {
|
|
expect(content).toContain('Scope Check:');
|
|
expect(content).toContain('DRIFT DETECTED');
|
|
expect(content).toContain('SCOPE CREEP');
|
|
expect(content).toContain('MISSING REQUIREMENTS');
|
|
expect(content).toContain('stated intent');
|
|
}
|
|
});
|
|
|
|
test('codex-host ship/review do NOT contain adversarial review step', () => {
|
|
// Codex artifacts come from the module-level out-dir render (CODEX_OUT).
|
|
const shipContent = fs.readFileSync(path.join(CODEX_OUT, '.agents', 'skills', 'gstack-ship', 'SKILL.md'), 'utf-8');
|
|
expect(shipContent).not.toContain('codex review --base');
|
|
expect(shipContent).not.toContain('CODEX_REVIEWS');
|
|
|
|
const reviewContent = fs.readFileSync(path.join(CODEX_OUT, '.agents', 'skills', 'gstack-review', 'SKILL.md'), 'utf-8');
|
|
expect(reviewContent).not.toContain('codex review --base');
|
|
expect(reviewContent).not.toContain('codex_reviews');
|
|
expect(reviewContent).not.toContain('CODEX_REVIEWS');
|
|
expect(reviewContent).not.toContain('adversarial-review');
|
|
expect(reviewContent).not.toContain('Investigate and fix');
|
|
});
|
|
|
|
test('codex integration in /plan-eng-review offers plan critique', () => {
|
|
// Carved skill: the Codex outside-voice plan critique lives in
|
|
// sections/review-sections.md — read the skeleton+sections union. (The
|
|
// skeleton alone used to match "Codex" only via an inline-bash comment
|
|
// that the gstack-skill-start consolidation removed.)
|
|
const content = readSkillUnion('plan-eng-review');
|
|
expect(content).toContain('Codex');
|
|
expect(content).toContain('codex exec');
|
|
});
|
|
|
|
// D5 regression guard: the Codex outside voice is default-on, not opt-in. A future
|
|
// gen-skill-docs change must not silently reintroduce the "Want an outside voice?"
|
|
// AskUserQuestion. The CODEX_PLAN_REVIEW content renders into each skill's
|
|
// sections/review-sections.md (the skeleton points at it). plan-design-review uses
|
|
// DESIGN_OUTSIDE_VOICES, not CODEX_PLAN_REVIEW, so it is excluded here.
|
|
test('plan reviews run the Codex outside voice default-on (no opt-in question)', () => {
|
|
for (const skill of ['plan-eng-review', 'plan-ceo-review', 'plan-devex-review']) {
|
|
const content = fs.readFileSync(
|
|
path.join(ROOT, skill, 'sections', 'review-sections.md'), 'utf-8');
|
|
expect(content).not.toContain('Want an outside voice');
|
|
expect(content).toContain('Outside Voice — Independent Plan Challenge (default-on)');
|
|
expect(content).toContain('CODEX_MODE');
|
|
expect(content).toContain('command -v codex'); // preflight install check (e2e relies on it)
|
|
}
|
|
});
|
|
|
|
test('/document-release includes the default-on Codex documentation review', () => {
|
|
// The doc-review renders into the carved release-body section (kept out of the
|
|
// always-loaded skeleton to respect the skeleton-byte budget).
|
|
const content = fs.readFileSync(
|
|
path.join(ROOT, 'document-release', 'sections', 'release-body.md'), 'utf-8');
|
|
expect(content).toContain('Codex Documentation Review (default-on)');
|
|
expect(content).toContain('CODEX_MODE');
|
|
expect(content).toContain('codex-doc-review');
|
|
});
|
|
|
|
test('codex-host document-release does NOT contain the Codex doc review', () => {
|
|
// Codex never invokes itself; artifacts come from the CODEX_OUT render.
|
|
const content = fs.readFileSync(
|
|
path.join(CODEX_OUT, '.agents', 'skills', 'gstack-document-release', 'SKILL.md'), 'utf-8');
|
|
expect(content).not.toContain('Codex Documentation Review');
|
|
expect(content).not.toContain('codex-doc-review');
|
|
});
|
|
|
|
test('codex review invocations avoid the prompt plus --base argument shape', () => {
|
|
// The real invariant is "never pass a positional [PROMPT] together with a
|
|
// scope flag" — the CLI rejects that combination at argv parse time
|
|
// (#1428, #1479). Two different shapes satisfy it, and these files have
|
|
// diverged on which one they use:
|
|
//
|
|
// scoped — `codex review --base <base>` with NO prompt argument. The
|
|
// scope comes from the CLI, which is the only thing that actually sets
|
|
// it. This is what all three files now use.
|
|
// broken — prompt-only `codex review "<text>"` describing the diff
|
|
// range in prose. This parses, but the CLI falls back to *uncommitted
|
|
// working-tree* scope, so the review silently covers the wrong changes.
|
|
//
|
|
// The old assertion banned the substring `--base <base> -c '...'`, which
|
|
// the correct scoped form also contains — it could not tell the two apart,
|
|
// so it effectively banned the fix.
|
|
for (const rel of ['codex/SKILL.md', 'review/SKILL.md', 'ship/SKILL.md']) {
|
|
// ship's AND review's codex commands moved into sections/adversarial.md;
|
|
// codex's own scoped invocation lives in sections/review-mode.md (T9 carve).
|
|
const content = rel === 'ship/SKILL.md' ? readShipUnion()
|
|
: rel === 'review/SKILL.md' ? readSkillUnion('review')
|
|
: readSkillUnion('codex');
|
|
expect(content).toMatch(/codex\s+review\s+--base\b/);
|
|
const offending: string[] = [];
|
|
for (const line of content.split('\n')) {
|
|
if (line.includes('`codex review`')) continue;
|
|
const match = line.match(/(?:^|[;&|]\s*|\s)codex\s+review\b(.*)$/);
|
|
if (!match) continue;
|
|
const rest = match[1];
|
|
if (!/--base\b|--commit\b|--uncommitted\b/.test(rest)) continue;
|
|
const beforeFlag = rest.split(/--base\b|--commit\b|--uncommitted\b/)[0].trim();
|
|
// A quoted string or variable expansion before the scope flag is the bug.
|
|
if (/^["'$]|^--\s*["']/.test(beforeFlag)) offending.push(`${rel}: ${line.trim()}`);
|
|
}
|
|
expect(offending).toEqual([]);
|
|
}
|
|
});
|
|
|
|
test('codex review prompts always carry the filesystem boundary (#1503/#1522 regression)', () => {
|
|
// Pre-#1209, the bare `codex review --base` path stripped the filesystem
|
|
// boundary instruction, letting Codex spend tokens reading skill files.
|
|
// #1209's prompt rewrite restored the boundary by routing every default
|
|
// call through a prompt — but routing through a prompt is what breaks the
|
|
// diff scope, so codex/ no longer does that. What this test pins is the
|
|
// boundary TEXT, which must still be present for the paths that do take a
|
|
// prompt (`codex exec` for challenge, consult, and custom review focus).
|
|
// Do NOT "restore" the boundary by putting a prompt argument back on a
|
|
// scoped `codex review` call: that combination fails to parse, and
|
|
// dropping the scope flag to make it parse silently reviews the wrong diff.
|
|
const boundaryLine =
|
|
'Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/';
|
|
for (const rel of ['codex/SKILL.md', 'review/SKILL.md', 'ship/SKILL.md']) {
|
|
// ship's AND review's codex/adversarial boundary lines moved into sections/adversarial.md.
|
|
const content = rel === 'ship/SKILL.md' ? readShipUnion()
|
|
: rel === 'review/SKILL.md' ? readSkillUnion('review')
|
|
: fs.readFileSync(path.join(ROOT, rel), 'utf-8');
|
|
expect(content).toContain(boundaryLine);
|
|
}
|
|
});
|
|
|
|
test('/review persists a review-log entry for ship readiness', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('"skill":"review"');
|
|
expect(content).toContain('"issues_found":N');
|
|
expect(content).toContain('Persist Eng Review result');
|
|
});
|
|
|
|
test('Review Readiness Dashboard includes Adversarial Review row', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('Adversarial');
|
|
expect(content).toContain('codex-review');
|
|
});
|
|
});
|
|
|
|
// --- Trigger phrase validation ---
|
|
|
|
describe('Skill trigger phrases', () => {
|
|
// Skills that must have "Use when" trigger phrases in their description.
|
|
// Excluded: root gstack (browser tool), gstack-upgrade (gstack-specific),
|
|
// humanizer (text tool)
|
|
const SKILLS_REQUIRING_TRIGGERS = [
|
|
'qa', 'qa-only', 'ship', 'review', 'investigate', 'office-hours',
|
|
'plan-ceo-review', 'plan-eng-review', 'plan-design-review',
|
|
'design-review', 'design-consultation', 'retro', 'document-release',
|
|
'codex', 'browse', 'setup-browser-cookies',
|
|
];
|
|
|
|
for (const skill of SKILLS_REQUIRING_TRIGGERS) {
|
|
test(`${skill}/SKILL.md has "Use when" trigger phrases`, () => {
|
|
const skillPath = path.join(ROOT, skill, 'SKILL.md');
|
|
if (!fs.existsSync(skillPath)) return;
|
|
const content = fs.readFileSync(skillPath, 'utf-8');
|
|
// v1.45.0.0 catalog trim moved trigger prose out of frontmatter into a
|
|
// body "## When to invoke" section. Search the full file content, not
|
|
// just frontmatter. The trigger phrase must still appear somewhere in
|
|
// the skill so agents can match user requests to the skill.
|
|
expect(content).toMatch(/Use when/i);
|
|
});
|
|
}
|
|
|
|
// Skills with proactive triggers should have "Proactively suggest" somewhere in the skill.
|
|
const SKILLS_REQUIRING_PROACTIVE = [
|
|
'qa', 'qa-only', 'ship', 'review', 'investigate', 'office-hours',
|
|
'plan-ceo-review', 'plan-eng-review', 'plan-design-review',
|
|
'design-review', 'design-consultation', 'retro', 'document-release',
|
|
];
|
|
|
|
for (const skill of SKILLS_REQUIRING_PROACTIVE) {
|
|
test(`${skill}/SKILL.md has proactive routing phrase`, () => {
|
|
const skillPath = path.join(ROOT, skill, 'SKILL.md');
|
|
if (!fs.existsSync(skillPath)) return;
|
|
const content = fs.readFileSync(skillPath, 'utf-8');
|
|
// Same catalog-trim consideration — search the full file content.
|
|
expect(content).toMatch(/Proactively (suggest|invoke)/i);
|
|
});
|
|
}
|
|
});
|
|
|
|
// ─── Private-path leak detector ──────────────────────────────
|
|
//
|
|
// Catches accidental references to maintainer-private files in skill output.
|
|
// Adapted from the McGluut fork's skill-contract-audit.ts (we don't take the
|
|
// whole script — these are the unique checks not already covered by
|
|
// test/gen-skill-docs.test.ts:1668-2074 .claude/skills leakage tests).
|
|
|
|
describe('Private-path leak detection', () => {
|
|
const PRIVATE_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
|
|
{ pattern: /coordination-board\.md/i, label: 'coordination-board.md' },
|
|
{ pattern: /SEEKING_LOG\.md/, label: 'SEEKING_LOG.md' },
|
|
{ pattern: /RATIONAL_SUBJECT\.md/, label: 'RATIONAL_SUBJECT.md' },
|
|
{ pattern: /VALUE_SIGNAL_LOOP\.md/, label: 'VALUE_SIGNAL_LOOP.md' },
|
|
{ pattern: /C:\\\\LLM Playground\\\\go/i, label: 'C:\\LLM Playground\\go' },
|
|
];
|
|
|
|
// Walk every SKILL.md and SKILL.md.tmpl in the repo (excluding node_modules,
|
|
// generated host outputs, and .git).
|
|
function discoverSkillSurface(): string[] {
|
|
const results: string[] = [];
|
|
function walk(dir: string) {
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
if (entry.name.startsWith('.') && entry.name !== '.agents') continue;
|
|
if (entry.name === 'node_modules' || entry.name === 'dist') continue;
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
walk(full);
|
|
} else if (entry.name === 'SKILL.md' || entry.name === 'SKILL.md.tmpl') {
|
|
results.push(full);
|
|
}
|
|
}
|
|
}
|
|
walk(ROOT);
|
|
return results;
|
|
}
|
|
|
|
test('no SKILL.md or SKILL.md.tmpl references private maintainer files', () => {
|
|
const files = discoverSkillSurface();
|
|
expect(files.length).toBeGreaterThan(0);
|
|
const leaks: string[] = [];
|
|
for (const file of files) {
|
|
const content = fs.readFileSync(file, 'utf-8');
|
|
for (const { pattern, label } of PRIVATE_PATTERNS) {
|
|
if (pattern.test(content)) {
|
|
leaks.push(`${path.relative(ROOT, file)} mentions ${label}`);
|
|
}
|
|
}
|
|
}
|
|
expect(leaks).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ─── Doc-inventory cross-check ───────────────────────────────
|
|
//
|
|
// Every skill directory (with a SKILL.md.tmpl) must appear in both AGENTS.md
|
|
// and docs/skills.md. Catches the inventory drift codex flagged (/debug
|
|
// → /investigate; missing /autoplan, /context-save, /plan-devex-review, etc.).
|
|
|
|
describe('Doc inventory cross-check', () => {
|
|
// Skills that don't get user-invocation lines in agent-facing docs.
|
|
// - 'qa-only' is a sub-mode of /qa with shared docs.
|
|
// - The 5 listed below are infrastructure (model overlays, shipped binary,
|
|
// hosts) that don't show up in the user-facing skill table.
|
|
const DOC_INVENTORY_EXCLUDE = new Set([
|
|
// Infra / non-skills
|
|
'agents', 'claude', 'connect-chrome', 'contrib', 'hosts',
|
|
'lib', 'model-overlays', 'openclaw', 'supabase', 'scripts', 'test',
|
|
]);
|
|
|
|
function discoverSkillDirs(): string[] {
|
|
const dirs: string[] = [];
|
|
for (const entry of fs.readdirSync(ROOT, { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) continue;
|
|
if (entry.name.startsWith('.')) continue;
|
|
if (DOC_INVENTORY_EXCLUDE.has(entry.name)) continue;
|
|
const tmplPath = path.join(ROOT, entry.name, 'SKILL.md.tmpl');
|
|
if (fs.existsSync(tmplPath)) dirs.push(entry.name);
|
|
}
|
|
return dirs.sort();
|
|
}
|
|
|
|
test('every skill is documented in AGENTS.md', () => {
|
|
const agents = fs.readFileSync(path.join(ROOT, 'AGENTS.md'), 'utf-8');
|
|
const missing: string[] = [];
|
|
for (const skill of discoverSkillDirs()) {
|
|
// Match `/skill-name` as a token boundary.
|
|
if (!new RegExp(`/${skill}\\b`).test(agents)) missing.push(skill);
|
|
}
|
|
expect(missing).toEqual([]);
|
|
});
|
|
|
|
test('every skill is documented in docs/skills.md', () => {
|
|
const docs = fs.readFileSync(path.join(ROOT, 'docs', 'skills.md'), 'utf-8');
|
|
const missing: string[] = [];
|
|
for (const skill of discoverSkillDirs()) {
|
|
if (!new RegExp(`/${skill}\\b`).test(docs)) missing.push(skill);
|
|
}
|
|
expect(missing).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ─── Codex Skill Validation ──────────────────────────────────
|
|
|
|
describe('Codex skill validation', () => {
|
|
// .agents/ is gitignored (v0.11.2.0) — read from the module-level out-dir
|
|
// render (CODEX_OUT) instead of regenerating the live tree in place.
|
|
const AGENTS_DIR = path.join(CODEX_OUT, '.agents', 'skills');
|
|
|
|
// Discover all shared skills with templates.
|
|
// Host-exclusive outside-voice skills are intentionally omitted here:
|
|
// - /codex is Claude-only
|
|
// - /claude is external-host-only
|
|
const CLAUDE_SKILLS_WITH_TEMPLATES = (() => {
|
|
const skills: string[] = [];
|
|
for (const entry of fs.readdirSync(ROOT, { withFileTypes: true })) {
|
|
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
if (entry.name === 'codex') continue; // Claude-only skill
|
|
if (entry.name === 'claude') continue; // External-host-only skill
|
|
if (fs.existsSync(path.join(ROOT, entry.name, 'SKILL.md.tmpl'))) {
|
|
skills.push(entry.name);
|
|
}
|
|
}
|
|
return skills;
|
|
})();
|
|
|
|
test('all skills (except /codex) have both Claude and Codex variants', () => {
|
|
for (const skillDir of CLAUDE_SKILLS_WITH_TEMPLATES) {
|
|
// Claude variant
|
|
const claudeMd = path.join(ROOT, skillDir, 'SKILL.md');
|
|
expect(fs.existsSync(claudeMd)).toBe(true);
|
|
|
|
// Codex variant
|
|
const codexName = skillDir.startsWith('gstack-') ? skillDir : `gstack-${skillDir}`;
|
|
const codexMd = path.join(AGENTS_DIR, codexName, 'SKILL.md');
|
|
expect(fs.existsSync(codexMd)).toBe(true);
|
|
}
|
|
// Root template has both too
|
|
expect(fs.existsSync(path.join(ROOT, 'SKILL.md'))).toBe(true);
|
|
expect(fs.existsSync(path.join(AGENTS_DIR, 'gstack', 'SKILL.md'))).toBe(true);
|
|
});
|
|
|
|
test('/codex skill is Claude-only — no Codex variant', () => {
|
|
// Claude variant should exist
|
|
expect(fs.existsSync(path.join(ROOT, 'codex', 'SKILL.md'))).toBe(true);
|
|
// Codex variant must NOT exist
|
|
expect(fs.existsSync(path.join(AGENTS_DIR, 'gstack-codex', 'SKILL.md'))).toBe(false);
|
|
});
|
|
|
|
test('Codex skill names follow gstack-{name} convention', () => {
|
|
const codexDirs = fs.readdirSync(AGENTS_DIR);
|
|
for (const dir of codexDirs) {
|
|
// Every directory should start with gstack
|
|
expect(dir.startsWith('gstack')).toBe(true);
|
|
// Root is just 'gstack', others are 'gstack-{name}'
|
|
if (dir !== 'gstack') {
|
|
expect(dir.startsWith('gstack-')).toBe(true);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('$B commands in Codex SKILL.md files are valid browse commands', () => {
|
|
const codexDirs = fs.readdirSync(AGENTS_DIR);
|
|
for (const dir of codexDirs) {
|
|
const skillMd = path.join(AGENTS_DIR, dir, 'SKILL.md');
|
|
if (!fs.existsSync(skillMd)) continue;
|
|
const content = fs.readFileSync(skillMd, 'utf-8');
|
|
// Only validate if the skill contains $B commands
|
|
if (!content.includes('$B ')) continue;
|
|
const result = validateSkill(skillMd);
|
|
expect(result.invalid).toHaveLength(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
// --- Repo mode and test failure triage validation ---
|
|
|
|
describe('Repo mode preamble validation', () => {
|
|
test('generated SKILL.md preamble contains REPO_MODE output', () => {
|
|
// Token-reduction Phase 1: the inline `gstack-repo-mode` call moved into
|
|
// bin/gstack-skill-start. The render pins the script invocation; the
|
|
// script pins the REPO_MODE echo + the gstack-repo-mode call.
|
|
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('bin/gstack-skill-start');
|
|
const script = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-skill-start'), 'utf-8');
|
|
expect(script).toContain('REPO_MODE:');
|
|
expect(script).toContain('gstack-repo-mode');
|
|
});
|
|
|
|
test('tier 3+ skills contain See Something Say Something section', () => {
|
|
// Root SKILL.md is tier 1 (no Repo Mode). Check a tier 3 skill instead.
|
|
const content = fs.readFileSync(path.join(ROOT, 'plan-ceo-review', 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('See Something, Say Something');
|
|
expect(content).toContain('REPO_MODE');
|
|
expect(content).toContain('solo');
|
|
expect(content).toContain('collaborative');
|
|
});
|
|
});
|
|
|
|
describe('Test failure triage in ship skill', () => {
|
|
test('ship/SKILL.md contains Test Failure Ownership Triage', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('Test Failure Ownership Triage');
|
|
});
|
|
|
|
test('ship/SKILL.md triage uses git diff for classification', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('git diff origin/<base>...HEAD --name-only');
|
|
});
|
|
|
|
test('ship/SKILL.md triage has solo and collaborative paths', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('REPO_MODE');
|
|
expect(content).toContain('solo');
|
|
expect(content).toContain('collaborative');
|
|
expect(content).toContain('Investigate and fix now');
|
|
expect(content).toContain('Add as P0 TODO');
|
|
});
|
|
|
|
test('ship/SKILL.md triage has GitHub issue assignment for collaborative mode', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('gh issue create');
|
|
expect(content).toContain('--assignee');
|
|
});
|
|
|
|
test('{{TEST_FAILURE_TRIAGE}} placeholder is fully resolved in ship/SKILL.md', () => {
|
|
const content = readShipUnion();
|
|
expect(content).not.toContain('{{TEST_FAILURE_TRIAGE}}');
|
|
});
|
|
|
|
test('ship/SKILL.md uses in-branch language for stop condition', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('In-branch test failures');
|
|
});
|
|
});
|
|
|
|
describe('no compiled binaries in git', () => {
|
|
// Tracked files enumerated once and reused by both assertions. git ls-files -z
|
|
// + split is ~ms; the previous xargs-per-file shell loops blew past 5s on CI.
|
|
const trackedFiles: string[] = require('child_process')
|
|
.execSync('git ls-files -z', { cwd: ROOT, encoding: 'utf-8', timeout: 30_000 })
|
|
.split('\0')
|
|
.filter(Boolean);
|
|
|
|
test('git tracks no Mach-O or ELF binaries', () => {
|
|
// Only mode 100755 (executable) files can be binaries we care about. Pre-filter
|
|
// via git ls-files -s to avoid running `file` on every text file.
|
|
const lsOut: string = require('child_process').execSync('git ls-files -s', {
|
|
cwd: ROOT,
|
|
encoding: 'utf-8',
|
|
timeout: 30_000,
|
|
});
|
|
const executableFiles = lsOut
|
|
.split('\n')
|
|
.filter(Boolean)
|
|
.map((line: string) => {
|
|
const parts = line.split(/\s+/);
|
|
return { mode: parts[0], file: line.split('\t')[1] };
|
|
})
|
|
.filter((e: { mode: string; file: string }) => e.mode === '100755')
|
|
.map((e: { mode: string; file: string }) => e.file);
|
|
|
|
if (executableFiles.length === 0) return;
|
|
|
|
// Batch-invoke `file --mime-type` across all executable files at once.
|
|
const result: string = require('child_process')
|
|
.execSync(`file --mime-type -- ${executableFiles.map((f: string) => `'${f.replace(/'/g, "'\\''")}'`).join(' ')}`, {
|
|
cwd: ROOT,
|
|
encoding: 'utf-8',
|
|
timeout: 30_000,
|
|
})
|
|
.trim();
|
|
|
|
const binaries = result
|
|
.split('\n')
|
|
.filter((l: string) =>
|
|
/application\/(x-mach-binary|x-executable|x-pie-executable|x-sharedlib)/.test(l)
|
|
)
|
|
.map((l: string) => l.split(':')[0].trim());
|
|
|
|
expect(binaries).toEqual([]);
|
|
});
|
|
|
|
test('warns about tracked files larger than 2MB', () => {
|
|
// Large fixtures can be legitimate test infrastructure. Keep visibility on
|
|
// repository size without blocking those fixtures from living in git.
|
|
// Known-good fixtures are exempted from the warning to keep CI logs clean.
|
|
const MAX_BYTES = 2 * 1024 * 1024;
|
|
const knownLargeFixtures = new Set<string>([
|
|
// Currently empty — add repo-relative paths of intentionally-committed
|
|
// large fixtures here with a reason.
|
|
]);
|
|
const oversized = trackedFiles.flatMap((f: string) => {
|
|
if (knownLargeFixtures.has(f)) return [];
|
|
const full = path.join(ROOT, f);
|
|
try {
|
|
const size = fs.statSync(full).size;
|
|
return size > MAX_BYTES ? [{ file: f, size }] : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
});
|
|
|
|
if (oversized.length > 0) {
|
|
const formatted = oversized
|
|
.map(({ file, size }: { file: string; size: number }) => {
|
|
const mib = (size / (1024 * 1024)).toFixed(1);
|
|
return `${file} (${mib} MiB)`;
|
|
})
|
|
.join(', ');
|
|
console.warn(`[size-warning] tracked files over 2 MiB: ${formatted}`);
|
|
}
|
|
|
|
expect(Array.isArray(oversized)).toBe(true);
|
|
});
|
|
});
|
|
|
|
|
|
// ─── Browser-skills validation ──────────────────────────────────
|
|
//
|
|
// Browser-skills are bundled in <gstack-root>/browser-skills/<name>/. Each
|
|
// must have a SKILL.md whose frontmatter satisfies the contract enforced by
|
|
// browse/src/browser-skills.ts:parseSkillFile (host required, args + triggers
|
|
// parseable as the right shape). This test catches malformed bundled skills
|
|
// at CI time, before they ship.
|
|
|
|
describe('Bundled browser-skills frontmatter contract', () => {
|
|
const browserSkillsRoot = path.join(ROOT, 'browser-skills');
|
|
|
|
function listBundledSkillDirs(): string[] {
|
|
if (!fs.existsSync(browserSkillsRoot)) return [];
|
|
return fs.readdirSync(browserSkillsRoot)
|
|
.filter(name => !name.startsWith('.'))
|
|
.map(name => path.join(browserSkillsRoot, name))
|
|
.filter(dir => {
|
|
try { return fs.statSync(dir).isDirectory(); } catch { return false; }
|
|
});
|
|
}
|
|
|
|
test('each bundled skill has a SKILL.md', () => {
|
|
for (const dir of listBundledSkillDirs()) {
|
|
const skillFile = path.join(dir, 'SKILL.md');
|
|
expect(fs.existsSync(skillFile)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('each bundled skill SKILL.md frontmatter parses with required fields', async () => {
|
|
const { parseSkillFile } = await import('../browse/src/browser-skills');
|
|
for (const dir of listBundledSkillDirs()) {
|
|
const name = path.basename(dir);
|
|
const content = fs.readFileSync(path.join(dir, 'SKILL.md'), 'utf-8');
|
|
// parseSkillFile throws on missing required fields; we just want to
|
|
// make sure none of our shipped skills tripwire it.
|
|
const { frontmatter } = parseSkillFile(content, { skillName: name });
|
|
expect(frontmatter.name).toBe(name);
|
|
expect(typeof frontmatter.host).toBe('string');
|
|
expect(frontmatter.host.length).toBeGreaterThan(0);
|
|
expect(Array.isArray(frontmatter.triggers)).toBe(true);
|
|
expect(Array.isArray(frontmatter.args)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('each bundled skill has a script.ts', () => {
|
|
for (const dir of listBundledSkillDirs()) {
|
|
expect(fs.existsSync(path.join(dir, 'script.ts'))).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('each bundled skill ships a sibling SDK at _lib/browse-client.ts', () => {
|
|
for (const dir of listBundledSkillDirs()) {
|
|
expect(fs.existsSync(path.join(dir, '_lib', 'browse-client.ts'))).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('each bundled skill has a script.test.ts', () => {
|
|
for (const dir of listBundledSkillDirs()) {
|
|
expect(fs.existsSync(path.join(dir, 'script.test.ts'))).toBe(true);
|
|
}
|
|
});
|
|
|
|
test("each bundled skill's _lib/browse-client.ts matches the canonical SDK", () => {
|
|
// If the canonical SDK changes, the bundled copy must be updated. This
|
|
// test enforces that — the _lib copy should be byte-identical.
|
|
const canonical = fs.readFileSync(path.join(ROOT, 'browse', 'src', 'browse-client.ts'), 'utf-8');
|
|
for (const dir of listBundledSkillDirs()) {
|
|
const sibling = fs.readFileSync(path.join(dir, '_lib', 'browse-client.ts'), 'utf-8');
|
|
expect(sibling).toBe(canonical);
|
|
}
|
|
});
|
|
|
|
test('script.ts imports browse from ./_lib/browse-client', () => {
|
|
for (const dir of listBundledSkillDirs()) {
|
|
const content = fs.readFileSync(path.join(dir, 'script.ts'), 'utf-8');
|
|
expect(content).toMatch(/from\s+['"]\.\/_lib\/browse-client['"]/);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Token-reduction Phase 5: the four ios skills demoted from preamble-tier 3
|
|
// to 2 — they never consume the tier-3 sections (repo-mode ownership, search
|
|
// before building) but DO fire AskUserQuestion, which tier >=2 provides.
|
|
describe('ios tier demotion (Phase 5)', () => {
|
|
const iosSkills = ['ios-fix', 'ios-clean', 'ios-sync', 'ios-design-review'];
|
|
for (const skill of iosSkills) {
|
|
test(`${skill} render drops tier-3 sections, keeps AUQ format`, () => {
|
|
const content = fs.readFileSync(path.join(ROOT, skill, 'SKILL.md'), 'utf-8');
|
|
expect(content).not.toContain('## Repo Ownership');
|
|
expect(content).not.toContain('## Search Before Building');
|
|
expect(content).toContain('## AskUserQuestion Format');
|
|
});
|
|
}
|
|
});
|