mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-24 15:02:30 +02:00
v1.68.0.0 fix: next tracker wave — 16 verified fixes in, 90 stale PRs and 21 issues closed with receipts (#2632)
* fix(plan-tune): reject never-ask on one-way ids at --write --check already ignored those prefs; --write still stored them and --stats counted them as a working NEVER_ASK. Refuse the write and count leftover on-disk prefs as INERT_ONE_WAY. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix: gstack-config get returns "" with exit 0 for keys that have no default Skill preambles read configuration with VAR=$(gstack-config get <key> 2>/dev/null || echo "<default>") and that fallback only fires on a non-zero exit. lookup_default ended in a catch-all that echoed "" and returned 0, so for any key missing from the table VAR came back empty and the default written right there in the preamble was unreachable. The skill then branched on a value it never specified: "skip entirely if QUESTION_TUNING is false", reached with QUESTION_TUNING="". Four keys that skills actually read had no entry and took that path: question_tuning -> callers assume "false" repo_mode -> callers assume "unknown" team_mode -> callers assume "false" transcript_ingest_mode -> callers assume "off" Each default above is the value the call sites already substitute in their own `|| echo` fallback, so this only makes reachable what was already intended. The catch-all now returns non-zero. That is deliberately scoped to the unknown-key arm alone: keys whose default is intentionally empty still exit 0, because "" is their real answer and their callers depend on it -- cross_project_learnings ("unset triggers the first-time prompt"), redact_repo_visibility ("empty falls through to gh/glab detection"), salience_allowlist, user_slug_at_*. Making every empty answer an error would have broken those. test/gstack-config-defaults.test.ts pins the class rather than the four instances: it parses the case arms and asserts every `gstack-config get <key>` site in the tree is covered, so adding a read without a default fails CI. It also pins the exit-code contract in both directions. Verified failing against the pre-fix script, where it names exactly those four keys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(redact): a typo'd subcommand no longer exits 0 having done nothing main() recognised exactly two subcommands and let everything else fall through to the stdin scan. On empty stdin that prints "(no findings)" and exits 0, so: $ gstack-redact install-prepush-hooks # plural typo gstack-redact scan — repo UNKNOWN (no findings) $ echo $? 0 No hook was installed, and the operator has every reason to believe the credential guard is armed. A guard that silently no-ops must never exit 0. Two smaller faults in the same dispatch, both of which lead people here: - There was no --help handler, so `gstack-redact --help` fell through to the scanner. Piping a credential to it scanned the secret and exited 3. - With no piped input and no --from-file, readInput() blocks on readSync(fd 0) until an EOF that an interactive terminal never sends. That prints nothing at all, so it reads as a hang rather than as "this is a filter, feed it". Now: --help/-h/help prints usage and exits 0; an unrecognised positional prints the offender and exits 1; a TTY with nothing piped in prints usage instead of blocking. "scan" stays accepted, because the human output header reads "gstack-redact scan — repo …" and that is what people type. Usage errors exit 1, deliberately not 2 or 3. Those mean MEDIUM and HIGH findings and callers gate dispatch on them, so a usage error exiting 2 would be read as "medium findings — prompt the user". A test pins that. Tests: 4 written failing first, then fixed. Full suite 7,722 pass / 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(browse): one ambiguous ref no longer kills the whole annotated screenshot `snapshot -a` exits 1 with "Selector matched multiple elements" on most real pages, so /qa, /canary and /land-and-deploy silently produce reports whose screenshots do not exist. Plain `screenshot <path>` is unaffected. Refs are built as getByRole(role, {name}) and disambiguated with .nth() when role+name repeats. That disambiguation cannot fire for a node with NO accessible name: the locator degrades to getByRole(role) with no name filter, and the count driving .nth() is taken from the FILTERED aria snapshot while getByRole matches the unfiltered DOM. Measured on a live page: the tree surfaced 2 unnamed paragraphs, the DOM had 9. Landmarks (banner/main/contentinfo) and paragraphs are correctly unnamed per ARIA, so this is the common case rather than an edge case. boundingBox() then hits Playwright strict mode, and the catch allowlisted only timeout/closed/Target/Execution-context messages — so the strict-mode error was re-thrown and aborted every remaining annotation. Two changes: - `.first()` before boundingBox(), so an ambiguous ref draws a box on its first match instead of aborting. The heatmap path below has always tolerated this via a bare `catch {}`; annotate was the only path that could be killed outright. - the catch no longer re-throws on unrecognised messages. A box we cannot measure is a box we do not draw, never a reason to lose the rest of the page. Set BROWSE_DEBUG to see what was skipped. Also: `-o` passed without `-a`/`-H` was silently ignored (exit 0, no file), which reads as "screenshots are broken" rather than "you forgot a flag". It now warns and points at `browse screenshot <path>`. Verified by rebuilding both ways against the same page with 51 refs present: before — "Selector matched multiple elements", no file written after — exit 0, 229KB PNG Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(version-bump): missing or empty VERSION no longer repairs a fabricated 0.0.0.0 into package.json repair now fails with exit 2 when the VERSION file is absent or empty instead of folding to DEFAULT ("0.0.0.0") — which passed VERSION_RE and regressed package.json below where it started. classify gains an additive versionFileExists field so /ship can tell a real 0.0.0.0 from a fabricated one. Re-derived from PR #2612 under the generated-file screening rule. Fixes #2600 (repair half; the path-configurability half landed in v1.67 via #2531). Contributed by @Lockyer228 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(memory-ingest): --probe counts post-attribution, through the same gate --bulk uses probeMode previously stat'd every walked file, so setup-gbrain gated its silent bulk ingest on pre-filter counts that the write path would never ingest (#2394). The attribution decision now lives in ONE shared gate (sessionIsAttributable — cheap-parse: cwd extraction + memoized resolveGitRemote, never a full page build) used by BOTH probeMode and preparePages, so the two stages' post-attribution counts are structurally identical. ProbeReport gains skipped_unattributed; the probe prints what it excluded and --include-unattributed restores raw counts. The parity is pinned at the prepare stage (probe post-attribution == transcripts reaching import), deliberately NOT == final written. Re-derived from PR #2612 under the generated-file screening rule; the shared-gate design and the remote memo are additions from the plan review. Fixes #2394. Contributed by @Lockyer228 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(browse): allow CPU and network throttling for performance measurement Adds Emulation.setCPUThrottlingRate and Network.emulateNetworkConditions to CDP_ALLOWLIST. Motivation: diagnosing a real "uploads take 1-2 minutes" report, the only machine available was a fast developer workstation. Client-side processing measured 1.4s where the user experienced minutes, so the conclusion had to be reached arithmetically rather than observed. Throttling would have let the measurement reproduce the reporter's conditions directly. Both fit the existing posture rather than widening it: - Emulation already allows setDeviceMetricsOverride, clearDeviceMetricsOverride and setUserAgentOverride, which are equally mutating and scoped to the tab. - Neither method reads page content. setCPUThrottlingRate affects only timing; emulateNetworkConditions constrains traffic rather than inspecting it, so no request bodies, headers or cookies are exposed. Both are output: 'trusted' because they return no page-derived data. scope 'tab' for both, matching the surrounding Emulation entries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(session-update): lock pidfile records the live holder; hard TTL bounds every wedge (#2613) echo $$ inside the backgrounded subshell recorded the PARENT hook's PID — which exits immediately — so every subsequent session judged the lock stale and rm -rf'd a LIVE holder's lock, letting concurrent updaters run over each other. The pidfile now records ${BASHPID:-$(sh -c 'echo $PPID')} (macOS bash 3.2 has no BASHPID; the sh child's PPID is exactly this subshell). Staleness is now two independent detectors: PID liveness (as before, but against the real holder), and a 30-minute hard TTL on the heartbeat mtime — reclaimed regardless of kill -0, so a recycled PID or hung holder can't wedge the lock forever. The holder touches the pidfile after the pull and after setup, so a legitimately-slow run keeps itself alive. Empty and missing pidfiles are respected inside the TTL window (the mkdir→echo race) and reclaimed past it. Fixes #2613. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(browse): explicit windowsHide on every Bun.spawn site + census tripwire (#2575 residual) Bun.spawn sites were structurally outside the windowsHide census (it swept child_process bindings only). The runtime was already safe — native Bun hides consoles by default and bun-polyfill.cjs defaults windowsHide !== false since #2523/#2539 — but implicit defaults are exactly what regress silently. Every Bun.spawn/spawnSync in browse/src now carries the explicit flag (harmless on unix-only sites like Xvfb/xattr/open), and a second SWEEP in windows-spawn-hide.test.ts fails CI on any new flagless Bun.spawn site. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gbrain): brain worktree advances on the daily sync — no more silently stale brains (#2516) The daily pull refreshed only ~/.gstack itself, never the detached worktree at ~/.gstack-brain-worktree that gbrain actually indexes — so after setup the brain served stale pages forever unless setup-gbrain/sync-gbrain happened to run. brain-sync --once now advances the worktree once per 24h behind an ATTEMPT stamp (.brain-worktree-last-advance — a persistently-failing advance warns once a day, not at every skill boundary), inside the existing run lock and before any ingest step touches the worktree. The new gstack-gbrain-source-wireup --advance-only is built for the unattended cadence: git-only (no gbrain prereqs), pins every operation to the managed worktree (refuses paths that are not worktrees of the artifacts repo), refuses dirty worktrees, and never runs the force-remove recovery — a cron path must not be able to delete local changes. A static pin keeps the force-remove out. docs/gbrain-sync.md stops overclaiming the old cadence. Fixes #2516. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(memory-ingest): honor the per-remote deny/read-only trust policy (#2392) Transcript ingest now respects the same trust store as code import — the gate existed only in gstack-gbrain-sync's runCodeImport, so memory-ingest happily ingested transcripts from deny-listed repos. preparePages filters prepared transcript pages through ONE batch policy lookup (new 'get --batch' verb on bin/gstack-gbrain-repo-policy — the script owns URL normalization; the client adds repoPolicyTierBatch, one spawn for all distinct remotes, so large corpora never pay a 10s-timeout subprocess per remote). Outcomes match code-import semantics: read-only → clean skip (skipped_policy_readonly), deny → counted refusal (skipped_policy_deny), corrupted/unreadable store → HARD ERROR before any write (state, staging, egress receipt, and import all untouched) with the recovery command named — policy corruption must never read as successful ingestion. Artifacts are never policy-filtered (their git_remote is a project slug, not a remote). Fixes #2392. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(config): repo_mode keeps its empty no-default semantics (#2611 follow-up) The ported defaults table synthesized repo_mode → "unknown", but EMPTY is load-bearing for that key: gstack-repo-mode treats any non-empty answer as a user override and skips its own repo classification — the synthesized default turned the classifier into dead code (REPO_MODE=unknown everywhere; caught by test/gstack-repo-mode.test.ts via the wave's cross-agent blame protocol). repo_mode joins the empty-is-real carve-outs (empty output, exit 0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pair-agent): consent before killing a healthy headless daemon The pair-agent headed switch spawned 'connect --force-restart' unconditionally — auto-killing a live headless daemon (open tabs, cookies, logins) in direct contradiction of the iron rule it sits beside ('only an explicit --force-restart may kill a live daemon'). The CLI now captures daemon liveness BEFORE ensureServer (which can itself boot a fresh daemon) and relaunches only when the user passed --force-restart to pair-agent; otherwise it prints the tab count and continues against the existing daemon. The /pair-agent skill gains a matching one-way-door consent question (template half rides the wave's template block). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gbrain-status): MCP scoping is per-project, and project-local beats user scope hasRemoteOnlyGbrainMcp scanned EVERY project's mcpServers in ~/.claude.json, so one project's remote gbrain registration reclassified broken local engines as thin-client machine-wide. It now reads user scope plus only the cwd's nearest-ancestor project key. The precedence itself was verified empirically and hermetically (fake HOME + CLAUDE_CONFIG_DIR fixtures, claude 2.1.233): with both scopes defining gbrain, 'claude mcp get gbrain' reports Scope: Local config — PROJECT-LOCAL WINS. Both in-repo consumers assumed the opposite; brain-cache's endpoint resolution flips to nearest-ancestor-project-first, and the stale user-first pin in brain-cache-roundtrip now pins the verified precedence. (The user-first jq in the brain-sync preamble resolver gets the same swap in the template block.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(slug): gstack-slug matches remote-slug's owner-repo canonical form (live misfile bug) Found live during this wave's CEO review: bin/gstack-slug emitted SLUG=garrytan for this garrytan/gstack worktree while remote-slug correctly gave garrytan-gstack — decisions, timeline, ceo-plans, and learnings were filing into the wrong project store (observed polluting Context Recovery with another repo's decisions). Root cause: a stray empty ~/.git directory made the walk-up crown $HOME as the outermost project root; the remote lookup ran only against that root, failed silently, and the basename fallback cached 'garrytan' sticky. NOT worktree-specific — any strong marker on a non-repo ancestor triggered it. Fix: the walk now finds the outermost ancestor whose .git actually resolves an origin remote and derives owner-repo with remote-slug's byte-identical parse; marker-only ancestors keep anchoring the basename fallback but can no longer shadow a real remote. A new cache self-heal recomputes the poisoned shape (cached == basename of a marker root while a remote-bearing repo exists below), preserving legit #2212 stickiness. Nested-repo walk-up, no-remote and non-git fallbacks, and the SLUG=/BRANCH= eval contract are unchanged, pinned by a 10-case parity suite. Store migration for pre-fix data is tracked in TODOS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(brain-sync): per-record spool dir — the enqueue/drain race dies structurally Producers appended lines to .brain-queue.jsonl while the drain re-read and os.replace'd it; the in-code comment admitted a lockless append between the re-read and the replace was lost. Locks and rename-rotation designs were both reviewed and rejected (each retained a tail race); the shipped design is a maildir-style spool: one FILE per record in .brain-queue.d/ (tmp + atomic rename), the drain snapshots filenames, processes, and deletes exactly what it snapshotted. Writer and drainer never share an inode — nothing to race. Semantics: at-least-once (a crash between process and unlink re-drains; downstream content-hash dedup absorbs duplicates); retained (privacy-held) records keep their files; unparseable records are kept + warned, never destroyed. Legacy .brain-queue.jsonl migrates atomically on the next drain (crash-leftover .migrating files recovered too); status/drop-queue count both surfaces; discover-new writes spool records and advances its cursor per-record-written. The preamble's queue-depth line switches to spool count in this wave's template block. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bin-context): native slug fallback walks up like bash gstack-slug slugFromEnvironment derived the slug from the INNERMOST repo's origin while bash gstack-slug walks to the outermost project root — nested/vendored repos split their stores across the bash/native boundary (win32 hits the native path constantly). The native fallback now ports _outermost_project_root faithfully (strong/weak markers, outermost-strong-wins, 64-depth cap, fixed-point termination) plus the full resolution order: env override → walk-up → sticky cache with the #1125 self-heal → remote get-url → basename. Twelve mirrored scenarios drive BOTH implementations against the same fixtures and pin identical slugs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(next-version): git fallback queries the live remote, never mutates, and keeps 3-digit width The degraded path counted every remote-tracking ref on every remote — stale experiment branches and second remotes inflated version allocation, and a failed base read flipped 3-digit repos to 4-digit slots. Now: ls-remote --heads origin first (GIT_TERMINAL_PROMPT=0, 5s timeout, zero local ref mutation); on failure, local refs/remotes/origin ONLY with an explicit stale-refs warning; a failed base read zeroes at the LOCAL version file's width so a 3-digit repo allocates 0.0.1, not 0.0.1.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(setup): hooks register the global-install path and re-point stale ones Registering hooks from a dev worktree baked that worktree's absolute path into settings.json — deleting the worktree left a dead hook erroring on every session stop, and the presence-only dedup (list-sources | grep) could never re-point it. setup's hook paths now route through _hook_install_path (global install preferred, source dir fallback), and the new ensure-event verb on gstack-settings-hook compares the registered command payload against canonical: identical → no write, different → single atomic replacement (never zero or two registrations). The plan-tune hooks had the same stale pattern and get the same fix without re-triggering their consent prompt. Also hardened: bun 1.3.13 turns an uncaught sync fs error in bun -e into a SILENT exit 0 — the registrar's write path now catches, prints, and exits 1, so a failed update can never report fake-green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(preamble): learnings capture is unconditional at completion (#2402) 43 of 44 learnings entries came from explicit /learn — the completion-status prose read 'if you discovered a durable project quirk... log it', which models treated as optional. The step now ALWAYS runs: review the session for durable learnings, log each one, and state 'No durable learnings this session' explicitly when the review comes up empty — an empty result, never a skipped step. Re-derived from PR #2612 under the generated-file screening rule. Fixes #2402. Contributed by @Lockyer228 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(scrape): untrusted-content warning on the page-fetching skills (#2441) /scrape and /skillify consumed page content with zero injection guidance — the CHANGELOG claimed coverage the skills didn't have. The warning now lives in ONE exported const (UNTRUSTED_CONTENT_WARNING in resolvers/browse.ts), embedded in the browse COMMAND_REFERENCE as before AND injected standalone into both skills via the new {{UNTRUSTED_CONTENT_WARNING}} token — single source, wording can never drift between surfaces. Re-derived from PR #2612 under the generated-file screening rule. (Structural isolation for skillify-generated code is tracked as its own TODO.) Fixes #2441. Contributed by @Lockyer228 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): checklist paths resolve from the installed skill root (#2518) /review Step 2 read .claude/skills/review/checklist.md — a path relative to the TARGET repo, which only resolves in gstack's own checkout. Every checklist/greptile-triage/TODOS-format reference (six across five templates — two more than the issue named, same class) now uses the installed-root form ~/.claude/skills/gstack/review/... that the templates' other references already use. The install-root class itself (non-default install dirs) is #1882, deliberately its own PR. Fixes #2518. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pair-agent): one-way-door consent question before a daemon relaunch (template half) The skill flow now checks daemon liveness before Step 4 and asks an explicit one-way-door question (tabs/cookies/logins are lost) before passing --force-restart — never proceeding on a vague reply. Pairs with the CLI-half commit that stopped pair-agent auto-killing live daemons. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(codex): resume does not amortize the ~21K session prelude (#2387) Measured (#2387): every codex exec call pays Codex's session prelude, and a resumed call came in slightly ABOVE a fresh one — resume buys continuity, never token savings. The skill now says so where the resume flow lives: prefer one codex call per skill, batch questions into it. Fixes #2387. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(upgrade): fast-forward first; reset --hard only behind a proved-safe gate (#2517) /gstack-upgrade went straight to stash + reset --hard origin/main. Now it tries git pull --ff-only --autostash first (the same policy session-update's auto-upgrade uses). The destructive fallback runs unprompted ONLY when both git status --porcelain AND git rev-list origin/main..HEAD are empty — a clean tree with unpushed local commits is NOT safe, reset destroys them. Anything else requires an explicit one-way-door confirmation that lists every dirty file and unpushed commit being discarded. Fixes #2517. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(preamble): brain-sync block counts the spool queue and resolves MCP project-first Two resolver halves deferred from earlier wave commits: the queue-depth line counts .brain-queue.d/*.json spool records (plus legacy lines until the drain migrates them), and GBRAIN_MCP_ENTRY_JQ swaps its operands to nearest-ancestor-project-first — matching the empirically verified Claude Code precedence (project-local beats user scope) instead of the backwards user-first assumption. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: regenerate SKILL.md docs + golden fixtures (single regen for the template block) Pure generator output for the six template/resolver commits above (learnings capture, untrusted-content warning, review paths, pair-agent consent, codex resume note, upgrade ff-only, brain-sync block) — bun run gen:skill-docs + --host codex + --host factory, with the three ship golden fixtures refreshed per the documented procedure. The three sidecar-path pins in gen-skill-docs.test.ts move to the new installed-root/$GSTACK_ROOT contract (#2518). Restores template freshness; full suite green from here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: TODOS.md — strike the six wave-fixed residuals, add two follow-ups The v1.67 adversarial-review residuals section shrinks to the one item the wave couldn't reach (iOS tap routing — needs real-device verification). New entries: skillify structural isolation (a prose warning is not a boundary for page-derived generated code) and the slug store migration (pre-fix sessions on stray-marker machines filed data under the degraded slug; post-fix reads go to the correct store, so history needs a merge/alias). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: align cross-cutting pins with the wave's contracts Three suites pinned pre-wave behavior: browse's gstack-config test asserted the old unknown-key ''/exit-0 shape (#2611 made it exit 1); the Windows-paths suite pinned O_APPEND enqueue atomicity (the spool design satisfies the same invariant via tmp + os.replace, one file per record — pinned in its new form); and nine carve-guard skeleton ceilings absorbed the #2402 unconditional-learnings prose (~450B per skill), bumped with measured values per the guard's own protocol. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: re-anchor the referenced-path scanner self-check to the gstack-rooted review refs The self-check pinned the review checklist as a class-1 alias-relative ref; #2518 moved those refs to the installed gstack root (class 2). The guard now proves the scanner sees them in their new class, so the class-2 assertion can't go vacuous. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: pin the wave's prose-tier behaviors (ship coverage-audit gap closure) The coverage audit found one regression-shaped gap: nothing pinned that the upgrade template's ff-only pull precedes the gated reset --hard (#2517) — a future template edit reverting to reset-first would fail nothing. Pinned: the ordering, the FF_OK gate, and the unpushed-commits check. Also pinned the two minor gaps: the {{UNTRUSTED_CONTENT_WARNING}} injection points in scrape/skillify (#2441) and brain-uninstall's spool-dir cleanup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: pre-landing review round — 8 auto-fixes + 8 accepted findings hardened The ship review army (4 specialists + red-team + checklist, 29 findings) produced 8 mechanical auto-fixes and 11 decisions; the accepted set: - win32 slug parity completed: lib/bin-context.ts gains the remote-first outermost walk + degraded-cache self-heal the bash side got this wave — the two implementations now agree on the stray-marker live-bug shape, pinned by shared fixtures (multi-specialist 9/10 finding). - probe honors the plan's bounded-read decision: 256KB prefix, extraction semantics mirrored from parseTranscriptJsonl so probe/prepare can never diverge on the same file (>1MB transcript test). - policy normalize parity: bash normalize() now matches canonicalizeRemote on .git/-trailing and uppercase-.GIT shapes (7-shape corpus pinned two ways) — a deny for those shapes could previously slip the transcript gate. - session-update reclaim is TOCTOU-safe (atomic mv-aside on both branches). - settings-hook: unparseable settings.json errors instead of being replaced with {}; ensure-event keys on (event, source) so matcher changes update in place — never zero or two registrations. - dot-only slug guard at both parse sites (hostile 'url = ..' can't escape projects/); enqueue tmp-file janitor (1h TTL, inside the drain lock); brain-sync .migrating never clobbered; drop-queue/status count .migrating; snapshot -o warning correct + surfaced in diff mode; version-bump test order-dependence removed; uninstall clears the advance stamp. Deferred with record: slug heal-probe cost sentinel (P3 TODO), FF_OK conflation (noted, misdiagnosis-only). 270 pass / 0 fail across the 10 touched suites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: adversarial round — the P0 finalize fail-safe and 12 hardened findings Three adversarial passes (Claude fresh-context, Codex chaos, Codex structured with P1 gate) on the full wave diff. Multi-source findings, all fixed: - P0: finalize_queue is now explicit-delete-only — a record is unlinked ONLY when classification proves it staged or dropped; a classifier crash, a missing class file, or a malformed pulled .brain-privacy-map.json (which previously nuked the whole snapshotted queue, remotely triggerable) now retains everything, warns, and re-drains next run. load_privacy_map treats corrupt maps as retain-all, never as empty. - next-version cannot silently drop a live claim: unreadable advertised refs get a targeted --depth=1 fetch + retry; still-unreadable claims surface as UNKNOWN warnings instead of duplicate-version silence. - session-update lock: ownership-checked EXIT trap (a TTL-reclaimed holder can no longer delete the new holder's lock) + a 5-min background heartbeat so a legitimately-slow pull/setup is never reclaimed while alive. - ensure-event collapses ALL same-(event,source) duplicates to one canonical entry; unique per-process tmp path; setup call sites surface (not swallow) the hardened refusals. - memory-ingest: --limit counts only policy-permitted pages (denied records no longer starve permitted ones); --probe applies the same policy filter as --bulk (skipped_policy_* fields on the report). - version-bump repair accepts a genuine literal 0.0.0.0 VERSION file. - slug heal restricted to the stray-.git shape — package.json-anchored wrapper roots keep their legit sticky identity (#2212 preserved). - brain-sync: idle fast path sees leftover .migrating records; unparseable spool records quarantine instead of warning forever; migration comment stops overclaiming the transition-window race. - CDP throttling justifications document override persistence (callers own restoration), pinned in the allowlist test. Deferred with record: deny retroactivity for already-ingested pages (P2 TODO, same semantics as the code-import gate); legacy-migration tail race (transition-window, requires pre-spool writers). 288 pass / 0 fail across the 10 touched suites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: regenerate SKILL.md docs + goldens (Windows-separator jq fix) Pure generator output for the brain-sync block's jq ancestor match now accepting backslash-formed Windows project keys — previously project-scoped brains were invisible on Windows while the TS scope resolvers saw them. Golden ship fixtures refreshed per the documented procedure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: codex verify-pass residuals — chunked cwd read, post-filter partial count, migrating depth The verify re-review passed the P1 gate (0 P1s) and left three residuals, all applied: transcriptCwdFromPrefix reads in chunks until one complete record (4MB cap) so a giant first prompt can't truncate mid-JSON and break probe/bulk parity; partial_pages derives from the FINAL prepared set instead of the whole scanned corpus; the preamble queue-depth line counts leftover .brain-queue.jsonl.migrating records like the status path does (regen + goldens included). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: bump version and changelog (v1.68.0.0) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: update project documentation for v1.68.0.0 BROWSER.md: fix the $B cdp example (positional JSON params, not --json; depth is the real CDP param) and add the new perf-throttling examples (Emulation.setCPUThrottlingRate, Network.emulateNetworkConditions) with their clear-override counterparts. USING_GBRAIN_WITH_GSTACK.md: the state-files table row for the sync queue now names the maildir-style spool dir .brain-queue.d/ that replaced .brain-queue.jsonl this release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: align memory-pipeline probe pins with the #2394 stage-count contract The paid-tier E2E pinned the pre-fix contract (probe headline = raw discovered). Probe now counts post-attribution — the same gate --bulk uses — with an explicit unattributed-skip line. Adds the --include-unattributed companion pin so all 9 fixtures stay accounted for. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(next-version): batch missing-tip fetches — one bounded round trip, never a per-branch crawl The targeted-fetch retry for branches whose advertised tip has no local object ran ONE git fetch per branch (10s cap each). On a shallow clone against a busy remote that crawls the network for minutes — CI's shard deadline killed the free suite mid-file. Missing tips now collect into a single batched shallow fetch (15s cap); refs still missing after the batch (one unservable ref fails the whole transfer) get a capped per-branch retry, and anything past the cap warns as an UNKNOWN claim instead of fetching. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(next-version): pin the batched fetch + make the offline-contract tests hermetic Two new G2 pins: N unfetched claim branches resolve with exactly ONE fetch spawn (PATH-shimmed git counts invocations), and one unservable ref no longer poisons the batch — live claims resolve via the bounded retry while only the ghost warns UNKNOWN. The #2545 offline-contract tests now run the CLI in a local fixture repo instead of the repo's own checkout: the checkout path did a live ls-remote against the real origin (operator-network-dependent, and the CI shard-deadline hang). The online-contract test gains a succeeding gh stub, so fallback:null is asserted deterministically instead of only when the operator happens to be authed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(redact-cli): derive the synthetic AWS-key fixture — no contiguous credential literal in source The CI quality gate scans every ADDED diff line with the redact engine, so the #2610 port's raw fixture literals failed the very gate they exist to test. The fixture is now assembled at runtime; the scanner still receives the identical bytes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(next-version): pin the fixture's host via origin-URL sniff — kills the last environment dependence The hermetic offline-contract fixture had no origin remote, so detectHost() fell through to auth probes: a machine with glab authed passed via the gitlab path while a bare CI runner read host:unknown (offline stays false there) and failed. The fixture now pushes to a local bare origin at a path containing github.com — the URL sniff pins host:github identically everywhere, asserted explicitly in both tests, with every git call still local. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: y$un_ <forrest.sun527@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: benjamin beres <benjamin.beres@bienpreter.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ricky <ricky@kinokostudio.com.hk> Co-authored-by: Connex Client Access <paul@paulkortman.com> Co-authored-by: henbima <henbima@gmail.com>
This commit is contained in:
co-authored by
Claude Fable 5
y$un_
Cursor
benjamin beres
Ricky
Connex Client Access
henbima
parent
60e51342b5
commit
28d59ad56c
@@ -6,6 +6,7 @@ import * as path from "path";
|
||||
import {
|
||||
toMsysPath,
|
||||
slugFromEnvironment,
|
||||
outermostProjectRoot,
|
||||
resolveSlug,
|
||||
NEEDS_NATIVE_SLUG_ON_WINDOWS,
|
||||
} from "../lib/bin-context";
|
||||
@@ -14,8 +15,21 @@ const ROOT = path.resolve(import.meta.dir, "..");
|
||||
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), "utf-8");
|
||||
|
||||
let tmp: string;
|
||||
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-slug-")); });
|
||||
afterEach(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} });
|
||||
let savedEnvSlug: string | undefined;
|
||||
beforeEach(() => {
|
||||
// realpathSync so the native cwd matches what the bash script's `pwd` reports
|
||||
// (macOS: /var/folders/... is a symlink to /private/var/folders/...).
|
||||
tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "gstack-slug-")));
|
||||
// An ambient GSTACK_PROJECT_SLUG (leaked from an operator shell or a sibling
|
||||
// test in a shared-process shard) would override every derivation under test.
|
||||
savedEnvSlug = process.env.GSTACK_PROJECT_SLUG;
|
||||
delete process.env.GSTACK_PROJECT_SLUG;
|
||||
});
|
||||
afterEach(() => {
|
||||
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
|
||||
if (savedEnvSlug === undefined) delete process.env.GSTACK_PROJECT_SLUG;
|
||||
else process.env.GSTACK_PROJECT_SLUG = savedEnvSlug;
|
||||
});
|
||||
|
||||
/**
|
||||
* Windows cannot exec bin/gstack-slug -- a `#!/usr/bin/env bash` script with no file
|
||||
@@ -111,3 +125,291 @@ describe("the fallback stays win32-gated", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Walk-up parity: the native fallback must resolve the same OUTERMOST project
|
||||
* root as bin/gstack-slug's `_outermost_project_root` (see
|
||||
* test/gstack-slug-cwd-walk-up.test.ts for the bash-side pins). Before this
|
||||
* port, the native path derived the slug from `git remote get-url origin` in
|
||||
* cwd — the INNERMOST repo — so a Windows session inside a nested/vendored
|
||||
* repo or an artifact-only subdir filed its state under a different slug than
|
||||
* every bash-side consumer.
|
||||
*
|
||||
* Each scenario is run through BOTH implementations on the same fixture
|
||||
* (separate GSTACK_HOMEs so neither reads the other's cache) and pinned to the
|
||||
* same expected slug. The bash leg is skipped on win32, where bash isn't
|
||||
* reliably spawnable — the native leg still pins the ported semantics there.
|
||||
*/
|
||||
describe("walk-up parity with bin/gstack-slug (outermost project root)", () => {
|
||||
const SCRIPT = path.join(ROOT, "bin", "gstack-slug");
|
||||
const HAS_BASH = process.platform !== "win32";
|
||||
|
||||
function bashSlug(cwd: string, extraEnv: Record<string, string> = {}): string {
|
||||
const env: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
HOME: path.join(tmp, "bash-home"),
|
||||
GSTACK_HOME: path.join(tmp, "bash-home", ".gstack"),
|
||||
};
|
||||
delete env.GSTACK_PROJECT_SLUG; // only set when a scenario passes it explicitly
|
||||
Object.assign(env, extraEnv);
|
||||
const r = spawnSync("bash", [SCRIPT], { cwd, env, encoding: "utf-8", timeout: 10_000 });
|
||||
const m = (r.stdout || "").match(/^SLUG=([^\n]*)$/m);
|
||||
return m ? m[1] : "";
|
||||
}
|
||||
|
||||
const nativeHome = () => path.join(tmp, "native-home");
|
||||
|
||||
/** Assert native === expected, and bash === expected where bash is available. */
|
||||
function expectBoth(cwd: string, expected: string) {
|
||||
expect(slugFromEnvironment(nativeHome(), cwd)).toBe(expected);
|
||||
if (HAS_BASH) expect(bashSlug(cwd)).toBe(expected);
|
||||
}
|
||||
|
||||
test("AC-1: .git at root, artifact-only subdir — slug is the ROOT basename", () => {
|
||||
const projectRoot = path.join(tmp, "loadout");
|
||||
const siteSubdir = path.join(projectRoot, "site");
|
||||
fs.mkdirSync(path.join(projectRoot, ".git"), { recursive: true });
|
||||
fs.mkdirSync(path.join(siteSubdir, ".vercel"), { recursive: true });
|
||||
fs.writeFileSync(path.join(siteSubdir, ".vercel", "project.json"), "{}\n");
|
||||
expectBoth(siteSubdir, "loadout");
|
||||
});
|
||||
|
||||
test("AC-1 variant: package.json at root, node_modules-only subdir — ROOT basename", () => {
|
||||
const projectRoot = path.join(tmp, "monorepo");
|
||||
const subdir = path.join(projectRoot, "packages", "web");
|
||||
fs.mkdirSync(subdir, { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, "package.json"), "{}\n");
|
||||
fs.mkdirSync(path.join(subdir, "node_modules"), { recursive: true });
|
||||
expectBoth(subdir, "monorepo");
|
||||
});
|
||||
|
||||
test("AC-2: stale cache (old-bug shape) self-heals to the outermost-root slug", () => {
|
||||
const projectRoot = path.join(tmp, "loadout");
|
||||
const siteSubdir = path.join(projectRoot, "site");
|
||||
fs.mkdirSync(path.join(projectRoot, ".git"), { recursive: true });
|
||||
fs.mkdirSync(path.join(siteSubdir, ".vercel"), { recursive: true });
|
||||
// Pre-seed the native cache with the WRONG value (pre-walk-up poisoning:
|
||||
// cached == basename(pwd) while pwd is NOT the project root).
|
||||
const cacheDir = path.join(nativeHome(), "slug-cache");
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const cacheFile = path.join(cacheDir, toMsysPath(siteSubdir).replace(/\//g, "_"));
|
||||
fs.writeFileSync(cacheFile, "site");
|
||||
|
||||
expect(slugFromEnvironment(nativeHome(), siteSubdir)).toBe("loadout");
|
||||
// The cache file itself must have been overwritten (self-healing).
|
||||
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("loadout");
|
||||
});
|
||||
|
||||
test("sticky cache (#2212): a cached identity that is NOT the old-bug shape survives", () => {
|
||||
const projectRoot = path.join(tmp, "renamed-project");
|
||||
fs.mkdirSync(path.join(projectRoot, ".git"), { recursive: true });
|
||||
const cacheDir = path.join(nativeHome(), "slug-cache");
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const cacheFile = path.join(cacheDir, toMsysPath(projectRoot).replace(/\//g, "_"));
|
||||
fs.writeFileSync(cacheFile, "legacy-name");
|
||||
// cached != basename(pwd), so the sticky rule holds — no recompute.
|
||||
expect(slugFromEnvironment(nativeHome(), projectRoot)).toBe("legacy-name");
|
||||
});
|
||||
|
||||
test("AC-3: cwd IS the project root with .git — slug = basename", () => {
|
||||
const projectRoot = path.join(tmp, "myproject");
|
||||
fs.mkdirSync(path.join(projectRoot, ".git"), { recursive: true });
|
||||
expectBoth(projectRoot, "myproject");
|
||||
});
|
||||
|
||||
test("AC-4: no markers anywhere on the chain — slug = pwd basename (fallback)", () => {
|
||||
const deep = path.join(tmp, "just", "a", "plain", "folder");
|
||||
fs.mkdirSync(deep, { recursive: true });
|
||||
expectBoth(deep, "folder");
|
||||
});
|
||||
|
||||
test("AC-5: subdir of a repo with a remote — slug derived from the ROOT's remote", () => {
|
||||
// Pins the `git -C "$PROJECT_ROOT"` port: the subdir has no repo of its
|
||||
// own, so the old native path (git in cwd) also reached the parent repo —
|
||||
// but only the walk-up guarantees BOTH implementations root the remote
|
||||
// lookup at the same directory.
|
||||
const projectRoot = path.join(tmp, "realgit");
|
||||
const subdir = path.join(projectRoot, "src", "deep");
|
||||
fs.mkdirSync(subdir, { recursive: true });
|
||||
spawnSync("git", ["init", "-q", projectRoot]);
|
||||
spawnSync("git", ["-C", projectRoot, "remote", "add", "origin", "https://github.com/foo/bar.git"]);
|
||||
expectBoth(subdir, "foo-bar");
|
||||
});
|
||||
|
||||
test("weak marker: README.md at root, artifact-only subdir — ROOT basename", () => {
|
||||
const projectRoot = path.join(tmp, "loadout");
|
||||
const siteSubdir = path.join(projectRoot, "site");
|
||||
fs.mkdirSync(siteSubdir, { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, "README.md"), "# loadout\n");
|
||||
fs.mkdirSync(path.join(siteSubdir, ".vercel"), { recursive: true });
|
||||
expectBoth(siteSubdir, "loadout");
|
||||
});
|
||||
|
||||
test("two-tier: vendored sub-repo with .git wins over parent README (strong > weak)", () => {
|
||||
const projectRoot = path.join(tmp, "loadout");
|
||||
const subRepo = path.join(projectRoot, "starter-pack");
|
||||
fs.mkdirSync(subRepo, { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, "README.md"), "# loadout\n");
|
||||
fs.mkdirSync(path.join(subRepo, ".git"), { recursive: true });
|
||||
expectBoth(subRepo, "starter-pack");
|
||||
});
|
||||
|
||||
test("two-tier: outermost weak wins when no strong marker exists on the chain", () => {
|
||||
const projectRoot = path.join(tmp, "loadout");
|
||||
const subdir = path.join(projectRoot, "docs");
|
||||
fs.mkdirSync(subdir, { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, "README.md"), "# loadout\n");
|
||||
fs.writeFileSync(path.join(subdir, "README.md"), "# docs\n");
|
||||
expectBoth(subdir, "loadout");
|
||||
});
|
||||
|
||||
test("nested repo: outermost .git wins — nested/vendored repos don't split stores", () => {
|
||||
// THE bug this port fixes: the old native path asked the INNERMOST repo's
|
||||
// remote. bin/gstack-slug resolves the OUTERMOST strong marker instead.
|
||||
const outer = path.join(tmp, "outer-project");
|
||||
const inner = path.join(outer, "vendor", "inner-lib");
|
||||
fs.mkdirSync(inner, { recursive: true });
|
||||
spawnSync("git", ["init", "-q", outer]);
|
||||
spawnSync("git", ["-C", outer, "remote", "add", "origin", "git@github.com:acme/outer.git"]);
|
||||
spawnSync("git", ["init", "-q", inner]);
|
||||
spawnSync("git", ["-C", inner, "remote", "add", "origin", "git@github.com:vendor/inner.git"]);
|
||||
expectBoth(inner, "acme-outer");
|
||||
});
|
||||
|
||||
test("LIVE BUG SHAPE: a stray empty .git ancestor no longer degrades the slug (remote-first)", () => {
|
||||
// The exact 2026-08-17 reproduction: an ancestor dir with an empty .git
|
||||
// (not a valid repo, no origin) above a canonical-remote repo. The
|
||||
// pre-remote-first native path resolved PROJECT_ROOT to the stray marker
|
||||
// ancestor, found no origin THERE, and degraded to its basename — filing
|
||||
// every repo under it into one shared ~/.gstack/projects/<basename>/.
|
||||
const strayHome = path.join(tmp, "strayhome");
|
||||
fs.mkdirSync(path.join(strayHome, ".git"), { recursive: true }); // empty — invalid repo
|
||||
const repo = path.join(strayHome, "work", "repo");
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
spawnSync("git", ["init", "-q", repo]);
|
||||
spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/garrytan/gstack"]);
|
||||
expectBoth(repo, "garrytan-gstack");
|
||||
expect(slugFromEnvironment(nativeHome(), repo)).not.toBe("strayhome");
|
||||
});
|
||||
|
||||
test("nested repos under a stray marker: a no-remote outer cannot shadow an inner remote", () => {
|
||||
// Outermost REMOTE-bearing repo wins — an outer repo whose origin does
|
||||
// not resolve is skipped by the remote walk, so the inner remote-bearing
|
||||
// repo carries identity (parity with bin/gstack-slug's _outermost_remote_repo).
|
||||
const outer = path.join(tmp, "outer-plain");
|
||||
const inner = path.join(outer, "vendor", "inner-lib");
|
||||
fs.mkdirSync(inner, { recursive: true });
|
||||
spawnSync("git", ["init", "-q", outer]); // no origin — marker-only repo
|
||||
spawnSync("git", ["init", "-q", inner]);
|
||||
spawnSync("git", ["-C", inner, "remote", "add", "origin", "git@github.com:vendor/inner.git"]);
|
||||
expectBoth(inner, "vendor-inner");
|
||||
});
|
||||
|
||||
test("degraded-ancestor cache self-heals: the pre-remote-first cached value is rewritten", () => {
|
||||
// Pre-fix, the resolver cached basename(PROJECT_ROOT) for the stray
|
||||
// marker ancestor. cached == the marker root's basename while a
|
||||
// remote-bearing repo BELOW it exists → recompute + heal the cache.
|
||||
const strayHome = path.join(tmp, "strayhome");
|
||||
fs.mkdirSync(path.join(strayHome, ".git"), { recursive: true });
|
||||
const repo = path.join(strayHome, "git", "proj");
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
spawnSync("git", ["init", "-q", repo]);
|
||||
spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/garrytan/gstack"]);
|
||||
|
||||
const cacheDir = path.join(nativeHome(), "slug-cache");
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const cacheFile = path.join(cacheDir, toMsysPath(repo).replace(/\//g, "_"));
|
||||
fs.writeFileSync(cacheFile, "strayhome"); // the degraded value the old resolver cached
|
||||
|
||||
expect(slugFromEnvironment(nativeHome(), repo)).toBe("garrytan-gstack");
|
||||
// The cache file itself must have been overwritten (self-healing).
|
||||
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("garrytan-gstack");
|
||||
});
|
||||
|
||||
test("package.json wrapper root (no .git): sticky basename slug is PRESERVED — heal is stray-repo-shape only", () => {
|
||||
// Legit #2212 shape: a monorepo wrapper anchored by package.json used
|
||||
// gstack before an inner dir grew a remote-bearing repo. The degraded-
|
||||
// ancestor heal must NOT fire — it is restricted to marker roots anchored
|
||||
// by a .git entry whose origin does NOT resolve (the live-bug shape).
|
||||
const wrapper = path.join(tmp, "wrapperproj");
|
||||
const inner = path.join(wrapper, "apps", "web");
|
||||
fs.mkdirSync(inner, { recursive: true });
|
||||
fs.writeFileSync(path.join(wrapper, "package.json"), '{"name":"wrapper"}\n');
|
||||
spawnSync("git", ["init", "-q", inner]);
|
||||
spawnSync("git", ["-C", inner, "remote", "add", "origin", "https://github.com/acme/web.git"]);
|
||||
|
||||
const cacheDir = path.join(nativeHome(), "slug-cache");
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const cacheFile = path.join(cacheDir, toMsysPath(inner).replace(/\//g, "_"));
|
||||
fs.writeFileSync(cacheFile, "wrapperproj"); // legit sticky identity
|
||||
|
||||
expect(slugFromEnvironment(nativeHome(), inner)).toBe("wrapperproj"); // NOT healed to acme-web
|
||||
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("wrapperproj");
|
||||
|
||||
// The bash implementation agrees on the same fixture (own home, seeded cache).
|
||||
if (HAS_BASH) {
|
||||
const bashCacheDir = path.join(tmp, "bash-home", ".gstack", "slug-cache");
|
||||
fs.mkdirSync(bashCacheDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(bashCacheDir, toMsysPath(inner).replace(/\//g, "_")), "wrapperproj");
|
||||
expect(bashSlug(inner)).toBe("wrapperproj");
|
||||
}
|
||||
});
|
||||
|
||||
test("sticky identity preserved (#2212): a remote adopted AT the marker root is NOT healed", () => {
|
||||
// Legit sticky shape: the repo that adopted the remote IS the marker root
|
||||
// (remote root == project root), so the degraded-ancestor heal must not
|
||||
// fire even though cached == basename(project root).
|
||||
const repo = path.join(tmp, "stickyproj");
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
spawnSync("git", ["init", "-q", repo]);
|
||||
spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/x/y.git"]);
|
||||
|
||||
const cacheDir = path.join(nativeHome(), "slug-cache");
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const cacheFile = path.join(cacheDir, toMsysPath(repo).replace(/\//g, "_"));
|
||||
fs.writeFileSync(cacheFile, "stickyproj"); // pre-origin basename identity
|
||||
|
||||
expect(slugFromEnvironment(nativeHome(), repo)).toBe("stickyproj");
|
||||
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("stickyproj");
|
||||
});
|
||||
|
||||
test('hostile origin `url = ..` never becomes a dot slug — basename fallback (dot-only guard)', () => {
|
||||
// git accepts `..` as a remote URL. Unchecked, the derived slug would be
|
||||
// ".." — path traversal one level above ~/.gstack/projects/. Both
|
||||
// implementations must reject it and fall through to the basename.
|
||||
const repo = path.join(tmp, "dotty");
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
spawnSync("git", ["init", "-q", repo]);
|
||||
spawnSync("git", ["-C", repo, "remote", "add", "origin", ".."]);
|
||||
expectBoth(repo, "dotty");
|
||||
});
|
||||
|
||||
test("GSTACK_PROJECT_SLUG env override beats every other resolution path, never cached", () => {
|
||||
const projectRoot = path.join(tmp, "loadout");
|
||||
const siteSubdir = path.join(projectRoot, "site");
|
||||
fs.mkdirSync(path.join(projectRoot, ".git"), { recursive: true });
|
||||
fs.mkdirSync(siteSubdir, { recursive: true });
|
||||
|
||||
process.env.GSTACK_PROJECT_SLUG = "custom-override";
|
||||
try {
|
||||
expect(slugFromEnvironment(nativeHome(), siteSubdir)).toBe("custom-override");
|
||||
if (HAS_BASH) {
|
||||
expect(bashSlug(siteSubdir, { GSTACK_PROJECT_SLUG: "custom-override" })).toBe("custom-override");
|
||||
}
|
||||
} finally {
|
||||
delete process.env.GSTACK_PROJECT_SLUG;
|
||||
}
|
||||
// Per-invocation escape hatch, never a durable identity: no cache written.
|
||||
const cacheFile = path.join(nativeHome(), "slug-cache", toMsysPath(siteSubdir).replace(/\//g, "_"));
|
||||
expect(fs.existsSync(cacheFile)).toBe(false);
|
||||
});
|
||||
|
||||
test("outermostProjectRoot terminates on hostile path forms (dirname fixed points)", () => {
|
||||
// Mirrors the windows-free-tests regression on the bash side: mixed-form
|
||||
// paths must hit the dirname fixed point, not loop. A hang here would trip
|
||||
// the suite timeout; reaching the assertions IS the pass.
|
||||
for (const hostile of ["C:/Users/nobody/project", ".", "//server/share/dir"]) {
|
||||
expect(typeof outermostProjectRoot(hostile)).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,7 +166,12 @@ describe('brain-cache endpoint detection', () => {
|
||||
expect(outer).not.toBe('local');
|
||||
});
|
||||
|
||||
test('detectEndpointHash still prefers user scope over project scope (#2499)', async () => {
|
||||
test('detectEndpointHash prefers project-local scope over user scope (#2392 wave)', async () => {
|
||||
// Empirically verified against claude 2.1.233 with hermetic fixtures:
|
||||
// `claude mcp get gbrain` reports "Scope: Local config" when both scopes
|
||||
// define the server — project-local WINS. The old pin here encoded the
|
||||
// opposite (user-first) assumption, which mis-hashed endpoints whenever
|
||||
// the two scopes disagreed.
|
||||
const mod = await importCache();
|
||||
const cj = join(TMP_HOME, 'claude.json');
|
||||
writeFileSync(cj, JSON.stringify({
|
||||
@@ -175,14 +180,14 @@ describe('brain-cache endpoint detection', () => {
|
||||
'/w/repo': { mcpServers: { gbrain: { url: 'https://proj.example/mcp' } } },
|
||||
},
|
||||
}));
|
||||
const userScoped = mod.detectEndpointHash(cj, '/w/repo');
|
||||
// Same file minus the user-scope entry → different hash proves user scope won.
|
||||
const conflictHash = mod.detectEndpointHash(cj, '/w/repo');
|
||||
// Same file minus the USER entry → identical hash proves project scope won.
|
||||
writeFileSync(cj, JSON.stringify({
|
||||
projects: {
|
||||
'/w/repo': { mcpServers: { gbrain: { url: 'https://proj.example/mcp' } } },
|
||||
},
|
||||
}));
|
||||
expect(mod.detectEndpointHash(cj, '/w/repo')).not.toBe(userScoped);
|
||||
expect(mod.detectEndpointHash(cj, '/w/repo')).toBe(conflictHash);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
* Gate-tier, free, pure import + assertion. Runs in <100ms.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { describe, test, expect, afterAll } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
BRAIN_CACHE_ENTITIES,
|
||||
SKILL_DIGEST_SUBSETS,
|
||||
@@ -167,3 +170,59 @@ describe('brain-cache-spec internal consistency', () => {
|
||||
expect(getPreflightSkills().sort()).toEqual(expected.sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('brain-cache MCP scope precedence (C15 pin)', () => {
|
||||
// Claude Code resolves a same-name MCP conflict in favor of the
|
||||
// PROJECT-LOCAL entry (.projects[cwd].mcpServers) over the user-scope
|
||||
// entry (.mcpServers). Verified empirically against claude 2.1.233 with a
|
||||
// hermetic fake $HOME: `claude mcp get gbrain` reported "Scope: Local
|
||||
// config" and the project-local URL when both scopes defined gbrain.
|
||||
// detectEndpointHash must hash the endpoint the project actually talks
|
||||
// to, or a brain switch would never invalidate the cache.
|
||||
const TMP = mkdtempSync(join(tmpdir(), 'brain-cache-precedence-'));
|
||||
afterAll(() => rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
const cache = () => import('../bin/gstack-brain-cache');
|
||||
const writeFixture = (name: string, cfg: object): string => {
|
||||
const p = join(TMP, name);
|
||||
writeFileSync(p, JSON.stringify(cfg));
|
||||
return p;
|
||||
};
|
||||
const USER_URL = { type: 'http', url: 'https://user.example/mcp' };
|
||||
const PROJ_URL = { type: 'http', url: 'https://proj.example/mcp' };
|
||||
|
||||
test('project-local gbrain entry beats user scope for a cwd inside the project', async () => {
|
||||
const mod = await cache();
|
||||
const conflict = writeFixture('claude-conflict.json', {
|
||||
mcpServers: { gbrain: USER_URL },
|
||||
projects: { '/w/repo': { mcpServers: { gbrain: PROJ_URL } } },
|
||||
});
|
||||
const conflictHash = mod.detectEndpointHash(conflict, '/w/repo/src');
|
||||
// Same hash as the project entry alone → the project-local entry won.
|
||||
const projOnly = writeFixture('claude-proj-only.json', {
|
||||
projects: { '/w/repo': { mcpServers: { gbrain: PROJ_URL } } },
|
||||
});
|
||||
expect(conflictHash).toBe(mod.detectEndpointHash(projOnly, '/w/repo/src'));
|
||||
// And NOT the user entry's hash.
|
||||
const userOnly = writeFixture('claude-user-only.json', {
|
||||
mcpServers: { gbrain: USER_URL },
|
||||
});
|
||||
expect(conflictHash).not.toBe(mod.detectEndpointHash(userOnly, '/w/repo/src'));
|
||||
});
|
||||
|
||||
test('user scope still resolves when the cwd has no project-local entry', async () => {
|
||||
const mod = await cache();
|
||||
const cj = writeFixture('claude-user-fallback.json', {
|
||||
mcpServers: { gbrain: USER_URL },
|
||||
projects: { '/other/repo': { mcpServers: { gbrain: PROJ_URL } } },
|
||||
});
|
||||
const hash = mod.detectEndpointHash(cj, '/w/unrelated');
|
||||
expect(hash).toHaveLength(8);
|
||||
// Matches the user-only hash — the OTHER project's entry is invisible
|
||||
// outside its own tree.
|
||||
const userOnly = writeFixture('claude-user-only-2.json', {
|
||||
mcpServers: { gbrain: USER_URL },
|
||||
});
|
||||
expect(hash).toBe(mod.detectEndpointHash(userOnly, '/w/unrelated'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,11 +48,15 @@ describe('gstack-brain-sync — Windows path/exec invariants', () => {
|
||||
expect(SRC.indexOf(CR_STRIP)).toBeLessThan(SRC.indexOf('add -f -- "$p"'));
|
||||
});
|
||||
|
||||
test('inline enqueue appends one atomic record at a time (codex P2 #1)', () => {
|
||||
expect(SRC).toContain('os.O_APPEND');
|
||||
expect(SRC).toContain('os.write(fd');
|
||||
// No buffered batch write to the queue (the interleave-corruption shape).
|
||||
test('inline enqueue writes one atomic record at a time (codex P2 #1, spool form)', () => {
|
||||
// The invariant is per-record write atomicity (no interleave corruption).
|
||||
// Pre-spool this was O_APPEND on the shared queue file; the spool design
|
||||
// satisfies it more strongly: one FILE per record, tmp write + atomic
|
||||
// os.replace — nothing shared to interleave.
|
||||
expect(SRC).toContain('os.replace(tmp');
|
||||
// No shared-file append anywhere (the interleave-corruption shape).
|
||||
expect(SRC).not.toContain('open(queue_path, "a"');
|
||||
expect(SRC).not.toContain('os.O_APPEND');
|
||||
});
|
||||
|
||||
test('skip-list is normalized on BOTH discover and drain sides (codex P2 #2)', () => {
|
||||
|
||||
+318
-38
@@ -51,6 +51,28 @@ function git(args: string[], cwd?: string) {
|
||||
return { stdout: res.stdout || '', stderr: res.stderr || '', status: res.status ?? -1 };
|
||||
}
|
||||
|
||||
// ---- spool helpers (maildir-style queue: one FILE per record) ----
|
||||
// Writers create <epoch>-<pid>-<uniq>.json under .brain-queue.d/ via tmp +
|
||||
// atomic rename; the drain deletes exactly the files it snapshotted. The
|
||||
// legacy single-file .brain-queue.jsonl exists only as a migration source.
|
||||
const spoolDir = () => path.join(tmpHome, '.brain-queue.d');
|
||||
const spoolFiles = () =>
|
||||
fs.existsSync(spoolDir())
|
||||
? fs.readdirSync(spoolDir()).filter((f) => f.endsWith('.json')).sort()
|
||||
: [];
|
||||
const spoolText = () =>
|
||||
spoolFiles()
|
||||
.map((f) => fs.readFileSync(path.join(spoolDir(), f), 'utf-8'))
|
||||
.join('');
|
||||
let spoolSeq = 0;
|
||||
function seedSpool(record: string): string {
|
||||
fs.mkdirSync(spoolDir(), { recursive: true });
|
||||
spoolSeq += 1;
|
||||
const name = `${Math.floor(Date.now() / 1000)}-${process.pid}-t${spoolSeq}.json`;
|
||||
fs.writeFileSync(path.join(spoolDir(), name), record.endsWith('\n') ? record : record + '\n');
|
||||
return name;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-sync-home-'));
|
||||
bareRemote = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-sync-remote-'));
|
||||
@@ -130,6 +152,7 @@ describe('gstack-brain-enqueue', () => {
|
||||
test('no-op when feature not initialized', () => {
|
||||
const r = run(['gstack-brain-enqueue', 'projects/foo/learnings.jsonl']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.existsSync(spoolDir())).toBe(false);
|
||||
expect(fs.existsSync(path.join(tmpHome, '.brain-queue.jsonl'))).toBe(false);
|
||||
});
|
||||
|
||||
@@ -137,18 +160,22 @@ describe('gstack-brain-enqueue', () => {
|
||||
fs.mkdirSync(path.join(tmpHome, '.git'), { recursive: true });
|
||||
const r = run(['gstack-brain-enqueue', 'projects/foo/learnings.jsonl']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.existsSync(path.join(tmpHome, '.brain-queue.jsonl'))).toBe(false);
|
||||
expect(fs.existsSync(spoolDir())).toBe(false);
|
||||
});
|
||||
|
||||
test('enqueues when mode is full and .git exists', () => {
|
||||
test('enqueues one spool file when mode is full and .git exists', () => {
|
||||
fs.mkdirSync(path.join(tmpHome, '.git'), { recursive: true });
|
||||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
|
||||
run(['gstack-brain-enqueue', 'projects/foo/learnings.jsonl']);
|
||||
const queue = fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8');
|
||||
expect(queue).toContain('projects/foo/learnings.jsonl');
|
||||
const obj = JSON.parse(queue.trim());
|
||||
const files = spoolFiles();
|
||||
expect(files.length).toBe(1);
|
||||
// Sortable maildir name: <epoch>-<pid>-<uniq>.json.
|
||||
expect(files[0]).toMatch(/^\d+-\d+-\d+\.json$/);
|
||||
const obj = JSON.parse(fs.readFileSync(path.join(spoolDir(), files[0]), 'utf-8').trim());
|
||||
expect(obj.file).toBe('projects/foo/learnings.jsonl');
|
||||
expect(obj.ts).toBeTruthy();
|
||||
// No tmp-file droppings left behind.
|
||||
expect(fs.readdirSync(spoolDir()).filter((f) => f.startsWith('.tmp-')).length).toBe(0);
|
||||
});
|
||||
|
||||
test('skip list honored', () => {
|
||||
@@ -157,12 +184,11 @@ describe('gstack-brain-enqueue', () => {
|
||||
fs.writeFileSync(path.join(tmpHome, '.brain-skip.txt'), 'projects/foo/secret.jsonl\n');
|
||||
run(['gstack-brain-enqueue', 'projects/foo/secret.jsonl']);
|
||||
run(['gstack-brain-enqueue', 'projects/foo/ok.jsonl']);
|
||||
const queue = fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8');
|
||||
expect(queue).not.toContain('secret.jsonl');
|
||||
expect(queue).toContain('ok.jsonl');
|
||||
expect(spoolText()).not.toContain('secret.jsonl');
|
||||
expect(spoolText()).toContain('ok.jsonl');
|
||||
});
|
||||
|
||||
test('concurrent enqueues all land (atomic append)', async () => {
|
||||
test('concurrent enqueues all land (one spool file per record)', async () => {
|
||||
fs.mkdirSync(path.join(tmpHome, '.git'), { recursive: true });
|
||||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
|
||||
const procs = [];
|
||||
@@ -176,9 +202,10 @@ describe('gstack-brain-enqueue', () => {
|
||||
}));
|
||||
}
|
||||
await Promise.all(procs);
|
||||
const queue = fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8');
|
||||
const lines = queue.trim().split('\n').filter(Boolean);
|
||||
expect(lines.length).toBe(10);
|
||||
expect(spoolFiles().length).toBe(10);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(spoolText()).toContain(`file-${i}.jsonl`);
|
||||
}
|
||||
});
|
||||
|
||||
test('no args does not crash', () => {
|
||||
@@ -366,9 +393,8 @@ describe('gstack-brain-sync egress receipt gate', () => {
|
||||
expect(refused.stderr).toContain('EGRESS_RECEIPT_FAILED');
|
||||
expect(refused.stderr).toContain('Fix: chmod -R u+w');
|
||||
expect(refused.stderr).toContain('ATTEMPTS to send off-machine');
|
||||
// Queue intact (receipt is written BEFORE the commit consumes it).
|
||||
const queue = fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8');
|
||||
expect(queue).toContain('projects/p/learnings.jsonl');
|
||||
// Spool intact (receipt is written BEFORE finalize consumes records).
|
||||
expect(spoolText()).toContain('projects/p/learnings.jsonl');
|
||||
// No local commit was created.
|
||||
expect(git(['rev-list', '--count', 'HEAD']).stdout.trim()).toBe(commitsBefore);
|
||||
// Nothing reached the remote.
|
||||
@@ -433,19 +459,43 @@ describe('gstack-brain-uninstall', () => {
|
||||
// --discover-new: cursor-based change detection
|
||||
// ---------------------------------------------------------------
|
||||
describe('gstack-brain-sync --discover-new', () => {
|
||||
test('enqueues new allowlisted files; idempotent on re-run', () => {
|
||||
test('enqueues new allowlisted files as spool records; idempotent on re-run', () => {
|
||||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
|
||||
fs.mkdirSync(path.join(tmpHome, 'retros'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpHome, 'retros/week-1.md'), '# retro\n');
|
||||
run(['gstack-brain-sync', '--discover-new']);
|
||||
let queue = fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8');
|
||||
expect(queue).toContain('retros/week-1.md');
|
||||
// Clear queue, run again — idempotent (no new entries).
|
||||
fs.writeFileSync(path.join(tmpHome, '.brain-queue.jsonl'), '');
|
||||
expect(spoolText()).toContain('retros/week-1.md');
|
||||
// Clear the spool, run again — idempotent (no new records).
|
||||
for (const f of spoolFiles()) fs.unlinkSync(path.join(spoolDir(), f));
|
||||
run(['gstack-brain-sync', '--discover-new']);
|
||||
queue = fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8');
|
||||
expect(queue.trim()).toBe('');
|
||||
expect(spoolFiles().length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Enqueue tmp janitor: a writer killed between its tmp write and the
|
||||
// atomic rename orphans a .tmp-* file forever (it never becomes a
|
||||
// record, nothing else touches it). The drain reaps ones older than
|
||||
// 1 hour, inside its lock; fresh ones (in-flight enqueues) survive.
|
||||
// ---------------------------------------------------------------
|
||||
describe('enqueue tmp janitor', () => {
|
||||
test('an orphaned .tmp-* older than 1h is reaped on --once; a fresh one survives', () => {
|
||||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
|
||||
fs.mkdirSync(spoolDir(), { recursive: true });
|
||||
|
||||
const oldTmp = path.join(spoolDir(), '.tmp-99999-x1');
|
||||
fs.writeFileSync(oldTmp, '{"file":"projects/p/learnings.jsonl"}\n');
|
||||
const past = new Date(Date.now() - 2 * 3600 * 1000);
|
||||
fs.utimesSync(oldTmp, past, past);
|
||||
|
||||
const freshTmp = path.join(spoolDir(), '.tmp-99999-x2');
|
||||
fs.writeFileSync(freshTmp, '{"file":"projects/p/learnings.jsonl"}\n');
|
||||
|
||||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||||
expect(fs.existsSync(oldTmp)).toBe(false); // orphan reaped
|
||||
expect(fs.existsSync(freshTmp)).toBe(true); // in-flight write untouched
|
||||
});
|
||||
});
|
||||
|
||||
@@ -458,7 +508,6 @@ describe('#2549 queue integrity', () => {
|
||||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||||
run(['gstack-config', 'set', 'artifacts_sync_mode', mode]);
|
||||
}
|
||||
const queueText = () => fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8');
|
||||
const statusJson = () => JSON.parse(fs.readFileSync(path.join(tmpHome, '.brain-sync-status.json'), 'utf-8'));
|
||||
|
||||
test('privacy-held entries are RETAINED and classified, not wiped as "no allowlisted changes"', () => {
|
||||
@@ -470,9 +519,9 @@ describe('#2549 queue integrity', () => {
|
||||
const r = run(['gstack-brain-sync', '--once']);
|
||||
expect(r.status).toBe(0);
|
||||
// The exact #2549 repro: the old code truncated the queue here and said
|
||||
// "no allowlisted changes in queue". The entry must survive, and the
|
||||
// "no allowlisted changes in queue". The record must survive, and the
|
||||
// status must attribute the hold honestly.
|
||||
expect(queueText()).toContain('projects/p/timeline.jsonl');
|
||||
expect(spoolText()).toContain('projects/p/timeline.jsonl');
|
||||
const s = statusJson();
|
||||
expect(s.status).toBe('idle');
|
||||
expect(s.message).toContain('privacy-held retained');
|
||||
@@ -484,13 +533,13 @@ describe('#2549 queue integrity', () => {
|
||||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||||
// Unmatched: no allowlist glob covers .txt scratch files.
|
||||
fs.writeFileSync(path.join(tmpHome, 'projects/p/scratch.txt'), 'x\n');
|
||||
fs.appendFileSync(path.join(tmpHome, '.brain-queue.jsonl'), '{"file":"projects/p/scratch.txt"}\n');
|
||||
seedSpool('{"file":"projects/p/scratch.txt"}');
|
||||
// Missing: allowlisted name that does not exist on disk.
|
||||
fs.appendFileSync(path.join(tmpHome, '.brain-queue.jsonl'), '{"file":"projects/p/learnings.jsonl"}\n');
|
||||
seedSpool('{"file":"projects/p/learnings.jsonl"}');
|
||||
const r = run(['gstack-brain-sync', '--once']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(queueText()).not.toContain('scratch.txt');
|
||||
expect(queueText()).not.toContain('learnings.jsonl');
|
||||
expect(spoolText()).not.toContain('scratch.txt');
|
||||
expect(spoolText()).not.toContain('learnings.jsonl');
|
||||
const s = statusJson();
|
||||
expect(s.message).toContain('1 unmatched dropped');
|
||||
expect(s.message).toContain('1 missing dropped');
|
||||
@@ -504,17 +553,25 @@ describe('#2549 queue integrity', () => {
|
||||
expect(detail.dropped.missing).toContain('projects/p/learnings.jsonl');
|
||||
});
|
||||
|
||||
test('an unparseable queue line is preserved, never destroyed', () => {
|
||||
test('an unparseable legacy queue line migrates as-is and is quarantined, never destroyed', () => {
|
||||
// The line lands in the legacy single-file queue (pre-spool writer);
|
||||
// migration converts it verbatim to a spool record, and the drain moves
|
||||
// what it cannot parse into quarantine (never deletes it, and never
|
||||
// leaves it re-warning at every boundary).
|
||||
initWithMode('full');
|
||||
fs.appendFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'not json at all\n');
|
||||
const r = run(['gstack-brain-sync', '--once']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(queueText()).toContain('not json at all');
|
||||
const qDir = path.join(spoolDir(), 'quarantine');
|
||||
expect(fs.existsSync(qDir)).toBe(true);
|
||||
const qFiles = fs.readdirSync(qDir);
|
||||
expect(qFiles.length).toBe(1);
|
||||
expect(fs.readFileSync(path.join(qDir, qFiles[0]), 'utf-8')).toContain('not json at all');
|
||||
});
|
||||
|
||||
test('surgical rewrite: a synced entry leaves the queue while a held sibling survives the same drain', () => {
|
||||
// Proves the rewrite is a live filtered rewrite, not a truncation: two
|
||||
// entries drain in one --once, one stages+pushes, one is mode-held.
|
||||
test('finalize: a synced record leaves the spool while a held sibling survives the same drain', () => {
|
||||
// Proves finalize is a per-record delete, not a truncation: two records
|
||||
// drain in one --once, one stages+pushes, one is mode-held.
|
||||
initWithMode('artifacts-only');
|
||||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","insight":"y","ts":"2026-01-01T00:00:00Z"}\n');
|
||||
@@ -523,8 +580,8 @@ describe('#2549 queue integrity', () => {
|
||||
run(['gstack-brain-enqueue', 'projects/p/timeline.jsonl']);
|
||||
const r = run(['gstack-brain-sync', '--once']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(queueText()).not.toContain('learnings.jsonl'); // synced, removed
|
||||
expect(queueText()).toContain('timeline.jsonl'); // held, retained
|
||||
expect(spoolText()).not.toContain('learnings.jsonl'); // synced, removed
|
||||
expect(spoolText()).toContain('timeline.jsonl'); // held, retained
|
||||
const log = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8' });
|
||||
expect(log.stdout).toMatch(/sync: 1 file/);
|
||||
});
|
||||
@@ -550,8 +607,8 @@ describe('#2549 queue integrity', () => {
|
||||
const s = statusJson();
|
||||
expect(s.status).toBe('push_failed');
|
||||
expect(s.message).toContain('commit retained locally');
|
||||
// Drained path left the queue — it lives in the local commit now.
|
||||
expect(queueText()).not.toContain('learnings.jsonl');
|
||||
// Drained record left the spool — it lives in the local commit now.
|
||||
expect(spoolText()).not.toContain('learnings.jsonl');
|
||||
// The commit exists locally, ahead of origin.
|
||||
const ahead = git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim();
|
||||
expect(Number(ahead)).toBeGreaterThan(0);
|
||||
@@ -680,3 +737,226 @@ describe('#2549 queue integrity', () => {
|
||||
expect(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim()).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// C12 spool queue: per-record files kill the enqueue/drain race.
|
||||
// One FILE per record under .brain-queue.d/ — writer and drainer never
|
||||
// share an inode, so the lockless append-vs-rewrite race is structurally
|
||||
// gone. Crash semantics are at-least-once (unfinalized records re-drain).
|
||||
// ---------------------------------------------------------------
|
||||
describe('C12 spool queue', () => {
|
||||
function initWithMode(mode: string) {
|
||||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||||
run(['gstack-config', 'set', 'artifacts_sync_mode', mode]);
|
||||
}
|
||||
const remoteLog = () =>
|
||||
spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8' }).stdout;
|
||||
|
||||
test('two rapid enqueues of different paths create two spool files; one drain syncs both', () => {
|
||||
initWithMode('full');
|
||||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpHome, 'retros'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","ts":"2026-01-01T00:00:00Z"}\n');
|
||||
fs.writeFileSync(path.join(tmpHome, 'retros/week-1.md'), '# retro\n');
|
||||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||||
run(['gstack-brain-enqueue', 'retros/week-1.md']);
|
||||
expect(spoolFiles().length).toBe(2);
|
||||
const r = run(['gstack-brain-sync', '--once']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(spoolFiles().length).toBe(0);
|
||||
expect(remoteLog()).toMatch(/sync: 2 file/);
|
||||
});
|
||||
|
||||
test('a record created after a drain survives untouched and drains on the NEXT --once', () => {
|
||||
// Structural form of the concurrent-append test: finalize deletes only
|
||||
// snapshot-manifest files, so a record the drain never listed cannot be
|
||||
// touched — whether it lands mid-drain or after.
|
||||
initWithMode('full');
|
||||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpHome, 'retros'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"a","ts":"2026-01-01T00:00:00Z"}\n');
|
||||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||||
expect(spoolFiles().length).toBe(0);
|
||||
// New record arrives (a writer that raced the previous drain).
|
||||
fs.writeFileSync(path.join(tmpHome, 'retros/week-1.md'), '# retro\n');
|
||||
run(['gstack-brain-enqueue', 'retros/week-1.md']);
|
||||
const [pending] = spoolFiles();
|
||||
expect(pending).toBeTruthy();
|
||||
const pendingContent = fs.readFileSync(path.join(spoolDir(), pending), 'utf-8');
|
||||
expect(pendingContent).toContain('retros/week-1.md');
|
||||
// Untouched by the completed drain; the NEXT drain delivers it.
|
||||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||||
expect(spoolFiles().length).toBe(0);
|
||||
expect(remoteLog()).toMatch(/sync: 1 file/);
|
||||
});
|
||||
|
||||
test('at-least-once: a drain that fails before finalize leaves every spool file for the next run', () => {
|
||||
if (process.platform === 'win32' || process.getuid?.() === 0) return; // chmod advisory there
|
||||
initWithMode('full');
|
||||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpHome, 'retros'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"a","ts":"2026-01-01T00:00:00Z"}\n');
|
||||
fs.writeFileSync(path.join(tmpHome, 'retros/week-1.md'), '# retro\n');
|
||||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||||
run(['gstack-brain-enqueue', 'retros/week-1.md']);
|
||||
const seeded = spoolFiles();
|
||||
expect(seeded.length).toBe(2);
|
||||
|
||||
// Break the egress-receipt ledger: the drain fails AFTER staging but
|
||||
// BEFORE any commit or finalize — simulating a crash mid-drain.
|
||||
fs.mkdirSync(path.join(tmpHome, 'security'), { recursive: true });
|
||||
fs.chmodSync(path.join(tmpHome, 'security'), 0o500);
|
||||
try {
|
||||
const refused = run(['gstack-brain-sync', '--once']);
|
||||
expect(refused.status).toBe(1);
|
||||
// The exact same spool files are still present — nothing consumed.
|
||||
expect(spoolFiles()).toEqual(seeded);
|
||||
} finally {
|
||||
fs.chmodSync(path.join(tmpHome, 'security'), 0o700);
|
||||
}
|
||||
|
||||
// Next run re-drains the surviving records.
|
||||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||||
expect(spoolFiles().length).toBe(0);
|
||||
expect(remoteLog()).toMatch(/sync: 2 file/);
|
||||
});
|
||||
|
||||
test('legacy migration: .brain-queue.jsonl lines convert to spool records, nothing lost', () => {
|
||||
// Pre-spool writers appended to the single-file queue. Three lines: two
|
||||
// stageable artifacts, one behavioral (mode-held under artifacts-only).
|
||||
initWithMode('artifacts-only');
|
||||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpHome, 'retros'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","ts":"2026-01-01T00:00:00Z"}\n');
|
||||
fs.writeFileSync(path.join(tmpHome, 'retros/week-1.md'), '# retro\n');
|
||||
fs.writeFileSync(path.join(tmpHome, 'projects/p/timeline.jsonl'), '{"skill":"x","event":"started"}\n');
|
||||
fs.writeFileSync(path.join(tmpHome, '.brain-queue.jsonl'),
|
||||
'{"file":"projects/p/learnings.jsonl","ts":"2026-01-01T00:00:00Z"}\n' +
|
||||
'{"file":"retros/week-1.md","ts":"2026-01-01T00:00:01Z"}\n' +
|
||||
'{"file":"projects/p/timeline.jsonl","ts":"2026-01-01T00:00:02Z"}\n');
|
||||
const r = run(['gstack-brain-sync', '--once']);
|
||||
expect(r.status).toBe(0);
|
||||
// Legacy file consumed; no .migrating remnant.
|
||||
expect(fs.existsSync(path.join(tmpHome, '.brain-queue.jsonl'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(tmpHome, '.brain-queue.jsonl.migrating'))).toBe(false);
|
||||
// Both artifacts synced; the behavioral record survives as a spool file.
|
||||
expect(remoteLog()).toMatch(/sync: 2 file/);
|
||||
expect(spoolText()).toContain('projects/p/timeline.jsonl');
|
||||
expect(spoolText()).not.toContain('learnings.jsonl');
|
||||
});
|
||||
|
||||
test('an unparseable spool record is quarantined with a warning; the drain continues', () => {
|
||||
initWithMode('full');
|
||||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","ts":"2026-01-01T00:00:00Z"}\n');
|
||||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||||
const badFile = seedSpool('this is not json');
|
||||
const r = run(['gstack-brain-sync', '--once']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain('unparseable');
|
||||
// The good sibling synced; the unreadable record was never destroyed —
|
||||
// it moved to quarantine so it stops re-warning at every boundary.
|
||||
expect(remoteLog()).toMatch(/sync: 1 file/);
|
||||
expect(spoolFiles()).toEqual([]);
|
||||
const qPath = path.join(spoolDir(), 'quarantine', badFile);
|
||||
expect(fs.existsSync(qPath)).toBe(true);
|
||||
expect(fs.readFileSync(qPath, 'utf-8')).toContain('this is not json');
|
||||
});
|
||||
|
||||
test('--status queue_depth counts spool records plus unmigrated legacy lines', () => {
|
||||
initWithMode('full');
|
||||
seedSpool('{"file":"projects/p/a.jsonl","ts":"t"}');
|
||||
seedSpool('{"file":"projects/p/b.jsonl","ts":"t"}');
|
||||
fs.writeFileSync(path.join(tmpHome, '.brain-queue.jsonl'), '{"file":"projects/p/c.jsonl","ts":"t"}\n');
|
||||
const r = run(['gstack-brain-sync', '--status']);
|
||||
expect(r.status).toBe(0);
|
||||
const supplemental = JSON.parse(r.stdout.trim().split('\n').pop()!);
|
||||
expect(supplemental.queue_depth).toBe(3);
|
||||
});
|
||||
|
||||
test('G1: a malformed pulled privacy map (["bad"]) holds the queue — warns, deletes NOTHING, next run re-drains', () => {
|
||||
// Remotely triggerable kill vector: the privacy map arrives via the
|
||||
// artifacts-repo pull. A non-dict entry used to raise mid-classification
|
||||
// AFTER the snapshot manifest was written, and the old finalize polarity
|
||||
// ("delete unless retained") then unlinked EVERY snapshotted record.
|
||||
initWithMode('full');
|
||||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","ts":"2026-01-01T00:00:00Z"}\n');
|
||||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||||
const seeded = spoolFiles();
|
||||
expect(seeded.length).toBe(1);
|
||||
fs.writeFileSync(path.join(tmpHome, '.brain-privacy-map.json'), '["bad"]');
|
||||
|
||||
const r = run(['gstack-brain-sync', '--once']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain('privacy map');
|
||||
// Zero records deleted; nothing pushed.
|
||||
expect(spoolFiles()).toEqual(seeded);
|
||||
expect(remoteLog()).not.toMatch(/sync:/);
|
||||
|
||||
// Fix the map: the surviving queue re-drains and syncs.
|
||||
fs.writeFileSync(path.join(tmpHome, '.brain-privacy-map.json'), '[]');
|
||||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||||
expect(spoolFiles().length).toBe(0);
|
||||
expect(remoteLog()).toMatch(/sync: 1 file/);
|
||||
});
|
||||
|
||||
test('G1: a classifier that dies AFTER the snapshot write consumes nothing (call-site exit check + explicit-delete finalize)', () => {
|
||||
// A dict entry with a non-string pattern passes the shape filter but
|
||||
// raises inside fnmatch DURING classification — the post-manifest crash
|
||||
// window (same shape as ENOSPC/OOM mid-run). The call site must see the
|
||||
// nonzero exit, warn, skip finalize, and leave everything queued.
|
||||
initWithMode('full');
|
||||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","ts":"2026-01-01T00:00:00Z"}\n');
|
||||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||||
const seeded = spoolFiles();
|
||||
expect(seeded.length).toBe(1);
|
||||
fs.writeFileSync(path.join(tmpHome, '.brain-privacy-map.json'), '[{"pattern": 123}]');
|
||||
|
||||
const r = run(['gstack-brain-sync', '--once']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain('classification failed');
|
||||
expect(spoolFiles()).toEqual(seeded); // zero records deleted
|
||||
expect(remoteLog()).not.toMatch(/sync:/);
|
||||
const status = JSON.parse(fs.readFileSync(path.join(tmpHome, '.brain-sync-status.json'), 'utf-8'));
|
||||
expect(status.status).toBe('error');
|
||||
expect(status.message).toContain('queue preserved');
|
||||
|
||||
// Fix the map: the surviving queue re-drains and syncs.
|
||||
fs.writeFileSync(path.join(tmpHome, '.brain-privacy-map.json'), '[]');
|
||||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||||
expect(spoolFiles().length).toBe(0);
|
||||
expect(remoteLog()).toMatch(/sync: 1 file/);
|
||||
});
|
||||
|
||||
test('G1: finalize is explicit-delete-only and the fast path is .migrating-aware (static pins)', () => {
|
||||
const src = fs.readFileSync(path.join(BIN, 'gstack-brain-sync'), 'utf-8');
|
||||
// The compute call site checks the python exit status before finalizing.
|
||||
expect(src).toMatch(/if ! compute_paths_to_stage /);
|
||||
// finalize_queue takes the staged-paths file and deletes only staged ∪ dropped.
|
||||
expect(src).toContain('deletable = staged | dropped');
|
||||
expect(src).toContain('if p not in deletable:');
|
||||
// The empty fast path also treats a leftover .migrating file as non-idle.
|
||||
expect(src).toMatch(/spool_has_records && \[ ! -s "\$QUEUE" \] && \[ ! -s "\$QUEUE\.migrating" \]/);
|
||||
});
|
||||
|
||||
test('--drop-queue keeps the --yes gate and counts spool + legacy entries', () => {
|
||||
initWithMode('full');
|
||||
seedSpool('{"file":"projects/p/a.jsonl","ts":"t"}');
|
||||
seedSpool('{"file":"projects/p/b.jsonl","ts":"t"}');
|
||||
fs.writeFileSync(path.join(tmpHome, '.brain-queue.jsonl'), '{"file":"projects/p/c.jsonl","ts":"t"}\n');
|
||||
const refused = run(['gstack-brain-sync', '--drop-queue']);
|
||||
expect(refused.status).toBe(1);
|
||||
expect(refused.stderr).toContain('--yes');
|
||||
expect(spoolFiles().length).toBe(2);
|
||||
const dropped = run(['gstack-brain-sync', '--drop-queue', '--yes']);
|
||||
expect(dropped.status).toBe(0);
|
||||
expect(dropped.stdout).toContain('dropped 3 queue entries');
|
||||
expect(spoolFiles().length).toBe(0);
|
||||
expect(fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8')).toBe('');
|
||||
const again = run(['gstack-brain-sync', '--drop-queue', '--yes']);
|
||||
expect(again.stdout).toContain('queue already empty');
|
||||
});
|
||||
});
|
||||
|
||||
+13
-3
@@ -515,7 +515,7 @@ _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || e
|
||||
_GBRAIN_MCP_MODE="none"
|
||||
_GBRAIN_MCP_ENTRY=""
|
||||
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then
|
||||
_GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '.mcpServers.gbrain // ((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // empty' "$HOME/.claude.json" 2>/dev/null)
|
||||
_GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/")) or ($cwd | startswith($k + "\\"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // .mcpServers.gbrain // empty' "$HOME/.claude.json" 2>/dev/null)
|
||||
_GBRAIN_MCP_TYPE=$(printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -r '.type // .transport // empty' 2>/dev/null)
|
||||
case "$_GBRAIN_MCP_TYPE" in
|
||||
url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;;
|
||||
@@ -555,7 +555,11 @@ if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then
|
||||
echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})"
|
||||
elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then
|
||||
_BRAIN_QUEUE_DEPTH=0
|
||||
[ -f "$_GSTACK_HOME/.brain-queue.jsonl" ] && _BRAIN_QUEUE_DEPTH=$(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl" | tr -d ' ')
|
||||
# Spool-dir queue (one file per record); legacy .brain-queue.jsonl lines are
|
||||
# counted too until the drain migrates them.
|
||||
[ -d "$_GSTACK_HOME/.brain-queue.d" ] && _BRAIN_QUEUE_DEPTH=$(find "$_GSTACK_HOME/.brain-queue.d" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' ')
|
||||
[ -f "$_GSTACK_HOME/.brain-queue.jsonl" ] && _BRAIN_QUEUE_DEPTH=$(( _BRAIN_QUEUE_DEPTH + $(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl" | tr -d ' ') ))
|
||||
[ -f "$_GSTACK_HOME/.brain-queue.jsonl.migrating" ] && _BRAIN_QUEUE_DEPTH=$(( _BRAIN_QUEUE_DEPTH + $(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl.migrating" | tr -d ' ') ))
|
||||
_BRAIN_LAST_PUSH="never"
|
||||
[ -f "$_GSTACK_HOME/.brain-last-push" ] && _BRAIN_LAST_PUSH=$(cat "$_GSTACK_HOME/.brain-last-push" 2>/dev/null || echo never)
|
||||
echo "ARTIFACTS_SYNC: mode=$_BRAIN_SYNC_MODE | last_push=$_BRAIN_LAST_PUSH | queue=$_BRAIN_QUEUE_DEPTH"
|
||||
@@ -773,7 +777,13 @@ Escalate after 3 failed attempts, uncertain security-sensitive changes, or scope
|
||||
|
||||
## Operational Self-Improvement
|
||||
|
||||
Before completing, if you discovered a durable project quirk or command fix that would save 5+ minutes next time, log it:
|
||||
Before completing, review the session for durable learnings and log each one —
|
||||
this step ALWAYS runs, it is not conditional on something feeling noteworthy
|
||||
(#2402: 43 of 44 learnings came from explicit /learn because "if you
|
||||
discovered" read as optional). A durable learning is a project quirk, command
|
||||
fix, pitfall, or pattern that would save 5+ minutes in a future session. If
|
||||
the review genuinely surfaces none, state "No durable learnings this session"
|
||||
in your completion summary — an explicit empty result, not a skipped step.
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"SKILL_NAME","type":"operational","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"observed"}'
|
||||
|
||||
+15
-5
@@ -501,7 +501,7 @@ _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || e
|
||||
_GBRAIN_MCP_MODE="none"
|
||||
_GBRAIN_MCP_ENTRY=""
|
||||
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then
|
||||
_GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '.mcpServers.gbrain // ((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // empty' "$HOME/.claude.json" 2>/dev/null)
|
||||
_GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/")) or ($cwd | startswith($k + "\\"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // .mcpServers.gbrain // empty' "$HOME/.claude.json" 2>/dev/null)
|
||||
_GBRAIN_MCP_TYPE=$(printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -r '.type // .transport // empty' 2>/dev/null)
|
||||
case "$_GBRAIN_MCP_TYPE" in
|
||||
url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;;
|
||||
@@ -541,7 +541,11 @@ if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then
|
||||
echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})"
|
||||
elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then
|
||||
_BRAIN_QUEUE_DEPTH=0
|
||||
[ -f "$_GSTACK_HOME/.brain-queue.jsonl" ] && _BRAIN_QUEUE_DEPTH=$(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl" | tr -d ' ')
|
||||
# Spool-dir queue (one file per record); legacy .brain-queue.jsonl lines are
|
||||
# counted too until the drain migrates them.
|
||||
[ -d "$_GSTACK_HOME/.brain-queue.d" ] && _BRAIN_QUEUE_DEPTH=$(find "$_GSTACK_HOME/.brain-queue.d" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' ')
|
||||
[ -f "$_GSTACK_HOME/.brain-queue.jsonl" ] && _BRAIN_QUEUE_DEPTH=$(( _BRAIN_QUEUE_DEPTH + $(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl" | tr -d ' ') ))
|
||||
[ -f "$_GSTACK_HOME/.brain-queue.jsonl.migrating" ] && _BRAIN_QUEUE_DEPTH=$(( _BRAIN_QUEUE_DEPTH + $(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl.migrating" | tr -d ' ') ))
|
||||
_BRAIN_LAST_PUSH="never"
|
||||
[ -f "$_GSTACK_HOME/.brain-last-push" ] && _BRAIN_LAST_PUSH=$(cat "$_GSTACK_HOME/.brain-last-push" 2>/dev/null || echo never)
|
||||
echo "ARTIFACTS_SYNC: mode=$_BRAIN_SYNC_MODE | last_push=$_BRAIN_LAST_PUSH | queue=$_BRAIN_QUEUE_DEPTH"
|
||||
@@ -781,7 +785,13 @@ Escalate after 3 failed attempts, uncertain security-sensitive changes, or scope
|
||||
|
||||
## Operational Self-Improvement
|
||||
|
||||
Before completing, if you discovered a durable project quirk or command fix that would save 5+ minutes next time, log it:
|
||||
Before completing, review the session for durable learnings and log each one —
|
||||
this step ALWAYS runs, it is not conditional on something feeling noteworthy
|
||||
(#2402: 43 of 44 learnings came from explicit /learn because "if you
|
||||
discovered" read as optional). A durable learning is a project quirk, command
|
||||
fix, pitfall, or pattern that would save 5+ minutes in a future session. If
|
||||
the review genuinely surfaces none, state "No durable learnings this session"
|
||||
in your completion summary — an explicit empty result, not a skipped step.
|
||||
|
||||
```bash
|
||||
$GSTACK_BIN/gstack-learnings-log '{"skill":"SKILL_NAME","type":"operational","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"observed"}'
|
||||
@@ -2024,7 +2034,7 @@ Before reviewing code quality, check: **did they build what was requested — no
|
||||
|
||||
Review the diff for structural issues that tests don't catch.
|
||||
|
||||
1. Read `.agents/skills/gstack/review/checklist.md`. If the file cannot be read, **STOP** and report the error.
|
||||
1. Read `$GSTACK_ROOT/review/checklist.md`. If the file cannot be read, **STOP** and report the error.
|
||||
|
||||
2. Run `git diff origin/<base>` to get the full diff (scoped to feature changes against the freshly-fetched base branch).
|
||||
|
||||
@@ -2206,7 +2216,7 @@ Save the review output — it goes into the PR body in Step 19.
|
||||
|
||||
**Subagent prompt:**
|
||||
|
||||
> You are classifying Greptile review comments for a /ship workflow. Read `.agents/skills/gstack/review/greptile-triage.md` and follow the fetch, filter, classify, and **escalation detection** steps. Do NOT fix code, do NOT reply to comments, do NOT commit — report only.
|
||||
> You are classifying Greptile review comments for a /ship workflow. Read `$GSTACK_ROOT/review/greptile-triage.md` and follow the fetch, filter, classify, and **escalation detection** steps. Do NOT fix code, do NOT reply to comments, do NOT commit — report only.
|
||||
>
|
||||
> For each comment, assign: `classification` (`valid_actionable`, `already_fixed`, `false_positive`, `suppressed`), `escalation_tier` (1 or 2), the file:line or [top-level] tag, body summary, and permalink URL.
|
||||
>
|
||||
|
||||
+15
-5
@@ -503,7 +503,7 @@ _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || e
|
||||
_GBRAIN_MCP_MODE="none"
|
||||
_GBRAIN_MCP_ENTRY=""
|
||||
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then
|
||||
_GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '.mcpServers.gbrain // ((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // empty' "$HOME/.claude.json" 2>/dev/null)
|
||||
_GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/")) or ($cwd | startswith($k + "\\"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // .mcpServers.gbrain // empty' "$HOME/.claude.json" 2>/dev/null)
|
||||
_GBRAIN_MCP_TYPE=$(printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -r '.type // .transport // empty' 2>/dev/null)
|
||||
case "$_GBRAIN_MCP_TYPE" in
|
||||
url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;;
|
||||
@@ -543,7 +543,11 @@ if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then
|
||||
echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})"
|
||||
elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then
|
||||
_BRAIN_QUEUE_DEPTH=0
|
||||
[ -f "$_GSTACK_HOME/.brain-queue.jsonl" ] && _BRAIN_QUEUE_DEPTH=$(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl" | tr -d ' ')
|
||||
# Spool-dir queue (one file per record); legacy .brain-queue.jsonl lines are
|
||||
# counted too until the drain migrates them.
|
||||
[ -d "$_GSTACK_HOME/.brain-queue.d" ] && _BRAIN_QUEUE_DEPTH=$(find "$_GSTACK_HOME/.brain-queue.d" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' ')
|
||||
[ -f "$_GSTACK_HOME/.brain-queue.jsonl" ] && _BRAIN_QUEUE_DEPTH=$(( _BRAIN_QUEUE_DEPTH + $(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl" | tr -d ' ') ))
|
||||
[ -f "$_GSTACK_HOME/.brain-queue.jsonl.migrating" ] && _BRAIN_QUEUE_DEPTH=$(( _BRAIN_QUEUE_DEPTH + $(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl.migrating" | tr -d ' ') ))
|
||||
_BRAIN_LAST_PUSH="never"
|
||||
[ -f "$_GSTACK_HOME/.brain-last-push" ] && _BRAIN_LAST_PUSH=$(cat "$_GSTACK_HOME/.brain-last-push" 2>/dev/null || echo never)
|
||||
echo "ARTIFACTS_SYNC: mode=$_BRAIN_SYNC_MODE | last_push=$_BRAIN_LAST_PUSH | queue=$_BRAIN_QUEUE_DEPTH"
|
||||
@@ -761,7 +765,13 @@ Escalate after 3 failed attempts, uncertain security-sensitive changes, or scope
|
||||
|
||||
## Operational Self-Improvement
|
||||
|
||||
Before completing, if you discovered a durable project quirk or command fix that would save 5+ minutes next time, log it:
|
||||
Before completing, review the session for durable learnings and log each one —
|
||||
this step ALWAYS runs, it is not conditional on something feeling noteworthy
|
||||
(#2402: 43 of 44 learnings came from explicit /learn because "if you
|
||||
discovered" read as optional). A durable learning is a project quirk, command
|
||||
fix, pitfall, or pattern that would save 5+ minutes in a future session. If
|
||||
the review genuinely surfaces none, state "No durable learnings this session"
|
||||
in your completion summary — an explicit empty result, not a skipped step.
|
||||
|
||||
```bash
|
||||
$GSTACK_BIN/gstack-learnings-log '{"skill":"SKILL_NAME","type":"operational","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"observed"}'
|
||||
@@ -2031,7 +2041,7 @@ Before reviewing code quality, check: **did they build what was requested — no
|
||||
|
||||
Review the diff for structural issues that tests don't catch.
|
||||
|
||||
1. Read `.factory/skills/gstack/review/checklist.md`. If the file cannot be read, **STOP** and report the error.
|
||||
1. Read `$GSTACK_ROOT/review/checklist.md`. If the file cannot be read, **STOP** and report the error.
|
||||
|
||||
2. Run `git diff origin/<base>` to get the full diff (scoped to feature changes against the freshly-fetched base branch).
|
||||
|
||||
@@ -2438,7 +2448,7 @@ Save the review output — it goes into the PR body in Step 19.
|
||||
|
||||
**Subagent prompt:**
|
||||
|
||||
> You are classifying Greptile review comments for a /ship workflow. Read `.factory/skills/gstack/review/greptile-triage.md` and follow the fetch, filter, classify, and **escalation detection** steps. Do NOT fix code, do NOT reply to comments, do NOT commit — report only.
|
||||
> You are classifying Greptile review comments for a /ship workflow. Read `$GSTACK_ROOT/review/greptile-triage.md` and follow the fetch, filter, classify, and **escalation detection** steps. Do NOT fix code, do NOT reply to comments, do NOT commit — report only.
|
||||
>
|
||||
> For each comment, assign: `classification` (`valid_actionable`, `already_fixed`, `false_positive`, `suppressed`), `escalation_tier` (1 or 2), the file:line or [top-level] tag, body summary, and permalink URL.
|
||||
>
|
||||
|
||||
@@ -594,13 +594,16 @@ describe("lib/gbrain-local-status — bearer-token thin-client (#2520)", () => {
|
||||
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
|
||||
});
|
||||
|
||||
it("returns 'thin-client' when config.json is absent and the registration is PROJECT-scoped (#2499)", () => {
|
||||
it("returns 'thin-client' when config.json is absent and the registration is PROJECT-scoped for THIS cwd (#2499)", () => {
|
||||
// The project key must be the running process's cwd (or an ancestor):
|
||||
// per-project scoping (C15) means only registrations visible to this
|
||||
// cwd count.
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "ok",
|
||||
withConfig: false,
|
||||
claudeJson: {
|
||||
projects: { "/some/repo": { mcpServers: { "gbrain-remote": REMOTE_GBRAIN } } },
|
||||
projects: { [process.cwd()]: { mcpServers: { "gbrain-remote": REMOTE_GBRAIN } } },
|
||||
},
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
@@ -659,6 +662,117 @@ describe("lib/gbrain-local-status — bearer-token thin-client (#2520)", () => {
|
||||
expect(localEngineStatus({ noCache: true })).toBe("missing-config");
|
||||
});
|
||||
|
||||
// ── C15: project scan is scoped to the cwd's nearest-ancestor project ──
|
||||
// Before the fix, hasRemoteOnlyGbrainMcp scanned EVERY project's
|
||||
// mcpServers, so one project's remote registration reclassified broken
|
||||
// local engines as thin-client machine-wide.
|
||||
|
||||
it("C15: an OTHER project's remote entry no longer flips thin-client for this cwd (no config)", () => {
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "ok",
|
||||
withConfig: false,
|
||||
claudeJson: {
|
||||
projects: { "/some/other/repo": { mcpServers: { gbrain: REMOTE_GBRAIN } } },
|
||||
},
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("missing-config");
|
||||
});
|
||||
|
||||
it("C15: an OTHER project's remote entry no longer reclassifies a broken local engine", () => {
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "engine-locked",
|
||||
withConfig: true,
|
||||
claudeJson: {
|
||||
projects: { "/some/other/repo": { mcpServers: { gbrain: REMOTE_GBRAIN } } },
|
||||
},
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("engine-locked");
|
||||
});
|
||||
|
||||
it("C15: path boundary — a sibling-prefix project key is NOT this cwd's project", () => {
|
||||
// /path/to/repo2 must never match a scan from /path/to/repo (and vice
|
||||
// versa) — same boundary rule as the jq resolver and brain-cache.
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "ok",
|
||||
withConfig: false,
|
||||
claudeJson: {
|
||||
projects: { [`${process.cwd()}-sibling`]: { mcpServers: { gbrain: REMOTE_GBRAIN } } },
|
||||
},
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("missing-config");
|
||||
});
|
||||
|
||||
it("C15: an ANCESTOR project key of this cwd still counts (nearest-ancestor matching)", () => {
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "ok",
|
||||
withConfig: false,
|
||||
claudeJson: {
|
||||
projects: { [dirname(process.cwd())]: { mcpServers: { gbrain: REMOTE_GBRAIN } } },
|
||||
},
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
|
||||
});
|
||||
|
||||
it("C15: a nearer project WITHOUT gbrain does not shadow an ancestor's registration (jq parity)", () => {
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "ok",
|
||||
withConfig: false,
|
||||
claudeJson: {
|
||||
projects: {
|
||||
[dirname(process.cwd())]: { mcpServers: { gbrain: REMOTE_GBRAIN } },
|
||||
[process.cwd()]: { mcpServers: { "other-server": { type: "http", url: "https://x.example/mcp" } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
|
||||
});
|
||||
|
||||
// ── C15: adopted precedence — project-local beats user scope per name ──
|
||||
// Claude Code's own conflict resolution, verified empirically against
|
||||
// claude 2.1.233 with a hermetic fake $HOME (`claude mcp get gbrain`
|
||||
// reports "Scope: Local config" when both scopes define the name).
|
||||
|
||||
it("C15 precedence: THIS project's remote gbrain shadows a user-scope local-stdio gbrain → thin-client", () => {
|
||||
// Union semantics would see the user-scope stdio entry and keep
|
||||
// engine-locked; the adopted precedence says this project's queries go
|
||||
// remote, so thin-client is the truthful classification here.
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "engine-locked",
|
||||
withConfig: true,
|
||||
claudeJson: {
|
||||
mcpServers: { gbrain: LOCAL_GBRAIN },
|
||||
projects: { [process.cwd()]: { mcpServers: { gbrain: REMOTE_GBRAIN } } },
|
||||
},
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
|
||||
});
|
||||
|
||||
it("C15 precedence: THIS project's local-stdio gbrain shadows a user-scope remote gbrain → local statuses keep their meaning", () => {
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "engine-locked",
|
||||
withConfig: true,
|
||||
claudeJson: {
|
||||
mcpServers: { gbrain: REMOTE_GBRAIN },
|
||||
projects: { [process.cwd()]: { mcpServers: { gbrain: LOCAL_GBRAIN } } },
|
||||
},
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("engine-locked");
|
||||
});
|
||||
|
||||
it("--is-ok exits 0 on a bearer thin-client fixture (end-to-end gate)", () => {
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* lib/gbrain-repo-policy-client — batch trust-tier lookup (#2392).
|
||||
*
|
||||
* Covers the `get --batch` verb of bin/gstack-gbrain-repo-policy (bash level:
|
||||
* multiple urls in, per-line verdicts out, input order preserved) and the
|
||||
* TypeScript client `repoPolicyTierBatch` (ONE spawn, dedup, fast paths, and
|
||||
* the whole-batch `unreadable` classification on a corrupt store).
|
||||
*
|
||||
* Each test uses a temp GSTACK_HOME so nothing leaks into the user's real
|
||||
* ~/.gstack. The client is exercised against the REAL bash script — the
|
||||
* script owns URL normalization, so stub stores are seeded through its own
|
||||
* `set` verb.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
import { spawnSync } from "child_process";
|
||||
|
||||
import { repoPolicyTierBatch } from "../lib/gbrain-repo-policy-client";
|
||||
import { canonicalizeRemote } from "../lib/gstack-memory-helpers";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "..");
|
||||
const BIN = path.join(ROOT, "bin", "gstack-gbrain-repo-policy");
|
||||
|
||||
let tmpHome: string;
|
||||
|
||||
function env(): NodeJS.ProcessEnv {
|
||||
return { ...process.env, GSTACK_HOME: tmpHome };
|
||||
}
|
||||
|
||||
function run(args: string[], input?: string) {
|
||||
const res = spawnSync(BIN, args, { env: env(), encoding: "utf-8", input });
|
||||
return {
|
||||
stdout: res.stdout || "",
|
||||
stderr: res.stderr || "",
|
||||
status: res.status ?? -1,
|
||||
};
|
||||
}
|
||||
|
||||
function policyFile(): string {
|
||||
return path.join(tmpHome, "gbrain-repo-policy.json");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "gbrain-policy-client-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("bin/gstack-gbrain-repo-policy get --batch (bash level)", () => {
|
||||
test("multiple urls in, per-line verdicts out, input order preserved", () => {
|
||||
expect(run(["set", "https://github.com/foo/bar.git", "deny"]).status).toBe(0);
|
||||
expect(run(["set", "git@github.com:baz/qux.git", "read-only"]).status).toBe(0);
|
||||
expect(run(["set", "https://github.com/rw/repo", "read-write"]).status).toBe(0);
|
||||
|
||||
const r = run(
|
||||
["get", "--batch"],
|
||||
// Mixed URL forms — the script's normalize() collapses them to the
|
||||
// stored keys. `nope/never` has no entry → none.
|
||||
"git@github.com:foo/bar.git\nhttps://github.com/nope/never\nhttps://github.com/baz/qux\nhttps://github.com/rw/repo.git\n",
|
||||
);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe("deny\nnone\nread-only\nread-write\n");
|
||||
});
|
||||
|
||||
test("no store on disk: every line is none, and no file is created", () => {
|
||||
const r = run(["get", "--batch"], "https://github.com/a/a\nhttps://github.com/b/b\n");
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe("none\nnone\n");
|
||||
expect(fs.existsSync(policyFile())).toBe(false);
|
||||
});
|
||||
|
||||
test("corrupt store: hard error (exit 2), NOT quarantined, names recovery", () => {
|
||||
fs.writeFileSync(policyFile(), "not valid json{", { mode: 0o600 });
|
||||
const r = run(["get", "--batch"], "https://github.com/foo/bar\n");
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain("corrupt");
|
||||
expect(r.stderr).toContain("gstack-gbrain-repo-policy list");
|
||||
// Unlike interactive `get`, batch must never quarantine-and-proceed —
|
||||
// that would bypass a set deny policy on an unattended ingest run.
|
||||
expect(fs.readFileSync(policyFile(), "utf-8")).toBe("not valid json{");
|
||||
expect(
|
||||
fs.readdirSync(tmpHome).find((f) => f.includes(".corrupt-")),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("legacy allow entries migrate to read-write on batch read", () => {
|
||||
fs.writeFileSync(
|
||||
policyFile(),
|
||||
JSON.stringify({ "github.com/foo/bar": "allow" }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const r = run(["get", "--batch"], "https://github.com/foo/bar\n");
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe("read-write\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("repoPolicyTierBatch (TypeScript client)", () => {
|
||||
test("maps each input url to its verdict, dedup included", () => {
|
||||
expect(run(["set", "https://github.com/foo/bar", "deny"]).status).toBe(0);
|
||||
expect(run(["set", "https://github.com/baz/qux", "read-only"]).status).toBe(0);
|
||||
|
||||
const verdicts = repoPolicyTierBatch(
|
||||
[
|
||||
"github.com/foo/bar", // canonical form, as memory-ingest passes it
|
||||
"github.com/baz/qux",
|
||||
"github.com/nope/never",
|
||||
"github.com/foo/bar", // duplicate — dedup keeps ONE map entry
|
||||
],
|
||||
env(),
|
||||
);
|
||||
expect(verdicts.size).toBe(3);
|
||||
expect(verdicts.get("github.com/foo/bar")).toEqual({ tier: "deny" });
|
||||
expect(verdicts.get("github.com/baz/qux")).toEqual({ tier: "read-only" });
|
||||
expect(verdicts.get("github.com/nope/never")).toEqual({ tier: "none" });
|
||||
});
|
||||
|
||||
test("no store on disk: every url is tier none with no error (fast path)", () => {
|
||||
const verdicts = repoPolicyTierBatch(["github.com/a/a", "github.com/b/b"], env());
|
||||
expect(verdicts.get("github.com/a/a")).toEqual({ tier: "none" });
|
||||
expect(verdicts.get("github.com/b/b")).toEqual({ tier: "none" });
|
||||
expect(fs.existsSync(policyFile())).toBe(false);
|
||||
});
|
||||
|
||||
test("empty url list returns an empty map without spawning", () => {
|
||||
const verdicts = repoPolicyTierBatch([], env());
|
||||
expect(verdicts.size).toBe(0);
|
||||
});
|
||||
|
||||
test("corrupt store: EVERY url maps to { tier: none, error: unreadable }", () => {
|
||||
fs.writeFileSync(policyFile(), "not valid json{", { mode: 0o600 });
|
||||
const verdicts = repoPolicyTierBatch(["github.com/foo/bar", "github.com/baz/qux"], env());
|
||||
expect(verdicts.get("github.com/foo/bar")).toEqual({ tier: "none", error: "unreadable" });
|
||||
expect(verdicts.get("github.com/baz/qux")).toEqual({ tier: "none", error: "unreadable" });
|
||||
});
|
||||
|
||||
test("store unreadable on disk (chmod 000): whole batch classified unreadable", () => {
|
||||
if (process.platform === "win32" || process.getuid?.() === 0) return; // chmod semantics differ
|
||||
expect(run(["set", "https://github.com/foo/bar", "deny"]).status).toBe(0);
|
||||
fs.chmodSync(policyFile(), 0o000);
|
||||
try {
|
||||
const verdicts = repoPolicyTierBatch(["github.com/foo/bar"], env());
|
||||
expect(verdicts.get("github.com/foo/bar")).toEqual({ tier: "none", error: "unreadable" });
|
||||
} finally {
|
||||
fs.chmodSync(policyFile(), 0o600);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Normalize parity: bash normalize() ↔ lib canonicalizeRemote ─────────────
|
||||
//
|
||||
// bin/gstack-memory-ingest.ts produces page.git_remote via canonicalizeRemote
|
||||
// (lib/gstack-memory-helpers) and then looks the policy up through
|
||||
// repoPolicyTierBatch — whose bash side re-normalizes with normalize(). If
|
||||
// the two functions disagree on ANY URL shape, a policy the user set via the
|
||||
// script silently fails to apply to ingest (a deny that doesn't deny). The
|
||||
// contract pinned here: for every shape X, `set X <tier>` followed by a batch
|
||||
// lookup of canonicalizeRemote(X) returns <tier>. Bash owns normalization —
|
||||
// any divergence is fixed in the SCRIPT's normalize(), never by re-normalizing
|
||||
// in TypeScript.
|
||||
|
||||
describe("normalize parity: bash normalize() ↔ canonicalizeRemote (edge URL shapes)", () => {
|
||||
// One distinct repo per shape so tiers don't overwrite each other.
|
||||
const CORPUS: Array<{ shape: string; tier: "read-write" | "read-only" | "deny" }> = [
|
||||
{ shape: "https://github.com/acme/plain", tier: "deny" },
|
||||
{ shape: "https://github.com/acme/dotgit.git", tier: "read-only" },
|
||||
{ shape: "https://github.com/acme/slash/", tier: "read-write" },
|
||||
// .git + trailing slash: bash must strip the slash BEFORE the .git suffix
|
||||
// (slash-first order), as canonicalizeRemote does.
|
||||
{ shape: "https://github.com/acme/dotgitslash.git/", tier: "deny" },
|
||||
// Uppercase .GIT: canonicalizeRemote strips case-insensitively; bash must
|
||||
// lowercase before the suffix strip or the key keeps a ".git" tail.
|
||||
{ shape: "https://github.com/ACME/UpperGit.GIT", tier: "read-only" },
|
||||
{ shape: "git@github.com:acme/scp.git", tier: "deny" },
|
||||
{ shape: "ssh://git@github.com/acme/sshurl.git", tier: "read-write" },
|
||||
];
|
||||
|
||||
test("normalize <url> prints exactly canonicalizeRemote(url) for every corpus shape", () => {
|
||||
for (const { shape } of CORPUS) {
|
||||
const r = run(["normalize", shape]);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout.trim()).toBe(canonicalizeRemote(shape));
|
||||
}
|
||||
});
|
||||
|
||||
test("a policy set via the script with shape X is found via canonicalizeRemote(X)", () => {
|
||||
for (const { shape, tier } of CORPUS) {
|
||||
expect(run(["set", shape, tier]).status).toBe(0);
|
||||
}
|
||||
const canon = CORPUS.map((c) => canonicalizeRemote(c.shape));
|
||||
const verdicts = repoPolicyTierBatch(canon, env());
|
||||
for (let i = 0; i < CORPUS.length; i++) {
|
||||
expect(verdicts.get(canon[i])).toEqual({ tier: CORPUS[i].tier });
|
||||
}
|
||||
});
|
||||
|
||||
test("cross-shape: set through one shape, looked up through another shape of the same repo", () => {
|
||||
// The store keys on the normalized form, so every spelling of the same
|
||||
// repo shares one entry — set through scp form, read through https form.
|
||||
expect(run(["set", "git@github.com:acme/xshape.git", "deny"]).status).toBe(0);
|
||||
const canon = canonicalizeRemote("https://github.com/ACME/XShape.GIT/");
|
||||
const verdicts = repoPolicyTierBatch([canon], env());
|
||||
expect(verdicts.get(canon)).toEqual({ tier: "deny" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* #2516: the brain worktree gbrain indexes must advance on the daily sync —
|
||||
* and the unattended advance path must be SAFE: refuse dirty worktrees,
|
||||
* refuse anything that is not a worktree of the artifacts repo, and never
|
||||
* force-remove. (Pre-fix, the worktree only moved when setup-gbrain /
|
||||
* sync-gbrain / brain-restore ran, so brains silently served stale pages.)
|
||||
*/
|
||||
import { describe, test as _test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
const test = (name: string, fn: any) => _test(name, fn, 30000);
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN = path.join(ROOT, 'bin');
|
||||
|
||||
let tmpHome: string;
|
||||
|
||||
function run(argv: string[], env: Record<string, string> = {}) {
|
||||
const full = path.join(BIN, argv[0]);
|
||||
const res = spawnSync(full, argv.slice(1), {
|
||||
env: { ...process.env, HOME: tmpHome, GSTACK_HOME: tmpHome, ...env },
|
||||
encoding: 'utf-8',
|
||||
cwd: ROOT,
|
||||
});
|
||||
return { stdout: res.stdout || '', stderr: res.stderr || '', status: res.status ?? -1 };
|
||||
}
|
||||
|
||||
function git(args: string[], cwd: string) {
|
||||
const res = spawnSync('git', args, { cwd, encoding: 'utf-8' });
|
||||
return { stdout: (res.stdout || '').trim(), status: res.status ?? -1 };
|
||||
}
|
||||
|
||||
function commit(cwd: string, msg: string): string {
|
||||
fs.appendFileSync(path.join(cwd, 'artifact.md'), `${msg}\n`);
|
||||
git(['add', 'artifact.md'], cwd);
|
||||
git(['commit', '-q', '-m', msg], cwd);
|
||||
return git(['rev-parse', 'HEAD'], cwd).stdout;
|
||||
}
|
||||
|
||||
const worktreePath = () => path.join(tmpHome, '.gstack-brain-worktree');
|
||||
|
||||
function makeArtifactsRepoWithWorktree(): { head: string } {
|
||||
git(['init', '-q', '-b', 'main'], tmpHome);
|
||||
git(['config', 'user.email', 't@t'], tmpHome);
|
||||
git(['config', 'user.name', 't'], tmpHome);
|
||||
const head = commit(tmpHome, 'seed');
|
||||
git(['worktree', 'add', '--detach', worktreePath(), head], tmpHome);
|
||||
return { head };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'wtree-adv-home-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('gstack-gbrain-source-wireup --advance-only (#2516)', () => {
|
||||
test('advances a clean, behind worktree to the parent HEAD', () => {
|
||||
makeArtifactsRepoWithWorktree();
|
||||
const newHead = commit(tmpHome, 'second');
|
||||
const r = run(['gstack-gbrain-source-wireup', '--advance-only']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout + r.stderr).toContain('advanced');
|
||||
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).toBe(newHead);
|
||||
});
|
||||
|
||||
test('up-to-date worktree is a no-op', () => {
|
||||
const { head } = makeArtifactsRepoWithWorktree();
|
||||
const r = run(['gstack-gbrain-source-wireup', '--advance-only']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout + r.stderr).toContain('up-to-date');
|
||||
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).toBe(head);
|
||||
});
|
||||
|
||||
test('REFUSES a dirty worktree — local changes are never advanced away', () => {
|
||||
const { head } = makeArtifactsRepoWithWorktree();
|
||||
commit(tmpHome, 'second');
|
||||
fs.writeFileSync(path.join(worktreePath(), 'artifact.md'), 'local edit\n');
|
||||
const r = run(['gstack-gbrain-source-wireup', '--advance-only']);
|
||||
expect(r.status).toBe(0); // benign skip, not a hard failure
|
||||
expect(r.stderr).toContain('local changes');
|
||||
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).toBe(head); // untouched
|
||||
expect(fs.readFileSync(path.join(worktreePath(), 'artifact.md'), 'utf-8')).toBe('local edit\n');
|
||||
});
|
||||
|
||||
test('REFUSES a path that is not a worktree of the artifacts repo', () => {
|
||||
makeArtifactsRepoWithWorktree();
|
||||
// A standalone user repo masquerading as the brain worktree.
|
||||
const userRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'wtree-adv-user-'));
|
||||
try {
|
||||
git(['init', '-q', '-b', 'main'], userRepo);
|
||||
git(['config', 'user.email', 't@t'], userRepo);
|
||||
git(['config', 'user.name', 't'], userRepo);
|
||||
const userHead = commit(userRepo, 'user work');
|
||||
const r = run(['gstack-gbrain-source-wireup', '--advance-only'], {
|
||||
GSTACK_BRAIN_WORKTREE: userRepo,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain('not a worktree of');
|
||||
expect(git(['rev-parse', 'HEAD'], userRepo).stdout).toBe(userHead); // untouched
|
||||
} finally {
|
||||
fs.rmSync(userRepo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('missing worktree is a benign skip', () => {
|
||||
git(['init', '-q', '-b', 'main'], tmpHome);
|
||||
git(['config', 'user.email', 't@t'], tmpHome);
|
||||
git(['config', 'user.name', 't'], tmpHome);
|
||||
commit(tmpHome, 'seed');
|
||||
const r = run(['gstack-gbrain-source-wireup', '--advance-only']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain('no managed worktree');
|
||||
});
|
||||
|
||||
test('never contains a force-remove on the advance-only path (static pin)', () => {
|
||||
// The unattended path must not be able to delete local worktree changes:
|
||||
// do_advance_only may not call safe_rm_worktree, `worktree remove`, or rm -rf.
|
||||
const src = fs.readFileSync(path.join(BIN, 'gstack-gbrain-source-wireup'), 'utf-8');
|
||||
const fn = src.slice(src.indexOf('do_advance_only()'), src.indexOf('do_uninstall()'));
|
||||
expect(fn.length).toBeGreaterThan(100);
|
||||
expect(fn).not.toContain('safe_rm_worktree');
|
||||
expect(fn).not.toContain('worktree remove');
|
||||
expect(fn).not.toContain('rm -rf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('brain-sync --once daily advance wiring (#2516)', () => {
|
||||
test('once advances the worktree behind a 24h attempt stamp', () => {
|
||||
makeArtifactsRepoWithWorktree();
|
||||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'artifacts-only']);
|
||||
const second = commit(tmpHome, 'second');
|
||||
|
||||
const r1 = run(['gstack-brain-sync', '--once']);
|
||||
expect(r1.status).toBe(0);
|
||||
const stamp = path.join(tmpHome, '.brain-worktree-last-advance');
|
||||
expect(fs.existsSync(stamp)).toBe(true);
|
||||
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).toBe(second);
|
||||
|
||||
// Within the 24h window: parent advances again, --once does NOT re-advance.
|
||||
const third = commit(tmpHome, 'third');
|
||||
const r2 = run(['gstack-brain-sync', '--once']);
|
||||
expect(r2.status).toBe(0);
|
||||
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).toBe(second);
|
||||
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).not.toBe(third);
|
||||
|
||||
// Expire the stamp → the next --once advances again.
|
||||
fs.writeFileSync(stamp, String(Math.floor(Date.now() / 1000) - 90000));
|
||||
const r3 = run(['gstack-brain-sync', '--once']);
|
||||
expect(r3.status).toBe(0);
|
||||
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).toBe(third);
|
||||
});
|
||||
});
|
||||
@@ -1958,12 +1958,14 @@ describe('Codex generation (--host codex)', () => {
|
||||
|
||||
// ─── Path rewriting regression tests ─────────────────────────
|
||||
|
||||
test('sidecar paths point to .agents/skills/gstack/review/ (not gstack-review/)', () => {
|
||||
// Regression: gen-skill-docs rewrote .claude/skills/review → .agents/skills/gstack-review
|
||||
// but setup puts sidecars under .agents/skills/gstack/review/. Must match setup layout.
|
||||
test('sidecar paths resolve through $GSTACK_ROOT (not gstack-review/)', () => {
|
||||
// #2518: templates now anchor sidecars at the installed skill root
|
||||
// (~/.claude/skills/gstack/review/...), which the codex path rewrite turns
|
||||
// into $GSTACK_ROOT/review/... — resolved by the preamble against the
|
||||
// repo-local .agents root or the global install. The old repo-relative
|
||||
// form (.claude/skills/review/) only resolved inside gstack's own checkout.
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-review', 'SKILL.md'), 'utf-8');
|
||||
// Correct: references to sidecar files use gstack/review/ path
|
||||
expect(content).toContain('.agents/skills/gstack/review/checklist.md');
|
||||
expect(content).toContain('$GSTACK_ROOT/review/checklist.md');
|
||||
// design-checklist.md is now referenced via Review Army specialist (Claude only, stripped for Codex)
|
||||
// Wrong: must NOT reference gstack-review/checklist.md (file doesn't exist there)
|
||||
expect(content).not.toContain('.agents/skills/gstack-review/checklist.md');
|
||||
@@ -1981,7 +1983,7 @@ describe('Codex generation (--host codex)', () => {
|
||||
test('greptile-triage sidecar path is correct', () => {
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-review', 'SKILL.md'), 'utf-8');
|
||||
if (content.includes('greptile-triage')) {
|
||||
expect(content).toContain('.agents/skills/gstack/review/greptile-triage.md');
|
||||
expect(content).toContain('$GSTACK_ROOT/review/greptile-triage.md');
|
||||
expect(content).not.toContain('.agents/skills/gstack-review/greptile-triage');
|
||||
}
|
||||
});
|
||||
@@ -2023,10 +2025,12 @@ describe('Codex generation (--host codex)', () => {
|
||||
|
||||
// ─── Claude output regression guard ─────────────────────────
|
||||
|
||||
test('Claude output unchanged: review skill still uses .claude/skills/ paths', () => {
|
||||
// Codex changes must NOT affect Claude output
|
||||
test('Claude output uses installed-root review paths (#2518)', () => {
|
||||
// Codex changes must NOT affect Claude output; the Claude form is the
|
||||
// installed-root anchor, not the old repo-relative path that only
|
||||
// resolved inside gstack's own checkout.
|
||||
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('.claude/skills/review/checklist.md');
|
||||
expect(content).toContain('~/.claude/skills/gstack/review/checklist.md');
|
||||
expect(content).toContain('~/.claude/skills/gstack');
|
||||
// Must NOT contain Codex HOST paths. `~/.codex/sessions/` is exempt: the
|
||||
// timeout-wrapper guidance documents the Codex CLI's own rollout-log
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* gstack-config default-table completeness (gate, free).
|
||||
*
|
||||
* Skill preambles read configuration with
|
||||
*
|
||||
* VAR=$(gstack-config get <key> 2>/dev/null || echo "<default>")
|
||||
*
|
||||
* and that fallback only fires on a NON-ZERO exit. `get` used to answer a key
|
||||
* it did not know with "" and exit 0, so VAR came back empty and the default
|
||||
* written right there in the preamble was unreachable. The skill then branched
|
||||
* on a value it never specified -- "skip entirely if QUESTION_TUNING is false"
|
||||
* reached with QUESTION_TUNING="".
|
||||
*
|
||||
* Four keys skills actually read had no entry in lookup_default and took that
|
||||
* path: question_tuning, repo_mode, team_mode, transcript_ingest_mode.
|
||||
*
|
||||
* Three invariants are pinned so the class cannot reopen:
|
||||
*
|
||||
* 1. every key read anywhere in the tree is matched by an arm of the DEFAULTS
|
||||
* table. Add a `gstack-config get some_new_key` to a preamble without
|
||||
* adding its default and this test fails. Checked by parsing the case arms
|
||||
* rather than shelling out per key, which keeps it fast and makes the
|
||||
* failure name the key.
|
||||
* 2. a genuinely unknown key exits non-zero, so the caller fallback fires.
|
||||
* 3. a known key whose default is intentionally empty still exits 0 --
|
||||
* cross_project_learnings ("unset triggers the first-time prompt") and
|
||||
* redact_repo_visibility ("empty falls through to gh/glab detection")
|
||||
* depend on receiving "" successfully.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const CONFIG_BIN = path.join(ROOT, 'bin', 'gstack-config');
|
||||
const SELF = 'gstack-config-defaults.test.ts';
|
||||
|
||||
// Isolated state dir, so a value the developer happens to have set in their own
|
||||
// ~/.gstack/config.yaml cannot mask a missing default.
|
||||
const STATE = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-config-test-'));
|
||||
|
||||
function get(key: string): { out: string; code: number } {
|
||||
const r = spawnSync('bash', [CONFIG_BIN, 'get', key], {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, GSTACK_STATE_ROOT: STATE },
|
||||
});
|
||||
return { out: r.stdout ?? '', code: r.status ?? -1 };
|
||||
}
|
||||
|
||||
/** Case-arm patterns of lookup_default, in order, excluding the catch-all. */
|
||||
function defaultArms(): string[] {
|
||||
const src = fs.readFileSync(CONFIG_BIN, 'utf-8');
|
||||
const body = src.slice(src.indexOf('lookup_default()'));
|
||||
const end = body.indexOf('\n}');
|
||||
const arms: string[] = [];
|
||||
// e.g. ` proactive) echo "true" ;;` or ` user_slug_at_*) echo "" ;;`
|
||||
for (const m of body.slice(0, end).matchAll(/^\s{4}([a-zA-Z0-9_*]+)\)/gm)) {
|
||||
if (m[1] !== '*') arms.push(m[1]);
|
||||
}
|
||||
return arms;
|
||||
}
|
||||
|
||||
function isCovered(key: string, arms: string[]): boolean {
|
||||
return arms.some((a) =>
|
||||
a.endsWith('*') ? key.startsWith(a.slice(0, -1)) : key === a,
|
||||
);
|
||||
}
|
||||
|
||||
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next']);
|
||||
|
||||
/** Every `gstack-config get <key>` call site in the tree. */
|
||||
function keysReadInTree(): string[] {
|
||||
const keys = new Set<string>();
|
||||
// [ \t]+ rather than \s+: \s crosses newlines and would pair a trailing
|
||||
// "gstack-config get" with the first word of the next line.
|
||||
const re = /gstack-config["']?[ \t]+get[ \t]+([a-zA-Z0-9_]+)/g;
|
||||
const stack = [ROOT];
|
||||
while (stack.length) {
|
||||
const cur = stack.pop()!;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(cur, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const ent of entries) {
|
||||
if (SKIP_DIRS.has(ent.name) || ent.isSymbolicLink()) continue;
|
||||
const full = path.join(cur, ent.name);
|
||||
if (ent.isDirectory()) {
|
||||
stack.push(full);
|
||||
continue;
|
||||
}
|
||||
// Skip this file: its own prose cites example keys.
|
||||
if (ent.name === SELF) continue;
|
||||
if (!/\.(md|ts|sh)$|^gstack-[a-z-]+$/.test(ent.name)) continue;
|
||||
let text: string;
|
||||
try {
|
||||
text = fs.readFileSync(full, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const m of text.matchAll(re)) keys.add(m[1]);
|
||||
}
|
||||
}
|
||||
return [...keys].sort();
|
||||
}
|
||||
|
||||
describe('gstack-config defaults (gate, free)', () => {
|
||||
test('every key read in the tree is covered by the DEFAULTS table', () => {
|
||||
const arms = defaultArms();
|
||||
expect(arms.length).toBeGreaterThan(10); // the parse actually found the table
|
||||
const uncovered = keysReadInTree().filter((k) => !isCovered(k, arms));
|
||||
expect(uncovered).toEqual([]);
|
||||
});
|
||||
|
||||
test('an unknown key exits non-zero, so the caller fallback fires', () => {
|
||||
const r = get('definitely_not_a_gstack_key_9f3a');
|
||||
expect(r.code).not.toBe(0);
|
||||
expect(r.out).toBe('');
|
||||
});
|
||||
|
||||
test('a known key whose default is intentionally empty still exits 0', () => {
|
||||
// repo_mode is in this class BY CONTRACT: gstack-repo-mode treats any
|
||||
// non-empty answer as a user override and skips classification, so a
|
||||
// synthesized "unknown" default would turn the classifier into dead code
|
||||
// (caught live by test/gstack-repo-mode.test.ts during the wave).
|
||||
for (const key of ['cross_project_learnings', 'salience_allowlist', 'redact_repo_visibility', 'repo_mode']) {
|
||||
expect({ key, ...get(key) }).toEqual({ key, out: '', code: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
test('the regressed keys resolve to the values their callers assume', () => {
|
||||
expect(get('question_tuning').out).toBe('false');
|
||||
expect(get('team_mode').out).toBe('false');
|
||||
expect(get('transcript_ingest_mode').out).toBe('off');
|
||||
});
|
||||
});
|
||||
@@ -93,7 +93,7 @@ describe("gstack-memory-ingest CLI", () => {
|
||||
const session = `{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"${new Date().toISOString()}","cwd":"/tmp/x"}\n{"type":"assistant","message":{"role":"assistant","content":"hi"},"timestamp":"${new Date().toISOString()}"}\n`;
|
||||
writeClaudeCodeSession(home, "tmp-x", "abc123", session);
|
||||
|
||||
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
const r = runScript(["--probe", "--include-unattributed"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toContain("Total files in window: 1");
|
||||
expect(r.stdout).toContain("transcript");
|
||||
@@ -109,7 +109,7 @@ describe("gstack-memory-ingest CLI", () => {
|
||||
const session = `{"type":"session_meta","payload":{"id":"sess-xyz","cwd":"/tmp/x","git":{"repository_url":"https://github.com/foo/bar"}},"timestamp":"${today.toISOString()}"}\n`;
|
||||
writeCodexSession(home, ymd, session);
|
||||
|
||||
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
const r = runScript(["--probe", "--include-unattributed"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toContain("Total files in window: 1");
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
@@ -269,7 +269,7 @@ describe("internal: parseTranscriptJsonl + buildTranscriptPage shape", () => {
|
||||
mkdirSync(projDir, { recursive: true });
|
||||
writeFileSync(join(projDir, "abc123.jsonl"), content, "utf-8");
|
||||
|
||||
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: join(home, ".gstack") });
|
||||
const r = runScript(["--probe", "--include-unattributed"], { HOME: home, GSTACK_HOME: join(home, ".gstack") });
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toContain("Total files in window: 1");
|
||||
|
||||
@@ -288,7 +288,7 @@ describe("internal: parseTranscriptJsonl + buildTranscriptPage shape", () => {
|
||||
`{"type":"assistant","message":{"role":"assistant","content":"this is truncat`; // no closing brace + no newline
|
||||
writeFileSync(join(projDir, "trunc.jsonl"), content, "utf-8");
|
||||
|
||||
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: join(home, ".gstack") });
|
||||
const r = runScript(["--probe", "--include-unattributed"], { HOME: home, GSTACK_HOME: join(home, ".gstack") });
|
||||
// Should not crash; should report 1 transcript
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toContain("Total files in window: 1");
|
||||
@@ -300,9 +300,16 @@ describe("internal: parseTranscriptJsonl + buildTranscriptPage shape", () => {
|
||||
|
||||
describe("gstack-memory-ingest --limit", () => {
|
||||
it("respects --limit by stopping after N writes (mocked via --probe shortcut)", () => {
|
||||
const r = runScript(["--probe", "--limit", "1"]);
|
||||
// Hermetic home: against the operator's real HOME this walked the whole
|
||||
// transcript corpus (and, post policy-parity, batch-checked its real
|
||||
// policy store), making a pure arg-parsing assertion slow and flaky.
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const r = runScript(["--probe", "--limit", "1"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
// --limit doesn't apply to probe but argument should parse without error
|
||||
expect(r.exitCode).toBe(0);
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rejects --limit 0 with exit 1", () => {
|
||||
@@ -861,3 +868,443 @@ describe("#2105 codex response_item rollout shape", () => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ── #2394: --probe counts post-attribution, matching what --bulk would write ─
|
||||
|
||||
describe("#2394: probe applies the same attribution gate as prepare", () => {
|
||||
function makeAttributableCwd(home: string): string {
|
||||
const repo = join(home, "work", "attributable-repo");
|
||||
mkdirSync(repo, { recursive: true });
|
||||
spawnSync("git", ["-C", repo, "init", "-q"], { encoding: "utf-8" });
|
||||
spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/foo/bar.git"], { encoding: "utf-8" });
|
||||
return repo;
|
||||
}
|
||||
|
||||
function writeMixedCorpus(home: string): void {
|
||||
const attributableCwd = makeAttributableCwd(home);
|
||||
const ts = new Date().toISOString();
|
||||
writeClaudeCodeSession(
|
||||
home, "work-attributable", "attr1",
|
||||
`{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"${ts}","cwd":"${attributableCwd.replace(/\\/g, "\\\\")}"}\n`,
|
||||
);
|
||||
writeClaudeCodeSession(
|
||||
home, "tmp-nowhere", "unattr1",
|
||||
`{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"${ts}","cwd":"${join(home, "not-a-repo").replace(/\\/g, "\\\\")}"}\n`,
|
||||
);
|
||||
mkdirSync(join(home, "not-a-repo"), { recursive: true });
|
||||
}
|
||||
|
||||
it("probe reports post-attribution counts and names what it skipped", () => {
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
writeMixedCorpus(home);
|
||||
|
||||
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
expect(r.exitCode).toBe(0);
|
||||
// Post-attribution: only the transcript whose cwd resolves to a remote.
|
||||
expect(r.stdout).toContain("Total files in window: 1");
|
||||
// The excluded remainder is visible, never silent.
|
||||
expect(r.stdout).toContain("Skipped (unattributed): 1");
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("--include-unattributed restores raw counts", () => {
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
writeMixedCorpus(home);
|
||||
|
||||
const r = runScript(["--probe", "--include-unattributed"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toContain("Total files in window: 2");
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("parity: probe post-attribution count equals what prepare actually processes", () => {
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
writeMixedCorpus(home);
|
||||
|
||||
const probe = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
expect(probe.exitCode).toBe(0);
|
||||
const probeNew = Number((probe.stdout.match(/New \(never ingested\):\s+(\d+)/) || [])[1]);
|
||||
expect(probeNew).toBe(1);
|
||||
|
||||
// Same stage on the ingest side: the transcripts that reach the import
|
||||
// step (written + failed) are exactly the ones that passed the shared
|
||||
// attribution gate in preparePages. No gbrain is configured in this
|
||||
// hermetic env, so the attributable transcript FAILS at import — that is
|
||||
// fine: parity is a prepare-stage invariant (probe post-attribution ==
|
||||
// prepare post-attribution), deliberately NOT == final written (#2394).
|
||||
const inc = runScript(["--incremental", "--quiet"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
const m = inc.stderr.match(/(\d+) written, (\d+) failed/) || inc.stdout.match(/(\d+) written, (\d+) failed/);
|
||||
expect(m).not.toBeNull();
|
||||
const reachedImport = Number(m![1]) + Number(m![2]);
|
||||
expect(reachedImport).toBe(probeNew);
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("a multi-MB transcript is still classified correctly (bounded probe read)", () => {
|
||||
// The probe reads a BOUNDED 256KB prefix, never the whole file (plan C7).
|
||||
// The cwd sits on the first line; >1MB of filler follows. Classification
|
||||
// must come out attributable — and stay cheap on real multi-MB corpora.
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const attributableCwd = join(home, "work", "attributable-repo");
|
||||
mkdirSync(attributableCwd, { recursive: true });
|
||||
spawnSync("git", ["-C", attributableCwd, "init", "-q"], { encoding: "utf-8" });
|
||||
spawnSync("git", ["-C", attributableCwd, "remote", "add", "origin", "https://github.com/foo/bar.git"], { encoding: "utf-8" });
|
||||
|
||||
const ts = new Date().toISOString();
|
||||
const cwdLine = `{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"${ts}","cwd":"${attributableCwd.replace(/\\/g, "\\\\")}"}\n`;
|
||||
const filler = `{"type":"assistant","message":{"role":"assistant","content":"${"x".repeat(1000)}"}}\n`;
|
||||
const body = cwdLine + filler.repeat(1100); // > 1MB after the cwd line
|
||||
expect(body.length).toBeGreaterThan(1024 * 1024);
|
||||
writeClaudeCodeSession(home, "work-attributable", "big1", body);
|
||||
|
||||
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toContain("Total files in window: 1");
|
||||
expect(r.stdout).not.toContain("Skipped (unattributed)");
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("Codex format: session_meta cwd attributes the transcript in the probe", () => {
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const attributableCwd = makeAttributableCwd(home);
|
||||
const today = new Date();
|
||||
const ymd = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
const session = `{"type":"session_meta","payload":{"id":"sess-meta-cwd","cwd":"${attributableCwd.replace(/\\/g, "\\\\")}"},"timestamp":"${today.toISOString()}"}\n`;
|
||||
writeCodexSession(home, ymd, session);
|
||||
|
||||
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toContain("Total files in window: 1");
|
||||
expect(r.stdout).not.toContain("Skipped (unattributed)");
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("parity: a Codex cwd appearing only on a LATER record is unattributed in probe AND prepare", () => {
|
||||
// parseTranscriptJsonl reads Codex cwd from the session_meta FIRST record
|
||||
// ONLY. The probe mirrors those exact rules — the pre-fix probe scanned
|
||||
// every line for any cwd and DIVERGED on this shape (probe said
|
||||
// attributable, prepare said not).
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const attributableCwd = makeAttributableCwd(home);
|
||||
const today = new Date();
|
||||
const ymd = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
const session =
|
||||
`{"type":"session_meta","payload":{"id":"sess-late-cwd"},"timestamp":"${today.toISOString()}"}\n` +
|
||||
`{"type":"response_item","payload":{"type":"message","role":"user","content":[{"text":"hi"}]},"cwd":"${attributableCwd.replace(/\\/g, "\\\\")}"}\n`;
|
||||
writeCodexSession(home, ymd, session);
|
||||
|
||||
const probe = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
expect(probe.exitCode).toBe(0);
|
||||
expect(probe.stdout).toContain("Total files in window: 0");
|
||||
expect(probe.stdout).toContain("Skipped (unattributed): 1");
|
||||
|
||||
// Prepare agrees: nothing reaches the import stage (written + failed = 0)
|
||||
// and the skip is attributed to the same gate.
|
||||
const inc = runScript(["--incremental"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
expect(inc.exitCode).toBe(0);
|
||||
const written = Number((inc.stdout.match(/written:\s+(\d+)/) || [])[1]);
|
||||
const failed = Number((inc.stdout.match(/failed:\s+(\d+)/) || [])[1]);
|
||||
const unattrib = Number((inc.stdout.match(/skipped \(unattrib\):\s+(\d+)/) || [])[1]);
|
||||
expect(written + failed).toBe(0);
|
||||
expect(unattrib).toBe(1);
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ── #2392: transcript ingest honors the per-remote trust policy ─────────────
|
||||
//
|
||||
// The same store the code-import gate honors (bin/gstack-gbrain-sync.ts):
|
||||
// tier `deny` and `read-only` transcripts are skipped with their own counters;
|
||||
// a store that EXISTS but can't be read is a hard error before any writes
|
||||
// (never a silent bypass of a set policy); no store at all = zero policy work.
|
||||
// The policy store is seeded through the REAL bin/gstack-gbrain-repo-policy
|
||||
// script (its `set` verb owns the file schema + URL normalization).
|
||||
|
||||
describe("#2392: transcript ingest honors per-remote trust policy", () => {
|
||||
const POLICY_BIN = join(import.meta.dir, "..", "bin", "gstack-gbrain-repo-policy");
|
||||
|
||||
/** Attributable temp git repo whose origin points at `remoteUrl`. */
|
||||
function makeRepoWithRemote(home: string, name: string, remoteUrl: string): string {
|
||||
const repo = join(home, "work", name);
|
||||
mkdirSync(repo, { recursive: true });
|
||||
spawnSync("git", ["-C", repo, "init", "-q"], { encoding: "utf-8" });
|
||||
spawnSync("git", ["-C", repo, "remote", "add", "origin", remoteUrl], { encoding: "utf-8" });
|
||||
return repo;
|
||||
}
|
||||
|
||||
function writeSessionForRepo(home: string, projectName: string, sessionId: string, cwd: string): void {
|
||||
const record = JSON.stringify({
|
||||
type: "user",
|
||||
message: { role: "user", content: `hello from ${sessionId}` },
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd,
|
||||
});
|
||||
writeClaudeCodeSession(home, projectName, sessionId, record + "\n");
|
||||
}
|
||||
|
||||
function setPolicy(gstackHome: string, url: string, tier: string): void {
|
||||
const r = spawnSync(POLICY_BIN, ["set", url, tier], {
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env, GSTACK_HOME: gstackHome },
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
}
|
||||
|
||||
function stateSessions(gstackHome: string): string[] {
|
||||
const statePath = join(gstackHome, ".transcript-ingest-state.json");
|
||||
if (!existsSync(statePath)) return [];
|
||||
return Object.keys(JSON.parse(readFileSync(statePath, "utf-8")).sessions || {});
|
||||
}
|
||||
|
||||
it("(a) deny remote's transcript is skipped and counted as skipped_policy_deny", () => {
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const { binDir, logFile } = installFakeGbrain(home);
|
||||
|
||||
const denyCwd = makeRepoWithRemote(home, "denied", "https://github.com/denyme/denied.git");
|
||||
const okCwd = makeRepoWithRemote(home, "allowed", "https://github.com/okorg/okrepo.git");
|
||||
writeSessionForRepo(home, "work-denied", "denysess1", denyCwd);
|
||||
writeSessionForRepo(home, "work-allowed", "oksess1", okCwd);
|
||||
setPolicy(gstackHome, "https://github.com/denyme/denied.git", "deny");
|
||||
|
||||
const r = runScript(["--bulk", "--quiet"], {
|
||||
HOME: home,
|
||||
GSTACK_HOME: gstackHome,
|
||||
PATH: `${binDir}:${process.env.PATH || ""}`,
|
||||
});
|
||||
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toMatch(/written:\s+1/);
|
||||
expect(r.stdout).toMatch(/skipped \(policy deny\):\s+1/);
|
||||
expect(r.stdout).not.toMatch(/skipped \(policy read-only\)/);
|
||||
|
||||
// Only the allowed session was imported + state-recorded.
|
||||
expect(existsSync(logFile)).toBe(true);
|
||||
const sessions = stateSessions(gstackHome);
|
||||
expect(sessions.length).toBe(1);
|
||||
expect(sessions[0]).toContain("oksess1");
|
||||
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("(b) read-only remote's transcript is skipped and counted as skipped_policy_readonly", () => {
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const { binDir } = installFakeGbrain(home);
|
||||
|
||||
const roCwd = makeRepoWithRemote(home, "readonly", "https://github.com/roorg/rorepo.git");
|
||||
const okCwd = makeRepoWithRemote(home, "allowed", "https://github.com/okorg/okrepo.git");
|
||||
writeSessionForRepo(home, "work-readonly", "rosess1", roCwd);
|
||||
writeSessionForRepo(home, "work-allowed", "oksess1", okCwd);
|
||||
setPolicy(gstackHome, "https://github.com/roorg/rorepo.git", "read-only");
|
||||
|
||||
const r = runScript(["--bulk", "--quiet"], {
|
||||
HOME: home,
|
||||
GSTACK_HOME: gstackHome,
|
||||
PATH: `${binDir}:${process.env.PATH || ""}`,
|
||||
});
|
||||
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toMatch(/written:\s+1/);
|
||||
expect(r.stdout).toMatch(/skipped \(policy read-only\):\s+1/);
|
||||
const sessions = stateSessions(gstackHome);
|
||||
expect(sessions.length).toBe(1);
|
||||
expect(sessions[0]).toContain("oksess1");
|
||||
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("(c) read-write remote's transcript is ingested (reaches gbrain import)", () => {
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const { binDir, logFile } = installFakeGbrain(home);
|
||||
|
||||
const rwCwd = makeRepoWithRemote(home, "readwrite", "https://github.com/rworg/rwrepo.git");
|
||||
writeSessionForRepo(home, "work-readwrite", "rwsess1", rwCwd);
|
||||
setPolicy(gstackHome, "https://github.com/rworg/rwrepo.git", "read-write");
|
||||
|
||||
const r = runScript(["--bulk", "--quiet"], {
|
||||
HOME: home,
|
||||
GSTACK_HOME: gstackHome,
|
||||
PATH: `${binDir}:${process.env.PATH || ""}`,
|
||||
});
|
||||
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toMatch(/written:\s+1/);
|
||||
expect(r.stdout).not.toMatch(/skipped \(policy/);
|
||||
// gbrain import ran exactly once — the page reached the import stage.
|
||||
const calls = readFileSync(logFile, "utf-8").trim().split("\n").filter(Boolean);
|
||||
expect(calls.length).toBe(1);
|
||||
expect(stateSessions(gstackHome).length).toBe(1);
|
||||
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("(d) corrupted store: hard error before any writes, message names recovery", () => {
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const { binDir, logFile } = installFakeGbrain(home);
|
||||
|
||||
const cwd = makeRepoWithRemote(home, "somerepo", "https://github.com/some/repo.git");
|
||||
writeSessionForRepo(home, "work-somerepo", "somesess1", cwd);
|
||||
// Corrupt store — the batch verb refuses (exit 2), the client classifies
|
||||
// `unreadable`, and ingest must abort rather than bypass a set policy.
|
||||
writeFileSync(join(gstackHome, "gbrain-repo-policy.json"), "not valid json{", "utf-8");
|
||||
|
||||
const r = runScript(["--bulk", "--quiet"], {
|
||||
HOME: home,
|
||||
GSTACK_HOME: gstackHome,
|
||||
PATH: `${binDir}:${process.env.PATH || ""}`,
|
||||
});
|
||||
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.stderr).toMatch(/\[memory-ingest\] ERR:.*repo policy store exists/);
|
||||
expect(r.stderr).toContain("gstack-gbrain-repo-policy list");
|
||||
expect(r.stderr).toContain("/setup-gbrain");
|
||||
// Nothing written: no gbrain import call, no state file, store untouched.
|
||||
expect(existsSync(logFile)).toBe(false);
|
||||
expect(stateSessions(gstackHome).length).toBe(0);
|
||||
expect(readFileSync(join(gstackHome, "gbrain-repo-policy.json"), "utf-8")).toBe("not valid json{");
|
||||
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("(e) no store at all: no policy filtering, transcript ingests normally", () => {
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const { binDir } = installFakeGbrain(home);
|
||||
|
||||
const cwd = makeRepoWithRemote(home, "freerepo", "https://github.com/free/repo.git");
|
||||
writeSessionForRepo(home, "work-freerepo", "freesess1", cwd);
|
||||
|
||||
const r = runScript(["--bulk", "--quiet"], {
|
||||
HOME: home,
|
||||
GSTACK_HOME: gstackHome,
|
||||
PATH: `${binDir}:${process.env.PATH || ""}`,
|
||||
});
|
||||
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toMatch(/written:\s+1/);
|
||||
expect(r.stdout).not.toMatch(/skipped \(policy/);
|
||||
expect(stateSessions(gstackHome).length).toBe(1);
|
||||
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("(f) probe policy parity: a denied remote's transcript lands in skipped_policy_deny, not new_count", () => {
|
||||
// --probe used to count policy-denied transcripts as ingestible (it only
|
||||
// applied attribution), so its numbers overstated what --bulk would write.
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
|
||||
const denyCwd = makeRepoWithRemote(home, "denied", "https://github.com/denyme/denied.git");
|
||||
writeSessionForRepo(home, "work-denied", "denysess1", denyCwd);
|
||||
setPolicy(gstackHome, "https://github.com/denyme/denied.git", "deny");
|
||||
|
||||
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toContain("Total files in window: 0");
|
||||
expect(r.stdout).toMatch(/New \(never ingested\):\s+0/);
|
||||
expect(r.stdout).toMatch(/Skipped \(policy deny\):\s+1/);
|
||||
expect(r.stdout).not.toMatch(/Skipped \(policy read-only\)/);
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("(g) probe policy parity: a read-only remote's transcript lands in skipped_policy_readonly", () => {
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
|
||||
const roCwd = makeRepoWithRemote(home, "readonly", "https://github.com/roorg/rorepo.git");
|
||||
writeSessionForRepo(home, "work-readonly", "rosess1", roCwd);
|
||||
setPolicy(gstackHome, "https://github.com/roorg/rorepo.git", "read-only");
|
||||
|
||||
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toContain("Total files in window: 0");
|
||||
expect(r.stdout).toMatch(/Skipped \(policy read-only\):\s+1/);
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("(h) --limit counts policy-PERMITTED pages only: a denied-first corpus still writes the allowed page", () => {
|
||||
// Walk order is deterministic here: Claude Code projects are walked
|
||||
// BEFORE Codex sessions (walkAllSources), so the DENIED transcript is
|
||||
// prepared first. Pre-fix, --limit 1 was applied to the unfiltered
|
||||
// prepared array — the denied record consumed the limit and the permitted
|
||||
// one starved (written: 0).
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const { binDir } = installFakeGbrain(home);
|
||||
|
||||
const denyCwd = makeRepoWithRemote(home, "denied", "https://github.com/denyme/denied.git");
|
||||
writeSessionForRepo(home, "work-denied", "denysess1", denyCwd); // Claude Code: walked first
|
||||
const okCwd = makeRepoWithRemote(home, "allowed", "https://github.com/okorg/okrepo.git");
|
||||
const today = new Date();
|
||||
const ymd = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
writeCodexSession(
|
||||
home, ymd,
|
||||
`{"type":"session_meta","payload":{"id":"oksess-codex","cwd":"${okCwd.replace(/\\/g, "\\\\")}"},"timestamp":"${today.toISOString()}"}\n`,
|
||||
);
|
||||
setPolicy(gstackHome, "https://github.com/denyme/denied.git", "deny");
|
||||
|
||||
const r = runScript(["--bulk", "--quiet", "--limit", "1"], {
|
||||
HOME: home,
|
||||
GSTACK_HOME: gstackHome,
|
||||
PATH: `${binDir}:${process.env.PATH || ""}`,
|
||||
});
|
||||
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toMatch(/written:\s+1/);
|
||||
expect(r.stdout).toMatch(/skipped \(policy deny\):\s+1/);
|
||||
// The page that landed is the PERMITTED one (the Codex session), not
|
||||
// whichever record happened to be walked first.
|
||||
const sessions = stateSessions(gstackHome);
|
||||
expect(sessions.length).toBe(1);
|
||||
expect(sessions[0]).toContain("rollout-");
|
||||
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("artifacts are never policy-filtered, even when their project's remote is denied", () => {
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(join(gstackHome, "projects", "denyme-denied"), { recursive: true });
|
||||
const { binDir } = installFakeGbrain(home);
|
||||
|
||||
// A learning artifact under a project slug matching a denied remote —
|
||||
// the policy is keyed by git remote, which artifacts don't have.
|
||||
writeFileSync(join(gstackHome, "projects", "denyme-denied", "learnings.jsonl"), '{"key":"a","insight":"b"}\n');
|
||||
setPolicy(gstackHome, "https://github.com/denyme/denied.git", "deny");
|
||||
|
||||
const r = runScript(["--bulk", "--quiet"], {
|
||||
HOME: home,
|
||||
GSTACK_HOME: gstackHome,
|
||||
PATH: `${binDir}:${process.env.PATH || ""}`,
|
||||
});
|
||||
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toMatch(/written:\s+1/);
|
||||
expect(r.stdout).not.toMatch(/skipped \(policy/);
|
||||
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
@@ -385,19 +385,54 @@ describe("offline output contract (what /ship branches on, #2545)", () => {
|
||||
// trustworthy when the PR queue is unreachable. That field is therefore
|
||||
// load-bearing prose-to-code coupling: if it silently stopped being emitted,
|
||||
// /ship would read undefined, treat the run as fully online, and lose the
|
||||
// "verify no sibling holds it" prompt. Asserted end-to-end with a stub `gh`
|
||||
// that always fails, which is what an expired token or an offline laptop
|
||||
// looks like from here.
|
||||
// "verify no sibling holds it" prompt.
|
||||
//
|
||||
// Both tests run the CLI in a LOCAL FIXTURE repo, never the checkout it
|
||||
// lives in: in the real checkout the git fallback does a live
|
||||
// `ls-remote origin` and reads the real branch census, which made this test
|
||||
// depend on the operator's network — and on a shallow CI clone it fetched
|
||||
// the remote's every branch (the shard-deadline hang fixed alongside this).
|
||||
const NEXTVER = join(import.meta.dir, "..", "bin", "gstack-next-version");
|
||||
|
||||
function fixtureRepo(): { root: string; work: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), "nextver-contract-"));
|
||||
// detectHost() sniffs "github.com" in the origin URL STRING before any
|
||||
// gh/glab auth probe — a bare origin at a path containing github.com
|
||||
// pins host:"github" identically on every machine (an auth-probe
|
||||
// fallthrough once made this test pass via glab locally and read
|
||||
// host:"unknown" on CI) while keeping ls-remote/fetch fully local.
|
||||
const bare = join(root, "github.com", "origin.git");
|
||||
mkdirSync(bare, { recursive: true });
|
||||
Bun.spawnSync(["git", "init", "-q", "--bare", "-b", "main", bare]);
|
||||
const work = join(root, "work");
|
||||
mkdirSync(work);
|
||||
const git = (...args: string[]) =>
|
||||
Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd: work });
|
||||
git("init", "-q", "-b", "main");
|
||||
writeFileSync(join(work, "VERSION"), "1.0.0.0\n");
|
||||
git("add", "-A");
|
||||
git("commit", "-qm", "v1.0.0.0 chore: base");
|
||||
git("remote", "add", "origin", bare);
|
||||
git("push", "-q", "origin", "main");
|
||||
return { root, work };
|
||||
}
|
||||
|
||||
test("emits fallback:'git' and still returns a version when gh fails", async () => {
|
||||
// Stub `gh` always fails — what an expired token or an offline laptop
|
||||
// looks like from here. No origin remote → ls-remote fails fast and the
|
||||
// allocation comes from local refs, never the network.
|
||||
const stubDir = mkdtempSync(join(tmpdir(), "nextver-stubgh-"));
|
||||
const { root, work } = fixtureRepo();
|
||||
writeFileSync(join(stubDir, "gh"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
const proc = Bun.spawnSync(
|
||||
["bun", "run", "./bin/gstack-next-version", "--base", "main",
|
||||
["bun", "run", NEXTVER, "--base", "main",
|
||||
"--bump", "patch", "--current-version", "1.0.0.0", "--workspace-root", "null"],
|
||||
{ env: { ...process.env, PATH: `${stubDir}:${process.env.PATH}` } },
|
||||
{ cwd: work, env: { ...process.env, PATH: `${stubDir}:${process.env.PATH}` } },
|
||||
);
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
const out = JSON.parse(new TextDecoder().decode(proc.stdout));
|
||||
expect(out.host).toBe("github"); // pinned by the fixture's URL sniff, not auth probes
|
||||
expect(out.offline).toBe(true);
|
||||
expect(out.fallback).toBe("git");
|
||||
// The whole point: degraded queue view, NOT a degraded allocation.
|
||||
@@ -406,13 +441,28 @@ describe("offline output contract (what /ship branches on, #2545)", () => {
|
||||
}, 30000);
|
||||
|
||||
test("online runs leave fallback null", async () => {
|
||||
const proc = Bun.spawnSync(
|
||||
["bun", "run", "./bin/gstack-next-version", "--base", "main",
|
||||
"--bump", "patch", "--current-version", "1.0.0.0", "--workspace-root", "null"],
|
||||
// Stub `gh` SUCCEEDS (empty PR queue) — the online path asserted
|
||||
// deterministically instead of only when the operator happens to be
|
||||
// authed. Before this stub the test silently no-opped on CI.
|
||||
const stubDir = mkdtempSync(join(tmpdir(), "nextver-stubgh-ok-"));
|
||||
const { root, work } = fixtureRepo();
|
||||
writeFileSync(
|
||||
join(stubDir, "gh"),
|
||||
'#!/bin/sh\ncase "$1" in\n pr) echo "[]" ;;\n repo) echo "testowner" ;;\n *) exit 0 ;;\nesac\n',
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const proc = Bun.spawnSync(
|
||||
["bun", "run", NEXTVER, "--base", "main",
|
||||
"--bump", "patch", "--current-version", "1.0.0.0", "--workspace-root", "null"],
|
||||
{ cwd: work, env: { ...process.env, PATH: `${stubDir}:${process.env.PATH}` } },
|
||||
);
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
const out = JSON.parse(new TextDecoder().decode(proc.stdout));
|
||||
if (out.offline) return; // no network / no gh auth on this machine: nothing to assert
|
||||
expect(out.host).toBe("github"); // pinned by the fixture's URL sniff, not auth probes
|
||||
expect(out.offline).toBe(false);
|
||||
expect(out.fallback).toBe(null);
|
||||
expect(out.version).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
@@ -542,6 +592,343 @@ describe("fetchGitClaimed (offline allocation — the anti-duplicate fallback, #
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGitClaimed — non-mutating live remote query (ls-remote first)", () => {
|
||||
// The degraded git-fallback used to count EVERY remote-tracking ref on EVERY
|
||||
// remote: branches deleted on origin (stale local refs) and an unrelated
|
||||
// `upstream` remote's branches all inflated the claim set, pushing the
|
||||
// allocation past the real queue. `git ls-remote --heads origin` returns the
|
||||
// remote's LIVE branch list with zero local mutation — a path/file remote
|
||||
// answers it offline, which is exactly what these fixtures use.
|
||||
function git(cwd: string, ...args: string[]) {
|
||||
return Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd });
|
||||
}
|
||||
|
||||
// Local origin with: main (0.1.66.0), sibling (0.1.67.0, live claim), and
|
||||
// dead (0.1.98.0) — deleted on origin AFTER the clone, so the clone keeps a
|
||||
// stale refs/remotes/origin/dead. Plus a second remote's stale claim ref.
|
||||
function liveFixture(): { root: string; clone: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), "nextver-lsremote-"));
|
||||
const origin = join(root, "origin");
|
||||
mkdirSync(origin);
|
||||
git(origin, "init", "-q", "-b", "main");
|
||||
writeFileSync(join(origin, "VERSION"), "0.1.66.0\n");
|
||||
git(origin, "add", "-A");
|
||||
git(origin, "commit", "-qm", "v0.1.66.0 chore: base");
|
||||
git(origin, "checkout", "-q", "-b", "sibling");
|
||||
writeFileSync(join(origin, "VERSION"), "0.1.67.0\n");
|
||||
git(origin, "add", "-A");
|
||||
git(origin, "commit", "-qm", "v0.1.67.0 feat: sibling claimed this");
|
||||
git(origin, "checkout", "-q", "-b", "dead");
|
||||
writeFileSync(join(origin, "VERSION"), "0.1.98.0\n");
|
||||
git(origin, "add", "-A");
|
||||
git(origin, "commit", "-qm", "v0.1.98.0 feat: deleted later");
|
||||
git(origin, "checkout", "-q", "main");
|
||||
const clone = join(root, "clone");
|
||||
git(root, "clone", "-q", origin, clone);
|
||||
// Deleted on the REMOTE after the clone — the stale local ref survives.
|
||||
git(origin, "branch", "-qD", "dead");
|
||||
// A second remote carrying a stale claim branch: must never be counted.
|
||||
git(clone, "checkout", "-q", "-b", "tmp-upstream");
|
||||
writeFileSync(join(clone, "VERSION"), "0.1.99.0\n");
|
||||
git(clone, "add", "-A");
|
||||
git(clone, "commit", "-qm", "v0.1.99.0 upstream stale claim");
|
||||
const upSha = new TextDecoder().decode(git(clone, "rev-parse", "HEAD").stdout).trim();
|
||||
git(clone, "checkout", "-q", "main");
|
||||
git(clone, "branch", "-qD", "tmp-upstream");
|
||||
git(clone, "update-ref", "refs/remotes/upstream/stale", upSha);
|
||||
return { root, clone };
|
||||
}
|
||||
|
||||
test("live path: only branches that exist on origin RIGHT NOW are claims", () => {
|
||||
const { root, clone } = liveFixture();
|
||||
const cwd = process.cwd();
|
||||
try {
|
||||
process.chdir(clone);
|
||||
const warnings: string[] = [];
|
||||
const claims = fetchGitClaimed("main", "VERSION", warnings);
|
||||
const versions = claims.map((c) => c.version);
|
||||
expect(versions).toContain("0.1.67.0"); // live sibling claim
|
||||
expect(versions).not.toContain("0.1.98.0"); // deleted on origin — stale local ref ignored
|
||||
expect(versions).not.toContain("0.1.99.0"); // second remote's refs are not our queue
|
||||
// The live path emits no staleness warning.
|
||||
expect(warnings.join(" ")).not.toContain("ls-remote");
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("zero local mutation: the stale remote-tracking ref survives the query", () => {
|
||||
// ls-remote reads the remote without fetch/prune — an allocator run must
|
||||
// never rewrite local refs as a side effect.
|
||||
const { root, clone } = liveFixture();
|
||||
const cwd = process.cwd();
|
||||
try {
|
||||
process.chdir(clone);
|
||||
fetchGitClaimed("main", "VERSION", []);
|
||||
const ref = git(clone, "rev-parse", "--verify", "-q", "refs/remotes/origin/dead");
|
||||
expect(ref.exitCode).toBe(0);
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("fallback: ls-remote failure uses LOCAL refs/remotes/origin only, with a staleness warning", () => {
|
||||
// No origin remote configured at all — ls-remote must fail, and the
|
||||
// fallback must scan refs/remotes/origin ONLY (never other remotes).
|
||||
const dir = mkdtempSync(join(tmpdir(), "nextver-lsfallback-"));
|
||||
const cwd = process.cwd();
|
||||
try {
|
||||
git(dir, "init", "-q", "-b", "main");
|
||||
writeFileSync(join(dir, "VERSION"), "0.1.66.0\n");
|
||||
git(dir, "add", "-A");
|
||||
git(dir, "commit", "-qm", "v0.1.66.0 chore: base");
|
||||
git(dir, "checkout", "-q", "-b", "sibling");
|
||||
writeFileSync(join(dir, "VERSION"), "0.1.67.0\n");
|
||||
git(dir, "add", "-A");
|
||||
git(dir, "commit", "-qm", "v0.1.67.0 feat: sibling claimed this");
|
||||
const sibSha = new TextDecoder().decode(git(dir, "rev-parse", "HEAD").stdout).trim();
|
||||
git(dir, "checkout", "-q", "-b", "stale2");
|
||||
writeFileSync(join(dir, "VERSION"), "0.1.99.0\n");
|
||||
git(dir, "add", "-A");
|
||||
git(dir, "commit", "-qm", "v0.1.99.0 upstream stale claim");
|
||||
const upSha = new TextDecoder().decode(git(dir, "rev-parse", "HEAD").stdout).trim();
|
||||
git(dir, "checkout", "-q", "main");
|
||||
git(dir, "update-ref", "refs/remotes/origin/sibling", sibSha);
|
||||
git(dir, "update-ref", "refs/remotes/upstream/stale", upSha);
|
||||
|
||||
process.chdir(dir);
|
||||
const warnings: string[] = [];
|
||||
const claims = fetchGitClaimed("main", "VERSION", warnings);
|
||||
const versions = claims.map((c) => c.version);
|
||||
expect(versions).toContain("0.1.67.0"); // origin's local snapshot still counts
|
||||
expect(versions).not.toContain("0.1.99.0"); // upstream remote is ignored
|
||||
expect(warnings.join(" ")).toContain("stale local refs/remotes/origin");
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGitClaimed — unfetched live claims (G2: ls-remote advertises SHAs without objects)", () => {
|
||||
// `git ls-remote` lists a branch's tip sha without transferring objects, so
|
||||
// a branch pushed AFTER the last local fetch has no local object and both
|
||||
// VERSION reads fail. The old `continue` silently dropped that LIVE claim —
|
||||
// the exact duplicate-allocation this fallback exists to prevent.
|
||||
function git(cwd: string, ...args: string[]) {
|
||||
return Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd });
|
||||
}
|
||||
|
||||
function cloneFixture(): { root: string; origin: string; clone: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), "nextver-unfetched-"));
|
||||
const origin = join(root, "origin");
|
||||
mkdirSync(origin);
|
||||
git(origin, "init", "-q", "-b", "main");
|
||||
writeFileSync(join(origin, "VERSION"), "0.1.66.0\n");
|
||||
git(origin, "add", "-A");
|
||||
git(origin, "commit", "-qm", "v0.1.66.0 chore: base");
|
||||
const clone = join(root, "clone");
|
||||
git(root, "clone", "-q", origin, clone);
|
||||
return { root, origin, clone };
|
||||
}
|
||||
|
||||
test("a claim branch pushed after the last local fetch is read via a targeted fetch", () => {
|
||||
const { root, origin, clone } = cloneFixture();
|
||||
const cwd = process.cwd();
|
||||
try {
|
||||
// The claim lands on origin AFTER the clone — its objects are absent
|
||||
// locally, so `git show <sha>:VERSION` and the remote-tracking read
|
||||
// both fail until the targeted fetch runs.
|
||||
git(origin, "checkout", "-q", "-b", "late-claim");
|
||||
writeFileSync(join(origin, "VERSION"), "0.1.70.0\n");
|
||||
git(origin, "add", "-A");
|
||||
git(origin, "commit", "-qm", "v0.1.70.0 feat: late claim");
|
||||
git(origin, "checkout", "-q", "main");
|
||||
|
||||
process.chdir(clone);
|
||||
const warnings: string[] = [];
|
||||
const claims = fetchGitClaimed("main", "VERSION", warnings);
|
||||
expect(claims.map((c) => c.version)).toContain("0.1.70.0");
|
||||
expect(warnings.join(" ")).not.toContain("UNKNOWN claim");
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("MANY unfetched claim branches resolve with ONE batched fetch, not a per-branch crawl", () => {
|
||||
// The per-branch fetch loop this replaces ground a shallow CI clone
|
||||
// against a busy remote for minutes (dozens of sequential network
|
||||
// fetches). The network budget must stay one round trip no matter how
|
||||
// many branches are missing — pinned by counting `git fetch` spawns
|
||||
// through a PATH shim.
|
||||
const { root, origin, clone } = cloneFixture();
|
||||
const cwd = process.cwd();
|
||||
const oldPath = process.env.PATH;
|
||||
const shimDir = mkdtempSync(join(tmpdir(), "nextver-gitshim-"));
|
||||
try {
|
||||
for (const v of ["0.1.70.0", "0.1.71.0", "0.1.72.0"]) {
|
||||
git(origin, "checkout", "-q", "-b", `late-${v.replace(/\./g, "-")}`);
|
||||
writeFileSync(join(origin, "VERSION"), `${v}\n`);
|
||||
git(origin, "add", "-A");
|
||||
git(origin, "commit", "-qm", `v${v} feat: late claim`);
|
||||
git(origin, "checkout", "-q", "main");
|
||||
}
|
||||
|
||||
const realGit = Bun.which("git");
|
||||
const spawnLog = join(shimDir, "spawns.log");
|
||||
writeFileSync(
|
||||
join(shimDir, "git"),
|
||||
`#!/bin/sh\necho "$@" >> "${spawnLog}"\nexec "${realGit}" "$@"\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
process.chdir(clone);
|
||||
process.env.PATH = `${shimDir}:${oldPath}`;
|
||||
const warnings: string[] = [];
|
||||
const claims = fetchGitClaimed("main", "VERSION", warnings);
|
||||
process.env.PATH = oldPath;
|
||||
|
||||
const versions = claims.map((c) => c.version);
|
||||
expect(versions).toContain("0.1.70.0");
|
||||
expect(versions).toContain("0.1.71.0");
|
||||
expect(versions).toContain("0.1.72.0");
|
||||
expect(warnings.join(" ")).not.toContain("UNKNOWN claim");
|
||||
const fetches = readFileSync(spawnLog, "utf-8")
|
||||
.split("\n")
|
||||
.filter((l) => l.startsWith("fetch "));
|
||||
expect(fetches.length).toBe(1);
|
||||
for (const v of ["0-1-70-0", "0-1-71-0", "0-1-72-0"]) {
|
||||
expect(fetches[0]).toContain(`refs/heads/late-${v}`);
|
||||
}
|
||||
} finally {
|
||||
process.env.PATH = oldPath;
|
||||
process.chdir(cwd);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
rmSync(shimDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("one unservable ref does not poison the batch — live claims still resolve, the ghost warns", () => {
|
||||
// A dangling sha fails the WHOLE batched transfer, so the still-missing
|
||||
// refs get a bounded per-branch retry: the real claim must come through
|
||||
// and only the ghost surfaces as UNKNOWN.
|
||||
const { root, origin, clone } = cloneFixture();
|
||||
const cwd = process.cwd();
|
||||
try {
|
||||
git(origin, "checkout", "-q", "-b", "late-claim");
|
||||
writeFileSync(join(origin, "VERSION"), "0.1.70.0\n");
|
||||
git(origin, "add", "-A");
|
||||
git(origin, "commit", "-qm", "v0.1.70.0 feat: late claim");
|
||||
git(origin, "checkout", "-q", "main");
|
||||
writeFileSync(
|
||||
join(origin, ".git", "refs", "heads", "ghost"),
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n",
|
||||
);
|
||||
|
||||
process.chdir(clone);
|
||||
const warnings: string[] = [];
|
||||
const claims = fetchGitClaimed("main", "VERSION", warnings);
|
||||
expect(claims.map((c) => c.version)).toContain("0.1.70.0");
|
||||
const joined = warnings.join(" ");
|
||||
expect(joined).toContain("origin/ghost");
|
||||
expect(joined).toContain("UNKNOWN claim");
|
||||
expect(joined).not.toContain("late-claim");
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a claim STILL unreadable after the fetch surfaces as an UNKNOWN-claim warning, never silence", () => {
|
||||
const { root, origin, clone } = cloneFixture();
|
||||
const cwd = process.cwd();
|
||||
try {
|
||||
// A ref origin advertises but cannot serve: dangling sha written
|
||||
// straight into refs/. ls-remote lists it; every local read fails, the
|
||||
// targeted fetch fails ("not our ref"), and the object never appears.
|
||||
writeFileSync(
|
||||
join(origin, ".git", "refs", "heads", "ghost"),
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n",
|
||||
);
|
||||
|
||||
process.chdir(clone);
|
||||
const warnings: string[] = [];
|
||||
const claims = fetchGitClaimed("main", "VERSION", warnings);
|
||||
expect(claims.map((c) => c.branch)).not.toContain("origin/ghost");
|
||||
const joined = warnings.join(" ");
|
||||
expect(joined).toContain("origin/ghost");
|
||||
expect(joined).toContain("UNKNOWN claim");
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a live branch that simply carries no VERSION file is not a claim and not an UNKNOWN warning", () => {
|
||||
const { root, origin, clone } = cloneFixture();
|
||||
const cwd = process.cwd();
|
||||
try {
|
||||
// Branch exists BEFORE the clone (objects local), VERSION deleted on it:
|
||||
// the read fails because the PATH is absent, not the object. Old
|
||||
// semantics (skip quietly) must hold — no phantom UNKNOWN noise.
|
||||
git(origin, "checkout", "-q", "-b", "docs-only");
|
||||
git(origin, "rm", "-q", "VERSION");
|
||||
git(origin, "commit", "-qm", "docs: no version file");
|
||||
git(origin, "checkout", "-q", "main");
|
||||
git(clone, "fetch", "-q", "origin");
|
||||
|
||||
process.chdir(clone);
|
||||
const warnings: string[] = [];
|
||||
const claims = fetchGitClaimed("main", "VERSION", warnings);
|
||||
expect(claims.map((c) => c.branch)).not.toContain("origin/docs-only");
|
||||
expect(warnings.join(" ")).not.toContain("docs-only");
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("width pinned on failed base read (3-digit repos)", () => {
|
||||
// readBaseVersion used to return a literal "0.0.0.0" when origin/<base> was
|
||||
// unreadable — a 4-digit string, which flipped versionWidth() to 4 and
|
||||
// handed a 3-digit repo a 4-digit slot its tooling can't read back (#2501's
|
||||
// width class, resurfacing through the failure path). The zero base is now
|
||||
// shaped by the LOCAL version file's width.
|
||||
const SCRIPT = join(import.meta.dir, "..", "bin", "gstack-next-version");
|
||||
|
||||
test("a 3-digit repo keeps 3-digit allocation when origin/<base> is unreadable", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "nextver-width3-"));
|
||||
const stubDir = mkdtempSync(join(tmpdir(), "nextver-width3-stub-"));
|
||||
try {
|
||||
// gh/glab stubs fail → host unknown → git fallback; no origin remote →
|
||||
// the base read fails too, which is the path under test.
|
||||
writeFileSync(join(stubDir, "gh"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
writeFileSync(join(stubDir, "glab"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
Bun.spawnSync(["git", "init", "-q", "-b", "main"], { cwd: dir });
|
||||
writeFileSync(join(dir, "VERSION"), "0.99.2\n");
|
||||
Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: dir });
|
||||
Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "init"], { cwd: dir });
|
||||
|
||||
const proc = Bun.spawnSync(
|
||||
["bun", "run", SCRIPT, "--base", "main", "--bump", "patch", "--workspace-root", "null"],
|
||||
{ cwd: dir, env: { ...process.env, PATH: `${stubDir}:${process.env.PATH}` } },
|
||||
);
|
||||
const out = JSON.parse(new TextDecoder().decode(proc.stdout));
|
||||
// Zero base at the repo's OWN width — never "0.0.0.0" in a 3-digit repo.
|
||||
expect(out.base_version).toBe("0.0.0");
|
||||
expect(out.version).toBe("0.0.1"); // 3-digit allocation, not 0.0.1.0
|
||||
expect(out.warnings.join(" ")).not.toContain("0.0.0.0");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe("integration (smoke)", () => {
|
||||
// Bumps timeout to 30s — the test spawns a real `bun run` subprocess that
|
||||
// does a `gh pr list` against the live GitHub API to inspect claimed slots.
|
||||
|
||||
@@ -53,6 +53,18 @@ function runWithStdin(input: string, ...args: string[]): { stdout: string; stder
|
||||
};
|
||||
}
|
||||
|
||||
/** Plant a pref by writing the file. Used for legacy / inert one-way prefs
|
||||
* that --write now refuses (#2488). --check and --stats still have to
|
||||
* handle files written before the reject landed. */
|
||||
function plantPref(id: string, pref: string) {
|
||||
run('--read');
|
||||
const projects = fs.readdirSync(path.join(tmpHome, 'projects'));
|
||||
const file = path.join(tmpHome, 'projects', projects[0], 'question-preferences.json');
|
||||
const prefs = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
prefs[id] = pref;
|
||||
fs.writeFileSync(file, JSON.stringify(prefs, null, 2));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// --check
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -92,7 +104,8 @@ describe('--check with preferences set', () => {
|
||||
});
|
||||
|
||||
test('one-way + never-ask → ASK_NORMALLY with safety note', () => {
|
||||
setPref('ship-test-failure-triage', 'never-ask');
|
||||
// Planted: --write now refuses never-ask on one-way ids (#2488).
|
||||
plantPref('ship-test-failure-triage', 'never-ask');
|
||||
const r = run('--check', 'ship-test-failure-triage');
|
||||
expect(r.stdout).toContain('ASK_NORMALLY');
|
||||
expect(r.stdout).toContain('one-way door overrides');
|
||||
@@ -111,7 +124,7 @@ describe('--check with preferences set', () => {
|
||||
});
|
||||
|
||||
test('one-way + ask-only-for-one-way → ASK_NORMALLY', () => {
|
||||
setPref('ship-test-failure-triage', 'ask-only-for-one-way');
|
||||
plantPref('ship-test-failure-triage', 'ask-only-for-one-way');
|
||||
const r = run('--check', 'ship-test-failure-triage');
|
||||
expect(r.stdout.trim()).toContain('ASK_NORMALLY');
|
||||
});
|
||||
@@ -389,6 +402,112 @@ describe('--write schema validation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// #2488: --write must refuse suppressing prefs on one-way ids. --check
|
||||
// already ignores them; storing them made --stats report a working NEVER_ASK.
|
||||
describe('--write one-way door reject (#2488)', () => {
|
||||
function prefsFile(): string {
|
||||
const projects = fs.readdirSync(path.join(tmpHome, 'projects'));
|
||||
return path.join(tmpHome, 'projects', projects[0], 'question-preferences.json');
|
||||
}
|
||||
|
||||
test('never-ask on one-way id is rejected and does not write', () => {
|
||||
const r = run(
|
||||
'--write',
|
||||
JSON.stringify({
|
||||
question_id: 'plan-eng-review-arch-finding',
|
||||
preference: 'never-ask',
|
||||
source: 'plan-tune',
|
||||
}),
|
||||
);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain('cannot set never-ask');
|
||||
expect(r.stderr).toContain('plan-eng-review-arch-finding');
|
||||
expect(r.stderr).toContain('door_type: one-way');
|
||||
expect(JSON.parse(fs.readFileSync(prefsFile(), 'utf-8'))).toEqual({});
|
||||
});
|
||||
|
||||
test('ask-only-for-one-way on one-way id is rejected and does not write', () => {
|
||||
const r = run(
|
||||
'--write',
|
||||
JSON.stringify({
|
||||
question_id: 'ship-test-failure-triage',
|
||||
preference: 'ask-only-for-one-way',
|
||||
source: 'plan-tune',
|
||||
}),
|
||||
);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain('cannot set ask-only-for-one-way');
|
||||
expect(r.stderr).toContain('door_type: one-way');
|
||||
expect(JSON.parse(fs.readFileSync(prefsFile(), 'utf-8'))).toEqual({});
|
||||
});
|
||||
|
||||
test('always-ask on one-way id is accepted (agrees with the safety override)', () => {
|
||||
const r = run(
|
||||
'--write',
|
||||
JSON.stringify({
|
||||
question_id: 'plan-eng-review-arch-finding',
|
||||
preference: 'always-ask',
|
||||
source: 'plan-tune',
|
||||
}),
|
||||
);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('OK');
|
||||
expect(JSON.parse(fs.readFileSync(prefsFile(), 'utf-8'))).toEqual({
|
||||
'plan-eng-review-arch-finding': 'always-ask',
|
||||
});
|
||||
});
|
||||
|
||||
test('never-ask on two-way id is still accepted', () => {
|
||||
const r = run(
|
||||
'--write',
|
||||
JSON.stringify({
|
||||
question_id: 'ship-changelog-voice-polish',
|
||||
preference: 'never-ask',
|
||||
source: 'plan-tune',
|
||||
}),
|
||||
);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('OK');
|
||||
});
|
||||
|
||||
test('rejected one-way write does not clobber an existing two-way pref', () => {
|
||||
run(
|
||||
'--write',
|
||||
JSON.stringify({
|
||||
question_id: 'ship-changelog-voice-polish',
|
||||
preference: 'never-ask',
|
||||
source: 'plan-tune',
|
||||
}),
|
||||
);
|
||||
const r = run(
|
||||
'--write',
|
||||
JSON.stringify({
|
||||
question_id: 'plan-eng-review-arch-finding',
|
||||
preference: 'never-ask',
|
||||
source: 'plan-tune',
|
||||
}),
|
||||
);
|
||||
expect(r.status).toBe(1);
|
||||
expect(JSON.parse(fs.readFileSync(prefsFile(), 'utf-8'))).toEqual({
|
||||
'ship-changelog-voice-polish': 'never-ask',
|
||||
});
|
||||
});
|
||||
|
||||
test('poisoning source on a one-way id still exits 2 (origin gate first)', () => {
|
||||
const r = run(
|
||||
'--write',
|
||||
JSON.stringify({
|
||||
question_id: 'plan-eng-review-arch-finding',
|
||||
preference: 'never-ask',
|
||||
source: 'inline-tool-output',
|
||||
}),
|
||||
);
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('profile poisoning defense');
|
||||
expect(r.stderr).not.toContain('door_type');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// --read, --clear, --stats
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -448,5 +567,21 @@ describe('--stats', () => {
|
||||
expect(r.stdout).toContain('TOTAL: 3');
|
||||
expect(r.stdout).toContain('NEVER_ASK: 2');
|
||||
expect(r.stdout).toContain('ALWAYS_ASK: 1');
|
||||
expect(r.stdout).toContain('INERT_ONE_WAY: 0');
|
||||
});
|
||||
|
||||
test('planted one-way never-ask is INERT_ONE_WAY, not a working NEVER_ASK (#2488)', () => {
|
||||
plantPref('plan-eng-review-arch-finding', 'never-ask');
|
||||
const r = run('--stats');
|
||||
expect(r.stdout).toContain('TOTAL: 1');
|
||||
expect(r.stdout).toContain('NEVER_ASK: 0');
|
||||
expect(r.stdout).toContain('INERT_ONE_WAY: 1');
|
||||
});
|
||||
|
||||
test('planted one-way ask-only-for-one-way is INERT_ONE_WAY, not ASK_ONLY_ONE_WAY', () => {
|
||||
plantPref('ship-test-failure-triage', 'ask-only-for-one-way');
|
||||
const r = run('--stats');
|
||||
expect(r.stdout).toContain('ASK_ONLY_ONE_WAY: 0');
|
||||
expect(r.stdout).toContain('INERT_ONE_WAY: 1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,13 @@ import * as os from "os";
|
||||
|
||||
const BIN = path.resolve(import.meta.dir, "..", "bin", "gstack-redact");
|
||||
|
||||
// A synthetic AWS access key for feeding the scanner. Derived by
|
||||
// concatenation so the contiguous credential-shaped literal never appears in
|
||||
// this file's source — the CI quality gate scans every ADDED diff line with
|
||||
// this same engine, and a raw fixture literal here fails the gate it exists
|
||||
// to test (#2610 port fallout). The scanner still sees the assembled bytes.
|
||||
const FAKE_AWS_KEY = ["AKIA", "1234567890ABCDEF"].join("");
|
||||
|
||||
function run(
|
||||
args: string[],
|
||||
stdin: string,
|
||||
@@ -28,7 +35,7 @@ describe("gstack-redact exit codes", () => {
|
||||
expect(run([], "just some prose").code).toBe(0);
|
||||
});
|
||||
test("HIGH → 3", () => {
|
||||
expect(run([], "key AKIA1234567890ABCDEF").code).toBe(3);
|
||||
expect(run([], `key ${FAKE_AWS_KEY}`).code).toBe(3);
|
||||
});
|
||||
test("MEDIUM only → 2", () => {
|
||||
expect(run(["--repo-visibility", "public"], "mail bob@corp.io").code).toBe(2);
|
||||
@@ -37,7 +44,7 @@ describe("gstack-redact exit codes", () => {
|
||||
|
||||
describe("gstack-redact --json", () => {
|
||||
test("emits valid JSON with findings + counts", () => {
|
||||
const { stdout, code } = run(["--json"], "key AKIA1234567890ABCDEF");
|
||||
const { stdout, code } = run(["--json"], `key ${FAKE_AWS_KEY}`);
|
||||
expect(code).toBe(3);
|
||||
const parsed = JSON.parse(stdout);
|
||||
expect(parsed.findings[0].id).toBe("aws.access_key");
|
||||
@@ -59,8 +66,8 @@ describe("gstack-redact --allowlist", () => {
|
||||
test("allowlisted span is suppressed", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "redact-allow-"));
|
||||
const allow = path.join(dir, "allow.txt");
|
||||
fs.writeFileSync(allow, "AKIA1234567890ABCDEF\n");
|
||||
const { code } = run(["--allowlist", allow], "key AKIA1234567890ABCDEF");
|
||||
fs.writeFileSync(allow, FAKE_AWS_KEY + "\n");
|
||||
const { code } = run(["--allowlist", allow], `key ${FAKE_AWS_KEY}`);
|
||||
expect(code).toBe(0);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
@@ -95,3 +102,47 @@ describe("gstack-redact oversize fails closed", () => {
|
||||
expect(stdout).toContain("too large");
|
||||
});
|
||||
});
|
||||
|
||||
describe("gstack-redact argv dispatch", () => {
|
||||
// The bug: main() recognised exactly two subcommands and let everything else
|
||||
// fall through to the stdin scan, which reports "no findings" and exits 0.
|
||||
// So `install-prepush-hooks` (plural typo) installed no hook and still looked
|
||||
// like success — the credential guard silently absent while the operator
|
||||
// believes it is armed. A guard that no-ops must never exit 0.
|
||||
test("a typo'd install subcommand fails loudly instead of exiting 0", () => {
|
||||
const { code, stderr } = run(["install-prepush-hooks"], "");
|
||||
expect(code).not.toBe(0);
|
||||
expect(stderr).toContain("unknown subcommand");
|
||||
});
|
||||
|
||||
test("an unknown positional never reports a clean scan", () => {
|
||||
const { code, stdout } = run(["totally-bogus"], "");
|
||||
expect(code).not.toBe(0);
|
||||
expect(stdout).not.toContain("HIGH=0");
|
||||
});
|
||||
|
||||
// Usage errors must not collide with the findings codes (2 = MEDIUM,
|
||||
// 3 = HIGH); a caller gating on those would read a typo as "findings".
|
||||
test("usage errors exit 1, not a findings code", () => {
|
||||
expect(run(["totally-bogus"], "").code).toBe(1);
|
||||
});
|
||||
|
||||
test("--help prints usage and exits 0 without scanning", () => {
|
||||
const { code, stdout } = run(["--help"], `key ${FAKE_AWS_KEY}`);
|
||||
expect(code).toBe(0);
|
||||
expect(stdout).toContain("STDIN");
|
||||
expect(stdout).not.toContain("HIGH=1");
|
||||
});
|
||||
|
||||
// "scan" is what the human output header ("gstack-redact scan — repo …")
|
||||
// invites people to type, so it stays an accepted alias for the default
|
||||
// filter mode. Rejecting it would break that muscle memory for no gain.
|
||||
test("the 'scan' alias still scans normally", () => {
|
||||
expect(run(["scan"], `key ${FAKE_AWS_KEY}`).code).toBe(3);
|
||||
expect(run(["scan"], "just prose").code).toBe(0);
|
||||
});
|
||||
|
||||
test("flags are still parsed, not mistaken for subcommands", () => {
|
||||
expect(run(["--json"], "just prose").code).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -219,6 +219,77 @@ describe('add-event', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// ensure-event: duplicate (event, source) collapse
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
describe('ensure-event collapses duplicate (event, source) entries', () => {
|
||||
test('two same-source entries from the old matcher-keyed dedup collapse to ONE updated entry', () => {
|
||||
// Pre-existing installs can carry two entries with the same
|
||||
// (event, _gstack_source) — the old dedup keyed on the matcher too, so a
|
||||
// matcher change pushed a second registration. `.find()` updated only the
|
||||
// first and left the stale twin running forever.
|
||||
const { spawnSync } = require('child_process');
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{ _gstack_source: 'plan-tune-cathedral', matcher: 'OldMatcherA', hooks: [{ type: 'command', command: '/old-a', timeout: 5 }] },
|
||||
{ matcher: 'Bash', hooks: [{ type: 'command', command: '/user-own-hook' }] },
|
||||
{ _gstack_source: 'plan-tune-cathedral', matcher: 'OldMatcherB', hooks: [{ type: 'command', command: '/old-b', timeout: 5 }] },
|
||||
],
|
||||
},
|
||||
}, null, 2));
|
||||
|
||||
const r = spawnSync('bash', [
|
||||
SETTINGS_HOOK, 'ensure-event',
|
||||
'--event', 'PostToolUse',
|
||||
'--matcher', 'NewMatcher',
|
||||
'--command', '/canonical',
|
||||
'--source', 'plan-tune-cathedral',
|
||||
'--timeout', '5',
|
||||
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
|
||||
|
||||
expect(r.status).toBe(0);
|
||||
// The collapse is reported on stderr, never silent.
|
||||
expect(r.stderr).toContain('collapsed 1 duplicate');
|
||||
const s = settings();
|
||||
const mine = s.hooks.PostToolUse.filter((e: any) => e._gstack_source === 'plan-tune-cathedral');
|
||||
expect(mine).toHaveLength(1); // ONE canonical entry — the stale twin is gone
|
||||
expect(mine[0].matcher).toBe('NewMatcher');
|
||||
expect(mine[0].hooks[0].command).toBe('/canonical');
|
||||
// Unrelated user hook untouched.
|
||||
const bash = s.hooks.PostToolUse.find((e: any) => e.matcher === 'Bash');
|
||||
expect(bash.hooks[0].command).toBe('/user-own-hook');
|
||||
expect(s.hooks.PostToolUse).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('no duplicates → no collapse message, single entry updated as before', () => {
|
||||
const { spawnSync } = require('child_process');
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{ _gstack_source: 'plan-tune-cathedral', matcher: 'OldMatcher', hooks: [{ type: 'command', command: '/old', timeout: 5 }] },
|
||||
],
|
||||
},
|
||||
}, null, 2));
|
||||
|
||||
const r = spawnSync('bash', [
|
||||
SETTINGS_HOOK, 'ensure-event',
|
||||
'--event', 'PostToolUse',
|
||||
'--matcher', 'NewMatcher',
|
||||
'--command', '/new',
|
||||
'--source', 'plan-tune-cathedral',
|
||||
'--timeout', '5',
|
||||
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
|
||||
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).not.toContain('collapsed');
|
||||
const s = settings();
|
||||
expect(s.hooks.PostToolUse).toHaveLength(1);
|
||||
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe('/new');
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// remove-source
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* bin/gstack-slug ↔ browse/bin/remote-slug parity.
|
||||
*
|
||||
* The bug this pins (2026-08-17, observed live in a Conductor worktree of
|
||||
* garrytan/gstack): a stray marker-bearing ancestor above the repo — an empty
|
||||
* `~/.git` directory that is not even a valid git repo — captured
|
||||
* gstack-slug's "outermost strong marker" walk-up as the project root. That
|
||||
* ancestor has no `origin` remote, so the resolver silently degraded to
|
||||
* `basename($HOME)` and emitted `SLUG=garrytan`, while remote-slug (which
|
||||
* asks git for the containing repo's remote) correctly said
|
||||
* `garrytan-gstack`. Every store keyed on the slug (decisions, timeline,
|
||||
* ceo-plans, learnings) filed into ~/.gstack/projects/garrytan/ — one bucket
|
||||
* shared by every repo under $HOME.
|
||||
*
|
||||
* The fix makes the canonical remote authoritative: gstack-slug now walks the
|
||||
* ancestor chain for the OUTERMOST dir with a `.git` entry (dir for normal
|
||||
* clones, FILE for git-worktrees) whose `origin` remote resolves, and derives
|
||||
* `owner-repo` with the exact same parse remote-slug uses. Marker-only
|
||||
* ancestors that are not remote-bearing repos can still anchor the basename
|
||||
* FALLBACK, but they can no longer shadow a real remote.
|
||||
*
|
||||
* Contracts pinned here:
|
||||
* - Parity: for any repo (plain clone or git-worktree) whose slug derivation
|
||||
* reaches a canonical remote, gstack-slug's SLUG equals remote-slug's
|
||||
* output — including under a stray-marker home.
|
||||
* - Walk-up preserved: a nested inner repo under an outer canonical-remote
|
||||
* repo resolves to the OUTER repo's owner-repo (outermost wins), matching
|
||||
* remote-slug run at the outer root.
|
||||
* - Fallback preserved: a no-remote repo still resolves to its basename.
|
||||
* - Cache self-heal: a pre-fix degraded cache entry (== the bogus marker
|
||||
* root's basename) is rewritten to the canonical slug; legit #2212 sticky
|
||||
* identity (repo that adopted a remote after first use) is NOT healed.
|
||||
*
|
||||
* Test pattern mirrors test/gstack-slug-cwd-walk-up.test.ts: per-test
|
||||
* tmpHome, spawnSync against the real bash scripts, fixtures on disk.
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { spawnSync, type SpawnSyncReturns } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const SLUG_SCRIPT = path.join(ROOT, 'bin', 'gstack-slug');
|
||||
const REMOTE_SLUG_SCRIPT = path.join(ROOT, 'browse', 'bin', 'remote-slug');
|
||||
|
||||
function baseEnv(tmpHome: string): Record<string, string | undefined> {
|
||||
// Drop any ambient override: a sibling test leaking GSTACK_PROJECT_SLUG in
|
||||
// a shared-process shard would flip runs into override mode.
|
||||
const { GSTACK_PROJECT_SLUG: _drop, ...ambient } = process.env;
|
||||
return { ...ambient, HOME: tmpHome, GSTACK_HOME: path.join(tmpHome, '.gstack') };
|
||||
}
|
||||
|
||||
function runSlug(cwd: string, tmpHome: string): SpawnSyncReturns<string> {
|
||||
return spawnSync('bash', [SLUG_SCRIPT], {
|
||||
cwd,
|
||||
env: baseEnv(tmpHome),
|
||||
encoding: 'utf8',
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
function runRemoteSlug(cwd: string, tmpHome: string): SpawnSyncReturns<string> {
|
||||
return spawnSync('bash', [REMOTE_SLUG_SCRIPT], {
|
||||
cwd,
|
||||
env: baseEnv(tmpHome),
|
||||
encoding: 'utf8',
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
function slugOf(r: SpawnSyncReturns<string>): string {
|
||||
const m = r.stdout.match(/^SLUG=([^\n]*)$/m);
|
||||
return m ? m[1]! : '';
|
||||
}
|
||||
|
||||
function git(args: string[], opts: { cwd?: string } = {}): void {
|
||||
const r = spawnSync('git', args, { encoding: 'utf8', timeout: 10_000, ...opts });
|
||||
if (r.status !== 0) {
|
||||
throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** git init -b main + optional origin remote. Returns the repo path. */
|
||||
function makeRepo(dir: string, originUrl?: string): string {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
git(['init', '-q', '-b', 'main', dir]);
|
||||
if (originUrl) git(['-C', dir, 'remote', 'add', 'origin', originUrl]);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function encodedCacheKey(absPath: string): string {
|
||||
return absPath.replace(/\//g, '_');
|
||||
}
|
||||
|
||||
/** Assert both scripts succeed in `cwd` and emit the same slug. */
|
||||
function expectParity(cwd: string, tmpHome: string, expected: string): void {
|
||||
const gstack = runSlug(cwd, tmpHome);
|
||||
const remote = runRemoteSlug(cwd, tmpHome);
|
||||
expect(gstack.status).toBe(0);
|
||||
expect(remote.status).toBe(0);
|
||||
const remoteOut = remote.stdout.trim();
|
||||
expect(slugOf(gstack)).toBe(expected);
|
||||
expect(remoteOut).toBe(expected);
|
||||
expect(slugOf(gstack)).toBe(remoteOut);
|
||||
}
|
||||
|
||||
describe('gstack-slug ↔ remote-slug parity', () => {
|
||||
let tmpHome: string;
|
||||
let fixtures: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// realpathSync: macOS tmpdir is a symlink (/var -> /private/var); the
|
||||
// scripts key their cache and walk on the resolved cwd.
|
||||
tmpHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'slug-parity-home-')));
|
||||
fixtures = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'slug-parity-fix-')));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch {}
|
||||
try { fs.rmSync(fixtures, { recursive: true, force: true }); } catch {}
|
||||
});
|
||||
|
||||
test('plain clone, https remote WITH .git suffix — identical owner-repo slug', () => {
|
||||
const repo = makeRepo(path.join(fixtures, 'proj'), 'https://github.com/acme/widgets.git');
|
||||
expectParity(repo, tmpHome, 'acme-widgets');
|
||||
});
|
||||
|
||||
test('plain clone, https remote WITHOUT .git suffix (live-bug URL shape) — identical slug', () => {
|
||||
const repo = makeRepo(path.join(fixtures, 'proj'), 'https://github.com/garrytan/gstack');
|
||||
expectParity(repo, tmpHome, 'garrytan-gstack');
|
||||
});
|
||||
|
||||
test('plain clone, scp-like ssh remote — identical owner-repo slug', () => {
|
||||
const repo = makeRepo(path.join(fixtures, 'proj'), 'git@github.com:acme/widgets.git');
|
||||
expectParity(repo, tmpHome, 'acme-widgets');
|
||||
});
|
||||
|
||||
test('git-worktree of a clone (.git FILE, the Conductor shape) — identical slug', () => {
|
||||
const main = makeRepo(path.join(fixtures, 'main-clone'), 'https://github.com/garrytan/gstack');
|
||||
git(['-C', main, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'init']);
|
||||
const wt = path.join(fixtures, 'wt');
|
||||
git(['-C', main, 'worktree', 'add', '-q', wt, '-b', 'feature-branch']);
|
||||
// Sanity: worktree roots carry a .git FILE, not a directory.
|
||||
expect(fs.statSync(path.join(wt, '.git')).isFile()).toBe(true);
|
||||
expectParity(wt, tmpHome, 'garrytan-gstack');
|
||||
});
|
||||
|
||||
test('LIVE BUG SHAPE: stray empty .git on an ancestor "home" no longer degrades the slug', () => {
|
||||
// The exact 2026-08-17 reproduction: an ancestor dir with an empty .git
|
||||
// (not a valid repo, no origin) above a canonical-remote worktree.
|
||||
const strayHome = path.join(fixtures, 'strayhome');
|
||||
fs.mkdirSync(path.join(strayHome, '.git'), { recursive: true }); // empty — invalid repo
|
||||
const main = makeRepo(
|
||||
path.join(strayHome, 'conductor', 'workspaces', 'gstack', 'main-clone'),
|
||||
'https://github.com/garrytan/gstack',
|
||||
);
|
||||
git(['-C', main, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'init']);
|
||||
const wt = path.join(strayHome, 'conductor', 'workspaces', 'gstack', 'beirut-v4');
|
||||
git(['-C', main, 'worktree', 'add', '-q', wt, '-b', 'gstack-fix-wave']);
|
||||
|
||||
// Both the plain clone and the worktree must resolve to owner-repo — the
|
||||
// pre-fix resolver emitted `strayhome` (the marker root's basename) here.
|
||||
expectParity(main, tmpHome, 'garrytan-gstack');
|
||||
expectParity(wt, tmpHome, 'garrytan-gstack');
|
||||
expect(slugOf(runSlug(wt, tmpHome))).not.toBe('strayhome');
|
||||
});
|
||||
|
||||
test('walk-up preserved: nested inner repo (no remote) resolves to the OUTER repo slug', () => {
|
||||
const outer = makeRepo(path.join(fixtures, 'outer'), 'git@github.com:acme/outer.git');
|
||||
const inner = makeRepo(path.join(outer, 'vendor', 'inner'));
|
||||
|
||||
const gstack = runSlug(inner, tmpHome);
|
||||
expect(gstack.status).toBe(0);
|
||||
// Outermost remote-bearing repo wins — same answer as remote-slug asked
|
||||
// at the outer root. (remote-slug asked from INSIDE the inner repo can't
|
||||
// see past the inner .git — its remote derivation does not succeed there,
|
||||
// so the parity clause doesn't apply; the walk-up contract does.)
|
||||
expect(slugOf(gstack)).toBe('acme-outer');
|
||||
expect(runRemoteSlug(outer, tmpHome).stdout.trim()).toBe('acme-outer');
|
||||
});
|
||||
|
||||
test('walk-up preserved: nested inner repo WITH its own remote still resolves to the OUTER repo slug', () => {
|
||||
const outer = makeRepo(path.join(fixtures, 'outer'), 'git@github.com:acme/outer.git');
|
||||
const inner = makeRepo(path.join(outer, 'vendor', 'inner'), 'git@github.com:acme/inner.git');
|
||||
|
||||
const gstack = runSlug(inner, tmpHome);
|
||||
expect(gstack.status).toBe(0);
|
||||
// Outermost wins — unchanged from the pre-fix walk-up semantics.
|
||||
expect(slugOf(gstack)).toBe('acme-outer');
|
||||
});
|
||||
|
||||
test('fallback unchanged: no-remote repo resolves to its basename (and remote-slug agrees)', () => {
|
||||
const repo = makeRepo(path.join(fixtures, 'lonely'));
|
||||
const gstack = runSlug(repo, tmpHome);
|
||||
expect(gstack.status).toBe(0);
|
||||
expect(slugOf(gstack)).toBe('lonely');
|
||||
// remote-slug's own no-remote fallback is basename(toplevel) — parity
|
||||
// holds incidentally on this shape too.
|
||||
expect(runRemoteSlug(repo, tmpHome).stdout.trim()).toBe('lonely');
|
||||
});
|
||||
|
||||
test('cache self-heal: a pre-fix degraded cache entry is rewritten to the canonical slug', () => {
|
||||
const strayHome = path.join(fixtures, 'strayhome');
|
||||
fs.mkdirSync(path.join(strayHome, '.git'), { recursive: true });
|
||||
const repo = makeRepo(path.join(strayHome, 'git', 'proj'), 'https://github.com/garrytan/gstack');
|
||||
|
||||
// Pre-seed the cache with the pre-fix degraded value: the bogus marker
|
||||
// root's basename (what the old resolver computed and cached).
|
||||
const cacheDir = path.join(tmpHome, '.gstack', 'slug-cache');
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const cacheFile = path.join(cacheDir, encodedCacheKey(repo));
|
||||
fs.writeFileSync(cacheFile, 'strayhome');
|
||||
|
||||
const gstack = runSlug(repo, tmpHome);
|
||||
expect(gstack.status).toBe(0);
|
||||
expect(slugOf(gstack)).toBe('garrytan-gstack');
|
||||
// The cache file itself must have been overwritten (self-healing).
|
||||
expect(fs.readFileSync(cacheFile, 'utf8').trim()).toBe('garrytan-gstack');
|
||||
});
|
||||
|
||||
test('hostile origin `url = ..` cannot become a ".." slug — basename fallback holds', () => {
|
||||
// git accepts `..` as a remote URL; the sed parse passes it through
|
||||
// unchanged, so unguarded it becomes SLUG=".." — filing state one level
|
||||
// ABOVE ~/.gstack/projects/ (confined to ~/.gstack, but still traversal).
|
||||
// The dot-only guard rejects it and the basename fallback anchors identity.
|
||||
const repo = makeRepo(path.join(fixtures, 'dotty'), '..');
|
||||
const r = runSlug(repo, tmpHome);
|
||||
expect(r.status).toBe(0);
|
||||
expect(slugOf(r)).toBe('dotty');
|
||||
// The cache must hold the healed value, never the dot slug.
|
||||
const cacheFile = path.join(tmpHome, '.gstack', 'slug-cache', encodedCacheKey(repo));
|
||||
expect(fs.readFileSync(cacheFile, 'utf8').trim()).toBe('dotty');
|
||||
});
|
||||
|
||||
test('package.json wrapper root (no .git): sticky basename slug is PRESERVED — heal is stray-repo-shape only', () => {
|
||||
// Legit #2212 shape: a monorepo wrapper anchored by package.json used
|
||||
// gstack before an inner dir grew a remote-bearing repo. The degraded-
|
||||
// ancestor heal must NOT fire here — it is restricted to marker roots
|
||||
// anchored by a .git entry whose origin does NOT resolve (the live-bug
|
||||
// stray-repo shape).
|
||||
const wrapper = path.join(fixtures, 'wrapperproj');
|
||||
fs.mkdirSync(wrapper, { recursive: true });
|
||||
fs.writeFileSync(path.join(wrapper, 'package.json'), '{"name":"wrapper"}\n');
|
||||
const inner = makeRepo(path.join(wrapper, 'apps', 'web'), 'https://github.com/acme/web.git');
|
||||
|
||||
const cacheDir = path.join(tmpHome, '.gstack', 'slug-cache');
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const cacheFile = path.join(cacheDir, encodedCacheKey(inner));
|
||||
fs.writeFileSync(cacheFile, 'wrapperproj');
|
||||
|
||||
const r = runSlug(inner, tmpHome);
|
||||
expect(r.status).toBe(0);
|
||||
expect(slugOf(r)).toBe('wrapperproj'); // NOT healed to acme-web
|
||||
expect(fs.readFileSync(cacheFile, 'utf8').trim()).toBe('wrapperproj');
|
||||
});
|
||||
|
||||
test('sticky identity preserved (#2212): repo that adopted a remote after first use is NOT healed', () => {
|
||||
// Legit sticky shape: the repo itself is the marker root (REMOTE_ROOT ==
|
||||
// PROJECT_ROOT) and its cached identity is its pre-origin basename slug.
|
||||
const repo = makeRepo(path.join(fixtures, 'stickyproj'), 'https://github.com/x/y.git');
|
||||
const cacheDir = path.join(tmpHome, '.gstack', 'slug-cache');
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const cacheFile = path.join(cacheDir, encodedCacheKey(repo));
|
||||
fs.writeFileSync(cacheFile, 'stickyproj');
|
||||
|
||||
const gstack = runSlug(repo, tmpHome);
|
||||
expect(gstack.status).toBe(0);
|
||||
expect(slugOf(gstack)).toBe('stickyproj');
|
||||
expect(fs.readFileSync(cacheFile, 'utf8').trim()).toBe('stickyproj');
|
||||
});
|
||||
});
|
||||
@@ -577,3 +577,182 @@ describe('path containment: pins and flags cannot escape the repo', () => {
|
||||
expect(JSON.parse(fs.readFileSync(path.join(dir, 'frontend', 'package.json'), 'utf-8')).version).toBe('1.1.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2600: repair must not write fabricated 0.0.0.0 when VERSION is missing', () => {
|
||||
// Per-test dirs: the tests assert both "VERSION absent" and "VERSION
|
||||
// present" states, so a shared dir made them order-dependent (test 1's
|
||||
// absence assertion only held because test 2 hadn't run yet).
|
||||
const dirs: string[] = [];
|
||||
const makeDir = (): string => {
|
||||
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-2600-'));
|
||||
dirs.push(d);
|
||||
return d;
|
||||
};
|
||||
afterAll(() => {
|
||||
for (const d of dirs) { try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* noop */ } }
|
||||
});
|
||||
|
||||
test('repair fails with exit 2 when VERSION file does not exist', () => {
|
||||
const dir = makeDir();
|
||||
// Set up: package.json exists with version 0.1.0.0, but no VERSION file
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '0.1.0.0' }, null, 2) + '\n');
|
||||
// VERSION file deliberately absent
|
||||
expect(fs.existsSync(path.join(dir, 'VERSION'))).toBe(false);
|
||||
|
||||
let code = 0;
|
||||
let stderr = '';
|
||||
try {
|
||||
execFileSync('bun', [BIN, 'repair'], { cwd: dir, stdio: 'pipe' });
|
||||
} catch (e: any) {
|
||||
code = e.status;
|
||||
stderr = (e.stderr || '').toString();
|
||||
}
|
||||
|
||||
// Should fail, not succeed
|
||||
expect(code).toBe(2);
|
||||
expect(stderr).toContain('VERSION file not found');
|
||||
// package.json must NOT be modified
|
||||
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('0.1.0.0');
|
||||
});
|
||||
|
||||
test('repair works normally when VERSION file exists', () => {
|
||||
const dir = makeDir();
|
||||
// Set up: both VERSION and package.json exist, with drift
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '2.0.0.0\n');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.9.0' }, null, 2) + '\n');
|
||||
|
||||
const out = execFileSync('bun', [BIN, 'repair'], { cwd: dir }).toString();
|
||||
const result = JSON.parse(out);
|
||||
|
||||
expect(result.repaired).toBe('2.0.0.0');
|
||||
expect(result.packageJsonVersion).toBe('2.0.0');
|
||||
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('2.0.0');
|
||||
});
|
||||
|
||||
test('repair refuses to propagate a fabricated version when VERSION file is empty (#2600)', () => {
|
||||
const dir = makeDir();
|
||||
// VERSION exists but is empty — readVersionFile folds this into DEFAULT ("0.0.0.0").
|
||||
// Without the `current === DEFAULT` guard, this would write 0.0.0 into package.json.
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '0.5.0' }, null, 2) + '\n');
|
||||
|
||||
let code = 0;
|
||||
let stderr = '';
|
||||
try {
|
||||
execFileSync('bun', [BIN, 'repair'], { cwd: dir, stdio: 'pipe' });
|
||||
} catch (e: any) {
|
||||
code = e.status;
|
||||
stderr = (e.stderr || '').toString();
|
||||
}
|
||||
|
||||
expect(code).toBe(2);
|
||||
expect(stderr).toContain('empty or contains no parsable version');
|
||||
// package.json must NOT be modified
|
||||
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('0.5.0');
|
||||
});
|
||||
|
||||
test('repair proceeds when VERSION genuinely reads 0.0.0.0 (a real file, not the sentinel)', () => {
|
||||
// current === DEFAULT is ambiguous: it is BOTH the missing/unparseable
|
||||
// sentinel AND a legitimate literal "0.0.0.0" in a brand-new repo. The
|
||||
// guard now disambiguates on the raw bytes — a real 0.0.0.0 repairs
|
||||
// package.json to the npm-valid 0.0.0.
|
||||
const dir = makeDir();
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '0.0.0.0\n');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '0.5.0' }, null, 2) + '\n');
|
||||
|
||||
const out = execFileSync('bun', [BIN, 'repair'], { cwd: dir }).toString();
|
||||
const result = JSON.parse(out);
|
||||
expect(result.repaired).toBe('0.0.0.0');
|
||||
expect(result.packageJsonVersion).toBe('0.0.0');
|
||||
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('0.0.0');
|
||||
});
|
||||
|
||||
test('repair still rejects whitespace-only VERSION content (sentinel path, not a real version)', () => {
|
||||
const dir = makeDir();
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), ' \n\n');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '0.5.0' }, null, 2) + '\n');
|
||||
|
||||
let code = 0;
|
||||
try { execFileSync('bun', [BIN, 'repair'], { cwd: dir, stdio: 'pipe' }); }
|
||||
catch (e: any) { code = e.status; }
|
||||
expect(code).toBe(2);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('0.5.0');
|
||||
});
|
||||
|
||||
test('repair reproduces the exact issue scenario: VERSION in root, package.json in app/ (#2600)', () => {
|
||||
// The exact layout from the issue: VERSION at repo root, package.json in app/
|
||||
// Running repair from app/ cwd with no VERSION there used to write 0.0.0.0 into app/package.json.
|
||||
const rootDir = makeDir();
|
||||
|
||||
fs.mkdirSync(path.join(rootDir, 'app'), { recursive: true });
|
||||
fs.writeFileSync(path.join(rootDir, 'VERSION'), '0.2.0.0\n');
|
||||
fs.writeFileSync(path.join(rootDir, 'app', 'package.json'), JSON.stringify({ name: 'x', version: '0.1.0.0' }, null, 2) + '\n');
|
||||
|
||||
// Run from app/ — no VERSION in cwd, readVersionFile would fold to 0.0.0.0
|
||||
let code = 0;
|
||||
let stderr = '';
|
||||
try {
|
||||
execFileSync('bun', [BIN, 'repair'], { cwd: path.join(rootDir, 'app'), stdio: 'pipe' });
|
||||
} catch (e: any) {
|
||||
code = e.status;
|
||||
stderr = (e.stderr || '').toString();
|
||||
}
|
||||
|
||||
expect(code).toBe(2);
|
||||
expect(stderr).toContain('VERSION file not found');
|
||||
// app/package.json must NOT be modified
|
||||
expect(JSON.parse(fs.readFileSync(path.join(rootDir, 'app', 'package.json'), 'utf-8')).version).toBe('0.1.0.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2600: classify must surface versionFileExists=false when VERSION is missing', () => {
|
||||
// Per-test dirs: one test asserts VERSION absent, the other creates it — a
|
||||
// shared dir made them order-dependent. Each test builds its own repo.
|
||||
const dirs: string[] = [];
|
||||
afterAll(() => {
|
||||
for (const d of dirs) { try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* noop */ } }
|
||||
});
|
||||
|
||||
/** Minimal git repo (no VERSION committed) so classify can resolve base. */
|
||||
function makeRepoDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-2600-classify-'));
|
||||
dirs.push(dir);
|
||||
const git = (...a: string[]) => execFileSync('git', a, { cwd: dir, stdio: 'pipe' });
|
||||
git('init', '-q', '-b', 'main');
|
||||
git('config', 'user.email', 't@t'); git('config', 'user.name', 't');
|
||||
// Commit with no VERSION file
|
||||
fs.writeFileSync(path.join(dir, 'README.md'), 'test\n');
|
||||
git('add', '-A'); git('commit', '-q', '-m', 'base');
|
||||
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir }).toString().trim();
|
||||
fs.mkdirSync(path.join(dir, '.git', 'refs', 'remotes', 'origin'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.git', 'refs', 'remotes', 'origin', 'main'), head + '\n');
|
||||
return dir;
|
||||
}
|
||||
|
||||
test('classify reports versionFileExists=false when VERSION is absent', () => {
|
||||
const dir = makeRepoDir();
|
||||
// No package.json: pkgExists=false, pkgAgrees=true, current===base → FRESH.
|
||||
// (A package.json with a non-zero version would cause DRIFT_UNEXPECTED.)
|
||||
|
||||
const out = execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: dir }).toString();
|
||||
const result = JSON.parse(out);
|
||||
|
||||
expect(result.versionFileExists).toBe(false);
|
||||
expect(result.currentVersion).toBe('0.0.0.0'); // fabricated default
|
||||
expect(result.state).toBe('FRESH'); // base also reads 0.0.0.0, no pkg drift
|
||||
});
|
||||
|
||||
test('classify reports versionFileExists=true when VERSION is present', () => {
|
||||
const dir = makeRepoDir();
|
||||
// Create VERSION AND sync package.json so pkgAgrees=true → ALREADY_BUMPED.
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '0.2.0.0\n');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '0.2.0.0' }, null, 2) + '\n');
|
||||
|
||||
const out = execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: dir }).toString();
|
||||
const result = JSON.parse(out);
|
||||
|
||||
expect(result.versionFileExists).toBe(true);
|
||||
expect(result.currentVersion).toBe('0.2.0.0');
|
||||
expect(result.state).toBe('ALREADY_BUMPED'); // base is 0.0.0.0, current is 0.2.0.0, pkg in sync
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,7 +126,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
},
|
||||
behavioral: 'external',
|
||||
externalTest: 'test/skill-e2e-ship-section-loading.test.ts',
|
||||
maxSkeletonBytes: 90_800, // v1.67 wave + v1.66.1's evidence-ledger prose (merged): measured 90,333
|
||||
maxSkeletonBytes: 91_600, // v1.68 fix wave: unconditional learnings capture (#2402, ~450B/skill); measured 91,061
|
||||
minUnionBytes: 120_000,
|
||||
mustContain: ['VERSION', 'CHANGELOG', 'review', 'merge', 'PR'],
|
||||
// v1.58.5.0: pre-push-guard install (#2077) stacks on the shared first-run-guidance preamble.
|
||||
@@ -157,7 +157,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// v1.65 merge: provisional larger-of-both-waves budget; re-measured below.
|
||||
// Fork port wave 2 (#703): the repo-doc-preference block in the design
|
||||
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
|
||||
maxSkeletonBytes: 93_000, // v1.67 fix wave: #2499 jq entry-resolution in the brain-sync preamble (~340B/skill) + wave doc additions; measured 92,531
|
||||
maxSkeletonBytes: 93_900, // v1.68 fix wave: #2402 learnings capture + spool queue-depth lines; measured 93,345
|
||||
minUnionBytes: 80_000,
|
||||
mustContain: ['SCOPE EXPANSION', 'SELECTIVE EXPANSION', 'HOLD SCOPE', 'SCOPE REDUCTION'],
|
||||
// Default-on Codex outside-voice (codexPreflight block + CODEX_MODE branch
|
||||
@@ -183,7 +183,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
|
||||
// #2499 project-scope MCP jq in the brain-sync block grew every tier-2+
|
||||
// skeleton ~1.5KB (entry resolution emitted once per SKILL.md).
|
||||
maxSkeletonBytes: 70_500, // measured 70,318
|
||||
maxSkeletonBytes: 71_800, // v1.68 fix wave (#2402); measured 71,228
|
||||
minUnionBytes: 70_000,
|
||||
mustContain: ['Architecture', 'Code Quality', 'Test', 'Performance'],
|
||||
// Cross-cutting preamble growth (v1.57.2.0 AUQ-failure prose fallback + the
|
||||
@@ -216,7 +216,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// tier-2+ skeleton (measured 89,184). Main's v1.64.0.0 adds ~340 B more
|
||||
// (telemetry --error-message/--failed-step preamble prose, PR #769).
|
||||
// Budget covers the sum of both waves.
|
||||
maxSkeletonBytes: 91_000,
|
||||
maxSkeletonBytes: 91_700, // v1.68 fix wave (#2402); measured 91,176
|
||||
minUnionBytes: 70_000,
|
||||
mustContain: ['design', 'visual'],
|
||||
maxSizeRatio: 1.12, // D1 1.104 + main's ~0.008
|
||||
@@ -240,7 +240,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
|
||||
// #2499 project-scope MCP jq in the brain-sync block grew every tier-2+
|
||||
// skeleton ~1.5KB (entry resolution emitted once per SKILL.md).
|
||||
maxSkeletonBytes: 82_500, // measured 82,031
|
||||
maxSkeletonBytes: 83_500, // v1.68 fix wave (#2402); measured 82,941
|
||||
minUnionBytes: 70_000,
|
||||
mustContain: ['developer experience', 'Getting Started'],
|
||||
// Default-on Codex outside-voice (codexPreflight block + CODEX_MODE branch
|
||||
@@ -270,7 +270,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// the #538 opt-out + D1 evidence directive — ratio 1.104 measured.
|
||||
// #2499 project-scope MCP jq in the brain-sync block grew every tier-2+
|
||||
// skeleton ~1.5KB (entry resolution emitted once per SKILL.md).
|
||||
maxSkeletonBytes: 101_500, // measured 101,314
|
||||
maxSkeletonBytes: 102_800, // v1.68 fix wave (#2402); measured 102,220
|
||||
minUnionBytes: 70_000,
|
||||
mustContain: ['design doc', 'problem statement'],
|
||||
maxSizeRatio: 1.12,
|
||||
@@ -291,7 +291,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// +Conductor AUQ-default-prose rule + one-way/continuation safety in the
|
||||
// always-loaded AskUserQuestion Format section.
|
||||
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
|
||||
maxSkeletonBytes: 57_000, // v1.67 fix wave: #2499 preamble growth; measured 56,571
|
||||
maxSkeletonBytes: 57_900, // v1.68 fix wave (#2402); measured 57,385
|
||||
minUnionBytes: 55_000,
|
||||
mustContain: ['CHANGELOG', 'Diataxis', 'coverage'],
|
||||
// Two intentional additions stack on this small skill: the AUQ-failure prose
|
||||
@@ -322,7 +322,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// v1.65 merge: provisional larger-of-both-waves budget; re-measured below.
|
||||
// v1.64.1.0: shared-preamble prose from the two parallel v1.64 waves lands
|
||||
// the skeleton at 69,022 B; +~1 KB headroom.
|
||||
maxSkeletonBytes: 70_500, // v1.67 fix wave: #2499 preamble growth; measured 70,003
|
||||
maxSkeletonBytes: 71_400, // v1.68 fix wave (#2402); measured 70,815
|
||||
minUnionBytes: 72_000,
|
||||
mustContain: ['Typography', 'Color', 'Aesthetic Direction'],
|
||||
// Cross-cutting preamble growth (v1.57.2.0 AUQ-failure prose fallback ~2KB +
|
||||
@@ -362,7 +362,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// +Conductor AUQ-default-prose rule + one-way/continuation safety in the
|
||||
// always-loaded AskUserQuestion Format section.
|
||||
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
|
||||
maxSkeletonBytes: 76_400, // v1.67 fix wave: #2499 preamble growth; measured 75,891
|
||||
maxSkeletonBytes: 77_300, // v1.68 fix wave (#2402); measured 76,705
|
||||
minUnionBytes: 72_000,
|
||||
mustContain: ['OWASP', 'STRIDE', 'daily', 'comprehensive', 'verif'],
|
||||
// cso keeps its mode-dispatch + FP-filtering phases always-loaded, so the
|
||||
|
||||
@@ -122,3 +122,181 @@ describe('gstack-session-update pull wedge (#2566)', () => {
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
// ── #2613: the lock pidfile must record the LIVE holder, not the exited parent ──
|
||||
//
|
||||
// `echo $$` inside the backgrounded subshell recorded the parent hook's PID.
|
||||
// The parent exits immediately, so every subsequent session judged the lock
|
||||
// stale and rm -rf'd a LIVE holder's lock — concurrent updaters, the exact
|
||||
// state the lock exists to prevent. Plus: a hard TTL (heartbeat-refreshed)
|
||||
// bounds PID-reuse wedges and the empty/missing-pidfile races.
|
||||
|
||||
describe('gstack-session-update lock identity + TTL (#2613)', () => {
|
||||
function makeSlowGitShim(base: string, sleepSecs: number): string {
|
||||
const shimDir = path.join(base, 'shim');
|
||||
fs.mkdirSync(shimDir, { recursive: true });
|
||||
const realGit = execFileSync('bash', ['-c', 'command -v git'], { encoding: 'utf8' }).trim();
|
||||
fs.writeFileSync(
|
||||
path.join(shimDir, 'git'),
|
||||
`#!/usr/bin/env bash\ncase "$*" in *pull*) sleep ${sleepSecs};; esac\nexec "${realGit}" "$@"\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
return shimDir;
|
||||
}
|
||||
|
||||
function runScriptWithPath(install: string, state: string, shimDir: string) {
|
||||
return spawnSync('bash', [SCRIPT], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, GSTACK_DIR: install, GSTACK_STATE_DIR: state, PATH: `${shimDir}:${process.env.PATH}` },
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
function isAlive(pid: number): boolean {
|
||||
try { process.kill(pid, 0); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
test('recorded pid is the live holder subshell, not the exited parent', async () => {
|
||||
const { base, install, state } = makeFixture();
|
||||
const shimDir = makeSlowGitShim(base, 3);
|
||||
try {
|
||||
const r = runScriptWithPath(install, state, shimDir);
|
||||
expect(r.status).toBe(0); // parent hook has EXITED by now (spawnSync waited)
|
||||
// Poll for the pidfile the detached subshell writes.
|
||||
const pidPath = path.join(state, '.setup-lock', 'pid');
|
||||
const deadline = Date.now() + 5000;
|
||||
let pid = 0;
|
||||
while (Date.now() < deadline) {
|
||||
if (fs.existsSync(pidPath)) {
|
||||
pid = Number(fs.readFileSync(pidPath, 'utf8').trim());
|
||||
if (pid > 0) break;
|
||||
}
|
||||
await new Promise((res) => setTimeout(res, 50));
|
||||
}
|
||||
expect(pid).toBeGreaterThan(0);
|
||||
// The lock is held (slow pull) — its recorded PID must be ALIVE.
|
||||
// Pre-fix this held the dead parent's PID and the assertion fails.
|
||||
expect(fs.existsSync(path.join(state, '.setup-lock'))).toBe(true);
|
||||
expect(isAlive(pid)).toBe(true);
|
||||
await waitForLog(state, /UP_TO_DATE|UPDATING|PULL_FAILED/);
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test('a live lock with a live pid is respected and survives', async () => {
|
||||
const { base, install, state } = makeFixture();
|
||||
const holder = require('child_process').spawn('sleep', ['30'], { stdio: 'ignore' });
|
||||
try {
|
||||
const lockDir = path.join(state, '.setup-lock');
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(lockDir, 'pid'), String(holder.pid));
|
||||
const r = runScript(install, state);
|
||||
expect(r.status).toBe(0);
|
||||
const log = await waitForLog(state, /SKIP locked_by=/);
|
||||
expect(log).toContain(`SKIP locked_by=${holder.pid}`);
|
||||
expect(fs.existsSync(lockDir)).toBe(true); // NOT rm -rf'd (#2613)
|
||||
} finally {
|
||||
holder.kill();
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test('a dead pid is reclaimed and the run proceeds', async () => {
|
||||
const { base, install, state } = makeFixture();
|
||||
try {
|
||||
const dead = spawnSync('true', { encoding: 'utf8' }); // reaped by the time spawnSync returns
|
||||
const lockDir = path.join(state, '.setup-lock');
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(lockDir, 'pid'), String(dead.pid));
|
||||
const r = runScript(install, state);
|
||||
expect(r.status).toBe(0);
|
||||
const log = await waitForLog(state, /UP_TO_DATE|UPDATING/);
|
||||
expect(log).toMatch(/UP_TO_DATE|UPDATING/);
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test('an empty pidfile inside the TTL window is NOT instantly reaped', async () => {
|
||||
const { base, install, state } = makeFixture();
|
||||
try {
|
||||
const lockDir = path.join(state, '.setup-lock');
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(lockDir, 'pid'), ''); // mkdir→echo race window
|
||||
const r = runScript(install, state);
|
||||
expect(r.status).toBe(0);
|
||||
const log = await waitForLog(state, /SKIP locked_by=/);
|
||||
expect(log).toContain('SKIP locked_by=');
|
||||
expect(fs.existsSync(lockDir)).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test('reclaim is TOCTOU-safe: both reclaim branches mv the lock aside atomically (static pin)', () => {
|
||||
// `rm -rf "$LOCK_DIR"` then `mkdir` lets TWO contenders both judge the
|
||||
// lock stale and both win (one rm can land between the other's rm and
|
||||
// mkdir). The atomic mv-aside makes exactly one contender own the reap:
|
||||
// the loser's mv fails and it backs off with SKIP lock_contested. Pin
|
||||
// that BOTH reclaim branches (TTL-expired and dead-PID) use it, and that
|
||||
// no bare in-place `rm -rf "$LOCK_DIR"` survives outside the holder's
|
||||
// own EXIT trap.
|
||||
const src = fs.readFileSync(SCRIPT, 'utf8');
|
||||
const mvAside = src.match(/mv "\$LOCK_DIR" "\$LOCK_DIR\.reap\.\$\$" 2>\/dev\/null \|\| \{ log_entry "SKIP lock_contested"; exit 0; \}/g) || [];
|
||||
expect(mvAside.length).toBe(2); // TTL branch + dead-PID branch
|
||||
// The only rm -rf of the live lock dir is the holder's EXIT trap — and
|
||||
// even that one is ownership-checked (see the static pin below).
|
||||
const bareRms = src.match(/rm -rf "\$LOCK_DIR"(?!\.)/g) || [];
|
||||
expect(bareRms.length).toBe(1);
|
||||
expect(src).toContain(
|
||||
`trap 'kill "$HB_PID" 2>/dev/null; [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] && rm -rf "$LOCK_DIR" 2>/dev/null' EXIT`,
|
||||
);
|
||||
});
|
||||
|
||||
test('EXIT trap is ownership-checked and a heartbeat runs during pull/setup (static pins)', () => {
|
||||
const src = fs.readFileSync(SCRIPT, 'utf8');
|
||||
// (a) After a TTL reclaim by another updater, $LOCK_DIR belongs to the
|
||||
// NEW holder — the old holder's trap must remove the lock ONLY while the
|
||||
// pidfile still contains ITS pid (MYPID captured at write time).
|
||||
const trapLine = src.split('\n').find((l) => l.includes("trap '") && l.includes('rm -rf "$LOCK_DIR"'));
|
||||
expect(trapLine).toBeDefined();
|
||||
expect(trapLine!).toContain('[ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] && rm -rf "$LOCK_DIR"');
|
||||
// MYPID is written to the pidfile (the identity the trap compares against).
|
||||
expect(src).toContain('MYPID="${BASHPID:-$(sh -c \'echo $PPID\')}"');
|
||||
expect(src).toContain('echo "$MYPID" > "$LOCK_DIR/pid"');
|
||||
// (b) In-flight heartbeat: the step-boundary touches only fire AFTER the
|
||||
// pull / setup return, so a legitimately-slow step past the 30-min TTL
|
||||
// got reclaimed while ALIVE. The loop re-checks ownership each beat and
|
||||
// exits instead of touching a reclaimed holder's pidfile.
|
||||
expect(src).toMatch(
|
||||
/while :; do sleep 300; \[ "\$\(cat "\$LOCK_DIR\/pid" 2>\/dev\/null\)" = "\$MYPID" \] \|\| exit 0; touch "\$LOCK_DIR\/pid" 2>\/dev\/null; done/,
|
||||
);
|
||||
expect(src).toContain('HB_PID=$!');
|
||||
// The trap stops the heartbeat so it can never outlive the holder.
|
||||
expect(trapLine!).toContain('kill "$HB_PID"');
|
||||
});
|
||||
|
||||
test('an expired-TTL lock is reclaimed even when its pid is alive (PID reuse)', async () => {
|
||||
const { base, install, state } = makeFixture();
|
||||
const holder = require('child_process').spawn('sleep', ['30'], { stdio: 'ignore' });
|
||||
try {
|
||||
const lockDir = path.join(state, '.setup-lock');
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
const pidPath = path.join(lockDir, 'pid');
|
||||
fs.writeFileSync(pidPath, String(holder.pid));
|
||||
// Age the heartbeat past the 30-min TTL: a recycled PID looks alive
|
||||
// forever, so liveness alone can never clear this wedge.
|
||||
const past = new Date(Date.now() - 40 * 60 * 1000);
|
||||
fs.utimesSync(pidPath, past, past);
|
||||
const r = runScript(install, state);
|
||||
expect(r.status).toBe(0);
|
||||
const log = await waitForLog(state, /RECLAIMED lock_ttl_expired/);
|
||||
expect(log).toContain('RECLAIMED lock_ttl_expired');
|
||||
await waitForLog(state, /UP_TO_DATE|UPDATING/);
|
||||
} finally {
|
||||
holder.kill();
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
@@ -207,12 +207,14 @@ describe('two-class referenced-paths (ENG-OV7)', () => {
|
||||
});
|
||||
|
||||
test('the referenced-path scan actually sees the review checklist refs (self-check)', () => {
|
||||
// Guard against the extraction regex silently rotting: the review skill is
|
||||
// KNOWN to carry alias-relative refs; if the scanner stops seeing them the
|
||||
// class-1 assertion is vacuous.
|
||||
const class1 = collectRefs().filter((r) => r.skillName !== 'gstack');
|
||||
expect(class1.length).toBeGreaterThan(0);
|
||||
expect(class1.some((r) => r.skillName === 'review' && r.rel === 'checklist.md')).toBe(true);
|
||||
// Guard against the extraction regex silently rotting: the review skill's
|
||||
// checklist refs are KNOWN to exist. Since #2518 they anchor at the
|
||||
// installed gstack root (class 2: gstack/review/checklist.md), not the
|
||||
// alias-relative form — if the scanner stops seeing them, the class-2
|
||||
// assertion is vacuous.
|
||||
const refs = collectRefs();
|
||||
expect(refs.length).toBeGreaterThan(0);
|
||||
expect(refs.some((r) => r.skillName === 'gstack' && r.rel === 'review/checklist.md')).toBe(true);
|
||||
});
|
||||
|
||||
test('KNOWN_BROKEN_CLASS2 entries are still actually broken (ratchet)', () => {
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* 1. Set up a fake $HOME with a Claude Code project + a Codex session +
|
||||
* ~/.gstack/ artifacts (eureka, learning, ceo-plan, design-doc, retro,
|
||||
* builder-profile)
|
||||
* 2. Run gstack-memory-ingest --probe → verify counts match disk
|
||||
* 2. Run gstack-memory-ingest --probe → verify stage counts match disk
|
||||
* (post-attribution headline + unattributed skip line, #2394)
|
||||
* 3. Run gstack-memory-ingest --bulk → verify state file gets written +
|
||||
* session_id dedup works on re-run (idempotency)
|
||||
* 4. Run gstack-gbrain-sync --dry-run → verify all 3 stages preview
|
||||
@@ -98,7 +99,7 @@ function runBun(script: string, args: string[], env: Record<string, string>): {
|
||||
// ── E2E pipeline ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("V1 memory ingest pipeline E2E", () => {
|
||||
it("--probe finds all 9 fixture files across all source types", () => {
|
||||
it("--probe accounts for all 9 fixture files: 7 attributable + 2 unattributed transcripts skipped (#2394)", () => {
|
||||
const home = makeFixtureHome();
|
||||
const { gstackHome, counts } = setupFixture(home);
|
||||
const env = { HOME: home, GSTACK_HOME: gstackHome, GSTACK_MEMORY_INGEST_NO_WRITE: "1" };
|
||||
@@ -106,11 +107,15 @@ describe("V1 memory ingest pipeline E2E", () => {
|
||||
const r = runBun(INGEST, ["--probe"], env);
|
||||
expect(r.exitCode).toBe(0);
|
||||
|
||||
const totalExpected = Object.values(counts).reduce((s, n) => s + n, 0);
|
||||
expect(r.stdout).toContain(`Total files in window: ${totalExpected}`);
|
||||
// #2394: probe counts what --bulk would ingest. The fixture transcripts
|
||||
// carry no resolvable git remote, so the shared attribution gate skips
|
||||
// both; the gstack artifacts are store-local and always attributable.
|
||||
const transcripts = counts.transcript;
|
||||
const attributable = Object.values(counts).reduce((s, n) => s + n, 0) - transcripts;
|
||||
expect(r.stdout).toContain(`Total files in window: ${attributable}`);
|
||||
expect(r.stdout).toContain(`Skipped (unattributed): ${transcripts}`);
|
||||
|
||||
// Spot-check that each type appears with the right count
|
||||
expect(r.stdout).toMatch(/transcript\s+2/);
|
||||
// Spot-check that each artifact type appears with the right count
|
||||
expect(r.stdout).toMatch(/eureka\s+1/);
|
||||
expect(r.stdout).toMatch(/learning\s+1/);
|
||||
expect(r.stdout).toMatch(/ceo-plan\s+1/);
|
||||
@@ -118,6 +123,21 @@ describe("V1 memory ingest pipeline E2E", () => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("--probe --include-unattributed counts all 9 fixture files, transcripts included", () => {
|
||||
const home = makeFixtureHome();
|
||||
const { gstackHome, counts } = setupFixture(home);
|
||||
const env = { HOME: home, GSTACK_HOME: gstackHome, GSTACK_MEMORY_INGEST_NO_WRITE: "1" };
|
||||
|
||||
const r = runBun(INGEST, ["--probe", "--include-unattributed"], env);
|
||||
expect(r.exitCode).toBe(0);
|
||||
|
||||
const totalExpected = Object.values(counts).reduce((s, n) => s + n, 0);
|
||||
expect(r.stdout).toContain(`Total files in window: ${totalExpected}`);
|
||||
expect(r.stdout).toMatch(/transcript\s+2/);
|
||||
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("--incremental writes a state file with schema_version: 1 + last_writer", () => {
|
||||
const home = makeFixtureHome();
|
||||
const { gstackHome } = setupFixture(home);
|
||||
|
||||
@@ -225,6 +225,8 @@ describe('timeline-stop-hook (#2553, F5 fail-open)', () => {
|
||||
});
|
||||
|
||||
describe('timeline-stop-hook wiring', () => {
|
||||
const SETTINGS_HOOK = path.join(ROOT, 'bin', 'gstack-settings-hook');
|
||||
|
||||
test('setup registers the Stop hook with its own source tag and tears it down on --no-team', () => {
|
||||
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
||||
expect(setup).toContain('--event Stop');
|
||||
@@ -235,6 +237,229 @@ describe('timeline-stop-hook wiring', () => {
|
||||
expect(teardown).toContain('remove-source --source gstack-timeline-stop');
|
||||
});
|
||||
|
||||
test('setup surfaces a settings-hook refusal instead of swallowing it', () => {
|
||||
// The hardened settings-hook refuses to rewrite a corrupt settings.json
|
||||
// (exit 1). Both setup call sites (ALREADY_INSTALLED plan-tune re-point,
|
||||
// timeline ensure-event) must stay non-fatal but PRINT the failure.
|
||||
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
||||
const warnings = setup.match(/settings hook update failed/g) || [];
|
||||
expect(warnings.length).toBeGreaterThanOrEqual(2);
|
||||
// The old swallow patterns are gone (the --no-team remove-source teardown
|
||||
// legitimately keeps its 2>/dev/null; only the ensure-event registration
|
||||
// must surface stderr).
|
||||
expect(setup).not.toContain('_install_plan_tune_hooks >/dev/null 2>&1 || true');
|
||||
expect(setup).not.toMatch(/ensure-event[\s\S]{0,220}--source gstack-timeline-stop[\s\S]{0,40}2>\/dev\/null/);
|
||||
});
|
||||
|
||||
test('setup routes the Stop hook through ensure-event, not presence-only dedup', () => {
|
||||
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
||||
// ensure-event registers when missing AND re-points a stale path in place.
|
||||
expect(setup).toMatch(/ensure-event[\s\S]{0,220}--source gstack-timeline-stop/);
|
||||
// The old guard skipped registration whenever the source tag was merely
|
||||
// PRESENT, so a stale absolute path (deleted dev worktree) was never
|
||||
// re-pointed on a setup re-run.
|
||||
expect(setup).not.toMatch(/list-sources 2>\/dev\/null \| grep -q "gstack-timeline-stop"/);
|
||||
});
|
||||
|
||||
test('fresh register prefers the global-install hook path when present', () => {
|
||||
// Drive setup's _hook_install_path directly: global install present → the
|
||||
// registration survives deleting the worktree setup ran from.
|
||||
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
||||
const fn = setup.match(/_hook_install_path\(\) \{[\s\S]*?\n\}/);
|
||||
expect(fn).not.toBeNull();
|
||||
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-hookpath-'));
|
||||
try {
|
||||
const globalHook = path.join(
|
||||
fakeHome, '.claude', 'skills', 'gstack', 'hosts', 'claude', 'hooks', 'timeline-stop-hook',
|
||||
);
|
||||
fs.mkdirSync(path.dirname(globalHook), { recursive: true });
|
||||
fs.writeFileSync(globalHook, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
const env = { ...process.env, HOME: fakeHome, SOURCE_GSTACK_DIR: '/some/dev/worktree' };
|
||||
|
||||
const withGlobal = spawnSync(
|
||||
'bash',
|
||||
['-c', `${fn![0]}\n_hook_install_path hosts/claude/hooks/timeline-stop-hook`],
|
||||
{ env, encoding: 'utf-8', timeout: 10_000 },
|
||||
);
|
||||
expect(withGlobal.stdout.trim()).toBe(globalHook);
|
||||
|
||||
// No global install (fresh first install from a clone) → setup-time path.
|
||||
fs.rmSync(globalHook);
|
||||
const withoutGlobal = spawnSync(
|
||||
'bash',
|
||||
['-c', `${fn![0]}\n_hook_install_path hosts/claude/hooks/timeline-stop-hook`],
|
||||
{ env, encoding: 'utf-8', timeout: 10_000 },
|
||||
);
|
||||
expect(withoutGlobal.stdout.trim()).toBe('/some/dev/worktree/hosts/claude/hooks/timeline-stop-hook');
|
||||
} finally {
|
||||
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('ensure-event re-points a stale absolute path and leaves exactly one registration', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ensure-'));
|
||||
try {
|
||||
const settingsFile = path.join(dir, 'settings.json');
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: {
|
||||
Stop: [{
|
||||
_gstack_source: 'gstack-timeline-stop',
|
||||
hooks: [{ type: 'command', command: '/deleted/worktree/hosts/claude/hooks/timeline-stop-hook', timeout: 5 }],
|
||||
}],
|
||||
},
|
||||
}, null, 2) + '\n');
|
||||
|
||||
const r = spawnSync('bash', [
|
||||
SETTINGS_HOOK, 'ensure-event',
|
||||
'--event', 'Stop',
|
||||
'--command', HOOK,
|
||||
'--source', 'gstack-timeline-stop',
|
||||
'--timeout', '5',
|
||||
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
|
||||
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('re-pointed');
|
||||
const s = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
|
||||
expect(s.hooks.Stop).toHaveLength(1); // replaced in place — never two
|
||||
expect(s.hooks.Stop[0].hooks[0].command).toBe(HOOK);
|
||||
expect(s.hooks.Stop[0]._gstack_source).toBe('gstack-timeline-stop');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('ensure-event is a true no-op when the registration already matches (no write, no backup churn)', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ensure-noop-'));
|
||||
try {
|
||||
const settingsFile = path.join(dir, 'settings.json');
|
||||
const args = [
|
||||
SETTINGS_HOOK, 'ensure-event',
|
||||
'--event', 'Stop',
|
||||
'--command', HOOK,
|
||||
'--source', 'gstack-timeline-stop',
|
||||
'--timeout', '5',
|
||||
];
|
||||
const env = { ...process.env, GSTACK_SETTINGS_FILE: settingsFile };
|
||||
const first = spawnSync('bash', args, { env, encoding: 'utf-8', timeout: 15_000 });
|
||||
expect(first.status).toBe(0);
|
||||
const bytesAfterFirst = fs.readFileSync(settingsFile, 'utf-8');
|
||||
|
||||
const second = spawnSync('bash', args, { env, encoding: 'utf-8', timeout: 15_000 });
|
||||
expect(second.status).toBe(0);
|
||||
expect(second.stdout).toContain('unchanged');
|
||||
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(bytesAfterFirst);
|
||||
// Re-running ./setup must not accumulate settings.json.bak.<ts> files.
|
||||
const baks = fs.readdirSync(dir).filter((f) => f.includes('.bak'));
|
||||
expect(baks).toEqual([]);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('corrupt settings.json: ensure-event refuses (exit 1) and never rewrites the file', () => {
|
||||
// The old catch{} folded an unparseable EXISTING settings.json into {}
|
||||
// and the atomic write replaced the user's permissions/env/other hooks
|
||||
// with just ours. Now: loud stderr error, exit 1, file byte-identical.
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ensure-corrupt-'));
|
||||
try {
|
||||
const settingsFile = path.join(dir, 'settings.json');
|
||||
const corrupt = '{ "permissions": { "allow": ["Bash(npm:*)"] }, INVALID';
|
||||
fs.writeFileSync(settingsFile, corrupt);
|
||||
|
||||
const r = spawnSync('bash', [
|
||||
SETTINGS_HOOK, 'ensure-event',
|
||||
'--event', 'Stop',
|
||||
'--command', HOOK,
|
||||
'--source', 'gstack-timeline-stop',
|
||||
'--timeout', '5',
|
||||
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
|
||||
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain('not valid JSON');
|
||||
// Never rewritten — the corrupt bytes (and whatever the user can still
|
||||
// salvage from them) survive verbatim.
|
||||
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(corrupt);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a matcher change updates the tagged entry in place — still exactly one registration', () => {
|
||||
// Identity key is (event, source): an existing gstack entry with a STALE
|
||||
// matcher must be updated, never joined by a second entry (the old key
|
||||
// included the matcher, so any future matcher change would duplicate).
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ensure-matcher-'));
|
||||
try {
|
||||
const settingsFile = path.join(dir, 'settings.json');
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: {
|
||||
PreToolUse: [{
|
||||
_gstack_source: 'gstack-plan-tune',
|
||||
matcher: 'OldMatcher',
|
||||
hooks: [{ type: 'command', command: '/old/path/hook', timeout: 5 }],
|
||||
}],
|
||||
},
|
||||
}, null, 2) + '\n');
|
||||
|
||||
const r = spawnSync('bash', [
|
||||
SETTINGS_HOOK, 'ensure-event',
|
||||
'--event', 'PreToolUse',
|
||||
'--command', '/new/path/hook',
|
||||
'--source', 'gstack-plan-tune',
|
||||
'--matcher', 'NewMatcher',
|
||||
'--timeout', '5',
|
||||
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
|
||||
|
||||
expect(r.status).toBe(0);
|
||||
const s = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
|
||||
expect(s.hooks.PreToolUse).toHaveLength(1); // updated in place — never two
|
||||
expect(s.hooks.PreToolUse[0].matcher).toBe('NewMatcher');
|
||||
expect(s.hooks.PreToolUse[0].hooks[0].command).toBe('/new/path/hook');
|
||||
expect(s.hooks.PreToolUse[0]._gstack_source).toBe('gstack-plan-tune');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a failed update leaves exactly one registration — never zero, never two', () => {
|
||||
// Root can write through 0o555 directories, so the failure injection
|
||||
// (read-only dir) does not bind there; the invariant is still covered by
|
||||
// the atomic tmp+rename pinned in the re-point test above.
|
||||
if (typeof process.getuid === 'function' && process.getuid() === 0) return;
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ensure-fail-'));
|
||||
try {
|
||||
const settingsFile = path.join(dir, 'settings.json');
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: {
|
||||
Stop: [{
|
||||
_gstack_source: 'gstack-timeline-stop',
|
||||
hooks: [{ type: 'command', command: '/stale/path/timeline-stop-hook', timeout: 5 }],
|
||||
}],
|
||||
},
|
||||
}, null, 2) + '\n');
|
||||
|
||||
fs.chmodSync(dir, 0o555); // every write path (backup, tmp, rename) fails
|
||||
const r = spawnSync('bash', [
|
||||
SETTINGS_HOOK, 'ensure-event',
|
||||
'--event', 'Stop',
|
||||
'--command', HOOK,
|
||||
'--source', 'gstack-timeline-stop',
|
||||
'--timeout', '5',
|
||||
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
|
||||
fs.chmodSync(dir, 0o755);
|
||||
|
||||
expect(r.status).not.toBe(0); // the failure is loud, not swallowed
|
||||
const s = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
|
||||
expect(s.hooks.Stop).toHaveLength(1); // old registration intact
|
||||
expect(s.hooks.Stop[0].hooks[0].command).toBe('/stale/path/timeline-stop-hook');
|
||||
} finally {
|
||||
try { fs.chmodSync(dir, 0o755); } catch {}
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('gstack-uninstall removes the Stop hook registration', () => {
|
||||
const uninstall = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-uninstall'), 'utf-8');
|
||||
expect(uninstall).toContain('remove-source --source gstack-timeline-stop');
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Static pins for the v1.68 wave's prose-tier behaviors — the coverage audit
|
||||
* flagged these as the only surfaces a future template edit could silently
|
||||
* revert without failing anything.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const read = (p: string) => fs.readFileSync(path.join(ROOT, p), 'utf-8');
|
||||
|
||||
describe('gstack-upgrade template: ff-only precedes the gated reset (#2517)', () => {
|
||||
const tmpl = read('gstack-upgrade/SKILL.md.tmpl');
|
||||
|
||||
test('git pull --ff-only runs before any reset --hard', () => {
|
||||
const ff = tmpl.indexOf('git pull --ff-only --autostash');
|
||||
const reset = tmpl.indexOf('git reset --hard origin/main');
|
||||
expect(ff).toBeGreaterThan(-1);
|
||||
expect(reset).toBeGreaterThan(-1);
|
||||
expect(ff).toBeLessThan(reset);
|
||||
});
|
||||
|
||||
test('the ff path carries the FF_OK success gate that skips the fallback', () => {
|
||||
expect(tmpl).toContain('FF_OK');
|
||||
expect(tmpl.indexOf('FF_OK')).toBeLessThan(tmpl.indexOf('git reset --hard origin/main'));
|
||||
});
|
||||
|
||||
test('the destructive fallback is gated on unpushed commits, not just a clean tree', () => {
|
||||
// A clean tree with unpushed local commits is NOT safe for reset --hard.
|
||||
expect(tmpl).toContain('git rev-list origin/main..HEAD');
|
||||
expect(tmpl.indexOf('git rev-list origin/main..HEAD')).toBeLessThan(
|
||||
tmpl.indexOf('git reset --hard origin/main'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('untrusted-content warning injection points (#2441)', () => {
|
||||
test('scrape and skillify templates carry the shared token', () => {
|
||||
// The wording lives in ONE exported const (resolvers/browse.ts); these
|
||||
// pins keep the injection POINTS from silently disappearing.
|
||||
expect(read('scrape/SKILL.md.tmpl')).toContain('{{UNTRUSTED_CONTENT_WARNING}}');
|
||||
expect(read('skillify/SKILL.md.tmpl')).toContain('{{UNTRUSTED_CONTENT_WARNING}}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('brain-uninstall removes the spool queue', () => {
|
||||
test('uninstall cleans .brain-queue.d alongside the legacy queue file', () => {
|
||||
const src = read('bin/gstack-brain-uninstall');
|
||||
expect(src).toContain('.brain-queue.d');
|
||||
expect(src).toContain('.brain-queue.jsonl');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user