mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* fix(mcp): map diff hunks to symbols without per-hunk OR conditions (#2915) `detect_changes` folded one `(n.startLine <= $hunkEndI AND n.endLine >= $hunkStartI)` pair per diff hunk into a single WHERE clause, one query per changed file. A machine-generated file (cache JSON, lockfile, golden fixture) diffs at thousands of hunks with `-U0`, and the expression tree that produces overflows LadybugDB's recursive evaluator copy on a TaskScheduler worker thread: a bare SIGBUS with no error output where secondary threads get 512 KB of stack (macOS), a swallowed 30s query timeout where they get more (Linux), which the CLI then printed as "No changes detected." with exit 0. Coalesce each file's hunks into sorted, disjoint ranges and run the overlap test in JS instead. Only ranges that overlap or abut are merged, so the union covers exactly the lines the raw hunks covered. Query text and parameters are now identical whether a file changed in 1 place or 100,000, and files are queried in batches of 100 rather than one full node scan each. Reproduced on Linux by running the engine with macOS-sized (512 KB) thread stacks: 2,500 hunks passed, 3,333 and 4,000 segfaulted — matching the reporter's macOS threshold table. After the change the same repo maps a 100,001-hunk diff in 2.1s with no crash. Also fixes a line-base mismatch the rewrite exposed: graph rows are 0-based (#2377) while git hunk lines are 1-based, so the raw comparison shifted every symbol one line up. An edit to a symbol's LAST line reported nothing changed — a one-line function whose body was edited was invisible to the pre-commit gate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS * fix(cli): say when a detect_changes result is partial (#2915) When a graph query fails, `detect_changes` swallows the error, sets `partial: true` and leaves the counts at zero (#2283). The CLI formatter never read that flag, so a degraded run printed "No changes detected." and exited 0 — the pre-commit safety gate reporting a clean bill of health for a check that did not complete. Print the partial note in both the empty and non-empty branches. Also restore the `Symbol` placeholder for rows whose label came back as an empty string: the changed-symbol mapping now keeps `''` instead of dropping it to undefined, so the formatter needs `||`, not `??`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS * refactor(mcp): bound the hunk→symbol query and simplify the overlap helpers (#2915) Cleanup pass over the #2915 fix. No change to which symbols detect_changes reports, except that a node matched by two changed paths is now reported once. * Push a per-file [lo, hi] span into the query. Coalesced ranges are sorted and disjoint, so a file's whole touched span is free, and the engine can drop the symbols outside it instead of shipping every row in the file across the native boundary. Measured on a 400-file batch against a 25k-node index: 546ms/13,870 rows before, 84ms/1,555 rows after, identical kept set. Depth stays constant (two comparisons per file, not per hunk), so #2915 cannot come back — the JS test still rejects symbols landing in the gaps between hunks. The struct-list parameter was verified against @ladybugdb/core 0.18.3 and 0.19.1. * Convert hunks into the graph's 0-based space once, at the point they are grouped, with the existing `toZeroBasedLine`. Every comparison downstream is then base-neutral, and `toDisplayLine` goes back to being what its doc says it is: an MCP response-boundary converter, not a filter input. * Deduplicate matched nodes by id. `ENDS WITH` is a plain string suffix, so a diff touching both `README.md` and `pkg/README.md` counted the same node twice (169 duplicates in 13,870 rows on a real 400-file diff). Pre-existing, free to fix now that the rows are shaped in one place. * Drop the positional `?? sym[N]` row fallbacks in this block. `executeParameterized` returns `getAll()` rows, which are alias-keyed objects, so the fallbacks were dead — and they coupled the mapping to RETURN column order, which is what made adding a column a renumbering exercise. * Build the path→hunks map in one pass, so "every value is coalesced" holds at every point rather than being repaired by a second loop. Simplify `coalesceHunks` (the length<2 branch and the sort tiebreaker changed nothing) and state `hunksOverlapRange` as a standard half-open lower bound. * Document `partial` in the detect_changes tool description. The CLI now prints it, but the MCP client — the main consumer of the pre-commit gate — was getting the flag as an undocumented raw key. * Tests: pin the query text as identical for a 1-hunk and a 3,000-hunk diff (replacing a magic length bound), pin the 0-based bounds parameter, pin the dedup, and fold two near-identical row mocks into one helper. Temp dirs now come from the shared pool helper, whose cleanup is per-directory and Windows-lock aware. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS * feat(mcp): bound and batch the hunk→symbol query, and anchor its path match (#2915) Follow-up review pass on the #2915 fix, implementing every remaining finding. * Push a per-file `[lo, hi]` span into the query. Coalesced hunks are sorted and disjoint, so a file's touched span is free, and the engine drops the symbols outside it instead of shipping every row in the file across the native boundary. Measured on a 25k-node index, 400-file batch: 546ms/13,870 rows before, 84ms/1,555 after, identical kept set. Depth stays constant (two comparisons per file, not per hunk), so #2915 cannot return. The struct-list parameter was probed against @ladybugdb/core 0.18.3 and 0.19.1 first; the index-subscript form `$paths[i]` does not parse. * Anchor the path match: `n.filePath = b.path OR n.filePath ENDS WITH b.suffix` where suffix is the path with a leading separator. A bare `ENDS WITH` is a plain string suffix, so a diff touching `lib/a.py` also reported a symbol from an indexed `src/mylib/a.py` — a file the diff never touched. This is the form `explain` already uses. Pinned by an integration test against a real engine (it fails 3/3 with the un-anchored predicate). * Run batches a few at a time. `executeParameterized` checks a connection out of the 8-connection per-repo pool for the duration of a query, so parallel calls never share one — the same reason ~15 other queries in this file already run under `Promise.all`. `allSettled`, so one failed batch degrades the result to `partial` instead of discarding the batches that succeeded beside it. * Deduplicate matched nodes by id, and count `changed_files` as distinct paths: a path can appear twice in one diff (a rename reported alongside an edit). * Cap the listed symbols at 1,000 with `symbols_truncated: {listed, total}`. A repo-wide diff otherwise puts an unbounded array in one MCP payload — the CLI has `--limit`, an MCP client has nothing. Counts are never capped, so the risk level and the CLI's "... and N more" still see the true total. * Extract `chunk` / `mapBatches` / `LBUG_QUERY_BATCH_SIZE` into `core/lbug/query-batch.ts`. Every query built from a caller-sized array has this ceiling; the shape now has one name and the measured batch size is recorded where it is defined rather than in three constants under three names. * Move hunk grouping and the 0-based conversion into `coalesceHunksByPath`, at the parse boundary. `parseDiffHunks` stays faithful to git (1-based, like the `@@` headers it reads), consumers compare graph-native values, and the conversion is unit-testable instead of living in the backend. * Document `partial` and `symbols_truncated` in the detect_changes tool description — the MCP client is the main consumer of the pre-commit gate and was getting both as undocumented raw keys. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS * refactor(core): batch every remaining repo-sized query list (#2915) `detect_changes` was not the only place building query text from a caller-sized array. `core/wiki/graph-queries.ts` interpolated the whole file list of a module into four `IN [...]` literals, growing the query with the repo — flat breadth rather than the nested depth that crashed #2915, but the same unbounded shape, and the one the repo's own `DELETE_FILES_CHUNK_SIZE` precedent already chunks elsewhere. All four now run one query per batch and merge in JS. The membership arms need care, and each is documented where it happens: * `getIntraModuleCallEdges` batches the caller arm only. A per-batch callee arm would drop a call from batch 0 to batch 2, both inside the module, so that predicate moves to JS against the whole set. Results are now sorted: the single-query form had no ORDER BY, and batch order would hand the entire 30-edge window `formatCallEdges` keeps to the first 100 files (#2787). * `getInterModuleCallEdges` keeps the SAME batch list in its `NOT` arm. That is sound — a file outside the module is outside every batch — and it preserves the null handling: `NOT null IN [...]` is null, so the original dropped edges to a node with no filePath, where a JS-only `!has(undefined)` would admit them. ORDER BY and LIMIT move to JS because a per-batch limit would cut rows before the cross-batch membership filter ran. * `getProcessesForFiles` keeps `LIMIT` inside the batch: `stepCount DESC, id` is a total order, so a process in the global top-N is in its own batch's top-N. Also adopt the shared `chunk()` at the hand-rolled slice loops in `lbug-adapter.ts`, `embeddings/http-client.ts` and `run-analyze.ts`. The loops whose index fed a progress callback or an error message use `chunk(...).entries()`, which removes the `i / SIZE` and `Math.floor(i / SIZE)` arithmetic rather than reproducing it. No batch size changed. One trap that survived tsc and is worth naming: after renaming a loop variable away from `chunk`, a leftover `chunk.length` silently resolved to the imported FUNCTION's arity, reporting `chunkSize: 1` for a 200-path batch. Only `lbug-query-importers-batch`'s exact-value assertion caught it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS * refactor: name the line-base conversions and share the symbol line (#2915) The 0-based-graph vs 1-based-elsewhere rule was open-coded in five places with the reasoning living only in comments — the same rule that, applied by hand and skipped once, hid every last-line edit from `detect_changes`. * Add `toOneBasedLine` beside `toZeroBasedLine` in `ingestion/utils/line-base.ts` so the module owns both directions, and adopt it at the four CFG/PDG join sites in `pdg-impact.ts` and the two in `local-backend.ts`. This is NOT `line-display.ts`'s `toDisplayLine`, which is documented as a response boundary converter with an `undefined` passthrough; the joins need arithmetic, and the guards that produce `Number.NaN` for an absent line are kept verbatim. * `http-route-extractor.ts` probed graph spans with a bare `line - 1` and a 20-line comment. It calls `toZeroBasedLine` now; the `?? pick(line)` fallback arm is untouched, so which node is picked cannot change (the clamp differs only for a negative line, which no emitter can produce). * Extract `formatSymbolLine`: `detect-changes-format.ts` and `eval-server.ts` rendered the same `type name → filePath` line. One behavior note — the two were not byte-identical, and eval-server had no placeholder on `name`, so a definition with an empty name rendered the literal `undefined` and now renders `?`. Both `definitions[]` shapes set name from a graph row, so this is unreachable in practice, and printing `undefined` into LLM-facing output is the bug, not the intent. `||` (not `??`) in the placeholders is deliberate and documented: a node label can come back as an empty string and still needs the placeholder. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS * perf(wiki): bind the module file list instead of splicing it into the query (#2915) The wiki's four `IN [...]` sites interpolated every file of a module into the query text, so the text grew with the repo — the shape that overflowed LadybugDB's recursive evaluator copy in `detect_changes`. The previous commit chunked them, which worked but cost real complexity: the callee arm had to leave Cypher and be re-implemented in JS, DISTINCT had to be re-established across batches, and ORDER BY/LIMIT had to move to JS so a per-batch window could not cut rows the cross-batch filter still needed. Binding the list as a parameter removes the reason for all of it. The text is constant at any list length, and measured against a real index a bound list is ~3x faster than the equivalent literal (5,000 items: 139ms vs 459ms; 20,000: 598ms vs 1,686ms). Every predicate goes back into Cypher, including the `NOT ... IN` arms whose null handling is load-bearing — `NOT null IN [...]` is null, so a callee with no filePath is dropped by the engine, where a JS membership test would have admitted it. Verified on this repo's own index: a 2,000-path bound list returns 14,856 rows in 877ms. Also collapses the per-process step query into one grouped `p.id IN $ids` fetch — 105ms to 13ms for 20 processes — and drops `fileListLiteral`, `callEdgeKey`, `compareProcessHeaders` and the batching loops with it. `compareStrings` was a byte-identical re-roll of `compareCodeUnits` (src/lib/utils.ts), including its #2787 rationale; it now calls the shared one. Intra-module edges are sorted where the original had no ORDER BY: `formatCallEdges` keeps only the first 30, and an unordered cut keeps a different subset per machine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS * refactor(core): one home for batching, and a backstop for the shape that crashed (#2915) `chunk` moves to `src/lib/utils.ts`, the repo's generic-utility home: it is an array helper, and leaving it in `core/lbug/query-batch.ts` made an HTTP embedding client import batching from the graph-DB namespace. `query-batch.ts` keeps what is actually about queries — the measured `LBUG_QUERY_BATCH_SIZE`, the concurrency helper, and the ceiling — and now documents the preference the wiki change proved: bind the list as a parameter first, chunk only when you cannot. `mapBatches` becomes `mapConcurrent`: nothing about it is batch-specific, and it now has non-query callers. Its body is a per-item try/catch plus `Promise.all`, so ordering comes from the primitive rather than from unwrapping a settled union. The wave barrier stays — measured against a rolling window it is 538ms vs 532ms on a 1,000-file diff, whose per-batch times spread only 1.35x. Adopted at the loops that were still hand-rolled: `file-hash.ts`, `cluster-enricher.ts` (its progress callback now accumulates `batch.length` instead of clamping an index), `filesystem-walker.ts` and `language-config.ts` (wave scheduling with `allSettled`, which is exactly `mapConcurrent`). Deliberately not adopted, each for a stated reason: the analyzer-identity probe runs as a standalone `node -e` script with no module resolution; the embedding sub-batch loop slices two parallel arrays and breaks early; `walkRepositoryPaths` reports progress from inside each wave, which `mapConcurrent` cannot express. `warnIfQueryTextUnbounded` is the backstop: #2915 died in native code with no message, and a query built by concatenating a caller-sized list is the shape that gets there. Wired at both execution chokepoints (`pool-adapter`'s `executeParameterized`, `lbug-adapter`'s `executePrepared`/`streamQuery`; their `executeQuery` siblings delegate and are covered once). It never throws — a long query the engine can actually run must not start failing on a heuristic — and it is deliberately absent from the raw write path, where a node's `content` is inlined and a large source file would warn legitimately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS * refactor(mcp): name the path-match rule, and key detect_changes by node id (#2915) * `path-predicate.ts` names the three ways a caller's path can match a stored `filePath` — `exact`, `pathSuffix`, `fragment` — instead of each call site copying whichever idiom its neighbour used. A bare `ENDS WITH` is a plain string suffix, which is how a diff touching `lib/a.ts` came to report a symbol from `src/mylib/a.ts`; the loose `CONTAINS` sites are loose ON PURPOSE (a user hint of `src/mcp` should match a directory fragment), and naming the modes is what lets a call site choose rather than inherit. * `detectChanges` kept four structures over one row set — an array, a dedup Set, an id list and an id→name Map — that had to stay in sync by hand. One id-keyed Map is all of them; insertion order is preserved, so every output is byte-identical. * `symbols_truncated: {listed, total}` becomes `truncated: true`, the key `explain`/`pdg_query`/`trace` already use. The true total was always in `summary.changed_count`, so the nested object said nothing the existing vocabulary could not. * `GraphLineRange` is now a distinct type from `DiffHunk`: they carry the same two fields in different bases, and mixing them IS #2377. The name means a 1-based hunk cannot reach `hunksOverlapRange` without a conversion between. * `coalesceHunksByPath` accumulates raw ranges and coalesces once per path rather than re-sorting on every occurrence. * `chunk` adopted at this file's own five loops — the point of extracting it — including two locals named `chunk` that shadowed the import. That shadowing is not cosmetic: it is how a leftover `chunk.length` silently became the function's arity earlier in this branch. One bug caught by the real-engine integration test and worth naming: Cypher comments are `//`, not `--`. A `--` comment inside the query string made LadybugDB reject the whole query at PREPARE, which `detect_changes` swallows into `partial` and renders as "No changes detected." Every mocked unit test passed. Prose stays out of query strings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS * refactor(test): share the git-repo bootstrap, and move the shared formatter out (#2915) `formatSymbolLine` lived in `detect-changes-format.ts` but is rendered by `eval-server`'s query formatter too, so a `query` formatter imported from a `detect_changes` module. It moves to `src/cli/format-symbol.ts`; both callers import it from there. The `||`-not-`??` fallbacks stay documented — a node label can come back as an empty string and still needs its placeholder. `test/helpers/temp-git-repo.ts` gives `initGitRepo(dir, identity?)` and `commitAll(dir, message)` to the ~10 test files that hand-rolled the same `git init -q` + two `git config` + `add -A` + `commit` sequence. It takes a directory and never owns one, matching `temp-dir-pool.ts`'s split of lifecycle from seeding; the identity is a parameter because the existing consumers genuinely disagree about it, and each keeps exactly what it configured. Four files stay hand-rolled for stated reasons — pinned author dates for a deterministic digest, remote handling, `--allow-empty`, and the `-c key=value` form that never persists to the repo. Test trims: the `formatSymbolLine` fallback cases collapse into one `it.each` table (the case pinning that BOTH consumers emit the helper's exact line stays — no table row can express it); two `line-base` cases that were compositions of their neighbours go; and `detect-changes-path-anchoring` runs its `detect_changes` call once in `beforeAll` instead of three times, keeping the three named failures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS * perf(mcp): filter the batched hunk query before the engine materialises (#2915) `UNWIND $bounds AS b MATCH (n) WHERE …b…` compiles to a CROSS_PRODUCT whose build side is a RESULT_COLLECTOR over the whole filtered node table: only the `n`-only predicates get pushed below the accumulate, so neither the anchored path match nor the [lo, hi] span could reduce the scan. Measured at 1M nodes: +242 MB for one batch and +922 MB for the four concurrent ones, paid even for a one-file diff — and at a 268 MB buffer pool the query died with `Buffer manager exception` where the old per-file query completed, landing in `partial:true` + `changed_count:0`, the #2915 false clean by another route. Adding the batch-wide, `b`-free disjunction as a redundant leading conjunct lets the planner push it below the accumulate: EXPLAIN now shows it as FILTER[2] directly under SCAN_NODE_TABLE[0]. It is a provable superset of the correlated predicate, so it cannot drop a row the correlated filter keeps. 10x less memory, ~20% faster, identical result sets. Also in detect_changes: - Sort rows on (filePath, startLine, id) before the 1000-symbol cut. The cut was slicing engine row order — measured 5 distinct orders across 8 runs on one connection, the #2787 class this branch fixes 200 lines away in the wiki. - Chunk `symIds`, the one caller-sized list left unbatched: 500k ids measured 4.0 GB RSS. Binding keeps the query TEXT constant, which is all the unbounded guard measures, while the bound VALUE stayed repo-sized. - Prefer exact path equality and widen to the anchored suffix only for paths that matched nothing, so a root README.md stops reporting pkg/*/README.md. - Report `risk_level:'unknown'` rather than 'low' when a query was swallowed. A degraded pre-commit gate must not read as an all-clear. - Pass --no-ext-diff --src-prefix=a/ --dst-prefix=b/. `diff.noprefix` in a user's gitconfig makes git emit `+++ f.py`, which parseDiffHunks cannot match, so every run printed "No changes detected." and exited 0 before any query ran. A diff that parses to zero files now raises `partial` instead of the clean branch. - `labels(n)`, not `labels(n)[0]`: labels() returns a scalar string here, so the subscript was always '' and `type` never carried a label. - Validate IMPACT_MAX_CHUNKS. The chunk() adoption turned an entry condition into an exit condition, so a non-numeric value ran every chunk instead of none. - Record why four-way concurrency is safe here, and scope the arm64 sequential comment to the query it was written for (#496). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv * fix(cli): fail the detect_changes gate instead of exiting 0 when it degrades (#2915) The secondary half of #2915 was that a swallowed query failure printed "No changes detected." and exited 0, so a shell pre-commit gate passed on a broken analysis. This branch added the PARTIAL text. It did not change the exit status, so `gitnexus detect-changes && git commit` still proceeded. `detectChangesCommand` passed a STRING to `output()`, and `output()` sets a failing code only for an OBJECT carrying `error` — under a comment calling itself "the one place that keeps scripted callers honest". A string never matches, so this command opted itself out of the only mechanism the file provides. It was broader than `partial`: the formatter also renders a backend `{error}` payload as text, so hard failures exited 0 too. Fixed narrowly in `detectChangesCommand`, following the object-first shape `checkCommand` already uses, rather than widening `output()`'s shared contract — every one of its other seven callers already passes an object and is unaffected. One code for both `error` and `partial`: `&&` only distinguishes zero from non-zero, and a softer code for `partial` would invite `|| [ $? -eq 2 ]` exemptions that reopen exactly this hole. `truncated` deliberately stays exit 0 — only the listing is capped, while the counts and risk are computed over the full set, so the verdict is sound and failing on it would fire on every large-but-healthy diff. Also wires `truncated` through the formatter, which this branch had left as a producer-only flag while `partial` went end to end, with the note in both locales and no count of its own so the existing "... and N more" line stays the sole numeric report. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv * fix(wiki): restore step order and symbol labels, and cut the edge list in Cypher (#2915) Found by running the queries against a real engine, which nothing did before: this branch's regrouped `withSteps` returned step traces OUT OF ORDER. `ORDER BY pid, r.step` combined with `WHERE p.id IN $ids` silently drops the second sort key — `proc_1_incrementalupdate` came back 2,7,1,3,4,5,6. `ORDER BY step` alone is correct, and so was the pre-branch per-process query, so this was introduced by the batching. `formatProcesses` prints "${s.step}. ${s.name}", so every module and overview page was getting scrambled execution traces. The mocked suite passed 112/112 before and after. `labels(x)[0]` is always the empty string: labels() returns a scalar string and the subscript is 1-based over its characters ([1] is "F"). `prompts.ts` renders "${s.name} (${s.type})", so all 5,027 exported symbols reached the LLM as "name ()". `getIntraModuleCallEdges` shipped every edge to use 30 — measured 18,299 rows and 851 ms with all 2,079 paths bound, against 30 rows and 94 ms with ORDER BY + LIMIT in Cypher, which the sibling `getInterModuleCallEdges` twenty lines below already did. The determinism fix (#2787) was right; the placement was not. `compareCallEdges` goes with it — it was intransitive when a name was null or empty, so `Array.sort` was input-permutation dependent, i.e. the nondeterminism it was added to remove. Deletes the positional row ABI this branch newly documented. The vendor declaration is `getAll(): Promise<Record<string, LbugValue>[]>` — string keys only — and `row[0]` probes back `undefined`; the same PR deleted ~30 identical fallbacks from local-backend.ts. They were already stale here: `withSteps` prepends `p.id AS pid`, so `toProcessStep` was reading the pre-branch layout. Rows are now typed by alias, so renaming an `AS` is a compile error. `??` for `||` so a step of 0 or an empty label keeps its own value. Tests: a real-engine integration suite covering all seven exported queries (PREPARE included — the trap that shipped a `--` comment on this branch), and the four holes that let the ordering bug through — a vacuous order assertion, a LIMIT never reached by a 2-edge fixture, a fake that returned rows pre-ordered and ignored ORDER BY, and a hardcoded `type: 'Function'` that hid labels(). The step-ordering fixture is empirically sized: 2 processes never reproduced the bug, ~400 step edges was intermittent, 710 (20 processes x 26-45 steps) hit 11 of 11 runs. Seeded descending and interleaved so no grouping looks sorted by accident. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv * refactor: put the shared helpers where their callers are, and make their contracts true (#2915) `mapConcurrent` moves to lib/utils.ts beside chunk(). Nothing about it is query-specific and it already had filesystem callers, while its docstring justified concurrency safety through the per-repo connection pool — an argument that does not apply to fs.readFile. This is the precondition the branch's own commit message stated ("it now has non-query callers") and then did not apply. LBUG_QUERY_BATCH_SIZE and warnIfQueryTextUnbounded genuinely are query-specific and stay. `pathMatch`/`PathMatchMode` deleted: zero callers, and none of the three sites its docstring cited were migrated, so the tree carried the abstraction and the copies it was written to replace. `pathSuffixOf` stays and the module now documents the anchoring rule it actually implements. Contracts that were not true: - QUERY_TEXT_CEILING_BYTES was compared against `cypher.length` — UTF-16 code units, not bytes — so non-ASCII query text was undercounted and the reported KB was wrong. Buffer.byteLength now, behind a `length * 3 <= ceiling` early return so only text over ~21 KB pays for the count. - chunk(items, NaN) returned [[]], against a docstring promising never to return an empty slice, and mapConcurrent's Math.max(1, NaN) propagated it — which would have resolved [] for non-empty input with no error, read as "no results" by every call site. - GraphLineRange claimed a 1-based hunk could not reach hunksOverlapRange without a conversion, but it was structurally identical to DiffHunk so tsc accepted one with no diagnostic, and coalesceHunks<T extends GraphLineRange> actively laundered the base while its accumulator was still DiffHunk[]. The useless generic is gone and a one-line phantom on each interface makes the claim real; a bare {startLine, endLine} literal still satisfies both, so no construction site needs a cast. Pure deletions no longer vanish. A -U0 deletion emits `+N,0`, which parseDiffHunks dropped, so the file survived with no hunks, no query ran, and detect_changes reported `changed_files:1, changed_count:0, risk_level:'low'` — "No changes detected." for a commit that deleted a function. A unified diff spells an empty range as the line before it, so the anchor is line N alone: a symbol containing the deleted text also contains N, while extending to N+1 would claim a symbol that merely starts after the gap — the widening coalesceHunks guarantees it never does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv * docs: say that a partial or truncated detect_changes is not a clean gate (#2915) The gate itself now fails loudly, but the instructions every agent reads still described a zero as a result. Fixed at the source: AGENTS.md's gitnexus block is generated from a template in cli/ai-context.ts and injected into every user's repo, so the sentence goes there and AGENTS.md/CLAUDE.md are regenerated through the real code path (which also picks up a pre-existing `analyze --index-only` drift the committed docs were behind). That block is under a test-enforced size cap with 30 characters of headroom, so the 144-character clause was paid for in the same currency: the header exhortation, which the Always Do list restates as MUSTs with commands, and a verbatim repeat of the detect-changes command in the regression-compare example. 3549 of 3552. Worth noting for whoever adds the next line — #2899 replaced an absolute cap with a 0.65 ratio to let "a legitimate clause fit without ceremony", but set the ratio flush against the block's then-current size, so it is a ratchet with no ratchet. The canonical block does not make the skills redundant: three of the four install channels ship skills without touching AGENTS.md, --skip-agents-md does the same in-repo, and a user-trimmed gitnexus:keep block legitimately has no Always Do section — in those repos the skill file is the only carrier. Precedent agrees: the risk:UNKNOWN rule is deliberately carried in both places. So one sentence each in gitnexus-work (the commit gate), gitnexus-impact-analysis (beside the UNKNOWN paragraph) and gitnexus-refactoring, whose post-hoc "verify only expected files changed" is the worst of the three because a degraded result makes it vacuously pass. gitnexus-taint-analysis is left alone: its audience is always inside this repo, where the canonical block loads. All copies mirrored to npm, plugin and cursor. The cursor copies are condensed checklists rather than byte-mirrors, so they carry the equivalent note placed where it governs every detect_changes line in the file — and nothing tests that, since standard skills are fragment-checked rather than byte-compared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv * refactor: break the seven small import cycles gitnexus check reports (#2915) `check` reported 11 cycles. Five are paths inside a single 257-file strongly connected component in core/ingestion (call-extractors / cfg visitors / utils/ast-helpers), with a second 26-file component behind it — fixing those paths would only make check print different ones, so both are left for their own PR. This closes the seven that are genuinely separable, taking the graph from 9 strongly connected components to 2. Six of the seven were one value import plus one `import type` edge. tsconfig sets neither verbatimModuleSyntax nor isolatedModules, so those edges erase entirely — the cut is a graph and readability change with no emitted-JS difference. Each moved type went to a leaf module, with a re-export left behind only where an importer outside the change actually needed it: - cli/ai-context <-> cli/skill-gen: GeneratedSkillInfo -> cli/generated-skill.ts. One importer, no package export surface, so a clean move with no re-export. - cli/analyze-config <-> cli/analyze (+core/run-analyze): AnalyzeOptions -> cli/analyze-options.ts. Re-export kept because a test imports it from analyze.js. run-analyze needed no edit — cutting the one type edge collapses the 3-file component into a DAG. Its own same-named AnalyzeOptions is a different interface and was deliberately not merged. - ingestion/import-resolvers/types <-> ingestion/language-config: type-only in BOTH directions, so it had no runtime existence at all. ImportConfigs has no importers outside the pair and is the return type of loadImportConfigs, so it moved into language-config. Side effect worth having: the shared resolver types module no longer names a single language, which is an AGENTS.md rule for core/ingestion shared pipeline code. - ingestion/di-extractors barrel <-> spring: DiResolver and the two match types -> di-extractors/types.ts, following the import-resolvers/types.ts precedent. - scope-resolution/walkers <-> workspace-index: WorkspaceResolutionIndex -> workspace-index-types.ts. Re-export is load-bearing — 9 src importers, 4 test files, and a dynamic import() at contract/scope-resolver.ts. Moving the value isClassLike instead was rejected: ~15 value importers, and it is documented as a pair with isShapeLike. - server/analyze-worker <-> analyze-worker-core: the WorkerMessage protocol -> analyze-worker-protocol.ts, a declarations-only leaf. storage/branch-index <-> storage/repo-manager was the one genuine two-way runtime cycle: branch-index called getStoragePaths/loadMeta, repo-manager used branchSlug/BRANCHES_DIR. branch-index's header conceded the cycle and argued it was ESM-safe because neither side calls across at module-evaluation time — a guarantee resting on call ordering rather than structure. Folding resolveBranchPlacement back the other way does not help, because BranchSummary.stats is typed RepoMeta['stats'], so RepoMeta had to move either way. Extracted storage/repo-meta.ts, a leaf importing only fs and path, holding the metadata read primitives; repo-manager re-exports the public names so all 54 RepoMeta and 50 loadMeta importers are untouched. The moved block diffs byte-identical against HEAD. Verified beyond typecheck, because the worker entrypoint is the risky part and nothing in the suite forks it: emitted analyze-worker.js still contains exactly one runtime import, and forking the real worker over IPC boots it through entry -> core -> protocol -> terminal-claim. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv * refactor: apply the reuse, simplification, efficiency and altitude cleanups (#2915) The one that mattered: the degradation exit code was fixed at the wrong depth. `output()` has never inspected `partial` — it tests `error` only — so putting the check in `detectChangesCommand` left every other tool exiting 0 on a degraded run. `partial` is cross-tool vocabulary: query (enrichmentDegraded || ftsPartial), impact (!traversalComplete, perSymbolEnrichmentCapped) and the mode:'pdg' envelope all emit it. A truncated impact traversal returns a short caller set and an under-ranked risk, then exits 0 — so `gitnexus impact … && <edit>` proceeds, in the tool AGENTS.md makes a MUST gate before every edit. The justification also cited checkCommand as precedent, but checkCommand passes STRINGS too — it was the second command already hand-rolling around this gap, while output()'s docstring called itself "the one place that keeps scripted callers honest". output() now takes an optional renderer and fails on error OR partial; two hand-rolled sites go away and three tools are covered instead of one. truncated stays exit 0 (only the listing is capped) and checkCommand's cycleCount policy stays put. Efficiency, all re-measured on the 25k-node index: - The process lookup was chunked with LBUG_QUERY_BATCH_SIZE, calibrated for the opposite query shape — that constant is for a whole-node-table scan where more items amortise the scan, while this is an `id IN $ids` probe where round trips dominate. 20k ids: 617ms at 100, 261ms at 1000. New LBUG_ID_PROBE_BATCH_SIZE, documented against its sibling so they cannot be re-merged. This also settles the older "chunking this query is a regression" measurement — that was chunk=100. - The sort comparator re-coerced fields ChangedSymbolRow already types, O(n log n) redundant conversions (+31-38%). Row shape probed directly: alias-keyed, no positional keys, numeric columns are JS numbers. - exactlyMatchedPaths built two throwaway arrays; one loop instead (40k rows 11.4ms -> 4.5ms). - The integration fixture seeded 710 step edges one round trip at a time; one UNWIND instead. File wall time 6.91s -> 3.63s. Fixture size unchanged — its docstring records the threshold below which the bug stops reproducing, and the mutation check still fails 3/3 when ORDER BY step is reverted. Reuse and simplification: - CALL_EDGE_LIMIT existed in four places; its own docstring predicted the drift it then caused. prompts.ts owns it now — it is a zero-import leaf so the direction cannot cycle, and had graph-queries.ts owned it the four suites that vi.mock that module would have left slice(0, undefined), silently returning every edge in exactly the tests meant to police the cap. - Six dead positional row fallbacks survived the rewrite in the loop this branch re-indented, in the same PR that deleted the identical ABI from graph-queries.ts. - Two test files independently modelled the same labels() scalar-string quirk. Deleted the wiki one — the file's own new header says semantics belong in the real-engine test — and kept projectTypeColumn, the only instrument that can see the bug for the detect_changes query. - makeRepo onto the shared git bootstrap (the eleventh copy of the sequence the helper was extracted to own), the duplicate diff-args unwrapper merged into test/helpers, hand-rolled comparators onto compareCodeUnits, real-timer sleeps replaced by wave-released promises with a strengthened per-wave assertion. - Re-exports trimmed to what is actually imported, a cross-reference this branch invalidated by moving mapConcurrent, and a "~20% faster" claim that does not survive at real index sizes (1-9%; the 10x memory win does). Also adds the drift guard the new doc text lacked: fragment coverage for the partial/truncated paragraph in every skill copy and in the managed AGENTS.md / CLAUDE.md block. Falsifiability checked — none of those fragments exist at the merge base. Not done here, deliberately: 27 live labels(x)[0] projections remain across impact/context/query/trace and MCP resources, with four load-bearing workarounds that have begun depending on each other and one that fabricates rather than degrades. That is a semantic change to five agent-facing tools and wants its own PR, scoped to delete the workarounds too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv * fix(cli): restore the detect-changes subcommand in the regression example (#2915) Caught by the gitnexus-check bot on the PR. The regression-review fallback in the injected mandate rendered as `--scope compare --base-ref "main" --repo .` with no command, so anyone copying it invokes the runner with an option as its first argument. Self-inflicted, and by exactly the mechanism flagged when it landed: the block is under a test-enforced size cap (#856) that had 30 characters of headroom, so adding the partial/truncated clause required paying for it, and the 38-character "repeat" that was dropped turned out to be the subcommand rather than a repeat. Paid for the restoration out of the clause instead — both parentheticals are gone, since `partial` and `truncated` are already defined in the tool description this text points at. Block is back under the cap at 3548/3552. Notably the cap has now been raised four times (2700 -> 2900 -> 2950, then 0.55 -> 0.65) each with the argument that the new line is load-bearing, and it has now also caused a user-facing defect. It is not functioning as a budget. Left at 0.65 here rather than making it five: moving the threshold to fit one's own text is how it got here. Worth restructuring separately. The fragment guard added a commit ago caught the rewording immediately, which is what it is for; its fragments now pin the two policy claims rather than the prose around them, since that prose is what gets re-trimmed under the cap. Also verified and NOT changed: the bot's other error, that detect_changes compares 1-based hunks against 0-based graph lines. `bounds` is built from `coalesceHunksByPath`, which applies `toZeroBasedLine` to both ends at the grouping boundary, and both a mocked and a real-engine test pin an edit landing on a symbol's last line. The bot read `parseDiffHunks` in isolation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv * fix(core): reject a fractional chunk size, and stop the truncation note overclaiming (#2915) All five from the gitnexus-check bot's pass on the previous push; two were introduced by the cleanup round that preceded it. `chunk` guarded with `Number.isFinite`, which admits a fractional size — and that one does not fail, it DUPLICATES. `slice` truncates its indices while `i` does not, so size 1.5 yields slice(0, 1.5) = items 0-1 then slice(1.5, 3) = items 1-2, putting item 1 in two batches; a caller batching a query would send it twice. A size is a count, so `Number.isInteger`. Unreachable today (every caller passes a constant) but the guard existed precisely for the unreachable case, and the NaN half of it was already there. `mapConcurrent`'s per-item degradation contract had a hole: `onError` is caller-supplied and was invoked outside a try, so a throwing reporter rejected `settle`, rejected the whole `Promise.all` wave, and discarded the neighbouring successes the function exists to preserve. Reporting a failure must not become one. The CLI truncation note asserted "the counts and risk level still cover all of them", which is true only when `truncated` fires alone — with `partial` the counts are summed from the batches that succeeded. It now varies: a distinct string when both flags are set, saying the counts are a lower bound. This is the same claim already corrected in the tool description; the CLI text still had the old one. The di-extractors contract docstring claimed the barrel re-exports everything from it. That stopped being true when the re-export was trimmed to what is actually imported, one commit earlier. The real-engine wiki test claimed to prepare "every exported query" and omitted `getInterModuleEdgesForOverview`, which `generateOverview` calls. Added — it aggregates in JS over `getInterFileCallEdges` rather than issuing its own Cypher, so the note says why it is in a prepare test. Verified and NOT changed: the bot's other error, that detect_changes compares 1-based hunks against 0-based graph lines. `bounds` is built from `coalesceHunksByPath`, which converts both ends at the grouping boundary (storage/git.ts), and two tests pin an edit landing on a symbol's last line. The remaining seven findings are changed-symbol heads-ups with no signature change; their callers' suites are green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv * fix(mcp): make the IMPACT_MAX_CHUNKS fallback actually fire (#2915) The validation added earlier this branch used `Number.parseInt`, which takes the numeric PREFIX: '1.5' parses to 1, satisfies `Number.isInteger`, and silently caps enrichment after a single 100-item batch — the opposite of the fallback the comment beside it promised. `Number` instead, so a fractional value is rejected and falls back to 10. The emptiness check is load-bearing rather than defensive: `Number('')` is 0 and 0 is a legitimate value here (enrich nothing), so an UNSET variable would otherwise mean "enrich nothing" rather than "use the default". Behaviour table, old vs new: '1.5' 1 -> 10 (the bug), and undefined/''/' '/ '10junk'/'-2'/'all' -> 10, '0' -> 0, '3' -> 3, ' 5 ' -> 5 all unchanged. So the only case that moves is the reported one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
3409 lines
132 KiB
TypeScript
3409 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';
|
||
import { commitAll, initGitRepo, type GitIdentity } from '../helpers/temp-git-repo.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;
|
||
|
||
const HOOK_TEST_IDENTITY: GitIdentity = { name: 'Test', email: 'test@test.com' };
|
||
|
||
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
|
||
initGitRepo(tmpDir, HOOK_TEST_IDENTITY);
|
||
fs.writeFileSync(path.join(tmpDir, 'dummy.txt'), 'hello');
|
||
commitAll(tmpDir, '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();
|
||
}
|
||
|
||
/** A repo with one commit — `worktree add` and `rev-parse HEAD` need one. */
|
||
function initRepoWithCommit(dir: string) {
|
||
initGitRepo(dir, HOOK_TEST_IDENTITY);
|
||
fs.writeFileSync(path.join(dir, 'file.txt'), 'hello');
|
||
commitAll(dir, '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 });
|
||
initRepoWithCommit(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 });
|
||
initRepoWithCommit(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 });
|
||
initRepoWithCommit(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 });
|
||
initRepoWithCommit(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 });
|
||
initRepoWithCommit(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 });
|
||
initRepoWithCommit(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');
|
||
});
|
||
}
|
||
});
|