mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
* feat(skills): add ce-plan — GitNexus+PDG implementation-planning skill Adds .claude/skills/ce-plan: a planning-only skill that builds implementation-ready plans from GitNexus graph navigation (query/context/ impact/trace), bounded statement-level PDG slices (pdg_query, impact mode:pdg, explain), and targeted source verification, with a context ledger to prevent repeated reads and a machine-readable implementation context pack (stable contract for a future ce-implement). Whitelisted in .gitignore and registered in AGENTS.md and CLAUDE.md outside the auto-managed gitnexus block. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skills): apply ce-plan validation findings (tool contract, consistency, conventions) Tool contract: impact mode:'pdg' shape now includes the schema-required direction param; CDG branch sense documented as the result 'label' field (reason is cypher/raw-edge only); explain caveats corrected to its real false-negative classes (cross-function TAINT_PATH is modeled). Consistency: PDG slice homed in working memory (ledger keeps one-liners); depth knob defined and category-overrides-baseline ordering stated; call_depth (consumed by nothing) and content-hash bookkeeping dropped; Never section folded into Hard rules; Phase 3 deduplicated to a pointer; allowed-repeat escalations defined; budget/discard accounting clarified; verification-commands gathering added to Phase 4; open_questions added to the context pack. From scenario runs: plans now pin the verified-at HEAD commit and index freshness in a header, tag claims [verified]/[graph]/[inferred]/[assumed], quote load-bearing tool output, prefer pre-hook-carrying npm scripts, and support an out:<path> destination override; output path defined as the Phase 1 target repo root. Conventions: AGENTS.md 1.9.0 / CLAUDE.md 1.4.0 changelog rows + metadata bumps; future ce-implement qualified as future. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): rename ce-plan → gitnexus-plan; add cross-CLI (Codex) entrypoints Renames the skill dir, frontmatter, output filename convention, plan H1 (GitNexus Engineering Plan), the future executor handle (gitnexus-implement), the .gitignore whitelist entry, and all AGENTS.md/CLAUDE.md references. Follows the pr-swarm-review cross-CLI pattern: SKILL.md is the canonical CLI-neutral spec, AGENTS.md § Engineering planning is the Codex/any-agent entrypoint, and the README documents the optional user-level ~/.codex/prompts/gitnexus-plan.md slash command plus an invocation matrix. Skill prose de-branded from Claude Code (agent-neutral verification layer). Also fixes two post-review README contradictions: the anti-reread claim now names the ledger's allowed escalations, and 'read-only by contract' is now 'planning-only' (the skill writes exactly one repo file — the plan); the scope-creep rule and template §12 now agree on where deferred follow-ups land. Drops the stale plugin-collision limitation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(skills): document Codex user-level install path for gitnexus-plan Codex discovers SKILL.md skills from ~/.agents/skills (same path the other gitnexus-* skills install to); README now documents the cp install plus the optional ~/.codex/prompts slash-command file, with the prompt body preferring the repo copy and falling back to the user-level install. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): gitnexus-plan freshness gate + active PDG-layer refresh Freshness is now a Phase 1 gate, not advisory: under the default freshness:strict, a stale index is refreshed once per planning session via node .gitnexus/run.cjs analyze --index-only (appending --pdg when the task will reach the PDG phase), then the context resource is re-read. A missing PDG layer likewise triggers the one permitted --index-only --pdg refresh and re-probe instead of a passive recommendation. freshness:accept (or a failed/impractical refresh) preserves the old behavior: plan on the stale graph, source-weighted, labelled in the plan header. --index-only is the load-bearing flag choice — it suppresses all file generation, so the planning-only contract holds (only the .gitnexus store changes). Ledger gains an index_refresh record; plan header states fresh / refreshed / refresh-skipped-with-reason. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): gitnexus-plan runner build check before freshness refresh When the target repo builds the analyzer from its own source (bin → dist/ mapping, as gitnexus/ does), the Phase 1 freshness gate now verifies dist/ is current before running the analyze refresh — rebuilding via the package's build script when any analyzer source file is newer than the built entrypoint — and prefers that freshly built CLI. Otherwise a stale dist re-indexes with outdated extraction logic and the 'fresh' index lies. Rebuilds are recorded in the ledger's index_refresh; the PDG-phase refresh inherits the same check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): add gitnexus-work executor and gitnexus-lfg pipeline gitnexus-work executes a gitnexus-plan as verified atomic commits: consumes the §11 implementation_context pack, drift-checks the plan's evidence pin against HEAD, re-verifies assumptions before relying on them, runs impact before every symbol edit and detect_changes before every commit (repo mandates), builds tests from the plan's scenarios, and routes structural drift back to gitnexus-plan Deepen mode instead of coding around it. gitnexus-lfg is a thin orchestrator: gitnexus-plan → blocking user gate (deepen / proceed / stop, deepen loops allowed) → gitnexus-work → review via the existing gitnexus-pr-review skill (open PR, else branch diff vs default). One bounded fix cycle for review findings; never pushes or opens a PR on its own. gitnexus-plan gains a Deepen mode (re-run freshness gate, escalate to depth:deep, re-verify graph/inferred/assumed claims toward verified, rewrite the same file); its 'future gitnexus-implement' placeholder is retired in favor of gitnexus-work. Registered via .gitignore whitelists, AGENTS.md 1.10.0 (section renamed to Engineering planning & execution), CLAUDE.md 1.5.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skills): apply cross-skill review findings to the gitnexus skill family Two P1s: gitnexus-plan Deepen mode now re-anchors before re-pinning (diffs the old evidence pin over every [verified]-claim file and re-reads or downgrades before the header moves — moving the pin without this laundered stale claims as verified); the index-refresh budget is stated once in Phase 1 (one --index-only refresh plus at most one Phase 3 --pdg upgrade per session, Deepen = its own session) with ledger and pdg-slice deferring to it. Contract fixes: gitnexus-work's drift check now covers every file the pack cites (not just files_to_modify) and parses the full pack incl. primary/related symbols and acceptance_criteria (walked in Phase 4 alongside §13); a pre-completed check skips §7 steps already landed and Deepen gains a reconcile-execution-state step, closing the mid-execution route-back loop; pack assumptions must name what to check and how. lfg: Lane 4 passes the merge-base to detect_changes compare (two-dot diff misattributes upstream commits when default advanced), branch-diff is the stated normal case, oversized review findings route to the plan gate instead of overflowing direct mode, the one-fix-cycle cap is explicit on re-run, and headless runs end at the plan gate with the plan as deliverable. work: blank mode narrowed to *gitnexus-plan*.md with a re-execution guard, direct-mode discipline spelled out, branch meaningfulness defined against the plan slug, and the plan document is committed as the branch's docs commit (review diff includes it). Planning-only contract now names the dist/ rebuild as the second permitted state change; Phase 5.1 names the four claim tags; stale AGENTS.md anchors fixed. Known latent issue left untouched: gitnexus/gitnexus-pr-review pairs a three-dot example with a two-dot detect_changes compare — that skill is also shipped by the plugin, so fixing it here would drift the copies; lfg compensates by passing the merge-base. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): ship the engineering skill family with the gitnexus package npm i -g gitnexus users now get gitnexus-plan / gitnexus-work / gitnexus-lfg: the three skills are added to gitnexus/skills/ in directory form (SKILL.md + references/), which installSkillsTo already enumerates dynamically and copies recursively to every editor target (~/.agents/skills for Codex, Cursor, OpenCode, Qoder, ...) on gitnexus setup — uninstall enumerates the same root, so removal stays clean. The Claude Code plugin channel (gitnexus-claude-plugin/skills/) carries the same copies plus the standard per-skill mcp.json. Global-install support in the skill text: gitnexus-plan Phase 1 now resolves the analyzer runner explicitly — node .gitnexus/run.cjs analyze when the project has a runner, else gitnexus analyze (installed CLI), else npx gitnexus analyze — and all analyze mentions route through it, satisfying the skills-steering policy (#1939/#1945) which sweeps the plugin copies. New drift guard test/unit/shipped-skills-sync.test.ts asserts the npm and plugin copies stay byte-identical to the canonical .claude/skills/ family (plugin = canonical + mcp.json), same discipline as run.cjs ↔ resolve-invocation.ts. skills-steering + shipped-skills-sync: 11/11 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(eval): workflow_bench — measure the skill workflow's token savings Benchmarks gitnexus-plan → gitnexus-work against a baseline agent (--disallowedTools Skill) on identical tasks, in fresh detached worktrees, using real headless Claude Code sessions; every number comes from the CLI's --output-format json usage report (field names validated against a live 2.1.207 session). Reports per-arm medians (input/cache/output tokens, cost, wall time, turns), a savings row, and resolve status from a per-task verify command — savings on failed tasks are flagged, not celebrated. Per-task setup hook prepares fresh worktrees (deps); --permission-mode bypassPermissions (default) lets sessions run unattended in the throwaway trees. Free-model support: --base-url/--auth-token/--model route headless sessions through any Anthropic-compatible endpoint; free-model.litellm.yaml is a ready litellm-proxy template for OpenRouter :free variants or local Ollama, so benchmarking burns no paid tokens (README documents rate limits and the small-model skill-following caveat). Harness validated end-to-end with a stub CLI (worktree lifecycle, both arms, plan→work chaining, verify, aggregation, report) and 4 pytest units for the pure aggregation/savings/report helpers. AGENTS.md 1.11.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(eval): record first workflow_bench calibration run Trivial-task calibration (add -V alias): both arms resolved; workflow arm ~4.3x baseline cost — the documented overhead-dominated regime, recorded so the regime boundary is empirical rather than asserted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(eval): workflow_bench scenario matrix — arm variants, task classes, churn Ground-base measurement across scenarios: tasks.scenarios.yaml spans four labeled classes (trivial → investigation-bug → investigation-feature → cross-module) with deterministic verifies (prescribed test files). New arms: workflow_direct (gitnexus-work direct mode — the middle option that locates the routing boundary lfg's gate and work's triage encode) and baseline_nomcp (no skills AND no graph tools — separates workflow-discipline value from GitNexus-tool value; off by default). Records now carry task class and diff churn (files/+ins/−del vs the starting commit) as an over-engineering proxy; the report renders a class column and per-arm savings rows vs baseline. 5 pytest units + stub-CLI e2e of the full three-arm matrix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(eval): record workflow_bench ground base; fix churn measurement bias Ground base (3 classes x 3 arms, n=1/cell): every arm resolved every task — pass/fail quality saturates at this difficulty, making the comparison pure cost. Full plan→work never amortized its ~$9-11 fixed cost on tasks a baseline finishes in ≤35 turns (−211% to −333% cost); workflow_direct sits near baseline (−15% to −55%, once faster wall) with more test coverage. Routing implication recorded: direct mode/plain agent below this scale, full workflow for cross-module / multi-session / plan-as-deliverable work. The cross-module cell and multi-run variance are the next measurements. Churn fix: git add --intent-to-add -A before diffing (arms that never commit no longer undercount new files) and :(exclude)docs/plans (the committed plan doc no longer inflates workflow churn); this run's churn numbers predate the fix and are omitted from the recorded table. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(skills): cost-optimize the workflow from measured ground base Every optimization targets a measured fixed-cost component (eval/workflow_bench ground base: workflow arm −211% to −333% vs baseline, all tasks resolved): - Plan form is category-priced: compact form (core sections w/ § anchors preserved, ≤80 lines excl. pack, mini-pack subset of the context pack) for narrow/default categories; the full 13 sections only for deep work (refactor/security/performance/concurrency/architecture). A compact plan outgrowing its cap reclassifies to full rather than overflowing. - Freshness gate is category-priced: compact categories default to accept (source-weighted, refresh only when a graph claim becomes load-bearing); strict stays the default for full-plan categories — the rebuild+re-index was the largest single fixed cost. - Turn economy: per-category tool-call budgets (~10 to ~45; architecture uncapped); budget exhaustion routes open questions to §12 instead of more digging. - gitnexus-work fast path: HEAD == evidence pin → skip all citation re-reading (the pin's entire point); mini-pack fields tolerated. - lfg Lane 1 boundary triage: tasks below the measured ~35-turn boundary get offered gitnexus-work direct mode before the plan lane is spent. Copies re-synced (npm skills/, plugin, ~/.agents); steering + sync guards green. Re-measurement of the workflow arm follows to verify the numbers actually improve. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(eval): record optimization re-measurement — inv-bug workflow cell −20% cost Same task, same conditions, post-830a0459 skills: $14.56→$11.70 (−20%), 83→72 turns, cache_read −24%; verified in-transcript that the compact form, turn budget, and skipped rebuild/re-index all fired. Wall +15% from a work- session test-debugging tail (n=1 variance). Regime unchanged (~3.5x baseline on this class) — routing rule stands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(eval): per-arm clone isolation — worktree ref-namespace leak contaminated an arm The cross-module workflow_direct cell reported an impossible 28-turn solve with churn byte-identical to the workflow arm: git worktree add shares the repo's ref namespace, so the workflow arm's slug branch (created by gitnexus-work Phase 2) survived worktree removal and the direct arm found and adopted the completed work. Arms now get isolated git clone --shared copies (object store via alternates, refs clone-local — agent branches and stashes die with the clone; origin/<ref> fallback for non-default refs). Leaked branch deleted; baseline arm verified clean (0 branch references in its transcript); cell marked invalidated pending re-run. Records the valid cross-module cells: workflow $18.32 vs baseline $18.03 (premium −1.6%, vs −211%..−333% on smaller classes) — fixed costs amortize at this scale, with a less destructive diff and a plan artifact as bonus; resolve rate still tied. Churn fingerprinting is what caught the contamination — noted in the README as an integrity check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(eval): complete cross-module cell — direct mode wins 47% cost / 56% wall Clean clone-isolated re-run: workflow_direct resolved the hardest class at $9.53/52 turns/15m vs $18.03/98/34m baseline and $18.32/107/37m full workflow. The measured story across all four classes: the execution discipline (gitnexus-work) is the consistent sweet spot and delivers real token savings on hard tasks; the planning pass buys its artifact, not same-session savings. Resolve rate tied everywhere (n=1/cell caveat). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(eval): add trajectory-gated skill evolution (#2431) - Pair prompt candidates with incumbent workflow arms - Gate promotions on pinned-model quality and efficiency - Expire router evidence and document its lifecycle * fix(eval): allow pr-review skill candidates * feat(skills): rename and generalize GitNexus review * feat(eval): external-comparator and review arms for workflow_bench - ce_workflow / ce_workflow_direct: compound-engineering ce-plan/ce-work arms prompted with the same structure as the gitnexus arms - review / ce_review: gitnexus-review vs ce-code-review on an identical diff applied by the task's setup - plan handoff is snapshot-based: committed example plans in docs/plans/ tie on clone mtimes and broke the name-glob pick (executed a stale plan) - verify output tail is recorded per run and the final working-tree patch is kept, so failed rows are diagnosable after the clone is destroyed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skills,eval): address #2431 review — data-safe rename migration, fail-closed bench evidence - setup: never delete a legacy renamed skill dir — the installer cannot prove ownership (users customize or hand-write skills under these names); warn with the path instead, and the test now asserts survival - workflow_bench: fail closed when a session's --output-format json report is empty, malformed, or missing usage fields — an exit-0 shell with no parseable usage no longer counts as measured evidence (5 parametrized regression tests) - workflow_bench: document the trust model prominently (task setup/verify are shell-executed, sessions run bypassPermissions with the parent env, candidate overlays are prompt injection surface) in README + docstring - free-model.litellm.yaml: master_key from LITELLM_MASTER_KEY env instead of a static token; loopback-binding warning - ci: run the eval workflow_bench pytest suite on ubuntu (pytest+pyyaml only — no full eval stack) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(eval): demand observed foreground verification in headless work-arm prompts In a headless -p session there is no later turn: a work arm backgrounded its slow test run, scheduled wakeups that can never fire, and reported done while two of its tests failed. All four work-arm prompts (both skill families, symmetric) now require verification output to be observed inside the session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): ask plan depth up front instead of offering deepen afterwards gitnexus-plan Phase 0 now asks one blocking question in interactive sessions — quick / standard / deep, mapped onto the existing depth/form/ freshness knobs — when the invocation carries no explicit depth signal. Explicit knobs and headless runs skip the question (category posture unchanged, so benchmarks and automation behave as before). gitnexus-lfg's plan gate slims to proceed/stop: depth was already the user's up-front choice, so deepening is no longer offered by default — an explicit deepen request at the gate and executor route-backs still run Deepen mode, which remains the mechanism for strengthening an existing plan document. All shipped copies resynced (npm skills/, Claude plugin); AGENTS.md 1.13.0 and CLAUDE.md 1.7.0 pointers updated, including the analyzer's regenerated index-stats block at this branch's head. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): taint pass, expert lenses, and post-work index refresh gitnexus-review gains a PDG-backed taint-and-dependence pass (explain + pdg_query, --pdg folded into the stale refresh on trust-boundary diffs) and an Expert lenses section: domain reviewers derived from the graph's clusters plus four cross-cutting lenses (architectural fit, language conformance per the repo's own contract, Definition of Done, simplicity), dispatched once after the evidence-gathering steps and scaled to the diff. gitnexus-work Phase 4 now refreshes the knowledge graph after the DoD walk via the resolved-runner ladder with analyze --index-only, so the lfg review lane and later sessions query the finished work without dirtying the tree. lfg's threshold-governance paragraph moves to its README; eval citations are tagged as measured in the GitNexus repo. All shipped copies re-synced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli): remove legacy gitnexus-pr-review on uninstall; cover the rename migration uninstall's removal set now includes LEGACY_SKILL_DIR_NAMES derived from RENAMED_SKILL_DIRS, so a pre-rename install is cleaned up instead of orphaned. The rename warning gains behavioral coverage (fires with a legacy dir present, silent without), and shipped-skills-sync asserts legacy names stay absent from every shipped tree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(eval): metric provenance, error-kind rows, skill-invocation verification, gate noise floor The promotion gate defaults to cost_usd (the only metric that includes subagent spend); token metrics carry an explicit main-loop-only warning in the report and promotion.json. Rows are classified by error_kind (session-error / verify-failed / infra-error), excluded from efficiency medians, and the gate requires equal valid-run counts. Each session's transcript is scanned for the expected Skill invocation and fails closed on a verified miss; a one-run resolution edge no longer promotes (noise floor). Per-run timeouts and setup failures record an infra-error row instead of aborting the sweep. Overlays touching skills no candidate arm exercises are rejected up front. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: fix skill routing paths, version headers, and skill rosters Routing tables point at the tracked direct skill paths (matching the post-#2434 generator output), AGENTS.md/CLAUDE.md headers match their latest changelog rows, the 1.12.0 row describes what the migration actually does, package/cursor READMEs list the full shipped skill roster, and the swarm READMEs describe /gitnexus-review's expert lenses instead of calling it single-agent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: drift-guard workflow for skill copies; pin eval pip deps; track docs/plans ci.yml ignores '**.md', so an md-only skill edit would merge without the shipped-skills-sync test running — skill-sync.yml triggers exactly on the guarded trees. The eval job's pip install is version-pinned, and docs/plans/ is unignored so gitnexus-plan output can be committed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): keep the runner-invocation literal in gitnexus-review; add concurrency block to skill-sync skills-steering requires skills with a stale-index hint to carry the exact 'node .gitnexus/run.cjs analyze' form — restore it with the fallback ladder as a parenthetical instead of replacing it. skill-sync.yml gains the top-level concurrency block the workflow-convention check enforces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): token-economy guidance for expert lenses Merge lenses that ground in the same material into one reviewer, and use cheaper model/effort tiers for mechanical lenses where the harness offers them, reserving the strongest engine for adversarial judgment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(eval): isolate transcript home on Windows Ensure workflow_bench transcript tests set USERPROFILE alongside HOME so Path.home() resolves to the temporary test home on Windows. * docs(skills): fold PR #2522 execution learnings into review/work/plan Eight incident-backed hardenings from running the full skill cycle (review -> plan -> work, 28-finding fix series) on PR #2522: gitnexus-review: - Expert lenses execute the code under review on candidate failing shapes (empirical probe outranks source reading — every HIGH the language lenses found came from a probe, not a read). - Step 7 re-runs the exact CI check for refreshed baselines/fingerprints (a stale committed artifact is invisible in the diff; caught a red benchmarks arm). - Step 8 treats version/invalidation constants as review surface (INCREMENTAL_SCHEMA_VERSION class recurred verbatim from #2494). gitnexus-work: - Step 4 proves regression tests discriminate against the pre-fix tree. - Step 5 rebuilds executed build output before every verification run (parse workers load dist/; a correct fix 'failed' until rebuilt). - Step 6 makes stage -> detect_changes -> commit one unbroken sequence. gitnexus-plan: - Phase 0 seeded-evidence mode: plan FROM a completed review's verified findings instead of re-running the graph ladder. - Template §7: fingerprint/golden-guarded output rebaselines once, at the series tip. All distribution copies resynced; shipped-skills-sync + skills-steering 24/24 locally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(eval): close the skill-evolution loop with an automated proposer driver workflow_bench.evolve adds the three arrows the README described as manual: a proposer session that turns loser trajectories (results.jsonl rows, transcripts, patches, the learning queue) into ONE bounded candidate overlay, a driver that iterates propose -> paired benchmark -> deterministic gate up to --generations, and an --apply step that copies a promoted overlay onto the canonical skills and shipped mirrors as a working-tree diff. The trust boundary is unchanged: overlays re-validate through candidate_overlay_files before any benchmark or apply consumes them, and committing, CI, and the PR merge stay human. learnings.jsonl is gitignored: it is machine-local evidence, like the session transcripts it complements. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(skills): route live-task friction into the evolution learning queue Each family skill gains a short 'Skill feedback' section: on friction with the skill's own instructions, append one JSON line to eval/workflow_bench/learnings.jsonl (GitNexus repo only) — never self-edit the skill from a live task. The proposer in workflow_bench.evolve consumes the queue as hints; a learning reaches a shipped skill only by beating the incumbent on the paired benchmark. All shipped mirrors re-copied byte- identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(tests): run the evolve helper tests in the eval pytest job test_evolve.py needs only pytest+pyyaml, same as the harness tests the job already runs — without this line the new module had no CI coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ci): comment-triggered GitNexus review agent for PRs '@gitnexus review' from a maintainer (OWNER/MEMBER/COLLABORATOR; the action re-validates write access) runs the repo's gitnexus-review skill headlessly against the PR and posts the review as a sticky comment — remote triggering with no local setup. Read-only by construction: contents: read token, Write/Edit and web tools disallowed, Bash allowlisted to git reads and the gitnexus CLI; analyze parses PR code with tree-sitter, never executes it. Requires the ANTHROPIC_API_KEY repository secret; activates once the file is on the default branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ci): dispatch lane + existing OAuth secret for the review agent Align with claude.yml: same action pin and the CLAUDE_CODE_OAUTH_TOKEN secret the repo already carries — no new secret to configure. Add a workflow_dispatch lane (PR number input) so the agent can be triggered from the Actions UI and tested before the issue_comment trigger reaches the default branch. Allowlist gh pr view/diff and gh api, which the review skill uses to pin PR SHAs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): close a fork-PR RCE vector in the review agent's tool allowlist A live headless run of the exact workflow session against PR #2431 (66 turns, full gitnexus-review pass) surfaced a real HIGH-severity confused deputy: .gitnexus/ is gitignored, not blocked — a fork PR can commit its own .gitnexus/run.cjs, issue_comment checks out PR-head content, and the skill's runner ladder tries 'node .gitnexus/run.cjs analyze' first. That would execute fork-controlled JS inside a job holding CLAUDE_CODE_OAUTH_TOKEN and a write-scoped GITHUB_TOKEN — the opposite of the 'PR code is read, never executed' claim in the workflow's own header. Fix: drop the run.cjs allowlist entry so analyze always resolves through npx gitnexus (npm registry, not the checked-out tree); the skill's documented fallback mode covers the resulting graceful degradation. Also drop 'gh api' (not read-only — accepts -X POST/PATCH/DELETE) and downgrade pull-requests: write to read (comment posting only needs issues: write; the prompt already forbids formal review submission). Same session flagged a latent evolve.py bug: select_evidence's cost sort used dict.get's missing-key default, which doesn't cover an explicit JSON null in a foreign --seed-results row and crashes proposer setup with TypeError. Guarded with 'or 0.0' and added a regression test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: harden PR review and evolution trust boundaries * ci: follow workflow concurrency convention * fix(eval): make terminating error paths explicit * fix: unblock hardened review runtime checks * test: make containment canaries deterministic * test: expose Claude canary tool failures * fix: adapt clean shell environment for Claude * fix(eval): accept the runner's transcript source key in evidence preflight The proposer evidence preflight required transcript-artifact metadata to be exactly {path, sha256, bytes}, but the runner stamps a fourth provenance key (source=parent-captured-stream-json). Any --seed-results or generation>=2 run therefore aborted with SandboxError before proposing or promoting. Pin the producer literal as PARENT_EVENT_STREAM_SOURCE and validate it in the metadata check, and round-trip real producer output through sum_sessions into the preflight so the schema can't drift again. * fix(eval): treat an unmeasured session cost as unavailable, not $0 well_formed validated only the nested usage block, so an otherwise-successful session missing total_cost_usd was recorded as cost_usd=0.0 — and cost_usd is the default promotion metric (lower wins), so a cost-less session scored as free and could win promotion it never earned. Extract cost via measured_cost() (None on absent/garbage, a measured 0.0 preserved), propagate None through sum_sessions/aggregate/savings/report, and have the gate refuse to rank on a metric that was not measured on every run in both arms. * fix(eval): warn when ranking on the main-loop-only num_turns metric num_turns comes from the CLI's top-level usage (main-loop session only), like output_tokens, but selecting it emitted no metric_warning — so a subagent-heavy candidate could look artificially efficient. Add num_turns to MAIN_LOOP_ONLY_METRICS and broaden the warning to cover turns. * fix(eval): fail closed when an overlay adds a file with no committed base An overlay adding a new .md under gitnexus-{plan,work} passes the structural overlay checks but has no committed base for committed_destination_base_digests to bind against, so it raised an uncaught ValueError that crashed the evolve driver (and runner --candidate-overlay) mid-run. Catch it at both call sites: evolve reports NOT PROMOTED and exits, runner routes it through parser.error. * feat(eval): circuit-break the runner sweep on a systemic outage A sustained upstream outage used to pay out every remaining --timeout window one session at a time. Track consecutive session/infra/cleanup failures via a pure systemic_outage_streak helper; after --outage-streak (default 5) in a row, stop the sweep, still write report.md/promotion.json from partial evidence, and exit non-zero so evolve.py halts instead of proposing from truncated evidence. A task's own resolved=False never trips the breaker. * fix(cli): report a dirty working tree as stale in gitnexus status status --json (and the human output) computed up-to-date from commit + runner identity + completeness only, so a repo with uncommitted source changes at a matching HEAD was reported up-to-date while analyze would still re-index it. A graph-backed agent gating on that JSON could skip re-analysis on a stale graph. Extract analyze's dirty-tree check into a shared isWorkingTreeDirty() in storage/git and fold it into the status freshness decision. * fix(ci): use single-slash deny globs in the review agent's disallowedTools github.workspace already expands to an absolute path, so Read(/${{ github.workspace }}/**) and Read(//proc/**),(//sys/**),(//dev/**) produced double-slash patterns that a normalizing matcher may not match — silently no-opping the deny layer. Not exploitable (the allowlist is the primary control and never grants those paths), but the globs should be well-formed. Update the pinned test strings. * ci: install gitnexus-shared with npm ci from the committed lockfile The gitnexus-shared build floated its deps via npm install in three workflows (skill-sync, ci-tests, and — most importantly — the release publish.yml) while every other install step uses npm ci. The lockfile is committed and in sync, so switch all three to npm ci for reproducible, locked installs. * test(cli): make the shipped-skills drift guard reject symlinks listFilesRecursive walked with readdirSync and snapshotDir read with readFileSync, both of which follow symlinks — so a mirror file symlinked to the canonical tree passed the byte-compare (and a symlinked mirror dir would be followed too). Reject a symlinked root via lstat and any symlinked entry via Dirent.isSymbolicLink, with negative tests (skipped on Windows). * test(eval): guard the candidate-skill vs mirror-root coverage invariant MIRROR_SKILL_ROOTS omits the Cursor tree, safe only because no candidate skill is cursor-shipped. Pin that invariant: every CANDIDATE_SKILLS entry must exist under canonical + every mirror root and must not ship to Cursor, so adding a cursor-shipped skill to the candidate set (the PR #2488 asymmetric-sync class) fails loudly instead of syncing three of four trees. * docs(ci): describe the review agent's staged post-merge rollout The DoD asked for a dry-run or triggered run before merge, but an issue_comment (or newly added workflow_dispatch) workflow only ever executes the default-branch copy, so it cannot be exercised from the PR that introduces it. Reword the DoD and the activation checklist to a staged rollout: merge registered-but-disabled, validate same-repo and fork execution post-merge, then enable the variable. * fix: pin plugin skill mcp.json to the release version via #2445 tooling The ten plugin skill mcp.json launched `npx -y gitnexus@latest mcp` on every skill connect — non-reproducible and a supply-chain surface, and (unlike the persisted setup config) never pinned. Extend sync-plugin-manifests.mjs with an mcp surface kind that stamps the gitnexus@<version> launch arg, pin all ten to 1.6.9 now, and keep them byte-identical so the drift guard stays green. The release lifecycle + publish.yml --check now re-stamp them like the four manifest surfaces; only READMEs stay on @latest as docs. * test(eval): prove the proposer's built-in file tools are confined The real-Claude canary only exercised Bash + MCP, so it proved process/MCP containment but not that the proposer's built-in file tools stay inside their mounts. Add a canary over the exact PROPOSER_ALLOWED_TOOLS surface and the same read-only /evidence mount as run_proposer (allowlist extracted to a shared constant so it can't drift): Read reaches /evidence, a Write into the read-only evidence mount is denied, and a Write lands in the output tree. * fix(eval): apply the candidate overlay after task setup for fair arms The candidate overlay was applied before the task's untrusted setup ran, so setup could observe candidate prose and the incumbent/candidate arms started from different pre-overlay state. Reorder within the sandbox: capture the base (pre-overlay) skill digest, run setup against the base skills, verify setup did not tamper them, then apply the overlay and capture the post-overlay digest the model must preserve. apply_candidate_overlay stages path-specific overlay files, so setup's uncommitted changes stay out of the baseline and churn is unchanged. Graph freshness for the review arm is handled by the status dirty-tree fix plus the review skill's stale-triggered re-index, not by reordering the cached per-task-sha graph materialization (which is mechanically blocked). * test(eval): end-to-end containment proof of the autonomous proposer Drives the real run_proposer through bubblewrap with a deterministic scripted model (no paid API): it reads the read-only evidence bundle and writes a candidate gitnexus-plan skill edit plus a rationale into the sandbox output tree; run_proposer enforces the trust boundary and copies only the validated overlay + proposal out. This exercises the autonomous-proposal stage of the self-evolution loop end-to-end in the eval/containment CI job (the gate and apply stages are covered by test_workflow_bench_evolution and test_promotion_apply). Env-gated on GITNEXUS_REQUIRE_CLAUDE_CANARY, so it runs only where the pinned Claude binary and user namespaces are available. * fix(eval): let the proposer author its overlay via Bash Running the end-to-end proposer canary in the containment CI job surfaced a real bug: run_proposer starts the session with --bare, which hard-disables the Write/Edit tools ("Write exists but is not enabled in this context"), yet allowlisted Edit/Write and omitted Bash. The proposer therefore had no working way to write its candidate overlay — the self-evolution loop could never produce a candidate. The sandbox settings already pre-authorize Bash (autoAllowBashIfSandboxed) and confine writes to workspace/tmp/home, so switch PROPOSER_ALLOWED_TOOLS to Read/Grep/Glob/Bash and tell the proposer to author files with Bash. The end-to-end test now drives the real run_proposer through bubblewrap and asserts a validated overlay + proposal are produced (this also replaces the earlier file-tool canary, whose Write/Edit premise was moot). * test(eval): author the proposer overlay with newline-free Bash content The nested shell-sandbox prefix mangles embedded newlines, so the multi-line overlay content never landed. Use single-line content for the deterministic proposer canary. * test(eval): drop the unverifiable end-to-end proposer canary The scripted proposer overlay never materialized in the containment job across runs, and the model tool-result content is not visible in CI logs, so the test cannot be finalized without an environment where the sandbox can actually run. Keep the verified production fix (Bash-authoring in run_proposer); the proposer sandbox/containment stays covered by the existing Bash+MCP and process-tree canaries. * test(cli): drop run-analyze.ts from the windowsHide spawn-family list U7 moved run-analyze.ts's only child_process call (the git status --porcelain dirty check) into storage/git.ts (already covered by this test, with windowsHide). run-analyze.ts no longer imports a spawn-family function, so the windowsHide-regression test's 'must have >=1 spawn call' invariant failed for it. Remove it from SRC_FILES. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Zander Raycraft <zanderjraycraft@gmail.com> Co-authored-by: Azizur Rahman <azizur100389@gmail.com>
3411 lines
132 KiB
TypeScript
3411 lines
132 KiB
TypeScript
/**
|
||
* Regression Tests: Claude Code Hooks
|
||
*
|
||
* Tests the hook scripts (gitnexus-hook.cjs and gitnexus-hook.js) that run
|
||
* as PreToolUse and PostToolUse hooks in Claude Code.
|
||
*
|
||
* Covers:
|
||
* - extractPattern: pattern extraction from Grep/Glob/Bash tool inputs
|
||
* - findGitNexusDir: .gitnexus directory discovery
|
||
* - handlePostToolUse: staleness detection after git mutations
|
||
* - cwd validation: rejects relative paths (defense-in-depth)
|
||
* - shell injection: verifies no shell: true in spawnSync calls
|
||
* - dispatch map: correct handler routing
|
||
* - cross-platform: Windows .cmd extension handling
|
||
* - cross-platform: DB lock probe (Linux /proc, Unix lsof, Windows RM)
|
||
*
|
||
* Since the hooks are CJS scripts that call main() on load, we test them
|
||
* by spawning them as child processes with controlled stdin JSON.
|
||
*/
|
||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||
import { spawnSync } from 'child_process';
|
||
import { createRequire } from 'node:module';
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
import os from 'os';
|
||
import {
|
||
runHook,
|
||
parseHookOutput,
|
||
createHookToolDir,
|
||
createFakeProcRoot,
|
||
hookEnv,
|
||
} from '../utils/hook-test-helpers.js';
|
||
|
||
// ─── Paths to both hook variants ────────────────────────────────────
|
||
|
||
const CJS_HOOK = path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'gitnexus-hook.cjs');
|
||
const CJS_HOOK_LOCK = path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'hook-lock.cjs');
|
||
const RESOLVE_CJS = path.resolve(
|
||
__dirname,
|
||
'..',
|
||
'..',
|
||
'hooks',
|
||
'claude',
|
||
'resolve-analyze-cmd.cjs',
|
||
);
|
||
const RESOLVE_PLUGIN_CJS = path.resolve(
|
||
__dirname,
|
||
'..',
|
||
'..',
|
||
'..',
|
||
'gitnexus-claude-plugin',
|
||
'hooks',
|
||
'resolve-analyze-cmd.cjs',
|
||
);
|
||
const PLUGIN_HOOK = path.resolve(
|
||
__dirname,
|
||
'..',
|
||
'..',
|
||
'..',
|
||
'gitnexus-claude-plugin',
|
||
'hooks',
|
||
'gitnexus-hook.js',
|
||
);
|
||
const PLUGIN_HOOK_LOCK = path.resolve(
|
||
__dirname,
|
||
'..',
|
||
'..',
|
||
'..',
|
||
'gitnexus-claude-plugin',
|
||
'hooks',
|
||
'hook-lock.js',
|
||
);
|
||
const CJS_HOOK_DB_PROBE = path.resolve(
|
||
__dirname,
|
||
'..',
|
||
'..',
|
||
'hooks',
|
||
'claude',
|
||
'hook-db-lock-probe.cjs',
|
||
);
|
||
const PLUGIN_HOOK_DB_PROBE = path.resolve(
|
||
__dirname,
|
||
'..',
|
||
'..',
|
||
'..',
|
||
'gitnexus-claude-plugin',
|
||
'hooks',
|
||
'hook-db-lock-probe.cjs',
|
||
);
|
||
|
||
// ─── lsof/ps-path lane gate (#2180) ─────────────────────────────────
|
||
//
|
||
// The owner-detection tests below drive the probe through its lsof + ps backend
|
||
// (via the fake lsof/ps in createHookToolDir). That backend is the macOS/other-
|
||
// Unix path; #2180 removed the Linux lsof fallback, so on Linux these tests
|
||
// would no longer exercise the real dispatch (the cmdline-first procfs scan
|
||
// answers instead, and a temp lbug held by nobody is simply not-owned). They
|
||
// remain valid coverage for the macOS lane; Linux gets equivalent three-phase
|
||
// coverage in test/unit/hook-db-lock-probe.test.ts (fake /proc + a live e2e).
|
||
const SKIP_LSOF_PATH = process.platform === 'win32' || process.platform === 'linux';
|
||
|
||
// ─── Host guard precheck for orphan-reaping tests (#2163) ───────────
|
||
//
|
||
// The reaping lanes depend on a host coreutils `timeout`/`gtimeout` that
|
||
// passes the probe's `-k` self-test. Without one, the SIGTERM-immune fake
|
||
// child simply survives and the aliveness poll times out — a red that says
|
||
// nothing about WHY. Resolve the guard once through the probe's own exported
|
||
// resolver (the exact candidate list + self-test the hook child will use,
|
||
// with any dev-shell GITNEXUS_HOOK_TIMEOUT_PATH override cleared to mirror
|
||
// the `''` these tests pass to the hook) and assert on it with an explicit
|
||
// message. Chosen form: precheck ASSERTION, not skipIf — a skip would
|
||
// silently drop the incident-mechanism coverage on a misconfigured host
|
||
// (green-but-vacuous lane), while a red with a one-line actionable cause
|
||
// keeps the contract honest. GitHub ubuntu runners always ship coreutils,
|
||
// so CI behavior is unchanged.
|
||
const GUARD_PRECHECK_MSG =
|
||
'precheck: no self-test-passing coreutils timeout/gtimeout on this host — ' +
|
||
'orphan reaping cannot work here (the wrapper IS the reaping mechanism). ' +
|
||
'Install coreutils or expose one via GITNEXUS_HOOK_TIMEOUT_PATH.';
|
||
|
||
let hostGuardMemo: string | null | undefined;
|
||
function resolveHostGuardForReapingTests(): string | null {
|
||
if (hostGuardMemo !== undefined) return hostGuardMemo;
|
||
const saved = process.env.GITNEXUS_HOOK_TIMEOUT_PATH;
|
||
process.env.GITNEXUS_HOOK_TIMEOUT_PATH = '';
|
||
try {
|
||
// createRequire: the probe is a CJS module; this also exercises the real
|
||
// export surface the adapters consume (#2163 follow-up). Unix-only — the
|
||
// resolver's self-test spawns /bin/sh — and all callers below live in
|
||
// linux-gated describes.
|
||
const probe = createRequire(import.meta.url)(CJS_HOOK_DB_PROBE) as {
|
||
resolveUnixGuardTimeout: () => string | null;
|
||
};
|
||
hostGuardMemo = probe.resolveUnixGuardTimeout();
|
||
} finally {
|
||
if (saved === undefined) delete process.env.GITNEXUS_HOOK_TIMEOUT_PATH;
|
||
else process.env.GITNEXUS_HOOK_TIMEOUT_PATH = saved;
|
||
}
|
||
return hostGuardMemo;
|
||
}
|
||
|
||
// ─── Test fixtures: temporary .gitnexus directory ───────────────────
|
||
|
||
function writeSelfTestingGuardWithMarkers(
|
||
guardPath: string,
|
||
markers: { lsof?: string; ps?: string },
|
||
) {
|
||
fs.writeFileSync(
|
||
guardPath,
|
||
`#!/usr/bin/env node
|
||
const fs = require('fs');
|
||
const { spawnSync } = require('child_process');
|
||
const args = process.argv.slice(2);
|
||
const lsofMarker = ${JSON.stringify(markers.lsof ?? '')};
|
||
const psMarker = ${JSON.stringify(markers.ps ?? '')};
|
||
if (args.includes('exit 42')) process.exit(42);
|
||
if (lsofMarker && args.includes('-nP')) fs.writeFileSync(lsofMarker, 'called');
|
||
if (psMarker && args.includes('-p')) fs.writeFileSync(psMarker, 'called');
|
||
const child = spawnSync(args[3], args.slice(4), {
|
||
encoding: 'utf-8',
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
});
|
||
if (child.stdout) process.stdout.write(child.stdout);
|
||
if (child.stderr) process.stderr.write(child.stderr);
|
||
if (child.error) process.exit(127);
|
||
process.exit(child.status ?? 0);
|
||
`,
|
||
{ mode: 0o755 },
|
||
);
|
||
}
|
||
|
||
let tmpDir: string;
|
||
let gitNexusDir: string;
|
||
|
||
beforeAll(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hook-test-'));
|
||
gitNexusDir = path.join(tmpDir, '.gitnexus');
|
||
fs.mkdirSync(gitNexusDir, { recursive: true });
|
||
|
||
// Initialize a bare git repo so git rev-parse HEAD works
|
||
runGit(tmpDir, ['init']);
|
||
runGit(tmpDir, ['config', 'user.email', 'test@test.com']);
|
||
runGit(tmpDir, ['config', 'user.name', 'Test']);
|
||
fs.writeFileSync(path.join(tmpDir, 'dummy.txt'), 'hello');
|
||
runGit(tmpDir, ['add', '.']);
|
||
runGit(tmpDir, ['commit', '-m', 'init']);
|
||
});
|
||
|
||
afterAll(() => {
|
||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||
});
|
||
|
||
// ─── Helper to get HEAD commit hash ─────────────────────────────────
|
||
|
||
function runGit(dir: string, args: string[]) {
|
||
const result = spawnSync('git', args, {
|
||
cwd: dir,
|
||
encoding: 'utf-8',
|
||
stdio: ['pipe', 'pipe', 'pipe'],
|
||
windowsHide: true,
|
||
});
|
||
if (result.status !== 0) {
|
||
const message = result.stderr || result.stdout || result.error?.message || 'unknown error';
|
||
throw new Error(`git ${args.join(' ')} failed in ${dir}: ${message}`);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function getHeadCommit(): string {
|
||
const result = runGit(tmpDir, ['rev-parse', 'HEAD']);
|
||
return (result.stdout || '').trim();
|
||
}
|
||
|
||
function initGitRepo(dir: string) {
|
||
runGit(dir, ['init']);
|
||
runGit(dir, ['config', 'user.email', 'test@test.com']);
|
||
runGit(dir, ['config', 'user.name', 'Test']);
|
||
fs.writeFileSync(path.join(dir, 'file.txt'), 'hello');
|
||
runGit(dir, ['add', '.']);
|
||
runGit(dir, ['commit', '-m', 'init']);
|
||
}
|
||
|
||
function createGlobalRegistry(homeDir: string, marker: 'both' | 'registry' | 'repos' = 'both') {
|
||
const registryDir = path.join(homeDir, '.gitnexus');
|
||
fs.mkdirSync(registryDir, { recursive: true });
|
||
if (marker === 'both' || marker === 'repos') {
|
||
fs.mkdirSync(path.join(registryDir, 'repos'), { recursive: true });
|
||
}
|
||
if (marker === 'both' || marker === 'registry') {
|
||
fs.writeFileSync(path.join(registryDir, 'registry.json'), JSON.stringify({ repos: [] }));
|
||
}
|
||
}
|
||
|
||
// createHookToolDir / hookEnv live in ../utils/hook-test-helpers so the antigravity
|
||
// e2e suite can reuse the same DB-owner-probe fakes.
|
||
|
||
// ─── Both hook files should exist ───────────────────────────────────
|
||
|
||
describe('Hook files exist', () => {
|
||
it('CJS hook exists', () => {
|
||
expect(fs.existsSync(CJS_HOOK)).toBe(true);
|
||
});
|
||
|
||
it('Plugin hook exists', () => {
|
||
expect(fs.existsSync(PLUGIN_HOOK)).toBe(true);
|
||
});
|
||
});
|
||
|
||
// ─── Source code regression: no shell: true ──────────────────────────
|
||
|
||
describe('Shell injection regression', () => {
|
||
for (const [label, hookPath] of [
|
||
['CJS', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
['Resolve CJS', RESOLVE_CJS],
|
||
['Resolve Plugin', RESOLVE_PLUGIN_CJS],
|
||
] as const) {
|
||
it(`${label} hook has no shell: true in spawnSync calls`, () => {
|
||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||
// Match spawnSync calls with shell option set to true or a variable
|
||
// Allowed: comments mentioning shell: true, string literals
|
||
const lines = source.split('\n');
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i];
|
||
// Skip comments and string literals
|
||
if (line.trim().startsWith('//') || line.trim().startsWith('*')) continue;
|
||
// Check for shell: true or shell: isWin in actual code
|
||
if (/shell:\s*(true|isWin)/.test(line)) {
|
||
throw new Error(`${label} hook line ${i + 1} has shell injection risk: ${line.trim()}`);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// ─── Source code regression: windowsHide:true on every spawn-family call ───
|
||
|
||
/**
|
||
* Every ``spawn`` / ``spawnSync`` / ``execFile`` / ``execFileSync`` /
|
||
* ``execFileAsync`` / ``execSync`` call in the hook layer **and the
|
||
* core/CLI/MCP/server source tree** must pass ``windowsHide: true``
|
||
* in its options object. Without it, Node's ``child_process`` module
|
||
* asks ``CreateProcess`` to use ``STARTF_USESHOWWINDOW`` with
|
||
* ``SW_SHOWDEFAULT`` and a black console window flashes onto the
|
||
* user's desktop for each call. Under active Claude Code / MCP /
|
||
* gitnexus-serve use that's a near-continuous stream of pop-ups —
|
||
* unusable in practice on Windows.
|
||
*
|
||
* ``windowsHide`` is a no-op on POSIX (silently dropped), so the
|
||
* flag is safe to require unconditionally. ``stdio: 'inherit'``
|
||
* callers (interactive editors etc.) are unaffected — windowsHide
|
||
* only suppresses NEW console allocation; an inherited parent
|
||
* console isn't touched.
|
||
*
|
||
* The check is source-level rather than behavioural because:
|
||
* - the flag's effect is observable only on Windows;
|
||
* - GitHub Actions runs vitest on Linux for these tests;
|
||
* - regressing this is easy (every new spawn site has to remember
|
||
* the flag), and a runtime check on a Windows-only CI leg would
|
||
* still let a PR land on the main branch first.
|
||
*
|
||
* The pre-existing fix at ``src/core/lbug/extension-loader.ts:96``
|
||
* established the convention. This test enforces it everywhere.
|
||
*/
|
||
describe('windowsHide regression', () => {
|
||
// Hook-layer files. Adding a new hook file MUST be reflected here.
|
||
const HOOK_FILES: Array<readonly [string, string]> = [
|
||
['gitnexus/hooks/claude/gitnexus-hook.cjs', CJS_HOOK],
|
||
['gitnexus/hooks/claude/resolve-analyze-cmd.cjs', RESOLVE_CJS],
|
||
['gitnexus-claude-plugin/hooks/resolve-analyze-cmd.cjs', RESOLVE_PLUGIN_CJS],
|
||
[
|
||
'gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs',
|
||
path.resolve(__dirname, '..', '..', 'hooks', 'antigravity', 'gitnexus-antigravity-hook.cjs'),
|
||
],
|
||
[
|
||
'gitnexus/hooks/claude/hook-db-lock-probe.cjs',
|
||
path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'hook-db-lock-probe.cjs'),
|
||
],
|
||
['gitnexus-claude-plugin/hooks/gitnexus-hook.js', PLUGIN_HOOK],
|
||
[
|
||
'gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs',
|
||
path.resolve(
|
||
__dirname,
|
||
'..',
|
||
'..',
|
||
'..',
|
||
'gitnexus-claude-plugin',
|
||
'hooks',
|
||
'hook-db-lock-probe.cjs',
|
||
),
|
||
],
|
||
[
|
||
'gitnexus-cursor-integration/hooks/gitnexus-hook.cjs',
|
||
path.resolve(
|
||
__dirname,
|
||
'..',
|
||
'..',
|
||
'..',
|
||
'gitnexus-cursor-integration',
|
||
'hooks',
|
||
'gitnexus-hook.cjs',
|
||
),
|
||
],
|
||
];
|
||
|
||
// Source-tree files. Every file that imports a spawn-family
|
||
// function from ``child_process`` belongs here. Discovered via
|
||
// grep -rn "from 'child_process'" -- gitnexus/src/
|
||
// plus the explicit ``await import('child_process')`` callers in
|
||
// local-backend.ts.
|
||
const SRC_FILES: Array<readonly [string, string]> = [
|
||
[
|
||
'gitnexus/src/cli/analyze.ts',
|
||
path.resolve(__dirname, '..', '..', 'src', 'cli', 'analyze.ts'),
|
||
],
|
||
['gitnexus/src/cli/setup.ts', path.resolve(__dirname, '..', '..', 'src', 'cli', 'setup.ts')],
|
||
['gitnexus/src/cli/wiki.ts', path.resolve(__dirname, '..', '..', 'src', 'cli', 'wiki.ts')],
|
||
[
|
||
'gitnexus/src/core/embeddings/onnxruntime-node-resolver.ts',
|
||
path.resolve(
|
||
__dirname,
|
||
'..',
|
||
'..',
|
||
'src',
|
||
'core',
|
||
'embeddings',
|
||
'onnxruntime-node-resolver.ts',
|
||
),
|
||
],
|
||
[
|
||
'gitnexus/src/core/git-staleness.ts',
|
||
path.resolve(__dirname, '..', '..', 'src', 'core', 'git-staleness.ts'),
|
||
],
|
||
[
|
||
'gitnexus/src/core/lbug/extension-loader.ts',
|
||
path.resolve(__dirname, '..', '..', 'src', 'core', 'lbug', 'extension-loader.ts'),
|
||
],
|
||
[
|
||
'gitnexus/src/core/wiki/cursor-client.ts',
|
||
path.resolve(__dirname, '..', '..', 'src', 'core', 'wiki', 'cursor-client.ts'),
|
||
],
|
||
[
|
||
'gitnexus/src/core/wiki/generator.ts',
|
||
path.resolve(__dirname, '..', '..', 'src', 'core', 'wiki', 'generator.ts'),
|
||
],
|
||
[
|
||
'gitnexus/src/mcp/local/local-backend.ts',
|
||
path.resolve(__dirname, '..', '..', 'src', 'mcp', 'local', 'local-backend.ts'),
|
||
],
|
||
[
|
||
'gitnexus/src/server/git-clone.ts',
|
||
path.resolve(__dirname, '..', '..', 'src', 'server', 'git-clone.ts'),
|
||
],
|
||
// New post-upstream-merge (May 2026 sync):
|
||
[
|
||
'gitnexus/src/storage/git.ts',
|
||
path.resolve(__dirname, '..', '..', 'src', 'storage', 'git.ts'),
|
||
],
|
||
];
|
||
|
||
/**
|
||
* Strip pure-comment lines so prose mentions of ``spawn`` /
|
||
* ``exec`` don't inflate the call count.
|
||
*/
|
||
function stripComments(source: string): string {
|
||
return source
|
||
.split('\n')
|
||
.filter((l) => {
|
||
const t = l.trim();
|
||
return !t.startsWith('//') && !t.startsWith('*') && !t.startsWith('/*');
|
||
})
|
||
.join('\n');
|
||
}
|
||
|
||
/**
|
||
* Count spawn-family invocations. The regex matches ``spawn(``,
|
||
* ``spawnSync(``, ``execFile(``, ``execFileSync(``,
|
||
* ``execFileAsync(``, ``execSync(`` as function calls — not
|
||
* destructures (``const { spawn } = ...``), not method calls
|
||
* (``.exec(``), not bare ``exec()`` (which collides with regex
|
||
* ``.exec()``; we explicitly drop it).
|
||
*/
|
||
function countSpawnCalls(codeSource: string): number {
|
||
const re =
|
||
/(^|[^a-zA-Z0-9_$.])(spawn|spawnSync|execFile|execFileSync|execFileAsync|execSync)\s*\(/gm;
|
||
let count = 0;
|
||
while (re.exec(codeSource) !== null) {
|
||
count++;
|
||
}
|
||
return count;
|
||
}
|
||
|
||
for (const [label, file] of [...HOOK_FILES, ...SRC_FILES]) {
|
||
it(`${label}: every spawn-family options object contains windowsHide: true`, () => {
|
||
// The file must exist — silent-skip would mask a deletion.
|
||
expect(fs.existsSync(file)).toBe(true);
|
||
const source = fs.readFileSync(file, 'utf-8');
|
||
const codeSource = stripComments(source);
|
||
|
||
const spawnCount = countSpawnCalls(codeSource);
|
||
const hideCount = (codeSource.match(/windowsHide\s*:\s*true/g) ?? []).length;
|
||
|
||
// Sanity: catch a refactor that accidentally deletes every
|
||
// spawn call (which would otherwise make the equality below
|
||
// trivially true at 0 == 0).
|
||
expect(spawnCount).toBeGreaterThan(0);
|
||
// One windowsHide per spawn-family call. We don't try to
|
||
// match brace structure — a same-count proxy is sufficient
|
||
// because every spawn site in these files passes an options
|
||
// object literal (no helper indirection).
|
||
expect(hideCount).toBe(spawnCount);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ─── Source code regression: .cmd extensions for Windows ─────────────
|
||
|
||
describe('Windows .cmd extension handling', () => {
|
||
for (const [label, hookPath] of [
|
||
['CJS', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
] as const) {
|
||
it(`${label} hook uses .cmd extensions for Windows npx`, () => {
|
||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||
expect(source).toContain('npx.cmd');
|
||
});
|
||
}
|
||
|
||
it('Plugin hook uses .cmd extension for Windows gitnexus binary', () => {
|
||
const source = fs.readFileSync(PLUGIN_HOOK, 'utf-8');
|
||
expect(source).toContain('gitnexus.cmd');
|
||
});
|
||
});
|
||
|
||
// ─── Source code regression: cwd validation ─────────────────────────
|
||
|
||
describe('cwd validation guards', () => {
|
||
for (const [label, hookPath] of [
|
||
['CJS', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
] as const) {
|
||
it(`${label} hook validates cwd is absolute path`, () => {
|
||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||
const cwdChecks = (source.match(/path\.isAbsolute\(cwd\)/g) || []).length;
|
||
// Should have at least 2 checks (one in PreToolUse, one in PostToolUse)
|
||
expect(cwdChecks).toBeGreaterThanOrEqual(2);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ─── Source code regression: sendHookResponse used consistently ──────
|
||
|
||
describe('sendHookResponse consistency', () => {
|
||
for (const [label, hookPath] of [
|
||
['CJS', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
] as const) {
|
||
it(`${label} hook uses sendHookResponse in both handlers`, () => {
|
||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||
const calls = (source.match(/sendHookResponse\(/g) || []).length;
|
||
// At least 3: definition + PreToolUse call + PostToolUse call
|
||
expect(calls).toBeGreaterThanOrEqual(3);
|
||
});
|
||
|
||
it(`${label} hook does not inline hookSpecificOutput JSON in handlers`, () => {
|
||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||
// Count inline hookSpecificOutput usage (should only be in sendHookResponse definition)
|
||
const inlineCount = (source.match(/hookSpecificOutput/g) || []).length;
|
||
// Exactly 1 occurrence: inside the sendHookResponse function body
|
||
expect(inlineCount).toBe(1);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ─── Source code regression: dispatch map pattern ────────────────────
|
||
|
||
describe('Dispatch map pattern', () => {
|
||
for (const [label, hookPath] of [
|
||
['CJS', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
] as const) {
|
||
it(`${label} hook uses dispatch map instead of if/else`, () => {
|
||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||
expect(source).toContain('const handlers = {');
|
||
expect(source).toContain('PreToolUse: handlePreToolUse');
|
||
expect(source).toContain('PostToolUse: handlePostToolUse');
|
||
// Should NOT have if/else dispatch in main()
|
||
expect(source).not.toMatch(/if\s*\(hookEvent\s*===\s*'PreToolUse'\)/);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ─── Source code regression: debug error truncation ──────────────────
|
||
|
||
describe('Debug error message truncation', () => {
|
||
for (const [label, hookPath] of [
|
||
['CJS', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
] as const) {
|
||
it(`${label} hook truncates error messages to 200 chars`, () => {
|
||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||
expect(source).toContain('.slice(0, 200)');
|
||
});
|
||
}
|
||
});
|
||
|
||
// ─── extractPattern regression (via source analysis) ────────────────
|
||
|
||
describe('extractPattern coverage', () => {
|
||
for (const [label, hookPath] of [
|
||
['CJS', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
] as const) {
|
||
it(`${label} hook extracts pattern from Grep tool input`, () => {
|
||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||
expect(source).toContain("toolName === 'Grep'");
|
||
expect(source).toContain('toolInput.pattern');
|
||
});
|
||
|
||
it(`${label} hook extracts pattern from Glob tool input`, () => {
|
||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||
expect(source).toContain("toolName === 'Glob'");
|
||
});
|
||
|
||
it(`${label} hook extracts pattern from Bash grep/rg commands`, () => {
|
||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||
expect(source).toMatch(/\\brg\\b.*\\bgrep\\b/);
|
||
});
|
||
|
||
it(`${label} hook rejects patterns shorter than 3 chars`, () => {
|
||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||
expect(source).toContain('cleaned.length >= 3');
|
||
});
|
||
}
|
||
});
|
||
|
||
// ─── PostToolUse: git mutation regex coverage ───────────────────────
|
||
|
||
describe('Git mutation regex', () => {
|
||
const GIT_REGEX = /\\bgit\\s\+\(commit\|merge\|rebase\|cherry-pick\|pull\)/;
|
||
|
||
for (const [label, hookPath] of [
|
||
['CJS', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
] as const) {
|
||
it(`${label} hook detects git commit`, () => {
|
||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||
expect(source).toContain('commit');
|
||
});
|
||
|
||
it(`${label} hook detects git merge`, () => {
|
||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||
expect(source).toContain('merge');
|
||
});
|
||
|
||
it(`${label} hook detects git rebase`, () => {
|
||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||
expect(source).toContain('rebase');
|
||
});
|
||
|
||
it(`${label} hook detects git cherry-pick`, () => {
|
||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||
expect(source).toContain('cherry-pick');
|
||
});
|
||
|
||
it(`${label} hook detects git pull`, () => {
|
||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||
// 'pull' in the regex alternation
|
||
expect(source).toMatch(/commit\|merge\|rebase\|cherry-pick\|pull/);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ─── Source code regression: PreToolUse concurrency guard (#1486) ──
|
||
|
||
describe('PreToolUse concurrency guard', () => {
|
||
for (const [label, hookPath, lockPath] of [
|
||
['CJS', CJS_HOOK, CJS_HOOK_LOCK],
|
||
['Plugin', PLUGIN_HOOK, PLUGIN_HOOK_LOCK],
|
||
] as const) {
|
||
it(`${label} hook loads acquireHookSlot helper`, () => {
|
||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||
expect(source).toContain('acquireHookSlot');
|
||
expect(source).toContain('hook-lock');
|
||
});
|
||
|
||
it(`${label} helper defines acquireHookSlot`, () => {
|
||
const source = fs.readFileSync(lockPath, 'utf-8');
|
||
expect(source).toContain('function acquireHookSlot');
|
||
expect(source).toContain('HOOK_LOCK_MAX_INFLIGHT');
|
||
});
|
||
|
||
it(`${label} hook calls acquireHookSlot in handlePreToolUse`, () => {
|
||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||
const preBody = source.slice(
|
||
source.indexOf('function handlePreToolUse'),
|
||
source.indexOf('function handlePostToolUse'),
|
||
);
|
||
expect(preBody).toContain('acquireHookSlot(');
|
||
expect(preBody).toMatch(/release\(\)/);
|
||
});
|
||
|
||
it(`${label} hook uses atomic fixed-name slot files (hard cap)`, () => {
|
||
// Regression for the TOCTOU soft-cap: an earlier revision counted
|
||
// entries then wrote a per-pid lock, which let simultaneous bursts
|
||
// exceed MAX_INFLIGHT. The hard-cap version writes to fixed-name
|
||
// slot-N.lock paths so O_CREAT|O_EXCL is atomic across processes.
|
||
const source = fs.readFileSync(lockPath, 'utf-8');
|
||
expect(source).toMatch(/slot-\$\{slot\}\.lock|`slot-/);
|
||
// And no longer reads the lock dir to count active hooks.
|
||
const slotFn = source.slice(
|
||
source.indexOf('function acquireHookSlot'),
|
||
source.indexOf('function', source.indexOf('function acquireHookSlot') + 1),
|
||
);
|
||
expect(slotFn).not.toContain('readdirSync');
|
||
});
|
||
|
||
it(`${label} hook fails closed when lock dir cannot be created`, () => {
|
||
// Regression: an earlier revision returned `() => {}` (truthy no-op) on
|
||
// mkdirSync failure, which left callers — `if (!release) return;` — to
|
||
// proceed unguarded and reintroduce the #1486 fan-out on read-only or
|
||
// cross-user `.gitnexus/` setups. The guard must fail closed (null).
|
||
const source = fs.readFileSync(lockPath, 'utf-8');
|
||
const slotFn = source.slice(
|
||
source.indexOf('function acquireHookSlot'),
|
||
source.indexOf('function', source.indexOf('function acquireHookSlot') + 1),
|
||
);
|
||
const mkdirCatch = slotFn.slice(
|
||
slotFn.indexOf('fs.mkdirSync(lockDir'),
|
||
slotFn.indexOf('const myPidStr'),
|
||
);
|
||
expect(mkdirCatch).toContain('return null');
|
||
expect(mkdirCatch).not.toMatch(/return\s*\(\s*\)\s*=>\s*\{\s*\}/);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ─── Integration: concurrency guard skips when slots are full ──────
|
||
|
||
// The burst tests spawn real child processes; under CI load a child can exit
|
||
// before printing its decision even though the slot hard cap still holds.
|
||
describe('PreToolUse concurrency guard (integration)', { retry: 1 }, () => {
|
||
for (const [label, hookPath] of [
|
||
['CJS', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
] as const) {
|
||
it(`${label}: hook exits silently when all MAX_INFLIGHT slots hold live pids`, async () => {
|
||
const { spawn } = await import('child_process');
|
||
const lockDir = path.join(gitNexusDir, '.hook-locks');
|
||
fs.mkdirSync(lockDir, { recursive: true });
|
||
|
||
// Spawn 3 long-sleeping node child processes to use as live PIDs.
|
||
const sleepers = [0, 1, 2].map(() =>
|
||
spawn(process.execPath, ['-e', 'setTimeout(()=>{},60000)'], {
|
||
stdio: 'ignore',
|
||
detached: false,
|
||
}),
|
||
);
|
||
const writtenLocks: string[] = [];
|
||
try {
|
||
for (let i = 0; i < sleepers.length; i++) {
|
||
// Slot files are named slot-N.lock; content is the owning PID.
|
||
const p = path.join(lockDir, `slot-${i}.lock`);
|
||
fs.writeFileSync(p, String(sleepers[i].pid));
|
||
writtenLocks.push(p);
|
||
}
|
||
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
});
|
||
|
||
expect(result.stdout.trim()).toBe('');
|
||
// Sentinel slot files survive; the hook bailed before claiming any of them.
|
||
for (let i = 0; i < sleepers.length; i++) {
|
||
const p = path.join(lockDir, `slot-${i}.lock`);
|
||
expect(fs.existsSync(p)).toBe(true);
|
||
// Owner unchanged.
|
||
expect(fs.readFileSync(p, 'utf-8').trim()).toBe(String(sleepers[i].pid));
|
||
}
|
||
} finally {
|
||
for (const child of sleepers) {
|
||
try {
|
||
child.kill();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
for (const p of writtenLocks) {
|
||
try {
|
||
fs.unlinkSync(p);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
try {
|
||
fs.rmdirSync(lockDir);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
});
|
||
|
||
it(`${label}: hook reclaims a slot held by a dead pid`, () => {
|
||
const lockDir = path.join(gitNexusDir, '.hook-locks');
|
||
fs.mkdirSync(lockDir, { recursive: true });
|
||
// PID 1 exists on every POSIX system (init); on Windows process.kill(1,0)
|
||
// throws. Use a definitely-dead PID instead: a very large number unlikely
|
||
// to be assigned.
|
||
const deadPid = 2_147_483_640;
|
||
const stalePath = path.join(lockDir, 'slot-0.lock');
|
||
try {
|
||
fs.writeFileSync(stalePath, String(deadPid));
|
||
expect(fs.readFileSync(stalePath, 'utf-8').trim()).toBe(String(deadPid));
|
||
|
||
runHook(hookPath, {
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
});
|
||
|
||
// The hook reclaimed and then released slot-0 — either the file is
|
||
// gone (released) or its content is something other than the dead PID.
|
||
if (fs.existsSync(stalePath)) {
|
||
expect(fs.readFileSync(stalePath, 'utf-8').trim()).not.toBe(String(deadPid));
|
||
}
|
||
} finally {
|
||
try {
|
||
fs.unlinkSync(stalePath);
|
||
} catch {
|
||
/* already pruned */
|
||
}
|
||
try {
|
||
fs.rmdirSync(lockDir);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
});
|
||
|
||
it(`${label}: hook does not exceed MAX_INFLIGHT under simultaneous bursts (hard cap)`, async () => {
|
||
// Spawn many hook processes concurrently and assert that at most
|
||
// MAX_INFLIGHT (3) slot files end up populated by live pids. The
|
||
// O_CREAT|O_EXCL slot scheme makes this a hard cap, not the soft cap
|
||
// that the count-then-claim approach gives.
|
||
const { spawn } = await import('child_process');
|
||
const lockDir = path.join(gitNexusDir, '.hook-locks');
|
||
// Clean any leftover slot files.
|
||
try {
|
||
for (const f of fs.readdirSync(lockDir)) fs.unlinkSync(path.join(lockDir, f));
|
||
} catch {
|
||
/* dir may not exist yet */
|
||
}
|
||
fs.mkdirSync(lockDir, { recursive: true });
|
||
|
||
// We use child workers that just claim a slot via the same algorithm
|
||
// and then sleep, so we can observe the on-disk state under contention
|
||
// without spawning the real gitnexus augment CLI.
|
||
const claimerScript = `
|
||
const fs = require('fs'); const path = require('path');
|
||
const lockDir = ${JSON.stringify(lockDir)};
|
||
const MAX = 3;
|
||
const STALE = 30000;
|
||
const myPid = String(process.pid);
|
||
function tryAcquire() {
|
||
for (let slot = 0; slot < MAX; slot++) {
|
||
const p = path.join(lockDir, 'slot-' + slot + '.lock');
|
||
for (let a = 0; a < 2; a++) {
|
||
try { fs.writeFileSync(p, myPid, { flag: 'wx' }); return p; }
|
||
catch {
|
||
let stat; try { stat = fs.statSync(p); } catch { continue; }
|
||
let live = false;
|
||
try {
|
||
const s = fs.readFileSync(p, 'utf-8').trim();
|
||
if (s === '') live = true;
|
||
else { const o = Number.parseInt(s, 10);
|
||
if (Number.isFinite(o) && o > 0) { try { process.kill(o, 0); live = true; } catch {} }
|
||
}
|
||
} catch {}
|
||
if (live && Date.now() - stat.mtimeMs > STALE) live = false;
|
||
if (live) break;
|
||
try { fs.unlinkSync(p); } catch {}
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
const claimed = tryAcquire();
|
||
if (claimed) {
|
||
process.stdout.write('CLAIMED:' + claimed + '\\n');
|
||
setTimeout(() => {}, 5000);
|
||
} else {
|
||
process.stdout.write('SKIPPED\\n');
|
||
}
|
||
`;
|
||
|
||
const N = 10;
|
||
const claimers = Array.from({ length: N }, () =>
|
||
spawn(process.execPath, ['-e', claimerScript], {
|
||
stdio: ['ignore', 'pipe', 'ignore'],
|
||
detached: false,
|
||
}),
|
||
);
|
||
try {
|
||
// Wait until every claimer has printed its decision.
|
||
const decisions = await Promise.all(
|
||
claimers.map(
|
||
(c) =>
|
||
new Promise<string>((resolve) => {
|
||
let buf = '';
|
||
c.stdout!.on('data', (d) => {
|
||
buf += d.toString();
|
||
if (buf.includes('\n')) resolve(buf.split('\n')[0]);
|
||
});
|
||
c.on('exit', () => resolve(buf.split('\n')[0] || 'EXIT'));
|
||
}),
|
||
),
|
||
);
|
||
const claimedCount = decisions.filter((d) => d.startsWith('CLAIMED:')).length;
|
||
const skippedCount = decisions.filter((d) => d === 'SKIPPED').length;
|
||
|
||
// HARD CAP: never more than 3 winners, regardless of how many bursts.
|
||
expect(claimedCount).toBeLessThanOrEqual(3);
|
||
// And the remainder must have all explicitly skipped.
|
||
expect(claimedCount + skippedCount).toBe(N);
|
||
|
||
// On-disk state matches.
|
||
const liveSlots = fs
|
||
.readdirSync(lockDir)
|
||
.filter((f) => /^slot-\d+\.lock$/.test(f))
|
||
.filter((f) => {
|
||
try {
|
||
const o = Number.parseInt(fs.readFileSync(path.join(lockDir, f), 'utf-8').trim(), 10);
|
||
return Number.isFinite(o) && o > 0;
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
expect(liveSlots.length).toBeLessThanOrEqual(3);
|
||
} finally {
|
||
for (const c of claimers) {
|
||
try {
|
||
c.kill();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
try {
|
||
for (const f of fs.readdirSync(lockDir)) fs.unlinkSync(path.join(lockDir, f));
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
try {
|
||
fs.rmdirSync(lockDir);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// ─── Source: cross-platform DB lock probe module (#1493) ─────────────
|
||
|
||
describe('Cross-platform DB lock probe (source)', () => {
|
||
for (const [label, hookPath, probePath] of [
|
||
['CJS', CJS_HOOK, CJS_HOOK_DB_PROBE],
|
||
['Plugin', PLUGIN_HOOK, PLUGIN_HOOK_DB_PROBE],
|
||
] as const) {
|
||
it(`${label} probe file exists`, () => {
|
||
expect(fs.existsSync(probePath)).toBe(true);
|
||
});
|
||
|
||
it(`${label} hook requires hook-db-lock-probe.cjs`, () => {
|
||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||
expect(source).toContain("require('./hook-db-lock-probe.cjs')");
|
||
});
|
||
|
||
it(`${label} probe covers Linux /proc, Unix lsof, and Windows Restart Manager`, () => {
|
||
const p = fs.readFileSync(probePath, 'utf-8');
|
||
expect(p).toContain('win-rm-list-json.ps1');
|
||
expect(p).toContain('/proc/');
|
||
expect(p).toContain('linuxProcScanFindGitNexusServer');
|
||
expect(p).toContain('unixLsofPsFindGitNexusServer');
|
||
expect(p).toContain('hasGitNexusServerOwnerWindows');
|
||
expect(p).toContain('GITNEXUS_HOOK_LSOF_PATH');
|
||
expect(p).toContain('GITNEXUS_HOOK_POWERSHELL_PATH');
|
||
expect(p).toContain('GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS');
|
||
// #2163: lsof/ps orphan containment via a self-tested coreutils
|
||
// timeout/gtimeout wrapper.
|
||
expect(p).toContain('GITNEXUS_HOOK_TIMEOUT_PATH');
|
||
expect(p).toContain('resolveUnixGuardTimeout');
|
||
});
|
||
}
|
||
|
||
// T5 (#2163): the two probe copies were only kept in sync by convention
|
||
// (setup.ts copies the canonical gitnexus/hooks/claude/ file; the plugin
|
||
// ships its own). Enforce byte-parity in CI, mirroring the
|
||
// resolve-analyze-cmd.cjs parity test. `.gitattributes` pins `eol=lf`
|
||
// repo-wide, so the byte comparison is safe on the Windows lane too.
|
||
it('keeps the two hook-db-lock-probe.cjs copies byte-identical', () => {
|
||
expect(fs.readFileSync(CJS_HOOK_DB_PROBE, 'utf-8')).toBe(
|
||
fs.readFileSync(PLUGIN_HOOK_DB_PROBE, 'utf-8'),
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── Source: hook slot must gate the DB-owner probe (#2163) ──────────
|
||
|
||
describe('Hook slot gates the DB-owner probe (source order, #2163)', () => {
|
||
const ANTIGRAVITY_HOOK = path.resolve(
|
||
__dirname,
|
||
'..',
|
||
'..',
|
||
'hooks',
|
||
'antigravity',
|
||
'gitnexus-antigravity-hook.cjs',
|
||
);
|
||
|
||
// T1: pin cheap guards → acquireHookSlot → probe. The probe spawns lsof/ps,
|
||
// so it must sit BEHIND the per-repo slot cap; and the acquire must stay
|
||
// AFTER the cheap gating (extractPattern), or every tool call churns slot
|
||
// files. The antigravity adapter splits the cheap gating (extractPattern in
|
||
// buildAfterToolContext) from probe+augment (runAugment), so its slice
|
||
// spans both functions to express the same call-order contract.
|
||
for (const [label, hookPath, sliceStart, sliceEnd] of [
|
||
['CJS', CJS_HOOK, 'function handlePreToolUse', 'function handlePostToolUse'],
|
||
['Plugin', PLUGIN_HOOK, 'function handlePreToolUse', 'function handlePostToolUse'],
|
||
[
|
||
'Antigravity',
|
||
ANTIGRAVITY_HOOK,
|
||
'function buildAfterToolContext',
|
||
'function buildStaleIndexHint',
|
||
],
|
||
] as const) {
|
||
it(`${label}: extractPattern → acquireHookSlot → hasGitNexusServerOwner`, () => {
|
||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||
const start = source.indexOf(sliceStart);
|
||
const end = source.indexOf(sliceEnd);
|
||
expect(start).toBeGreaterThanOrEqual(0);
|
||
expect(end).toBeGreaterThan(start);
|
||
const slice = source.slice(start, end);
|
||
const patternIdx = slice.indexOf('extractPattern(');
|
||
const acquireIdx = slice.indexOf('acquireHookSlot(');
|
||
const probeIdx = slice.indexOf('hasGitNexusServerOwner(');
|
||
expect(patternIdx).toBeGreaterThanOrEqual(0);
|
||
expect(acquireIdx).toBeGreaterThan(patternIdx);
|
||
expect(probeIdx).toBeGreaterThan(acquireIdx);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ─── Behavior: slot-gated probe + wrapper-reaped orphans (#2163) ─────
|
||
|
||
describe.skipIf(process.platform === 'win32')(
|
||
'DB-owner probe is gated behind the hook slot (behavior, #2163)',
|
||
() => {
|
||
for (const [label, hookPath] of [
|
||
['CJS', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
] as const) {
|
||
it(`${label}: when all slots are full, the lsof probe never runs`, async () => {
|
||
const { spawn } = await import('child_process');
|
||
const lockDir = path.join(gitNexusDir, '.hook-locks');
|
||
fs.mkdirSync(lockDir, { recursive: true });
|
||
// REQUIRED: the probe's first guard is
|
||
// `if (!fs.existsSync(dbPath)) return false;` — without a real lbug
|
||
// file the probe never reaches lsof even before the fix and this
|
||
// test would pass vacuously.
|
||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||
fs.writeFileSync(lbugPath, '');
|
||
const lsofMarkerPath = path.join(os.tmpdir(), `gn-hook-slotgate-${process.pid}-${label}`);
|
||
fs.rmSync(lsofMarkerPath, { force: true });
|
||
const binDir = createHookToolDir({
|
||
lsofMarkerPath,
|
||
lsofOutput: '12345\n',
|
||
psOutput: 'node /tmp/node_modules/.bin/gitnexus mcp\n',
|
||
});
|
||
|
||
// Fill all 3 slots with live sleeper PIDs (same pattern as the
|
||
// concurrency-guard integration tests above).
|
||
const sleepers = [0, 1, 2].map(() =>
|
||
spawn(process.execPath, ['-e', 'setTimeout(()=>{},60000)'], {
|
||
stdio: 'ignore',
|
||
detached: false,
|
||
}),
|
||
);
|
||
const writtenLocks: string[] = [];
|
||
try {
|
||
for (let i = 0; i < sleepers.length; i++) {
|
||
const p = path.join(lockDir, `slot-${i}.lock`);
|
||
fs.writeFileSync(p, String(sleepers[i].pid));
|
||
writtenLocks.push(p);
|
||
}
|
||
|
||
const result = runHook(
|
||
hookPath,
|
||
{
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{
|
||
env: {
|
||
...hookEnv(binDir),
|
||
// The slot gate rejects this invocation before the probe runs
|
||
// at all, so this budget never actually bounds a scan — it is
|
||
// set low only to keep the test fast in the (asserted-absent)
|
||
// case the gate ever regressed and let the probe through.
|
||
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '1',
|
||
},
|
||
},
|
||
);
|
||
|
||
expect(result.stdout.trim()).toBe('');
|
||
// Core assertion: the probe (and therefore its lsof child) never
|
||
// ran — the slot gate now sits in front of it. Before the fix the
|
||
// probe ran un-gated and the marker existed.
|
||
expect(fs.existsSync(lsofMarkerPath)).toBe(false);
|
||
} finally {
|
||
for (const child of sleepers) {
|
||
try {
|
||
child.kill();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
for (const p of writtenLocks) {
|
||
try {
|
||
fs.unlinkSync(p);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
try {
|
||
fs.rmdirSync(lockDir);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
fs.rmSync(lbugPath, { force: true });
|
||
fs.rmSync(lsofMarkerPath, { force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
}
|
||
|
||
// F5-3 (#2165 review): behavior-level slot-gate coverage for the
|
||
// ANTIGRAVITY adapter (the loop above only covers CJS/Plugin; the
|
||
// antigravity copy was pinned at source level only). The source adapter
|
||
// requires sibling helpers that live in hooks/claude/ — it is designed to
|
||
// be installed by copy (see the antigravity e2e suite) — so stage adapter
|
||
// + helpers into a temp dir and spawn that copy directly.
|
||
it('Antigravity: when all slots are full, the lsof probe never runs', async () => {
|
||
const { spawn } = await import('child_process');
|
||
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-antigravity-stage-'));
|
||
const antigravitySrc = path.resolve(
|
||
__dirname,
|
||
'..',
|
||
'..',
|
||
'hooks',
|
||
'antigravity',
|
||
'gitnexus-antigravity-hook.cjs',
|
||
);
|
||
const claudeHooksDir = path.resolve(__dirname, '..', '..', 'hooks', 'claude');
|
||
const stagedHook = path.join(stageDir, 'gitnexus-antigravity-hook.cjs');
|
||
fs.copyFileSync(antigravitySrc, stagedHook);
|
||
for (const helper of [
|
||
'hook-lock.cjs',
|
||
'hook-db-lock-probe.cjs',
|
||
'resolve-analyze-cmd.cjs',
|
||
'win-rm-list-json.ps1',
|
||
]) {
|
||
fs.copyFileSync(path.join(claudeHooksDir, helper), path.join(stageDir, helper));
|
||
}
|
||
|
||
const lockDir = path.join(gitNexusDir, '.hook-locks');
|
||
fs.mkdirSync(lockDir, { recursive: true });
|
||
// REQUIRED: without a real lbug file the probe never reaches lsof even
|
||
// before the fix and this test would pass vacuously.
|
||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||
fs.writeFileSync(lbugPath, '');
|
||
const lsofMarkerPath = path.join(os.tmpdir(), `gn-hook-slotgate-${process.pid}-antigravity`);
|
||
fs.rmSync(lsofMarkerPath, { force: true });
|
||
const binDir = createHookToolDir({
|
||
lsofMarkerPath,
|
||
lsofOutput: '12345\n',
|
||
psOutput: 'node /tmp/node_modules/.bin/gitnexus mcp\n',
|
||
});
|
||
|
||
const sleepers = [0, 1, 2].map(() =>
|
||
spawn(process.execPath, ['-e', 'setTimeout(()=>{},60000)'], {
|
||
stdio: 'ignore',
|
||
detached: false,
|
||
}),
|
||
);
|
||
const writtenLocks: string[] = [];
|
||
try {
|
||
for (let i = 0; i < sleepers.length; i++) {
|
||
const p = path.join(lockDir, `slot-${i}.lock`);
|
||
fs.writeFileSync(p, String(sleepers[i].pid));
|
||
writtenLocks.push(p);
|
||
}
|
||
|
||
const result = runHook(
|
||
stagedHook,
|
||
{
|
||
hook_event_name: 'AfterTool',
|
||
tool_name: 'search_file_content',
|
||
tool_input: { pattern: 'validateUser' },
|
||
tool_response: { llmContent: '...' },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{
|
||
env: {
|
||
...hookEnv(binDir),
|
||
// '1', NOT '0' — see the CJS/Plugin slot-gate test above.
|
||
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '1',
|
||
},
|
||
},
|
||
);
|
||
|
||
// Guards against a vacuous pass: if the staged copy crashes (e.g. a
|
||
// future sibling require missing from the staging list), stdout is
|
||
// empty and the marker absent for the wrong reason.
|
||
expect(result.status).toBe(0);
|
||
expect(result.stdout.trim()).toBe('');
|
||
// Core assertion: the probe (and therefore its lsof child) never ran —
|
||
// runAugment bailed at the slot gate before hasGitNexusServerOwner.
|
||
expect(fs.existsSync(lsofMarkerPath)).toBe(false);
|
||
} finally {
|
||
for (const child of sleepers) {
|
||
try {
|
||
child.kill();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
for (const p of writtenLocks) {
|
||
try {
|
||
fs.unlinkSync(p);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
try {
|
||
fs.rmdirSync(lockDir);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
fs.rmSync(lbugPath, { force: true });
|
||
fs.rmSync(lsofMarkerPath, { force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
fs.rmSync(stageDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
},
|
||
);
|
||
|
||
// ─── #2180: the probe no longer spawns lsof on Linux ───────────────
|
||
//
|
||
// The 'Orphaned lsof is reaped by the timeout wrapper (#2163)' suite that
|
||
// lived here (T3 + the env-guard-points-at-a-directory fall-through test)
|
||
// drove the Linux probe to spawn a SIGTERM-immune fake lsof and asserted the
|
||
// coreutils `timeout -k 1` wrapper reaped it after the hook was SIGKILLed.
|
||
// #2180 replaced the O(procs×fds) scan + lsof fallback with a pure cmdline-
|
||
// first procfs scan and DELETED the Linux lsof leg entirely, so the probe can
|
||
// no longer create an lsof orphan on Linux by construction — there is nothing
|
||
// left for those tests to exercise. The wrapper-reaping mechanism they pinned
|
||
// is still covered where it still applies: the augment CLI child (the
|
||
// direct-exec and npx-grandchild reaping suites below) and the macOS/other-
|
||
// Unix lsof+ps path (the `Ladybug DB owner guard` suite, now relaned off
|
||
// Linux). The env-guard fall-through self-test behaviour is still pinned by
|
||
// the bad-wrapper / dir-guard guard-resolution tests in that relaned suite.
|
||
|
||
// ─── Behavior: SIGKILLed hook cannot strand the augment CLI (#2163 f-up) ──
|
||
|
||
describe.skipIf(process.platform !== 'linux')(
|
||
'Orphaned augment CLI is reaped by the timeout wrapper (#2163 follow-up)',
|
||
() => {
|
||
// Same incident mechanism as the lsof reaping suite above, one layer up:
|
||
// the augment CLI is the longest-lived hook child (7s local / 12s npx
|
||
// inner budgets), so a hook SIGKILLed mid-augment used to strand it with
|
||
// nothing left to signal it. These tests pin GITNEXUS_HOOK_CLI_PATH, so
|
||
// they exercise the DIRECT-EXEC branch only (the CLI is the guard's
|
||
// direct child): the fake CLI here is SIGTERM-immune and sleeps 30s;
|
||
// with the wrap in place the guard SIGTERMs it at 8s (= ceil(7000/1000)
|
||
// +1) and the `-k` escalation SIGKILLs it 1s later, so it must be gone
|
||
// well inside the 12s poll window. (The npx branch reaps differently —
|
||
// `-s KILL` group-kills at budget because the CLI is a grandchild there;
|
||
// see the staged npx suite below.) With runGitNexusCli's wrap reverted,
|
||
// nothing can reap it and the poll times out → red. The antigravity
|
||
// adapter shares the identical runGitNexusCli shape and is pinned at
|
||
// source level (see 'Augment CLI guard wrap (source)').
|
||
for (const [label, hookPath] of [
|
||
['CJS', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
] as const) {
|
||
it(`${label}: SIGKILLed hook leaves no immortal augment CLI child`, async () => {
|
||
// Guard-availability precheck — see resolveHostGuardForReapingTests.
|
||
expect(resolveHostGuardForReapingTests(), GUARD_PRECHECK_MSG).not.toBeNull();
|
||
const { spawn } = await import('child_process');
|
||
// REQUIRED: a real lbug file means the probe runs. #2180 removed the
|
||
// Linux lsof fallback, so we route the probe at an EMPTY fake /proc
|
||
// (no gitnexus server holding the fd → not-owned) so the augment runs
|
||
// through the same probe-then-spawn flow as production. (Pre-#2180 this
|
||
// used GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS:'1' to fall through to a fake
|
||
// lsof; that path no longer exists on Linux — a '1' budget now fails
|
||
// CLOSED and would skip the augment entirely.)
|
||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||
fs.writeFileSync(lbugPath, '');
|
||
const emptyProcRoot = createFakeProcRoot([]);
|
||
const pidFile = path.join(os.tmpdir(), `gn-hook-clipid-${process.pid}-${label}`);
|
||
fs.rmSync(pidFile, { force: true });
|
||
const binDir = createHookToolDir({
|
||
gitnexusPidFile: pidFile,
|
||
gitnexusSleepMs: 30000,
|
||
gitnexusIgnoreSigterm: true,
|
||
lsofOutput: '',
|
||
psOutput: '',
|
||
});
|
||
let cliPid = 0;
|
||
let hookChild: ReturnType<typeof spawn> | null = null;
|
||
|
||
const isFakeCliAlive = () => {
|
||
try {
|
||
process.kill(cliPid, 0);
|
||
} catch {
|
||
return false; // ESRCH — reaped
|
||
}
|
||
// PID-reuse guard: only count it alive while the cmdline still
|
||
// points at our fake CLI.
|
||
try {
|
||
return fs.readFileSync(`/proc/${cliPid}/cmdline`, 'utf-8').includes(binDir);
|
||
} catch {
|
||
return false;
|
||
}
|
||
};
|
||
|
||
try {
|
||
hookChild = spawn(process.execPath, [hookPath], {
|
||
stdio: ['pipe', 'ignore', 'ignore'],
|
||
env: {
|
||
...hookEnv(binDir),
|
||
// #2180: empty fake /proc → scan completes as not-owned → augment
|
||
// runs (the path under test). Generous budget so the scan never
|
||
// times out and fails closed.
|
||
GITNEXUS_HOOK_PROC_ROOT: emptyProcRoot,
|
||
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '5000',
|
||
// Hermeticity: a dev-shell GITNEXUS_HOOK_TIMEOUT_PATH=disabled
|
||
// would unwrap the CLI and fake-red this test. Empty string
|
||
// falls through to the built-in candidates (the path under test).
|
||
GITNEXUS_HOOK_TIMEOUT_PATH: '',
|
||
},
|
||
});
|
||
hookChild.stdin!.end(
|
||
JSON.stringify({
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
}),
|
||
);
|
||
|
||
// The fake CLI writes its PID as its FIRST statement; poll tightly.
|
||
const spawnDeadline = Date.now() + 8000;
|
||
while (Date.now() < spawnDeadline) {
|
||
try {
|
||
const raw = fs.readFileSync(pidFile, 'utf-8').trim();
|
||
if (raw) {
|
||
cliPid = Number.parseInt(raw, 10);
|
||
break;
|
||
}
|
||
} catch {
|
||
/* not written yet */
|
||
}
|
||
await new Promise((r) => setTimeout(r, 10));
|
||
}
|
||
expect(cliPid).toBeGreaterThan(0);
|
||
|
||
// Kill the hook while its augment CLI child is alive.
|
||
hookChild.kill('SIGKILL');
|
||
|
||
// Wrapper budget for the 7000ms call site is 8s, plus 1s `-k`
|
||
// grace — poll past that with margin, far short of the 30s sleep.
|
||
const reapDeadline = Date.now() + 12000;
|
||
let alive = isFakeCliAlive();
|
||
while (alive && Date.now() < reapDeadline) {
|
||
await new Promise((r) => setTimeout(r, 100));
|
||
alive = isFakeCliAlive();
|
||
}
|
||
expect(alive).toBe(false);
|
||
} finally {
|
||
// PID-reuse guard (#2169 review): re-run the detection loop's
|
||
// /proc/<pid>/cmdline identity check before the cleanup SIGKILL,
|
||
// so a PID already reaped and recycled by the OS is never
|
||
// signalled.
|
||
if (cliPid > 0 && isFakeCliAlive()) {
|
||
try {
|
||
process.kill(cliPid, 'SIGKILL');
|
||
} catch {
|
||
/* already gone */
|
||
}
|
||
}
|
||
try {
|
||
hookChild?.kill('SIGKILL');
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
// The hook claims a slot before probing; it died holding it.
|
||
const lockDir = path.join(gitNexusDir, '.hook-locks');
|
||
try {
|
||
for (const f of fs.readdirSync(lockDir)) fs.unlinkSync(path.join(lockDir, f));
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
try {
|
||
fs.rmdirSync(lockDir);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
fs.rmSync(lbugPath, { force: true });
|
||
fs.rmSync(pidFile, { force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
fs.rmSync(emptyProcRoot, { recursive: true, force: true });
|
||
}
|
||
}, 30000);
|
||
}
|
||
},
|
||
);
|
||
|
||
// ─── Behavior: npx branch — SIGKILLed hook cannot strand the CLI grandchild ──
|
||
|
||
describe.skipIf(process.platform !== 'linux')(
|
||
'Orphaned npx-branch CLI grandchild is reaped by the -s KILL wrapper (#2163 follow-up)',
|
||
() => {
|
||
// The npx branch has a DEEPER topology than the direct-exec suite above:
|
||
// guard → npx → CLI, so the CLI is the guard's GRANDCHILD. Under the
|
||
// TERM-first `-k 1` guard the budget's group SIGTERM kills the obedient
|
||
// npx parent; `timeout` reaps its direct child and exits IMMEDIATELY, so
|
||
// its `-k` SIGKILL never fires — and a SIGTERM-immune CLI grandchild
|
||
// survives unbounded (reproduced on coreutils 9.x). The `-s KILL`
|
||
// wrapped arm instead SIGKILLs the whole process group at budget
|
||
// (13s = ceil((7000+5000)/1000)+1 here), which nothing can ignore.
|
||
// Reverting the npx arm to plain `-k 1` TERM-first makes this test red.
|
||
//
|
||
// Topology notes: the hook is STAGED into a bare temp dir together with
|
||
// its sibling helpers (the install-shaped copy, like the antigravity e2e
|
||
// suite uses), so resolveCliPath() finds no local dist/ and no
|
||
// resolvable gitnexus package; with GITNEXUS_HOOK_CLI_PATH cleared the
|
||
// npx fallback branch is the one that runs. A fake `npx` injected on
|
||
// PATH then spawns the SIGTERM-immune fake CLI as its own child and
|
||
// waits on it, mirroring the real npx process tree.
|
||
it('CJS (staged): SIGKILLed hook leaves no immortal CLI grandchild behind npx', async () => {
|
||
// Guard-availability precheck — see resolveHostGuardForReapingTests.
|
||
expect(resolveHostGuardForReapingTests(), GUARD_PRECHECK_MSG).not.toBeNull();
|
||
const { spawn } = await import('child_process');
|
||
// REQUIRED: a real lbug file means the probe runs. #2180 removed the
|
||
// Linux lsof fallback, so we route the probe at an EMPTY fake /proc
|
||
// (not-owned) so the augment runs through the same probe-then-spawn flow
|
||
// as production. (Pre-#2180 this used BUDGET_MS:'1' to fall through to a
|
||
// fake lsof; that path no longer exists on Linux.)
|
||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||
fs.writeFileSync(lbugPath, '');
|
||
const emptyProcRoot = createFakeProcRoot([]);
|
||
const pidFile = path.join(os.tmpdir(), `gn-hook-npxclipid-${process.pid}`);
|
||
fs.rmSync(pidFile, { force: true });
|
||
// Route self-proof (#2169 review): written by the fake npx as its first
|
||
// statement, so the test fails loudly if a future resolveCliPath /
|
||
// hookEnv change silently re-routes the augment to the direct arm.
|
||
const npxMarkerPath = path.join(os.tmpdir(), `gn-hook-npxmarker-${process.pid}`);
|
||
fs.rmSync(npxMarkerPath, { force: true });
|
||
const binDir = createHookToolDir({
|
||
gitnexusPidFile: pidFile,
|
||
gitnexusSleepMs: 30000,
|
||
gitnexusIgnoreSigterm: true,
|
||
lsofOutput: '',
|
||
psOutput: '',
|
||
});
|
||
// Fake npx: spawns the fake CLI as the guard's grandchild and waits on
|
||
// it like real npx; npx itself stays SIGTERM-obedient (Node default).
|
||
fs.writeFileSync(
|
||
path.join(binDir, 'npx'),
|
||
`#!/usr/bin/env node\n` +
|
||
`require('fs').writeFileSync(${JSON.stringify(npxMarkerPath)}, String(process.pid));\n` +
|
||
`const { spawn } = require('child_process');\n` +
|
||
`const child = spawn(process.execPath, [${JSON.stringify(
|
||
path.join(binDir, 'gitnexus-cli.js'),
|
||
)}], { stdio: 'ignore' });\n` +
|
||
`child.on('exit', (code) => process.exit(code === null ? 1 : code));\n`,
|
||
{ mode: 0o755 },
|
||
);
|
||
// Stage the hook + its sibling helpers into a bare dir with no dist/
|
||
// and no reachable node_modules/gitnexus, so resolveCliPath() → ''.
|
||
const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-staged-hook-'));
|
||
const hookSrcDir = path.dirname(CJS_HOOK);
|
||
for (const f of [
|
||
'gitnexus-hook.cjs',
|
||
'hook-lock.cjs',
|
||
'hook-db-lock-probe.cjs',
|
||
'resolve-analyze-cmd.cjs',
|
||
]) {
|
||
fs.copyFileSync(path.join(hookSrcDir, f), path.join(stagedDir, f));
|
||
}
|
||
const stagedHook = path.join(stagedDir, 'gitnexus-hook.cjs');
|
||
let cliPid = 0;
|
||
let hookChild: ReturnType<typeof spawn> | null = null;
|
||
|
||
const isFakeCliAlive = () => {
|
||
try {
|
||
process.kill(cliPid, 0);
|
||
} catch {
|
||
return false; // ESRCH — reaped
|
||
}
|
||
// PID-reuse guard: only count it alive while the cmdline still
|
||
// points at our fake CLI.
|
||
try {
|
||
return fs.readFileSync(`/proc/${cliPid}/cmdline`, 'utf-8').includes(binDir);
|
||
} catch {
|
||
return false;
|
||
}
|
||
};
|
||
|
||
try {
|
||
hookChild = spawn(process.execPath, [stagedHook], {
|
||
stdio: ['pipe', 'ignore', 'ignore'],
|
||
env: {
|
||
...hookEnv(binDir),
|
||
// Force the npx fallback: no CLI-path override (empty string
|
||
// fails resolveCliPath's trim check), and nothing for the staged
|
||
// copy's require.resolve to find via NODE_PATH.
|
||
GITNEXUS_HOOK_CLI_PATH: '',
|
||
NODE_PATH: '',
|
||
// #2180: empty fake /proc → not-owned → augment runs. Generous
|
||
// budget so the scan completes rather than failing closed.
|
||
GITNEXUS_HOOK_PROC_ROOT: emptyProcRoot,
|
||
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '5000',
|
||
// Hermeticity: fall through to the built-in guard candidates.
|
||
GITNEXUS_HOOK_TIMEOUT_PATH: '',
|
||
},
|
||
});
|
||
hookChild.stdin!.end(
|
||
JSON.stringify({
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
}),
|
||
);
|
||
|
||
// The fake CLI writes its PID as its FIRST statement; poll tightly.
|
||
const spawnDeadline = Date.now() + 8000;
|
||
while (Date.now() < spawnDeadline) {
|
||
try {
|
||
const raw = fs.readFileSync(pidFile, 'utf-8').trim();
|
||
if (raw) {
|
||
cliPid = Number.parseInt(raw, 10);
|
||
break;
|
||
}
|
||
} catch {
|
||
/* not written yet */
|
||
}
|
||
await new Promise((r) => setTimeout(r, 10));
|
||
}
|
||
expect(cliPid).toBeGreaterThan(0);
|
||
// The augment really took the npx fallback arm, not the direct arm.
|
||
expect(fs.existsSync(npxMarkerPath)).toBe(true);
|
||
|
||
// Kill the hook while the npx → CLI chain is alive (orphan topology).
|
||
hookChild.kill('SIGKILL');
|
||
|
||
// The npx call site's wrapper budget is 13s (= ceil((7000+5000)/
|
||
// 1000)+1) from the guard's start; the group SIGKILL lands then.
|
||
// Poll past it with margin, far short of the CLI's 30s sleep.
|
||
const reapDeadline = Date.now() + 18000;
|
||
let alive = isFakeCliAlive();
|
||
while (alive && Date.now() < reapDeadline) {
|
||
await new Promise((r) => setTimeout(r, 100));
|
||
alive = isFakeCliAlive();
|
||
}
|
||
expect(alive).toBe(false);
|
||
} finally {
|
||
// PID-reuse guard (#2169 review): re-run the detection loop's
|
||
// /proc/<pid>/cmdline identity check before the cleanup SIGKILL, so
|
||
// a PID already reaped and recycled by the OS is never signalled.
|
||
if (cliPid > 0 && isFakeCliAlive()) {
|
||
try {
|
||
process.kill(cliPid, 'SIGKILL');
|
||
} catch {
|
||
/* already gone */
|
||
}
|
||
}
|
||
try {
|
||
hookChild?.kill('SIGKILL');
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
// The hook claims a slot before probing; it died holding it.
|
||
const lockDir = path.join(gitNexusDir, '.hook-locks');
|
||
try {
|
||
for (const f of fs.readdirSync(lockDir)) fs.unlinkSync(path.join(lockDir, f));
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
try {
|
||
fs.rmdirSync(lockDir);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
fs.rmSync(lbugPath, { force: true });
|
||
fs.rmSync(pidFile, { force: true });
|
||
fs.rmSync(npxMarkerPath, { force: true });
|
||
fs.rmSync(stagedDir, { recursive: true, force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
fs.rmSync(emptyProcRoot, { recursive: true, force: true });
|
||
}
|
||
}, 45000);
|
||
},
|
||
);
|
||
|
||
// ─── Wrapping equivalence: disabled guard ⇒ pre-wrap augment behavior ──
|
||
|
||
describe.skipIf(process.platform === 'win32')(
|
||
'Augment CLI guard wrap degrades cleanly when disabled (#2163 follow-up)',
|
||
() => {
|
||
// T6-style equivalence pin for the AUGMENT path: with the wrapper
|
||
// explicitly off (the `disabled` sentinel, NOT a bogus path — invalid
|
||
// values fall through to the real candidate list by design) the augment
|
||
// path must behave exactly as it did before the wrap existed: the probe
|
||
// fails open on an idle DB, the CLI runs unwrapped, and its context is
|
||
// emitted verbatim. (The wrapped arm's equivalence is covered by the
|
||
// whole existing augment suite, which now runs under the host's real
|
||
// guard on the Linux/macOS lanes.)
|
||
for (const [label, hookPath] of [
|
||
['CJS', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
] as const) {
|
||
it(`${label}: augment runs and emits context with the wrapper disabled`, () => {
|
||
const markerPath = path.join(os.tmpdir(), `gn-hook-nowrap-aug-${process.pid}-${label}`);
|
||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||
fs.writeFileSync(lbugPath, '');
|
||
fs.rmSync(markerPath, { force: true });
|
||
const binDir = createHookToolDir({
|
||
gitnexusMarkerPath: markerPath,
|
||
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
|
||
lsofOutput: '',
|
||
psOutput: '',
|
||
});
|
||
try {
|
||
const result = runHook(
|
||
hookPath,
|
||
{
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{
|
||
env: {
|
||
...hookEnv(binDir),
|
||
GITNEXUS_HOOK_TIMEOUT_PATH: 'disabled',
|
||
},
|
||
},
|
||
);
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output).not.toBeNull();
|
||
expect(output!.additionalContext).toContain('[GitNexus] 1 related symbol found');
|
||
expect(fs.existsSync(markerPath)).toBe(true);
|
||
} finally {
|
||
fs.rmSync(lbugPath, { force: true });
|
||
fs.rmSync(markerPath, { force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
}
|
||
},
|
||
);
|
||
|
||
// ─── Source: augment CLI guard wrap present in every adapter (#2163 f-up) ──
|
||
|
||
describe('Augment CLI guard wrap (source, #2163 follow-up)', () => {
|
||
const ANTIGRAVITY_HOOK = path.resolve(
|
||
__dirname,
|
||
'..',
|
||
'..',
|
||
'hooks',
|
||
'antigravity',
|
||
'gitnexus-antigravity-hook.cjs',
|
||
);
|
||
|
||
// The cursor integration is deliberately ABSENT from this list: it does
|
||
// not install hook-db-lock-probe.cjs (see gitnexus-cursor-integration/
|
||
// README.md "What's installed manually vs. automated"), so there is no
|
||
// resolver sibling to require — wrapping its augment child is the
|
||
// "cursor probe" item on the #2163 follow-up list.
|
||
for (const [label, hookPath] of [
|
||
['CJS', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
['Antigravity', ANTIGRAVITY_HOOK],
|
||
] as const) {
|
||
it(`${label}: runGitNexusCli wraps via resolveUnixGuardTimeout with a ceil(ms/1000)+1 budget`, () => {
|
||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||
const start = source.indexOf('function runGitNexusCli');
|
||
expect(start).toBeGreaterThanOrEqual(0);
|
||
const end = source.indexOf('\nfunction ', start + 1);
|
||
const fn = source.slice(start, end === -1 ? undefined : end);
|
||
// Consults the probe's exported resolver (memo shared with the probe),
|
||
// and never on Windows — the npx.cmd / gitnexus.cmd argv stay exactly
|
||
// as before the wrap. The typeof check is the probe version-skew guard
|
||
// (#2169 review): an old probe without the resolveUnixGuardTimeout
|
||
// export must degrade to the unwrapped argv, not throw a TypeError
|
||
// that the caller's catch swallows into a silently dead augment.
|
||
expect(fn).toMatch(
|
||
/isWin \|\| typeof resolveUnixGuardTimeout !== 'function'\s*\?\s*null\s*:\s*resolveUnixGuardTimeout\(\)/,
|
||
);
|
||
// Coreutils `-k 1` escalation…
|
||
expect(fn).toContain("'-k',");
|
||
// …with a budget STRICTLY above each branch's inner spawnSync timeout:
|
||
// ceil(inner/1000)+1 for both the direct (timeout) and npx
|
||
// (timeout + 5000) call sites. The direct-budget formula is counted
|
||
// exactly — once per wrapped direct-exec branch (the Plugin adapter has
|
||
// two: GITNEXUS_HOOK_CLI_PATH and the PATH-direct `gitnexus` branch,
|
||
// its most common production path) — so a partial revert of any single
|
||
// branch cannot pass unnoticed.
|
||
const directBudgetCount = (fn.match(/Math\.ceil\(timeout \/ 1000\) \+ 1/g) ?? []).length;
|
||
expect(directBudgetCount).toBe(label === 'Plugin' ? 2 : 1);
|
||
expect(fn).toMatch(/Math\.ceil\(\(timeout \+ 5000\) \/ 1000\) \+ 1/);
|
||
// Argv-order pin (#2169 review): the budget token must appear BEFORE
|
||
// the command word — `timeout … <budget> <cmd>` — or coreutils would
|
||
// parse the command word as its DURATION argument. Token presence and
|
||
// the counts above alone would let a transposed argv pass. Every
|
||
// direct-exec budget must be immediately followed by its command token
|
||
// (process.execPath, or the PATH-direct 'gitnexus' on Plugin), and the
|
||
// npx budget by 'npx'.
|
||
const directOrderCount = (
|
||
fn.match(
|
||
/String\(Math\.ceil\(timeout \/ 1000\) \+ 1\),\s*(?:process\.execPath|'gitnexus')/g,
|
||
) ?? []
|
||
).length;
|
||
expect(directOrderCount).toBe(label === 'Plugin' ? 2 : 1);
|
||
expect(fn).toMatch(/String\(Math\.ceil\(\(timeout \+ 5000\) \/ 1000\) \+ 1\),\s*'npx'/);
|
||
// npx-branch grandchild containment (#2169 review): the npx wrapped
|
||
// arm must SIGKILL the process group at budget (`-s KILL`) — a group
|
||
// SIGTERM there kills only the obedient npx parent, `timeout` returns
|
||
// before its `-k` escalation fires, and a SIGTERM-immune CLI
|
||
// grandchild escapes unbounded.
|
||
expect(fn).toMatch(
|
||
/'-s',\s*'KILL',\s*'-k',\s*'1',\s*String\(Math\.ceil\(\(timeout \+ 5000\)/,
|
||
);
|
||
// …and the direct-exec arm(s) must NOT lead with `-s KILL`: TERM-first
|
||
// is gentler and sufficient there (the CLI is the guard's direct
|
||
// child), so `-s` appears exactly once — in the npx arm.
|
||
expect((fn.match(/'-s',/g) ?? []).length).toBe(1);
|
||
});
|
||
}
|
||
|
||
for (const [label, probePath] of [
|
||
['CJS', CJS_HOOK_DB_PROBE],
|
||
['Plugin', PLUGIN_HOOK_DB_PROBE],
|
||
] as const) {
|
||
it(`${label} probe exports resolveUnixGuardTimeout`, () => {
|
||
const source = fs.readFileSync(probePath, 'utf-8');
|
||
const exportsSlice = source.slice(source.indexOf('module.exports'));
|
||
expect(exportsSlice).toContain('resolveUnixGuardTimeout');
|
||
});
|
||
}
|
||
});
|
||
|
||
// ─── Source: cursor hook slot-skip diagnostic (#2163 follow-up) ─────
|
||
|
||
describe('Cursor hook slot-skip diagnostic (source, #2163 follow-up)', () => {
|
||
const CURSOR_HOOK = path.resolve(
|
||
__dirname,
|
||
'..',
|
||
'..',
|
||
'..',
|
||
'gitnexus-cursor-integration',
|
||
'hooks',
|
||
'gitnexus-hook.cjs',
|
||
);
|
||
|
||
it('debug-gates the slot-saturated skip under the cursor truthy convention', () => {
|
||
const source = fs.readFileSync(CURSOR_HOOK, 'utf-8');
|
||
const idx = source.indexOf('augment skipped: hook slots saturated');
|
||
expect(idx).toBeGreaterThanOrEqual(0);
|
||
// Must sit inside the cursor hook's own debug gate (truthy
|
||
// `process.env.GITNEXUS_DEBUG`, unlike the claude adapters' strict
|
||
// '1'/'true' gate) so the default path stays silent.
|
||
const before = source.slice(Math.max(0, idx - 600), idx);
|
||
expect(before).toContain('process.env.GITNEXUS_DEBUG');
|
||
});
|
||
});
|
||
|
||
// ─── Integration: PreToolUse augmentation filtering (#1492) ─────────
|
||
|
||
describe('PreToolUse augmentation filtering (integration)', () => {
|
||
for (const [label, hookPath] of [
|
||
['CJS', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
] as const) {
|
||
it(`${label}: emits valid GitNexus augmentation context`, () => {
|
||
const binDir = createHookToolDir({
|
||
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
|
||
});
|
||
try {
|
||
const result = runHook(
|
||
hookPath,
|
||
{
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{ env: hookEnv(binDir) },
|
||
);
|
||
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output).not.toBeNull();
|
||
expect(output!.hookEventName).toBe('PreToolUse');
|
||
expect(output!.additionalContext).toContain('[GitNexus] 1 related symbol found');
|
||
} finally {
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it(`${label}: suppresses LadybugDB lock warnings from augment stderr`, () => {
|
||
const markerPath = path.join(os.tmpdir(), 'gn-hook-lockwarn-' + process.pid + '-' + label);
|
||
fs.rmSync(markerPath, { force: true });
|
||
const binDir = createHookToolDir({
|
||
gitnexusMarkerPath: markerPath,
|
||
gitnexusStderr:
|
||
'GitNexus: FTS extension load failed: IO exception: Could not set lock on file : /tmp/repo/.gitnexus/lbug\n',
|
||
});
|
||
try {
|
||
const result = runHook(
|
||
hookPath,
|
||
{
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{ env: hookEnv(binDir) },
|
||
);
|
||
|
||
expect(result.stdout.trim()).toBe('');
|
||
expect(fs.existsSync(markerPath)).toBe(true);
|
||
|
||
// Finding #18: when GITNEXUS_DEBUG=1 is set, the discarded prefix is
|
||
// recoverable on the hook's stderr (not silently dropped).
|
||
const debugResult = runHook(
|
||
hookPath,
|
||
{
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '1' } },
|
||
);
|
||
expect(debugResult.stderr).toContain('augment stderr discarded prefix');
|
||
expect(debugResult.stderr).toContain('Could not set lock on file');
|
||
} finally {
|
||
fs.rmSync(markerPath, { force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
// #2396: when a GitNexus MCP process owns the repo DB the CLI augment can't
|
||
// run, so the hook hands the agent the MCP-query hint on stdout (the sanctioned
|
||
// additionalContext channel). By default (GITNEXUS_DEBUG unset) the stderr skip
|
||
// diagnostic stays silent, so strict hook runners (e.g. Codex `PreToolUse`) see
|
||
// no unexpected diagnostic noise — only the augmentation itself (#1913). This is
|
||
// the GITNEXUS_DEBUG='' owner-hint coverage; the debug variants are below.
|
||
it.skipIf(SKIP_LSOF_PATH)(
|
||
`${label}: emits the MCP-query hint on stdout, stderr silent by default, when a GitNexus MCP process owns the repo DB`,
|
||
() => {
|
||
const markerPath = path.join(os.tmpdir(), `gitnexus-hook-called-${process.pid}-${label}`);
|
||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||
fs.writeFileSync(lbugPath, '');
|
||
fs.rmSync(markerPath, { force: true });
|
||
const binDir = createHookToolDir({
|
||
gitnexusMarkerPath: markerPath,
|
||
lsofOutput: '12345\n',
|
||
psOutput: 'node /tmp/node_modules/.bin/gitnexus mcp\n',
|
||
});
|
||
try {
|
||
const result = runHook(
|
||
hookPath,
|
||
{
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '' } },
|
||
);
|
||
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output!.additionalContext).toContain('mcp__gitnexus__query');
|
||
expect(output!.additionalContext).toContain('validateUser');
|
||
expect(result.stderr.trim()).toBe('');
|
||
expect(result.status).toBe(0);
|
||
expect(fs.existsSync(markerPath)).toBe(false);
|
||
} finally {
|
||
fs.rmSync(lbugPath, { force: true });
|
||
fs.rmSync(markerPath, { force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
}
|
||
},
|
||
);
|
||
|
||
// #2396: when the MCP server owns the DB the CLI augment can't run, so the
|
||
// hook hands the agent an MCP-query hint on stdout (the sanctioned
|
||
// additionalContext channel) instead of doing nothing. The CLI still never
|
||
// spawns (marker absent). #1913: the stderr skip diagnostic stays gated
|
||
// behind GITNEXUS_DEBUG.
|
||
it.skipIf(SKIP_LSOF_PATH)(
|
||
`${label}: MCP-owner path emits the MCP query hint; stderr reason gated by GITNEXUS_DEBUG`,
|
||
() => {
|
||
const markerPath = path.join(os.tmpdir(), `gitnexus-hook-dbg-${process.pid}-${label}`);
|
||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||
fs.writeFileSync(lbugPath, '');
|
||
fs.rmSync(markerPath, { force: true });
|
||
const binDir = createHookToolDir({
|
||
gitnexusMarkerPath: markerPath,
|
||
lsofOutput: '12345\n',
|
||
psOutput: 'node /tmp/node_modules/.bin/gitnexus mcp\n',
|
||
});
|
||
try {
|
||
const result = runHook(
|
||
hookPath,
|
||
{
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '1' } },
|
||
);
|
||
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output!.additionalContext).toContain('mcp__gitnexus__query');
|
||
expect(output!.additionalContext).toContain('validateUser');
|
||
expect(result.status).toBe(0);
|
||
expect(result.stderr).toContain('[GitNexus] augment skipped: MCP server owns DB');
|
||
expect(fs.existsSync(markerPath)).toBe(false);
|
||
} finally {
|
||
fs.rmSync(lbugPath, { force: true });
|
||
fs.rmSync(markerPath, { force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
}
|
||
},
|
||
);
|
||
|
||
// #1913: the GITNEXUS_DEBUG contract is strict — ONLY '1' and 'true' enable
|
||
// the stderr diagnostic. Pin that non-canonical truthy-looking values ('0',
|
||
// 'false') are treated as OFF, so stderr stays silent. The #2396 MCP-query
|
||
// hint on stdout is independent of GITNEXUS_DEBUG (it is the augmentation, not
|
||
// a diagnostic) and must still be emitted here.
|
||
for (const debugValue of ['0', 'false']) {
|
||
it.skipIf(SKIP_LSOF_PATH)(
|
||
`${label}: MCP-owner hint emits on stdout; stderr stays silent with GITNEXUS_DEBUG='${debugValue}'`,
|
||
() => {
|
||
const markerPath = path.join(
|
||
os.tmpdir(),
|
||
`gitnexus-hook-dbg-${debugValue}-${process.pid}-${label}`,
|
||
);
|
||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||
fs.writeFileSync(lbugPath, '');
|
||
fs.rmSync(markerPath, { force: true });
|
||
const binDir = createHookToolDir({
|
||
gitnexusMarkerPath: markerPath,
|
||
lsofOutput: '12345\n',
|
||
psOutput: 'node /tmp/node_modules/.bin/gitnexus mcp\n',
|
||
});
|
||
try {
|
||
const result = runHook(
|
||
hookPath,
|
||
{
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: debugValue } },
|
||
);
|
||
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output!.additionalContext).toContain('mcp__gitnexus__query');
|
||
expect(result.stderr.trim()).toBe('');
|
||
expect(result.status).toBe(0);
|
||
expect(fs.existsSync(markerPath)).toBe(false);
|
||
} finally {
|
||
fs.rmSync(lbugPath, { force: true });
|
||
fs.rmSync(markerPath, { force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
}
|
||
},
|
||
);
|
||
}
|
||
}
|
||
});
|
||
|
||
// #2396: the owner-path hint is throttled to at most once per repo per window
|
||
// (GITNEXUS_MCP_HINT_THROTTLE_MS, default 10min) via a per-repo `.mcp-hint-shown`
|
||
// marker, so an owner-locked session isn't nudged on every search. macOS/other-
|
||
// Unix lsof+ps lane only (SKIP_LSOF_PATH), like the sibling owner tests. hookEnv
|
||
// sets the window to 0 (disabled) elsewhere for determinism; here we set a real
|
||
// window to exercise the throttle.
|
||
describe.skipIf(SKIP_LSOF_PATH)('MCP-owner hint throttle (#2396)', () => {
|
||
for (const [label, hookPath] of [
|
||
['CJS', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
] as const) {
|
||
it(`${label}: emits once, then throttles within the window (marker gates it)`, () => {
|
||
const markerPath = path.join(os.tmpdir(), `gn-hook-throttle-${process.pid}-${label}`);
|
||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||
const throttleMarker = path.join(gitNexusDir, '.mcp-hint-shown');
|
||
fs.writeFileSync(lbugPath, '');
|
||
fs.rmSync(markerPath, { force: true });
|
||
fs.rmSync(throttleMarker, { force: true });
|
||
const binDir = createHookToolDir({
|
||
gitnexusMarkerPath: markerPath,
|
||
lsofOutput: '12345\n',
|
||
psOutput: 'node /tmp/node_modules/.bin/gitnexus mcp\n',
|
||
});
|
||
const runOnce = () =>
|
||
runHook(
|
||
hookPath,
|
||
{
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{ env: { ...hookEnv(binDir), GITNEXUS_MCP_HINT_THROTTLE_MS: '600000' } },
|
||
);
|
||
try {
|
||
// First owner-locked search: emits the hint and writes the marker.
|
||
const first = runOnce();
|
||
const out1 = parseHookOutput(first.stdout);
|
||
expect(out1!.additionalContext).toContain('mcp__gitnexus__query');
|
||
expect(first.status).toBe(0);
|
||
expect(fs.existsSync(throttleMarker)).toBe(true);
|
||
// Second search, marker still fresh (10-min window): throttled — no hint.
|
||
const second = runOnce();
|
||
expect(second.stdout.trim()).toBe('');
|
||
expect(second.status).toBe(0);
|
||
} finally {
|
||
fs.rmSync(lbugPath, { force: true });
|
||
fs.rmSync(markerPath, { force: true });
|
||
fs.rmSync(throttleMarker, { force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// #2396: buildMcpQueryHint and its throttle are triplicated across the three hook
|
||
// copies (the repo's deliberate no-shared-module hook convention). Guard against
|
||
// silent drift with a source-level byte-identity check — runs on every platform,
|
||
// unlike the owner-path behavior tests which are macOS-only.
|
||
describe('hook copy drift guard (#2396)', () => {
|
||
const ANTIGRAVITY_HOOK = path.resolve(
|
||
__dirname,
|
||
'..',
|
||
'..',
|
||
'hooks',
|
||
'antigravity',
|
||
'gitnexus-antigravity-hook.cjs',
|
||
);
|
||
const HOOK_SOURCES: ReadonlyArray<readonly [string, string]> = [
|
||
['claude', CJS_HOOK],
|
||
['plugin', PLUGIN_HOOK],
|
||
['antigravity', ANTIGRAVITY_HOOK],
|
||
];
|
||
|
||
function extractFn(source: string, name: string): string {
|
||
const match = source.match(new RegExp(`function ${name}\\([^)]*\\) \\{[\\s\\S]*?\\n\\}`));
|
||
return match ? match[0] : `<${name} not found>`;
|
||
}
|
||
|
||
for (const fnName of ['buildMcpQueryHint', 'shouldEmitMcpHint']) {
|
||
it(`${fnName} is byte-identical across all three hook copies`, () => {
|
||
const [claude, plugin, antigravity] = HOOK_SOURCES.map(([, p]) =>
|
||
extractFn(fs.readFileSync(p, 'utf-8'), fnName),
|
||
);
|
||
expect(claude).toContain(`function ${fnName}`);
|
||
expect(plugin).toBe(claude);
|
||
expect(antigravity).toBe(claude);
|
||
});
|
||
}
|
||
});
|
||
|
||
// #2396: an adversarial search pattern (embedded quote + newline) must not break
|
||
// the additionalContext JSON envelope — JSON.stringify in the emit path escapes it
|
||
// structurally. Owner-path only (macOS/other-Unix lsof+ps lane, SKIP_LSOF_PATH).
|
||
describe.skipIf(SKIP_LSOF_PATH)('MCP hint pattern escaping (#2396)', () => {
|
||
for (const [label, hookPath] of [
|
||
['CJS', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
] as const) {
|
||
it(`${label}: quote+newline pattern stays JSON-safe in additionalContext`, () => {
|
||
const markerPath = path.join(os.tmpdir(), `gn-hook-esc-${process.pid}-${label}`);
|
||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||
fs.writeFileSync(lbugPath, '');
|
||
fs.rmSync(markerPath, { force: true });
|
||
const binDir = createHookToolDir({
|
||
gitnexusMarkerPath: markerPath,
|
||
lsofOutput: '12345\n',
|
||
psOutput: 'node /tmp/node_modules/.bin/gitnexus mcp\n',
|
||
});
|
||
const evilPattern = 'foo"bar\nbaz';
|
||
try {
|
||
const result = runHook(
|
||
hookPath,
|
||
{
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: evilPattern },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{ env: hookEnv(binDir) },
|
||
);
|
||
// parseHookOutput JSON.parses stdout — a broken envelope would throw/return null.
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output!.additionalContext).toContain('foo"bar');
|
||
expect(output!.additionalContext).toContain('search_query');
|
||
expect(result.status).toBe(0);
|
||
} finally {
|
||
fs.rmSync(lbugPath, { force: true });
|
||
fs.rmSync(markerPath, { force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
describe.skipIf(SKIP_LSOF_PATH)(
|
||
'Ladybug DB owner guard — production-shaped ps + failure modes (#1493)',
|
||
() => {
|
||
// These tests assert owner *detection* via the lsof + ps backend: a positive
|
||
// skip is signalled by the `[GitNexus] augment skipped` diagnostic. Since
|
||
// #1913 made that diagnostic debug-gated (silent by default for strict hook
|
||
// runners), they run with GITNEXUS_DEBUG=1 so the discriminator remains
|
||
// observable. Default-silence itself is covered by the 'augmentation
|
||
// filtering' describe above.
|
||
//
|
||
// #2180: skipped on Linux (SKIP_LSOF_PATH) — Linux no longer routes through
|
||
// lsof/ps, so these would no longer exercise the real dispatch there. They
|
||
// stay as the macOS/other-Unix lsof+ps lane; the equivalent Linux owner-
|
||
// detection (incl. the EACCES / cross-user fail-closed edge and the budget
|
||
// timeout fail-closed) is covered directly against a fake /proc in
|
||
// test/unit/hook-db-lock-probe.test.ts.
|
||
for (const [label, hookPath] of [
|
||
['CJS', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
] as const) {
|
||
it(`${label}: skips augment for real node_modules/gitnexus ps line (npx child)`, () => {
|
||
const markerPath = path.join(os.tmpdir(), `gn-hook-prodps-${process.pid}-${label}`);
|
||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||
fs.writeFileSync(lbugPath, '');
|
||
fs.rmSync(markerPath, { force: true });
|
||
const binDir = createHookToolDir({
|
||
gitnexusMarkerPath: markerPath,
|
||
lsofOutput: '99901\n',
|
||
psOutput: 'node /tmp/node_modules/gitnexus/dist/cli/index.js mcp\n',
|
||
});
|
||
try {
|
||
const result = runHook(
|
||
hookPath,
|
||
{
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '1' } },
|
||
);
|
||
// #2396: owner path now hands the agent the MCP-query hint on stdout;
|
||
// the CLI augment is still skipped (marker absent) and the stderr
|
||
// skip diagnostic remains debug-gated.
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output!.additionalContext).toContain('mcp__gitnexus__query');
|
||
expect(result.status).toBe(0);
|
||
expect(result.stderr).toContain('[GitNexus] augment skipped');
|
||
expect(fs.existsSync(markerPath)).toBe(false);
|
||
} finally {
|
||
fs.rmSync(markerPath, { force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it(`${label}: npx parent command line is NOT treated as GitNexus server owner`, () => {
|
||
const markerPath = path.join(os.tmpdir(), `gn-hook-npx-${process.pid}-${label}`);
|
||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||
fs.writeFileSync(lbugPath, '');
|
||
fs.rmSync(markerPath, { force: true });
|
||
const binDir = createHookToolDir({
|
||
gitnexusMarkerPath: markerPath,
|
||
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
|
||
lsofOutput: '99902\n',
|
||
psOutput: 'npx -y gitnexus@latest mcp\n',
|
||
});
|
||
try {
|
||
const result = runHook(
|
||
hookPath,
|
||
{
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{ env: hookEnv(binDir) },
|
||
);
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output).not.toBeNull();
|
||
expect(fs.existsSync(markerPath)).toBe(true);
|
||
} finally {
|
||
fs.rmSync(markerPath, { force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it(`${label}: skips augment for gitnexus serve child`, () => {
|
||
const markerPath = path.join(os.tmpdir(), `gn-hook-serve-${process.pid}-${label}`);
|
||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||
fs.writeFileSync(lbugPath, '');
|
||
fs.rmSync(markerPath, { force: true });
|
||
const binDir = createHookToolDir({
|
||
gitnexusMarkerPath: markerPath,
|
||
lsofOutput: '99903\n',
|
||
psOutput: 'node /repo/node_modules/gitnexus/dist/cli/index.js serve\n',
|
||
});
|
||
try {
|
||
const result = runHook(
|
||
hookPath,
|
||
{
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '1' } },
|
||
);
|
||
// #2396: owner path now hands the agent the MCP-query hint on stdout;
|
||
// the CLI augment is still skipped (marker absent) and the stderr
|
||
// skip diagnostic remains debug-gated.
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output!.additionalContext).toContain('mcp__gitnexus__query');
|
||
expect(result.status).toBe(0);
|
||
expect(result.stderr).toContain('[GitNexus] augment skipped');
|
||
expect(fs.existsSync(markerPath)).toBe(false);
|
||
} finally {
|
||
fs.rmSync(markerPath, { force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it(`${label}: ENOENT lsof → augment still runs (fail-open)`, () => {
|
||
const markerPath = path.join(os.tmpdir(), `gn-hook-enoent-${process.pid}-${label}`);
|
||
const lsofWrapMarkerPath = path.join(
|
||
os.tmpdir(),
|
||
`gn-hook-enoent-lsofwrap-${process.pid}-${label}`,
|
||
);
|
||
const psWrapMarkerPath = path.join(
|
||
os.tmpdir(),
|
||
`gn-hook-enoent-pswrap-${process.pid}-${label}`,
|
||
);
|
||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||
fs.writeFileSync(lbugPath, '');
|
||
fs.rmSync(markerPath, { force: true });
|
||
fs.rmSync(lsofWrapMarkerPath, { force: true });
|
||
fs.rmSync(psWrapMarkerPath, { force: true });
|
||
const binDir = createHookToolDir({
|
||
gitnexusMarkerPath: markerPath,
|
||
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
|
||
lsofOutput: '',
|
||
psOutput: '',
|
||
});
|
||
const guardPath = path.join(binDir, 'marker-guard');
|
||
writeSelfTestingGuardWithMarkers(guardPath, {
|
||
lsof: lsofWrapMarkerPath,
|
||
ps: psWrapMarkerPath,
|
||
});
|
||
try {
|
||
const env = {
|
||
...hookEnv(binDir),
|
||
GITNEXUS_HOOK_LSOF_PATH: path.join(binDir, '__missing_lsof__'),
|
||
GITNEXUS_HOOK_TIMEOUT_PATH: guardPath,
|
||
};
|
||
const result = runHook(
|
||
hookPath,
|
||
{
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{ env },
|
||
);
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output).not.toBeNull();
|
||
expect(fs.existsSync(markerPath)).toBe(true);
|
||
expect(fs.existsSync(lsofWrapMarkerPath)).toBe(false);
|
||
expect(fs.existsSync(psWrapMarkerPath)).toBe(false);
|
||
} finally {
|
||
fs.rmSync(markerPath, { force: true });
|
||
fs.rmSync(lsofWrapMarkerPath, { force: true });
|
||
fs.rmSync(psWrapMarkerPath, { force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it(`${label}: ETIMEDOUT lsof → augment skipped (fail-closed)`, () => {
|
||
const markerPath = path.join(os.tmpdir(), `gn-hook-etime-${process.pid}-${label}`);
|
||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||
fs.writeFileSync(lbugPath, '');
|
||
fs.rmSync(markerPath, { force: true });
|
||
const binDir = createHookToolDir({
|
||
gitnexusMarkerPath: markerPath,
|
||
lsofSleepMs: 5000,
|
||
psOutput: '',
|
||
});
|
||
try {
|
||
const result = runHook(
|
||
hookPath,
|
||
{
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '1' } },
|
||
);
|
||
// #2396: owner path now hands the agent the MCP-query hint on stdout;
|
||
// the CLI augment is still skipped (marker absent) and the stderr
|
||
// skip diagnostic remains debug-gated.
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output!.additionalContext).toContain('mcp__gitnexus__query');
|
||
expect(result.status).toBe(0);
|
||
expect(result.stderr).toContain('[GitNexus] augment skipped');
|
||
expect(fs.existsSync(markerPath)).toBe(false);
|
||
} finally {
|
||
fs.rmSync(markerPath, { force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
// #2396/#1913: the fail-closed (probe-timeout) skip routes through the SAME
|
||
// owner branch, so it now emits the conditional MCP-query hint on stdout —
|
||
// truthful here because the hint only asks the agent to use the MCP tools
|
||
// "if they are live". The stderr diagnostic stays debug-gated (empty by
|
||
// default), so strict runners still see no unexpected diagnostic. Symmetric
|
||
// counterpart to the debug-on test above.
|
||
it(`${label}: ETIMEDOUT lsof → emits hint on stdout, stderr silent by default`, () => {
|
||
const markerPath = path.join(os.tmpdir(), `gn-hook-etime-silent-${process.pid}-${label}`);
|
||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||
fs.writeFileSync(lbugPath, '');
|
||
fs.rmSync(markerPath, { force: true });
|
||
const binDir = createHookToolDir({
|
||
gitnexusMarkerPath: markerPath,
|
||
lsofSleepMs: 5000,
|
||
psOutput: '',
|
||
});
|
||
try {
|
||
const result = runHook(
|
||
hookPath,
|
||
{
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '' } },
|
||
);
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output!.additionalContext).toContain('mcp__gitnexus__query');
|
||
expect(result.stderr.trim()).toBe('');
|
||
expect(result.status).toBe(0);
|
||
expect(fs.existsSync(markerPath)).toBe(false);
|
||
} finally {
|
||
fs.rmSync(lbugPath, { force: true });
|
||
fs.rmSync(markerPath, { force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
// T6 (#2163): with the timeout wrapper explicitly disabled the probe must
|
||
// degrade to EXACTLY the pre-wrapper behavior — lsof ETIMEDOUT stays
|
||
// fail-closed and the augment is silently skipped. Uses the `disabled`
|
||
// sentinel, NOT a bogus path: an invalid GITNEXUS_HOOK_TIMEOUT_PATH falls
|
||
// through to the real candidate list by design. CI lane note: the sibling
|
||
// wrapped ETIMEDOUT tests above exercise GNU /usr/bin/timeout on the
|
||
// Linux lane, and on macos-latest hit BSD /usr/bin/timeout (macOS ≥13,
|
||
// `-k`-compatible) or Homebrew gtimeout — a de-facto BSD-wrapper
|
||
// regression test.
|
||
it(`${label}: ETIMEDOUT lsof with wrapper disabled → identical fail-closed skip`, () => {
|
||
const markerPath = path.join(os.tmpdir(), `gn-hook-nowrap-${process.pid}-${label}`);
|
||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||
fs.writeFileSync(lbugPath, '');
|
||
fs.rmSync(markerPath, { force: true });
|
||
const binDir = createHookToolDir({
|
||
gitnexusMarkerPath: markerPath,
|
||
lsofSleepMs: 5000,
|
||
psOutput: '',
|
||
});
|
||
try {
|
||
const result = runHook(
|
||
hookPath,
|
||
{
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{
|
||
env: {
|
||
...hookEnv(binDir),
|
||
GITNEXUS_DEBUG: '1',
|
||
GITNEXUS_HOOK_TIMEOUT_PATH: 'disabled',
|
||
},
|
||
},
|
||
);
|
||
// #2396: owner path now hands the agent the MCP-query hint on stdout;
|
||
// the CLI augment is still skipped (marker absent) and the stderr
|
||
// skip diagnostic remains debug-gated.
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output!.additionalContext).toContain('mcp__gitnexus__query');
|
||
expect(result.status).toBe(0);
|
||
expect(result.stderr).toContain('[GitNexus] augment skipped');
|
||
expect(fs.existsSync(markerPath)).toBe(false);
|
||
} finally {
|
||
fs.rmSync(lbugPath, { force: true });
|
||
fs.rmSync(markerPath, { force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
// T7 (#2163): a wrapper that fails the `-k` self-test (busybox <1.34,
|
||
// toybox, broken symlink…) must be REJECTED — resolution falls through
|
||
// to the built-in candidates (or, with none usable, to the unwrapped
|
||
// status quo); either way ETIMEDOUT stays fail-closed. Adopted blindly,
|
||
// the bad wrapper would exit with a usage error before ever running
|
||
// lsof — empty stdout, status≠0, no ETIMEDOUT — silently flipping the
|
||
// fail-closed contract to fail-open.
|
||
it(`${label}: bad wrapper (no -k support) is rejected by the self-test → still fail-closed`, () => {
|
||
const markerPath = path.join(os.tmpdir(), `gn-hook-badwrap-${process.pid}-${label}`);
|
||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||
fs.writeFileSync(lbugPath, '');
|
||
fs.rmSync(markerPath, { force: true });
|
||
const binDir = createHookToolDir({
|
||
gitnexusMarkerPath: markerPath,
|
||
lsofSleepMs: 5000,
|
||
psOutput: '',
|
||
});
|
||
// Models busybox <1.34: `-k` unsupported → usage error, exit 2.
|
||
const badTimeout = path.join(binDir, 'bad-timeout');
|
||
fs.writeFileSync(
|
||
badTimeout,
|
||
`#!/usr/bin/env node\nprocess.stderr.write('usage: timeout [-t SECS] [-s SIG] PROG ARGS\\n');\nprocess.exit(2);\n`,
|
||
{ mode: 0o755 },
|
||
);
|
||
try {
|
||
const result = runHook(
|
||
hookPath,
|
||
{
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{
|
||
env: {
|
||
...hookEnv(binDir),
|
||
GITNEXUS_DEBUG: '1',
|
||
GITNEXUS_HOOK_TIMEOUT_PATH: badTimeout,
|
||
},
|
||
},
|
||
);
|
||
// #2396: owner path now hands the agent the MCP-query hint on stdout;
|
||
// the CLI augment is still skipped (marker absent) and the stderr
|
||
// skip diagnostic remains debug-gated.
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output!.additionalContext).toContain('mcp__gitnexus__query');
|
||
expect(result.status).toBe(0);
|
||
expect(result.stderr).toContain('[GitNexus] augment skipped');
|
||
expect(fs.existsSync(markerPath)).toBe(false);
|
||
} finally {
|
||
fs.rmSync(lbugPath, { force: true });
|
||
fs.rmSync(markerPath, { force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
// F5-1 (#2165 review): pin the LIVE arm of the 124 mapping — a guard
|
||
// that passes the `-k` self-test and then reports coreutils budget
|
||
// expiry (exit 124) must map to "unresponsive holder" → fail-closed
|
||
// skip. The fake guard distinguishes the self-test invocation
|
||
// (`-k 1 1 /bin/sh -c 'exit 42'`) from a real wrap by the `exit 42`
|
||
// argv token, and PROPAGATES the requested status — the self-test now
|
||
// demands exit-status propagation (status 42), not just exit 0
|
||
// (#2169 review).
|
||
it(`${label}: guard exit 124 (budget expiry) → fail-closed skip`, () => {
|
||
const markerPath = path.join(os.tmpdir(), `gn-hook-guard124-${process.pid}-${label}`);
|
||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||
fs.writeFileSync(lbugPath, '');
|
||
fs.rmSync(markerPath, { force: true });
|
||
const binDir = createHookToolDir({
|
||
gitnexusMarkerPath: markerPath,
|
||
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
|
||
lsofOutput: '',
|
||
psOutput: '',
|
||
});
|
||
const fakeGuard = path.join(binDir, 'guard-exit-124');
|
||
fs.writeFileSync(
|
||
fakeGuard,
|
||
`#!/usr/bin/env node\nif (process.argv.includes('exit 42')) process.exit(42);\nprocess.exit(124);\n`,
|
||
{ mode: 0o755 },
|
||
);
|
||
try {
|
||
const result = runHook(
|
||
hookPath,
|
||
{
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{
|
||
env: {
|
||
...hookEnv(binDir),
|
||
GITNEXUS_DEBUG: '1',
|
||
GITNEXUS_HOOK_TIMEOUT_PATH: fakeGuard,
|
||
// '1', NOT '0' — see the slot-gate test above.
|
||
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '1',
|
||
},
|
||
},
|
||
);
|
||
// #2396: owner path now hands the agent the MCP-query hint on stdout;
|
||
// the CLI augment is still skipped (marker absent) and the stderr
|
||
// skip diagnostic remains debug-gated.
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output!.additionalContext).toContain('mcp__gitnexus__query');
|
||
expect(result.status).toBe(0);
|
||
expect(result.stderr).toContain('[GitNexus] augment skipped');
|
||
expect(fs.existsSync(markerPath)).toBe(false);
|
||
} finally {
|
||
fs.rmSync(lbugPath, { force: true });
|
||
fs.rmSync(markerPath, { force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
// F5-2 (#2165 review): a guard-wrapped probe that dies BY SIGNAL with
|
||
// no spawnSync .error must fail closed. coreutils timeout SELF-RAISES
|
||
// the signal when `-k` escalates to SIGKILL, so spawnSync sees
|
||
// {status: null, signal: 'SIGKILL'} — NOT exit 137. The same shape
|
||
// appears when the hook is frozen >2s (SIGSTOP / laptop suspend) and
|
||
// resumes after the guard expired. Before the F1 patch this shape fell
|
||
// through every check → empty stdout → fail-open, silently inverting
|
||
// this call's fail-closed contract.
|
||
it(`${label}: guard signal-death (status null + signal, no error) → fail-closed skip`, () => {
|
||
const markerPath = path.join(os.tmpdir(), `gn-hook-guardsig-${process.pid}-${label}`);
|
||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||
fs.writeFileSync(lbugPath, '');
|
||
fs.rmSync(markerPath, { force: true });
|
||
const binDir = createHookToolDir({
|
||
gitnexusMarkerPath: markerPath,
|
||
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
|
||
lsofOutput: '',
|
||
psOutput: '',
|
||
});
|
||
const fakeGuard = path.join(binDir, 'guard-sigkill');
|
||
fs.writeFileSync(
|
||
fakeGuard,
|
||
`#!/usr/bin/env node\nif (process.argv.includes('exit 42')) process.exit(42);\nprocess.kill(process.pid, 'SIGKILL');\n`,
|
||
{ mode: 0o755 },
|
||
);
|
||
try {
|
||
const result = runHook(
|
||
hookPath,
|
||
{
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{
|
||
env: {
|
||
...hookEnv(binDir),
|
||
GITNEXUS_DEBUG: '1',
|
||
GITNEXUS_HOOK_TIMEOUT_PATH: fakeGuard,
|
||
// '1', NOT '0' — see the slot-gate test above.
|
||
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '1',
|
||
},
|
||
},
|
||
);
|
||
// #2396: owner path now hands the agent the MCP-query hint on stdout;
|
||
// the CLI augment is still skipped (marker absent) and the stderr
|
||
// skip diagnostic remains debug-gated.
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output!.additionalContext).toContain('mcp__gitnexus__query');
|
||
expect(result.status).toBe(0);
|
||
expect(result.stderr).toContain('[GitNexus] augment skipped');
|
||
expect(fs.existsSync(markerPath)).toBe(false);
|
||
} finally {
|
||
fs.rmSync(lbugPath, { force: true });
|
||
fs.rmSync(markerPath, { force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
// F5-4 (#2169 review; F5-3 was taken by the #2165 slot-gate test
|
||
// above): an always-exit-0 stub (/bin/true shape) at
|
||
// GITNEXUS_HOOK_TIMEOUT_PATH must be REJECTED by the self-test. The
|
||
// old self-test only demanded exit 0, which such a stub satisfies
|
||
// without ever RUNNING the wrapped command — once adopted it instantly
|
||
// "succeeds" every wrapped spawn with empty output, turning the probe
|
||
// into a constant no-owner answer and, worse, the augment into a
|
||
// silent no-op (status 0 + empty stderr passes the success check with
|
||
// no context, so the feature dies without a trace). The propagation
|
||
// self-test (`sh -c 'exit 42'` must yield 42) rejects the stub;
|
||
// resolution falls through to the built-in candidates (or, with none
|
||
// usable, to the unwrapped status quo) and the augment runs for real.
|
||
it(`${label}: always-exit-0 stub guard is rejected → augment still runs and emits context`, () => {
|
||
const markerPath = path.join(os.tmpdir(), `gn-hook-stubguard-${process.pid}-${label}`);
|
||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||
fs.writeFileSync(lbugPath, '');
|
||
fs.rmSync(markerPath, { force: true });
|
||
const binDir = createHookToolDir({
|
||
gitnexusMarkerPath: markerPath,
|
||
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
|
||
lsofOutput: '',
|
||
psOutput: '',
|
||
});
|
||
// Models /bin/true: exits 0 for ANY argv without running anything.
|
||
const stubGuard = path.join(binDir, 'true-stub');
|
||
fs.writeFileSync(stubGuard, `#!/usr/bin/env node\nprocess.exit(0);\n`, { mode: 0o755 });
|
||
try {
|
||
const result = runHook(
|
||
hookPath,
|
||
{
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{
|
||
env: {
|
||
...hookEnv(binDir),
|
||
GITNEXUS_HOOK_TIMEOUT_PATH: stubGuard,
|
||
// '1', NOT '0' — see the slot-gate test above.
|
||
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '1',
|
||
},
|
||
},
|
||
);
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output).not.toBeNull();
|
||
expect(output!.additionalContext).toContain('[GitNexus] 1 related symbol found');
|
||
expect(fs.existsSync(markerPath)).toBe(true);
|
||
} finally {
|
||
fs.rmSync(lbugPath, { force: true });
|
||
fs.rmSync(markerPath, { force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it(`${label}: non-GitNexus ps line → augment runs`, () => {
|
||
const markerPath = path.join(os.tmpdir(), `gn-hook-other-${process.pid}-${label}`);
|
||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||
fs.writeFileSync(lbugPath, '');
|
||
fs.rmSync(markerPath, { force: true });
|
||
const binDir = createHookToolDir({
|
||
gitnexusMarkerPath: markerPath,
|
||
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
|
||
lsofOutput: '99904\n',
|
||
psOutput: '/usr/bin/bash -l\n',
|
||
});
|
||
try {
|
||
const result = runHook(
|
||
hookPath,
|
||
{
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{ env: hookEnv(binDir) },
|
||
);
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output).not.toBeNull();
|
||
expect(fs.existsSync(markerPath)).toBe(true);
|
||
} finally {
|
||
fs.rmSync(markerPath, { force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it(`${label}: multiple PIDs — skip if any ps line is GitNexus MCP`, () => {
|
||
const markerPath = path.join(os.tmpdir(), `gn-hook-multi-${process.pid}-${label}`);
|
||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||
fs.writeFileSync(lbugPath, '');
|
||
fs.rmSync(markerPath, { force: true });
|
||
const binDir = createHookToolDir({
|
||
gitnexusMarkerPath: markerPath,
|
||
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
|
||
lsofOutputLines: ['111', '222'],
|
||
psOutputByPid: {
|
||
'111': 'vim /tmp/x\n',
|
||
'222': 'node /x/node_modules/gitnexus/dist/cli/index.js mcp\n',
|
||
},
|
||
});
|
||
try {
|
||
const result = runHook(
|
||
hookPath,
|
||
{
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '1' } },
|
||
);
|
||
// #2396: owner path now hands the agent the MCP-query hint on stdout;
|
||
// the CLI augment is still skipped (marker absent) and the stderr
|
||
// skip diagnostic remains debug-gated.
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output!.additionalContext).toContain('mcp__gitnexus__query');
|
||
expect(result.status).toBe(0);
|
||
expect(result.stderr).toContain('[GitNexus] augment skipped');
|
||
expect(fs.existsSync(markerPath)).toBe(false);
|
||
} finally {
|
||
fs.rmSync(markerPath, { force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it(`${label}: ps ENOENT → augment runs (ignore that PID)`, () => {
|
||
const markerPath = path.join(os.tmpdir(), `gn-hook-pseno-${process.pid}-${label}`);
|
||
const lsofWrapMarkerPath = path.join(
|
||
os.tmpdir(),
|
||
`gn-hook-pseno-lsofwrap-${process.pid}-${label}`,
|
||
);
|
||
const psWrapMarkerPath = path.join(
|
||
os.tmpdir(),
|
||
`gn-hook-pseno-pswrap-${process.pid}-${label}`,
|
||
);
|
||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||
fs.writeFileSync(lbugPath, '');
|
||
fs.rmSync(markerPath, { force: true });
|
||
fs.rmSync(lsofWrapMarkerPath, { force: true });
|
||
fs.rmSync(psWrapMarkerPath, { force: true });
|
||
const binDir = createHookToolDir({
|
||
gitnexusMarkerPath: markerPath,
|
||
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
|
||
lsofOutput: '99905\n',
|
||
psOutputByPid: {
|
||
'99905': 'node /x/node_modules/gitnexus/dist/cli/index.js mcp\n',
|
||
},
|
||
});
|
||
const guardPath = path.join(binDir, 'marker-guard');
|
||
writeSelfTestingGuardWithMarkers(guardPath, {
|
||
lsof: lsofWrapMarkerPath,
|
||
ps: psWrapMarkerPath,
|
||
});
|
||
try {
|
||
const env = {
|
||
...hookEnv(binDir),
|
||
GITNEXUS_HOOK_PS_PATH: path.join(binDir, '__missing_ps__'),
|
||
GITNEXUS_HOOK_TIMEOUT_PATH: guardPath,
|
||
};
|
||
const result = runHook(
|
||
hookPath,
|
||
{
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: tmpDir,
|
||
},
|
||
undefined,
|
||
{ env },
|
||
);
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output).not.toBeNull();
|
||
expect(fs.existsSync(markerPath)).toBe(true);
|
||
expect(fs.existsSync(lsofWrapMarkerPath)).toBe(true);
|
||
expect(fs.existsSync(psWrapMarkerPath)).toBe(false);
|
||
} finally {
|
||
fs.rmSync(markerPath, { force: true });
|
||
fs.rmSync(lsofWrapMarkerPath, { force: true });
|
||
fs.rmSync(psWrapMarkerPath, { force: true });
|
||
fs.rmSync(binDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
}
|
||
},
|
||
);
|
||
|
||
// ─── Integration: PostToolUse staleness detection ───────────────────
|
||
|
||
describe('PostToolUse staleness detection (integration)', () => {
|
||
for (const [label, hookPath] of [
|
||
['CJS', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
] as const) {
|
||
it(`${label}: emits stale notification when HEAD differs from meta`, () => {
|
||
// Write meta.json with a different commit
|
||
fs.writeFileSync(
|
||
path.join(gitNexusDir, 'meta.json'),
|
||
JSON.stringify({ lastCommit: 'aaaaaaa0000000000000000000000000deadbeef', stats: {} }),
|
||
);
|
||
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PostToolUse',
|
||
tool_name: 'Bash',
|
||
tool_input: { command: 'git commit -m "test"' },
|
||
tool_output: { exit_code: 0 },
|
||
cwd: tmpDir,
|
||
});
|
||
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output).not.toBeNull();
|
||
expect(output!.hookEventName).toBe('PostToolUse');
|
||
expect(output!.additionalContext).toContain('stale');
|
||
expect(output!.additionalContext).toContain('aaaaaaa');
|
||
});
|
||
|
||
it(`${label}: silent when HEAD matches meta lastCommit`, () => {
|
||
const head = getHeadCommit();
|
||
fs.writeFileSync(
|
||
path.join(gitNexusDir, 'meta.json'),
|
||
JSON.stringify({ lastCommit: head, stats: {} }),
|
||
);
|
||
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PostToolUse',
|
||
tool_name: 'Bash',
|
||
tool_input: { command: 'git commit -m "test"' },
|
||
tool_output: { exit_code: 0 },
|
||
cwd: tmpDir,
|
||
});
|
||
|
||
expect(result.stdout.trim()).toBe('');
|
||
});
|
||
|
||
it(`${label}: silent when tool is not Bash`, () => {
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PostToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { command: 'git commit -m "test"' },
|
||
cwd: tmpDir,
|
||
});
|
||
expect(result.stdout.trim()).toBe('');
|
||
});
|
||
|
||
it(`${label}: silent when command is not a git mutation`, () => {
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PostToolUse',
|
||
tool_name: 'Bash',
|
||
tool_input: { command: 'git status' },
|
||
tool_output: { exit_code: 0 },
|
||
cwd: tmpDir,
|
||
});
|
||
expect(result.stdout.trim()).toBe('');
|
||
});
|
||
|
||
it(`${label}: silent when exit code is non-zero`, () => {
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PostToolUse',
|
||
tool_name: 'Bash',
|
||
tool_input: { command: 'git commit -m "fail"' },
|
||
tool_output: { exit_code: 1 },
|
||
cwd: tmpDir,
|
||
});
|
||
expect(result.stdout.trim()).toBe('');
|
||
});
|
||
|
||
it(`${label}: includes --embeddings in suggestion when meta had embeddings`, () => {
|
||
fs.writeFileSync(
|
||
path.join(gitNexusDir, 'meta.json'),
|
||
JSON.stringify({ lastCommit: 'deadbeef', stats: { embeddings: 42 } }),
|
||
);
|
||
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PostToolUse',
|
||
tool_name: 'Bash',
|
||
tool_input: { command: 'git merge feature' },
|
||
tool_output: { exit_code: 0 },
|
||
cwd: tmpDir,
|
||
});
|
||
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output).not.toBeNull();
|
||
expect(output!.additionalContext).toContain('--embeddings');
|
||
});
|
||
|
||
it(`${label}: omits --embeddings when meta had no embeddings`, () => {
|
||
fs.writeFileSync(
|
||
path.join(gitNexusDir, 'meta.json'),
|
||
JSON.stringify({ lastCommit: 'deadbeef', stats: { embeddings: 0 } }),
|
||
);
|
||
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PostToolUse',
|
||
tool_name: 'Bash',
|
||
tool_input: { command: 'git commit -m "test"' },
|
||
tool_output: { exit_code: 0 },
|
||
cwd: tmpDir,
|
||
});
|
||
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output).not.toBeNull();
|
||
expect(output!.additionalContext).not.toContain('--embeddings');
|
||
});
|
||
|
||
it(`${label}: detects git rebase as a mutation`, () => {
|
||
fs.writeFileSync(
|
||
path.join(gitNexusDir, 'meta.json'),
|
||
JSON.stringify({ lastCommit: 'oldcommit', stats: {} }),
|
||
);
|
||
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PostToolUse',
|
||
tool_name: 'Bash',
|
||
tool_input: { command: 'git rebase main' },
|
||
tool_output: { exit_code: 0 },
|
||
cwd: tmpDir,
|
||
});
|
||
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output).not.toBeNull();
|
||
expect(output!.additionalContext).toContain('stale');
|
||
});
|
||
|
||
it(`${label}: detects git cherry-pick as a mutation`, () => {
|
||
fs.writeFileSync(
|
||
path.join(gitNexusDir, 'meta.json'),
|
||
JSON.stringify({ lastCommit: 'oldcommit', stats: {} }),
|
||
);
|
||
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PostToolUse',
|
||
tool_name: 'Bash',
|
||
tool_input: { command: 'git cherry-pick abc123' },
|
||
tool_output: { exit_code: 0 },
|
||
cwd: tmpDir,
|
||
});
|
||
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output).not.toBeNull();
|
||
});
|
||
|
||
it(`${label}: detects git pull as a mutation`, () => {
|
||
fs.writeFileSync(
|
||
path.join(gitNexusDir, 'meta.json'),
|
||
JSON.stringify({ lastCommit: 'oldcommit', stats: {} }),
|
||
);
|
||
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PostToolUse',
|
||
tool_name: 'Bash',
|
||
tool_input: { command: 'git pull origin main' },
|
||
tool_output: { exit_code: 0 },
|
||
cwd: tmpDir,
|
||
});
|
||
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output).not.toBeNull();
|
||
});
|
||
}
|
||
});
|
||
|
||
// ─── Integration: PostToolUse staleness detection with gitnexus.json ────
|
||
// (the current primary metadata filename; meta.json is a dual-written
|
||
// compatibility mirror — see repo-manager.ts's saveMeta/loadMeta)
|
||
|
||
describe('PostToolUse staleness detection with gitnexus.json (integration)', () => {
|
||
for (const [label, hookPath] of [
|
||
['CJS', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
] as const) {
|
||
it(`${label}: emits stale notification when HEAD differs from gitnexus.json`, () => {
|
||
const gitnexusJsonPath = path.join(gitNexusDir, 'gitnexus.json');
|
||
const metaJsonPath = path.join(gitNexusDir, 'meta.json');
|
||
fs.rmSync(metaJsonPath, { force: true });
|
||
fs.writeFileSync(
|
||
gitnexusJsonPath,
|
||
JSON.stringify({ lastCommit: 'aaaaaaa0000000000000000000000000deadbeef', stats: {} }),
|
||
);
|
||
|
||
try {
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PostToolUse',
|
||
tool_name: 'Bash',
|
||
tool_input: { command: 'git commit -m "test"' },
|
||
tool_output: { exit_code: 0 },
|
||
cwd: tmpDir,
|
||
});
|
||
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output).not.toBeNull();
|
||
expect(output!.additionalContext).toContain('stale');
|
||
expect(output!.additionalContext).toContain('aaaaaaa');
|
||
} finally {
|
||
fs.rmSync(gitnexusJsonPath, { force: true });
|
||
fs.writeFileSync(metaJsonPath, JSON.stringify({ lastCommit: 'old', stats: {} }));
|
||
}
|
||
});
|
||
|
||
it(`${label}: silent when HEAD matches gitnexus.json lastCommit`, () => {
|
||
const gitnexusJsonPath = path.join(gitNexusDir, 'gitnexus.json');
|
||
const metaJsonPath = path.join(gitNexusDir, 'meta.json');
|
||
const head = getHeadCommit();
|
||
fs.rmSync(metaJsonPath, { force: true });
|
||
fs.writeFileSync(gitnexusJsonPath, JSON.stringify({ lastCommit: head, stats: {} }));
|
||
|
||
try {
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PostToolUse',
|
||
tool_name: 'Bash',
|
||
tool_input: { command: 'git commit -m "test"' },
|
||
tool_output: { exit_code: 0 },
|
||
cwd: tmpDir,
|
||
});
|
||
|
||
expect(result.stdout.trim()).toBe('');
|
||
} finally {
|
||
fs.rmSync(gitnexusJsonPath, { force: true });
|
||
fs.writeFileSync(metaJsonPath, JSON.stringify({ lastCommit: 'old', stats: {} }));
|
||
}
|
||
});
|
||
|
||
it(`${label}: prefers gitnexus.json over meta.json when both are present (dual-write steady state)`, () => {
|
||
const gitnexusJsonPath = path.join(gitNexusDir, 'gitnexus.json');
|
||
const metaJsonPath = path.join(gitNexusDir, 'meta.json');
|
||
fs.writeFileSync(gitnexusJsonPath, JSON.stringify({ lastCommit: 'freshcommit', stats: {} }));
|
||
fs.writeFileSync(metaJsonPath, JSON.stringify({ lastCommit: 'stalecommit', stats: {} }));
|
||
|
||
try {
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PostToolUse',
|
||
tool_name: 'Bash',
|
||
tool_input: { command: 'git commit -m "test"' },
|
||
tool_output: { exit_code: 0 },
|
||
cwd: tmpDir,
|
||
});
|
||
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output).not.toBeNull();
|
||
// Reports staleness against gitnexus.json's commit, not meta.json's —
|
||
// proves gitnexus.json is consulted first.
|
||
expect(output!.additionalContext).toContain('freshco');
|
||
} finally {
|
||
fs.rmSync(gitnexusJsonPath, { force: true });
|
||
fs.writeFileSync(metaJsonPath, JSON.stringify({ lastCommit: 'old', stats: {} }));
|
||
}
|
||
});
|
||
|
||
it(`${label}: falls back to meta.json when gitnexus.json is corrupt`, () => {
|
||
const gitnexusJsonPath = path.join(gitNexusDir, 'gitnexus.json');
|
||
const metaJsonPath = path.join(gitNexusDir, 'meta.json');
|
||
const head = getHeadCommit();
|
||
fs.writeFileSync(gitnexusJsonPath, 'not valid json!!!');
|
||
fs.writeFileSync(metaJsonPath, JSON.stringify({ lastCommit: head, stats: {} }));
|
||
|
||
try {
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PostToolUse',
|
||
tool_name: 'Bash',
|
||
tool_input: { command: 'git commit -m "test"' },
|
||
tool_output: { exit_code: 0 },
|
||
cwd: tmpDir,
|
||
});
|
||
|
||
// meta.json's lastCommit matches HEAD, so a correct fallback stays silent.
|
||
expect(result.stdout.trim()).toBe('');
|
||
} finally {
|
||
fs.rmSync(gitnexusJsonPath, { force: true });
|
||
fs.writeFileSync(metaJsonPath, JSON.stringify({ lastCommit: 'old', stats: {} }));
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// ─── Integration: cwd validation rejects relative paths ─────────────
|
||
|
||
describe('cwd validation (integration)', () => {
|
||
for (const [label, hookPath] of [
|
||
['CJS', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
] as const) {
|
||
it(`${label}: PostToolUse silent when cwd is relative`, () => {
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PostToolUse',
|
||
tool_name: 'Bash',
|
||
tool_input: { command: 'git commit -m "test"' },
|
||
tool_output: { exit_code: 0 },
|
||
cwd: 'relative/path',
|
||
});
|
||
expect(result.stdout.trim()).toBe('');
|
||
});
|
||
|
||
it(`${label}: PreToolUse silent when cwd is relative`, () => {
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: 'relative/path',
|
||
});
|
||
expect(result.stdout.trim()).toBe('');
|
||
});
|
||
}
|
||
});
|
||
|
||
// ─── Integration: global registry lookup ────────────────────────────
|
||
|
||
describe('Global registry lookup', () => {
|
||
for (const [label, hookPath] of [
|
||
['CJS', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
] as const) {
|
||
it(`${label}: PostToolUse stays silent for unindexed repo under global registry`, () => {
|
||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-home-'));
|
||
const repoDir = path.join(homeDir, 'work', 'unindexed');
|
||
try {
|
||
createGlobalRegistry(homeDir);
|
||
fs.mkdirSync(repoDir, { recursive: true });
|
||
initGitRepo(repoDir);
|
||
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PostToolUse',
|
||
tool_name: 'Bash',
|
||
tool_input: { command: 'git commit -m "test"' },
|
||
tool_output: { exit_code: 0 },
|
||
cwd: repoDir,
|
||
});
|
||
|
||
expect(result.stdout.trim()).toBe('');
|
||
} finally {
|
||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it(`${label}: PreToolUse stays silent for unindexed repo under global registry`, () => {
|
||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-home-'));
|
||
const repoDir = path.join(homeDir, 'work', 'unindexed');
|
||
try {
|
||
createGlobalRegistry(homeDir);
|
||
fs.mkdirSync(repoDir, { recursive: true });
|
||
initGitRepo(repoDir);
|
||
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'Grep',
|
||
tool_input: { pattern: 'validateUser' },
|
||
cwd: repoDir,
|
||
});
|
||
|
||
expect(result.stdout.trim()).toBe('');
|
||
} finally {
|
||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it(`${label}: PostToolUse emits stale for indexed repo under parent global registry`, () => {
|
||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-home-'));
|
||
const repoDir = path.join(homeDir, 'work', 'indexed-repo');
|
||
try {
|
||
createGlobalRegistry(homeDir);
|
||
fs.mkdirSync(path.join(repoDir, '.gitnexus'), { recursive: true });
|
||
initGitRepo(repoDir);
|
||
fs.writeFileSync(
|
||
path.join(repoDir, '.gitnexus', 'meta.json'),
|
||
JSON.stringify({ lastCommit: 'oldcommit', stats: {} }),
|
||
);
|
||
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PostToolUse',
|
||
tool_name: 'Bash',
|
||
tool_input: { command: 'git commit -m "test"' },
|
||
tool_output: { exit_code: 0 },
|
||
cwd: repoDir,
|
||
});
|
||
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output).not.toBeNull();
|
||
expect(output!.additionalContext).toContain('stale');
|
||
} finally {
|
||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
for (const marker of ['registry', 'repos'] as const) {
|
||
it(`${label}: PostToolUse skips global registry with only ${marker} marker`, () => {
|
||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-home-'));
|
||
const repoDir = path.join(homeDir, 'work', `unindexed-${marker}`);
|
||
try {
|
||
createGlobalRegistry(homeDir, marker);
|
||
fs.mkdirSync(repoDir, { recursive: true });
|
||
initGitRepo(repoDir);
|
||
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PostToolUse',
|
||
tool_name: 'Bash',
|
||
tool_input: { command: 'git commit -m "test"' },
|
||
tool_output: { exit_code: 0 },
|
||
cwd: repoDir,
|
||
});
|
||
|
||
expect(result.stdout.trim()).toBe('');
|
||
} finally {
|
||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// ─── Integration: linked-worktree resolution (#1224) ───────────────
|
||
|
||
describe('Linked git worktree resolution', () => {
|
||
for (const [label, hookPath] of [
|
||
['CJS', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
] as const) {
|
||
it(`${label}: PostToolUse emits stale from a linked worktree pointing at an indexed canonical repo`, () => {
|
||
// Layout mirrors `git worktree add ../<repo>-worktrees/feature-x`:
|
||
// <root>/main-repo/.git (canonical)
|
||
// <root>/main-repo/.gitnexus/ (only here)
|
||
// <root>/main-repo-worktrees/feat/ (linked worktree, no .gitnexus)
|
||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worktree-'));
|
||
const mainRepo = path.join(root, 'main-repo');
|
||
const worktreePath = path.join(root, 'main-repo-worktrees', 'feat');
|
||
try {
|
||
fs.mkdirSync(mainRepo, { recursive: true });
|
||
initGitRepo(mainRepo);
|
||
fs.mkdirSync(path.join(mainRepo, '.gitnexus'), { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(mainRepo, '.gitnexus', 'meta.json'),
|
||
JSON.stringify({ lastCommit: 'oldcommit', stats: {} }),
|
||
);
|
||
|
||
// Create the linked worktree on a new branch.
|
||
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
|
||
runGit(mainRepo, ['worktree', 'add', '-b', 'feat', worktreePath]);
|
||
|
||
// Sanity: walking up from the worktree never reaches `.gitnexus`.
|
||
expect(fs.existsSync(path.join(worktreePath, '.gitnexus'))).toBe(false);
|
||
expect(fs.existsSync(path.join(path.dirname(worktreePath), '.gitnexus'))).toBe(false);
|
||
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PostToolUse',
|
||
tool_name: 'Bash',
|
||
tool_input: { command: 'git commit -m "test"' },
|
||
tool_output: { exit_code: 0 },
|
||
cwd: worktreePath,
|
||
});
|
||
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output).not.toBeNull();
|
||
expect(output!.additionalContext).toContain('stale');
|
||
} finally {
|
||
fs.rmSync(root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it(`${label}: PostToolUse silent from a linked worktree when canonical repo has no .gitnexus`, () => {
|
||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worktree-'));
|
||
const mainRepo = path.join(root, 'main-repo');
|
||
const worktreePath = path.join(root, 'main-repo-worktrees', 'feat');
|
||
try {
|
||
fs.mkdirSync(mainRepo, { recursive: true });
|
||
initGitRepo(mainRepo);
|
||
// Note: NO .gitnexus/ in the canonical repo.
|
||
|
||
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
|
||
runGit(mainRepo, ['worktree', 'add', '-b', 'feat', worktreePath]);
|
||
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PostToolUse',
|
||
tool_name: 'Bash',
|
||
tool_input: { command: 'git commit -m "test"' },
|
||
tool_output: { exit_code: 0 },
|
||
cwd: worktreePath,
|
||
});
|
||
|
||
expect(result.stdout.trim()).toBe('');
|
||
} finally {
|
||
fs.rmSync(root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// ─── Integration: dispatch map routes correctly ─────────────────────
|
||
|
||
describe('Dispatch map routing (integration)', () => {
|
||
for (const [label, hookPath] of [
|
||
['CJS', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
] as const) {
|
||
it(`${label}: unknown hook_event_name produces no output`, () => {
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'UnknownEvent',
|
||
tool_name: 'Bash',
|
||
tool_input: { command: 'echo hello' },
|
||
cwd: tmpDir,
|
||
});
|
||
expect(result.stdout.trim()).toBe('');
|
||
expect(result.status).toBe(0);
|
||
});
|
||
|
||
it(`${label}: empty hook_event_name produces no output`, () => {
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: '',
|
||
tool_name: 'Bash',
|
||
cwd: tmpDir,
|
||
});
|
||
expect(result.stdout.trim()).toBe('');
|
||
expect(result.status).toBe(0);
|
||
});
|
||
|
||
it(`${label}: missing hook_event_name produces no output`, () => {
|
||
const result = runHook(hookPath, {
|
||
tool_name: 'Bash',
|
||
cwd: tmpDir,
|
||
});
|
||
expect(result.stdout.trim()).toBe('');
|
||
expect(result.status).toBe(0);
|
||
});
|
||
|
||
it(`${label}: invalid JSON input exits cleanly`, () => {
|
||
const result = spawnSync(process.execPath, [hookPath], {
|
||
input: 'not json at all',
|
||
encoding: 'utf-8',
|
||
timeout: 10000,
|
||
stdio: ['pipe', 'pipe', 'pipe'],
|
||
});
|
||
expect(result.status).toBe(0);
|
||
expect(result.stdout.trim()).toBe('');
|
||
});
|
||
|
||
it(`${label}: empty stdin exits cleanly`, () => {
|
||
const result = spawnSync(process.execPath, [hookPath], {
|
||
input: '',
|
||
encoding: 'utf-8',
|
||
timeout: 10000,
|
||
stdio: ['pipe', 'pipe', 'pipe'],
|
||
});
|
||
expect(result.status).toBe(0);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ─── Integration: PostToolUse with missing meta.json ────────────────
|
||
|
||
describe('PostToolUse with missing/corrupt meta.json', () => {
|
||
for (const [label, hookPath] of [
|
||
['CJS', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
] as const) {
|
||
it(`${label}: emits stale when meta.json does not exist`, () => {
|
||
const metaPath = path.join(gitNexusDir, 'meta.json');
|
||
const hadMeta = fs.existsSync(metaPath);
|
||
if (hadMeta) fs.unlinkSync(metaPath);
|
||
|
||
try {
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PostToolUse',
|
||
tool_name: 'Bash',
|
||
tool_input: { command: 'git commit -m "test"' },
|
||
tool_output: { exit_code: 0 },
|
||
cwd: tmpDir,
|
||
});
|
||
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output).not.toBeNull();
|
||
expect(output!.additionalContext).toContain('never');
|
||
} finally {
|
||
// Restore meta.json for subsequent tests
|
||
fs.writeFileSync(metaPath, JSON.stringify({ lastCommit: 'old', stats: {} }));
|
||
}
|
||
});
|
||
|
||
it(`${label}: emits stale when meta.json is corrupt`, () => {
|
||
const metaPath = path.join(gitNexusDir, 'meta.json');
|
||
fs.writeFileSync(metaPath, 'not valid json!!!');
|
||
|
||
const result = runHook(hookPath, {
|
||
hook_event_name: 'PostToolUse',
|
||
tool_name: 'Bash',
|
||
tool_input: { command: 'git commit -m "test"' },
|
||
tool_output: { exit_code: 0 },
|
||
cwd: tmpDir,
|
||
});
|
||
|
||
const output = parseHookOutput(result.stdout);
|
||
expect(output).not.toBeNull();
|
||
expect(output!.additionalContext).toContain('never');
|
||
|
||
// Restore
|
||
fs.writeFileSync(metaPath, JSON.stringify({ lastCommit: 'old', stats: {} }));
|
||
});
|
||
}
|
||
});
|
||
|
||
// ─── Drift guard: every shipped hook must know about gitnexus.json ──
|
||
// This repo has hit the "N mirrored copies silently drift" failure mode
|
||
// twice for skills (#2356/#2360/#2362) — this test is the same class of
|
||
// guardrail for the four hook copies.
|
||
|
||
describe('Hook metadata-filename drift guard', () => {
|
||
const ANTIGRAVITY_HOOK = path.resolve(
|
||
__dirname,
|
||
'..',
|
||
'..',
|
||
'hooks',
|
||
'antigravity',
|
||
'gitnexus-antigravity-hook.cjs',
|
||
);
|
||
const CURSOR_HOOK = path.resolve(
|
||
__dirname,
|
||
'..',
|
||
'..',
|
||
'..',
|
||
'gitnexus-cursor-integration',
|
||
'hooks',
|
||
'gitnexus-hook.cjs',
|
||
);
|
||
|
||
for (const [label, hookPath] of [
|
||
['CJS (claude)', CJS_HOOK],
|
||
['Plugin', PLUGIN_HOOK],
|
||
['Antigravity', ANTIGRAVITY_HOOK],
|
||
['Cursor', CURSOR_HOOK],
|
||
] as const) {
|
||
it(`${label}: source references gitnexus.json, not only meta.json`, () => {
|
||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||
expect(source).toContain('gitnexus.json');
|
||
});
|
||
}
|
||
});
|