Compare commits

...
Author SHA1 Message Date
github-actions[bot] f462a1f573 release: v1.6.5-rc.24 2026-05-13 12:55:53 +00:00
dependabot[bot]andGergő Magyar a9d72e2dbf chore(deps): bump urllib3 in /eval in the uv group across 1 directory (#1512)
Bumps the uv group with 1 update in the /eval directory: [urllib3](https://github.com/urllib3/urllib3).


Updates `urllib3` from 2.6.3 to 2.7.0
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.6.3...2.7.0)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-version: 2.7.0
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-13 13:31:07 +01:00
GoGoLin 4cc4e9c84b fix(build): use platform-aware tsc command for win32 (#1531) 2026-05-13 13:02:58 +01:00
azizur100389andGergő Magyar 48cd55a120 fix(search): guard against undefined bm25Results when FTS unavailable (#1489) (#1540)
* fix(search): guard against undefined bm25Results when FTS unavailable (#1489)

When the FTS extension is unavailable in the MCP process,
searchFTSFromLbug can return an unexpected shape or throw,
leaving bm25Results undefined. The for-loop then crashes with
"bm25Results is not iterable".

- mergeWithRRF: default both inputs via ?? [] so undefined
  never reaches the iteration loops
- hybridSearch: wrap searchFTSFromLbug in try/catch and fall
  back to semantic-only search instead of crashing
- local-backend query handler: guard bm25SearchResult?.results
  and semanticResults with ?? []
- bm25Search: wrap the dynamic import in try/catch for
  sandboxed MCP contexts; guard ftsResponse?.results

Adds 6 regression tests covering undefined inputs and FTS
failure fallback.

Fixes #1489

* fix(search): address review findings on #1489 crash guards

- Guard ftsResponse.results with ?? [] in hybridSearch (Finding 1)
- Add logger.warn on bm25-index.js import failure (Finding 3)
- Add unit test for callTool query FTS throw path (Finding 2)

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-13 12:30:21 +01:00
azizur100389andGergő Magyar e8c8ddec8a fix(wiki): sanitize generated mermaid diagrams (#1539)
* fix(wiki): sanitize generated mermaid diagrams

* fix(wiki): address mermaid sanitizer review

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-13 11:53:09 +01:00
ec4624af87 fix(hooks): cap concurrent augment subprocesses (#1486) (#1510)
* fix(hooks): cap concurrent augment subprocesses to prevent runaway process spawn (#1486)

When Claude Code fires PreToolUse hooks for parallel Grep/Glob/Bash tool
calls, each invocation spawned its own `gitnexus augment` subprocess —
a Node + LadybugDB cold start that holds resources for several seconds.
Under heavy parallel search load (issue #1486: 180+ piled-up processes,
load avg > 100), these accumulated faster than they completed because
nothing capped concurrent in-flight augments.

Add a lockfile-based concurrency guard under `<.gitnexus>/.hook-locks/`:
each running hook claims a `<pid>.lock`, the guard counts live PIDs and
prunes stale entries (>30s mtime or pid no longer alive), and bails
silently when MAX_INFLIGHT (3) is reached. Augment is best-effort
enrichment — missing a few fires under burst load is preferable to
melting the system.

Applied to all three hook variants that spawn augment:
- gitnexus/hooks/claude/gitnexus-hook.cjs (npm-installed Claude hook)
- gitnexus-claude-plugin/hooks/gitnexus-hook.js (plugin Claude hook)
- gitnexus-cursor-integration/hooks/gitnexus-hook.cjs (Cursor hook)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(hooks): make augment concurrency cap a hard cap via atomic slot files

Address Claude's review of #1510. The original count-then-claim guard had
a TOCTOU window: N hooks could each read `active < MAX_INFLIGHT` between
readdirSync and the per-pid `wx` write and all proceed, briefly exceeding
the cap. The PR title's "cap" language overstated this.

Replace with fixed-name `slot-0.lock` ... `slot-N.lock` under `.hook-locks/`.
`O_CREAT|O_EXCL` on a fixed path is OS-atomic — exactly one process wins
each slot, so the cap is hard regardless of burst arrival timing. Each
slot file contains the owning PID so stale-takeover still works when a
hook crashes without releasing.

PID liveness is checked before age (Claude's Finding 3): a slow-but-alive
hook is never wrongly evicted. The 30s age window only kicks in to defend
against PID reuse on a long-abandoned slot, well above the 7s augment
timeout so a healthy run never hits it.

Also adds the missing concurrency-guard tests to cursor-hook.test.ts
(Claude's Finding 2): source-level wiring + dead-PID reclaim + 3-slots-full
bail. Previously only the CJS and Plugin variants had test coverage for
the guard; the Cursor variant was validated only by code inspection.

Tests: 5726 passing, +9 from baseline (1 hard-cap burst test + 4 source
regressions in hooks.test.ts; 3 source + 2 integration in cursor-hook.test.ts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(hooks): inspect slot mtime + content via single fd (codeql TOCTOU)

CodeQL flagged the stale-takeover path in acquireHookSlot as a potential
filesystem race (js/file-system-race): statSync(slotPath) followed by
readFileSync(slotPath) gives a TOCTOU window where the file could be
swapped between the metadata check and the content read.

Replace the two separate path-based calls with a single openSync + fstatSync
+ readSync + closeSync sequence. Both mtime and owner PID now come from the
same file descriptor, so the operations are atomic on one inode. No
behavioral change beyond closing the race.

Applied to all three hook variants (CJS, Plugin, Cursor).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(hooks): distinguish EPERM from ESRCH in PID liveness check

Cursor Bugbot caught a contradiction with the stated design: the bare
`catch` after `process.kill(owner, 0)` was treating EPERM (process exists
but owned by another user) the same as ESRCH (process gone), which would
evict a live slot whenever the lock dir straddled user boundaries.

Inspect the error code: ESRCH → dead, evict; EPERM → still alive, keep
the slot; anything else → assume alive (be conservative under unexpected
failure rather than over-evict).

Applied to all three hook variants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(hooks): fail closed when lock dir cannot be created

Previously the mkdirSync catch in acquireHookSlot returned `() => {}`
(a truthy no-op). The caller checks `if (!release) return;` to skip
augment when the guard can't be established — but a truthy no-op
slipped through that check and let augment spawn unguarded. On a
cross-user shared `.gitnexus/` or read-only filesystem, N concurrent
hooks would each take that branch and reintroduce the #1486 fan-out
the guard exists to prevent.

Return `null` instead so the caller's `if (!release) return;` skips
augment cleanly. Augment is best-effort enrichment — skipping it when
the guard fails is strictly safer than running unguarded.

Also clarify the stale-slot comment: PID-liveness wins for slots
younger than HOOK_LOCK_STALE_MS, but age is the final arbiter beyond
30s (PID-reuse defense). The previous wording said "PID-liveness wins
over age" without qualifying it, which contradicted the >30s branch.

Add source-level regression tests in hooks.test.ts and
cursor-hook.test.ts asserting acquireHookSlot returns null (not
() => {}) on lock-dir failure. Note in the Cursor test file that the
10-spawner burst test is not duplicated because the algorithm is
byte-for-byte identical to the CJS hook and already covered there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(hooks): extract lock guard into helper modules

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/04dd20c5-28fd-433a-83cf-ad83fd03fb32

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-13 08:56:27 +01:00
dependabot[bot] aed6cfc7ea chore(deps)(deps): bump mermaid (#1514) 2026-05-13 08:00:14 +01:00
dependabot[bot] 6a23616873 chore(deps)(deps): bump protobufjs from 7.5.5 to 7.5.8 in /gitnexus (#1536) 2026-05-13 06:40:24 +01:00
dependabot[bot] 7637bd1c83 chore(deps)(deps): bump @protobufjs/utf8 in /gitnexus (#1535) 2026-05-12 23:41:05 +01:00
Gergő Magyar 8083c39f6d feat(php): migrate PHP to scope-based resolution model (#938) [supersedes #1124] (#1497) 2026-05-12 16:56:31 +01:00
a2f1b07700 fix: resolve TypeScript ESM .js extension imports to .ts source files (#1525)
* fix: resolve TypeScript ESM .js extension imports to .ts source files

TypeScript ESM requires imports to use .js extensions even when source
files are .ts (moduleResolution: node16/bundler). The import resolver
now strips JS-family extensions (.js/.jsx/.mjs/.cjs) and retries with
TS equivalents (.ts/.tsx/.mts/.cts) when the literal .js file does not
exist. This fallback only applies to TypeScript/JavaScript languages.

Also adds .mts/.cts to the EXTENSIONS list for completeness.

Fixes #1503

* fix: address review findings — normalization, edge-case tests, integration test

- Fix makeCtx to use production normalization (.replace backslash)
  instead of .toLowerCase() (Finding 3)
- Add tests for .mjs/.cjs with competing .ts/.mts siblings (Finding 1)
- Add tests for ./dir.js → dir/index.ts boundary (Finding 2)
- Add integration test verifying full pipeline CALLS edges for ESM
  .js imports (Finding 4)
- Document path alias .js limitation as known follow-up (Finding 5)

* chore(autofix): apply prettier + eslint fixes via /autofix command

* chore: retrigger CI after bot-only tip commit

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 16:05:58 +01:00
evolutionandGergő Magyar 0daae93701 fix(lbug): drain checkpoint result before close (#1506)
* fix(lbug): drain checkpoint result before close

* test(lbug): cover checkpoint drain lifecycle

* fix(lbug): close query results after reads

* fix(lbug): close all stream query results

* fix(lbug): harden query result cleanup

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-12 14:03:45 +01:00
4fa40e9881 feat(analyze): incremental indexing (parse cache + DB writeback + scope-res short-circuit) (#1479)
* docs: incremental indexing design spec

Captures the design agreed in brainstorming on 2026-05-10:
- Transitive importer closure with public-surface-change optimization
- Git-only change detection (non-git repos: full rebuild as today)
- New default behavior; --force opts out
- New hydratePhase + loadGraphFromLbug primitive
- Iterative closure expansion with parseCache reuse
- incrementalInProgress dirty flag for crash recovery

Prior art: PR #592 (zenprocess), PR #533 (davidbeesley),
PR #1146 (azeemshaik025) — referenced and credited.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(communities): seed Leiden RNG for deterministic community detection

The vendored Leiden algorithm defaults to Math.random for tie-breaking
and randomized walks, which produces non-deterministic community
assignments and modularity values across runs on the same graph.

Pass a seeded mulberry32 RNG (LEIDEN_SEED=0xC0DE) so:
- The same graph always produces the same partition
- Modularity values are reproducible
- Equivalence tests for incremental indexing can compare community
  assignments byte-for-byte

This is foundational for the upcoming incremental-indexing feature
(see docs/superpowers/specs/2026-05-10-incremental-indexing-design.md)
where the correctness contract is incremental output ≡ full rebuild
output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(incremental): change-detection, surface signatures, closure expansion

Three new modules supporting the incremental-indexing pipeline:

* core/incremental/git-diff.ts — getChangedFilesSinceCommit() unions
  'git diff lastCommit HEAD' (committed) with 'git status --porcelain'
  (dirty tree). Renames flattened to delete(orig) + add(new). Throws
  LastCommitMissingError when lastCommit is gone (caller falls back to
  full rebuild).

* core/incremental/surface.ts — extractSurfaceSignature() produces a
  stable hash of a file's publicly-visible symbols (functions, classes,
  methods, interfaces, types, heritage). Body-only edits → same hash.
  Signature/heritage changes → different hash. Drives the closure
  scoping optimization.

* core/incremental/closure.ts — computeImporterClosure() iterative
  fixpoint: parse each closure file, extract surface, query DB
  importers, expand. Uses a parseCache so each file is parsed once.
  Generic over TParseResult so closure logic is decoupled from the
  pipeline's parse representation.

32 unit tests across the three modules. Tests cover edge cases:
clean tree, dirty-only, mixed, renames, deletes, multi-hop cascade,
cycle termination, surface invariance, etc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(lbug): loadGraphFromLbug, queryImporters, deleteAllCommunitiesAndProcesses

Three new primitives in lbug-adapter.ts to support incremental indexing:

* loadGraphFromLbug(graph, unchangedFilePaths) — streams all nodes for
  files in the set across every hydratable node table (excludes
  Community/Process — graph-wide, regenerated downstream). Then loads
  edges where both endpoints belong to loaded nodes, excluding
  MEMBER_OF / STEP_IN_PROCESS edges (also graph-wide).
  FilePaths chunked at 200 per query to keep statement size bounded
  on huge repos. Endpoint-level join filters by source-side filePath
  in the query, target-side checked JS-side via the loadedNodeIds set.

* queryImporters(targetFilePath) — returns DISTINCT a.filePath where
  a -[IMPORTS]-> b and b.filePath = target. Powers closure expansion:
  when a changed file's surface signature changes, all its importers
  must be re-parsed.

* deleteAllCommunitiesAndProcesses() — drops Community/Process nodes
  (and their edges via DETACH DELETE) at the start of each incremental
  run so the communities/processes phases regenerate them from the
  fully-merged graph. Required for the 'Leiden runs on full graph'
  correctness invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(pipeline): hydrate phase + parse-filter for incremental indexing

Wires the incremental-indexing infrastructure into the phase-based
pipeline. Three coordinated changes:

* New hydratePhase (deps: structure) — loads node/edge state for files
  OUTSIDE ctx.options.filesToParse from the existing LadybugDB index.
  Runs before parse so the parse phase can produce a partial graph
  while downstream phases (mro, communities, processes) still see the
  full graph. No-op in full-rebuild mode (filesToParse unset).

* PipelineOptions.filesToParse: optional ReadonlySet<string>. When
  set, parse phase filters scanned files to this set; hydrate fills
  the complement. Set by runFullAnalysis when it detects an eligible
  incremental run; never set by callers directly.

* gitnexus-shared PipelinePhase enum: 'hydrate' added so progress
  callbacks can report the new phase distinctly from 'structure'.

Phase order: scan → structure → hydrate → markdown,cobol → parse
→ routes,tools,orm → crossFile → scopeResolution → mro → communities
→ processes. Communities (Leiden) still runs on the full graph,
satisfying the correctness invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(analyze): incremental orchestrator branch + meta schema

Wires incremental indexing into runFullAnalysis. Highlights:

* RepoMeta schema extended: schemaVersion, surfaceSignatures, and
  incrementalInProgress fields. INCREMENTAL_SCHEMA_VERSION = 1.

* core/incremental/file-hash.ts — v1 surface signature: SHA-256 of file
  content. v2 will switch to a true surface-only signature (defined in
  surface.ts) so body-only edits don't expand the closure. The plumbing
  is signature-agnostic so the swap is local.

* core/incremental/orchestrator.ts — eligibility check, closure
  computation (uses file-hash as the surface signal), dirty-flag
  management, subgraph extraction, signature merge.

* run-analyze.ts adds:
  - hasDirtyTree() check on the existing 'lastCommit==HEAD' early-exit
    so an uncommitted edit triggers re-index (was a coarse equality
    check before).
  - incremental branch: try incremental first; fall through to full
    rebuild on any setup failure or eligibility miss.
  - runIncrementalBranch() — opens existing DB, deletes closure-file
    rows + Community/Process, runs pipeline with filesToParse, writes
    only the changed-subgraph back, refreshes FTS, updates meta with
    new surfaceSignatures and clears the dirty flag.
  - Full-rebuild path now populates surfaceSignatures + schemaVersion
    in meta.json so the next run is eligible for incremental.

Crash recovery: incrementalInProgress is set BEFORE any DB mutation
and cleared on success by overwriting meta.json. A crash anywhere in
between leaves the flag set, and the next analyze run forces a full
rebuild (cheapest path back to a known-good index).

v1 limitation documented: body-only edits trigger 1-hop closure
expansion (content-hash signal). True surface-only optimization is
deferred to v2 — see design doc for the integration path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(incremental): drop invalid --no-renames=false from git diff

The flag --no-renames=false isn't valid git syntax (it's parsed as a
file path). Git's default rename detection is on; removing the flag
keeps that behavior.

Caught while running an end-to-end smoke test against a small fixture
repo: incremental setup failed with 'Command failed: git diff
--name-status -z --no-renames=false ...'. After the fix, the
incremental path runs cleanly: closure is computed, hydrate phase
loads unchanged-file state from DB, parse phase only re-parses files
in closure, and the writeback updates only changed nodes/edges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Revert v1 incremental indexing (5 commits)

Reverts the v1 design that parsed only closure files into a fresh
graph and tried to hydrate the rest from DB. Real-repo equivalence
test failed: cross-file resolution operates on partial parse data
(closure files only), so CALLS edges that resolve through unchanged
files silently fall off. Diff against full rebuild on the same
edited state: -50 nodes, -425 edges, -5 communities, -48 processes.

Architecture pivot: switch to PR #533-style content-addressed parse
cache. Pipeline parses every file (cache-served when possible),
giving cross-file resolution full data, with DB writeback then
restricted to changed-file rows.

Reverts:
  d4b9de47 fix(incremental): drop invalid --no-renames=false
  f35f7634 feat(analyze): incremental orchestrator branch + meta schema
  bc039686 feat(pipeline): hydrate phase + parse-filter
  98bb893d feat(lbug): loadGraphFromLbug, queryImporters, ...
  aa8d7ae3 feat(incremental): change-detection, surface signatures, closure

Kept:
  d9e340b0 feat(communities): seed Leiden RNG (foundational)
  8235ca36 docs: incremental indexing design spec (will be revised)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(analyze): incremental DB writeback (Option B)

Equivalence-preserving incremental analyze. The pipeline still parses
every file (correctness invariant: cross-file resolution / scope
resolution / MRO / community detection all need full graph data); the
saving comes from selectively replacing only changed-file rows in
LadybugDB instead of wiping and reloading the whole graph.

How it works:

* On every analyze, we hash all source files (SHA-256 of content) and
  store the map in meta.json.fileHashes alongside schemaVersion.
* The next run loads the prior map and diffs:
  - changed: content hash differs → file's DB rows replaced.
  - added: not in prior map → file's DB rows inserted.
  - deleted: in prior map but not on disk → file's DB rows dropped.
* If the diff is non-empty AND no --force / no schema mismatch / no
  dirty flag, take the incremental path:
  - Set incrementalInProgress dirty flag (BEFORE any DB mutation).
  - Open existing DB (no wipe).
  - deleteNodesForFile() for each changed/added/deleted file.
  - deleteAllCommunitiesAndProcesses() — Leiden regenerates these.
  - extractChangedSubgraph() from the in-memory ctx.graph: nodes whose
    filePath is in the writable set + Community + Process + edges with
    at least one endpoint in the writable set (edges entirely between
    hydrated unchanged nodes are skipped — already in DB).
  - loadGraphToLbug() on the subgraph. Unchanged-file rows in DB
    untouched.
  - Recreate FTS indexes.
  - Update meta with new fileHashes; clear dirty flag.
* Otherwise full-rebuild path runs as before.

Crash recovery: incrementalInProgress is the dirty flag. Set before
destructive ops; cleared on success. Set on next-run startup → forces
full rebuild (cheapest path back to known-good).

Other changes:
* Dirty-tree gate on the existing 'lastCommit==HEAD' early-return:
  uncommitted edits no longer slip through as 'already up to date'.
* deleteAllCommunitiesAndProcesses helper in lbug-adapter.
* Skip the embedding cache+restore cycle when willTryIncremental is
  true — embeddings stay in DB; re-inserting them would PK-conflict.

End-to-end equivalence verified on this repo (993 files, 24K nodes):
incremental run produces byte-identical {nodes, edges, clusters,
flows} to a full rebuild from the same edited state.

Speedup is currently modest (~5% on this repo) because the parse
phase still runs in full. Parse-cache integration is a separate
follow-up that composes cleanly on top of this work.

See docs/superpowers/specs/2026-05-10-incremental-indexing-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(analyze): chunk-level parse cache for full incremental speedup

Composes with the incremental DB writeback (commit 27f3b49d) to deliver
the major-speedup half of incremental indexing. Previously, the parse
phase ran in full on every analyze; the speedup came purely from
selective DB rewriting. With this commit the parse phase also reuses
prior tree-sitter output for chunks whose contents haven't changed.

How it works:

* Cache layer (gitnexus/src/storage/parse-cache.ts):
  - File: <repo>/.gitnexus/parse-cache.json. Versioned, atomic write.
  - Key: chunk content hash = sha256(sorted(filePath:fileContentHash
    for each file in chunk)).
  - Value: ParseWorkerResult[] (raw worker output for the chunk,
    pre-merge).
  - Granularity: per chunk (~20MB byte-budget). A change to one file
    invalidates only its chunk — typically 1 of ~50 on a 1000-file
    repo (~98% cache hit ratio on a small edit).

* Worker contract (gitnexus/src/core/ingestion/parsing-processor.ts):
  - Extracted the chunk-result merge loop into a public
    mergeChunkResults() so the same logic applies to live worker
    output AND replayed cache entries.
  - processParsingWithWorkers / processParsing accept an optional
    outRawResults out-parameter that captures worker output before
    merging — used by parse-impl to populate the cache after a miss.

* Parse phase wiring (parse-impl.ts):
  - For each chunk, compute its content hash (after reading file
    contents). Cache hit → mergeChunkResults() on cached results,
    skip the worker dispatch entirely. Cache miss → run workers
    normally, capture raw results, store under the chunk hash.
  - Cache mutations happen in-place on the ParseCache passed via
    PipelineOptions.parseCache.

* Lifecycle (run-analyze.ts):
  - loadParseCache() before pipeline runs.
  - Cache passed via runPipelineFromRepo's PipelineOptions.
  - saveParseCache() after the pipeline + DB writeback succeed.

Equivalence verified on this repo (993 files, 24K nodes):

  Cold (no cache, full work):           141.1s
  Warm cache + 1-file edit, incremental: 63.6s  ← 55% speedup
  Warm cache + 1-file edit, --force:     71.6s  ← 49% speedup

All three runs produce byte-identical {nodes, edges, clusters,
flows}. The cache survives --force (content-addressed = always
correct), so even forced rebuilds get the parse-skip benefit.

Why chunk-level rather than per-file: workers process sub-batches and
emit aggregated ParseWorkerResults. Per-file granularity would require
restructuring the worker contract; chunk-level captures most of the
practical speedup with no worker-side changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf(parse-impl): smaller default chunk budget (20MB→2MB) for cache granularity

The parse cache is keyed at chunk granularity. With the previous 20MB
budget, a typical mid-size repo (e.g. this worktree at 9MB total
parseable source) fits in a single chunk — meaning ANY file change
invalidates the whole chunk and re-parses every file.

2MB default produces ~5x more chunks on the same input, so a one-file
edit invalidates ~1/N of cached chunks instead of the whole thing.
Cold-run overhead from more chunks is <5% (one extra serialization
pass per chunk).

Override via GITNEXUS_CHUNK_BYTE_BUDGET env var for benchmarking.

Measured on this repo (~9MB / 887 parseable files):
  Cold (no cache):                    143s
  Warm cache, no source changes:        2s  (early-return)
  Warm cache + 1-file edit:            81s  (~43% off cold)

Speedup is bounded by the scopeResolution phase (~58s flat regardless
of parse cache) and by GitNexus's own auto-writes during analyze
(AGENTS.md / .claude/skills/ etc. mutate between runs and invalidate
chunks containing them). Both are addressable in follow-ups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf(scope-resolution): reuse worker-produced ParsedFile + stabilize chunk order

Two compounding optimizations that drop warm-cache analyze from
~134s to ~38s on a 1000-file repo (72% faster), and cold rebuild
from ~143s to ~86s (40% faster) by short-circuiting work that was
previously re-done.

1. SCOPE-RESOLUTION: REUSE WORKER PARSEDFILE

Previously, the scope-resolution phase re-parsed every file with
tree-sitter on the main thread (~58s on a 1000-file repo) because
worker-produced tree-sitter Trees can't cross the worker MessageChannel.

But the worker ALSO produces a  artifact via
, which structured-clones fine — and it's exactly
what scope-resolution would re-derive. Threading those ParsedFiles
through the parse phase () into
 ( map) lets scope-
resolution skip its extract loop on a per-file basis.

The fast path is bounded only by  per file (cheap
graph mutation). On this repo: scopeResolution went from 58s → 5s.

2. MAP-PRESERVING PARSE-CACHE SERIALIZATION

 is a
which JSON.stringify collapses to . The first attempt at threading
parsedFiles through the parse cache crashed at runtime with
"importerModule.typeBindings is not iterable" because cached entries
came back as plain objects.

Added a JSON replacer/reviver pair in parse-cache.ts that round-trips
Map and Set instances through tagged plain objects (). Symmetric: save uses replacer, load uses reviver.

3. STABLE CHUNK ORDERING

The byte-budget chunker walked files in filesystem-scan order, which
on Windows isn't guaranteed to be stable across runs. Even with
identical source content, two scans could place files in different
chunks, shifting chunk hashes and causing 100% parse-cache misses.

Added a deterministic alphabetical sort on  before
chunking. Chunk membership is now stable across runs, so a single-file
edit invalidates exactly one chunk, not all of them.

Measured on this repo (993 files, 24K nodes):
  Cold rebuild:                        86s  (was 143s)
  Warm cache, no source changes:        3s  (early-return)
  Warm cache + 1-file edit:            38s  (was 134s)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(incremental): update spec + AGENTS.md + GUARDRAILS.md for shipped design

- Rewrite docs/superpowers/specs/2026-05-10-incremental-indexing-design.md
  to describe the architecture that actually shipped (parse cache +
  incremental DB writeback + scope-resolution short-circuit), with the
  v1 hydrate-phase post-mortem preserved as historical context.
- AGENTS.md "Keeping the Index Fresh" section: note that incremental
  is the new default and --force is the explicit opt-out; mention
  the parse-cache file location and that it's safe to delete.
- GUARDRAILS.md Signs: add an "Index seems corrupt or incremental is
  misbehaving" entry pointing users to --force as the manual escape
  hatch (the dirty flag handles automatic recovery).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(incremental): bugbot review + CI test failures

Bugbot (PR #1479):
- Medium: pruneCache was exported but never called -> cache grew
  unbounded. Wire pruneCache into run-analyze before saveParseCache,
  using a transient usedKeys Set on ParseCache that the parse phase
  populates as it processes chunks.
- Low: willTryIncremental (pre-pipeline) and isIncremental
  (post-pipeline) could desync, silently dropping embeddings on
  mispredicted runs. Removed the prediction; the embedding cache
  now loads unconditionally when shouldLoadCache is true. The
  re-insert step gates on the actual isIncremental value to avoid
  PK-conflicts when the incremental-writeback path keeps DB rows.

CI test failures:
- cli-e2e #1169 + run-analyze.test.ts #1233: my dirty-tree gate on
  the lastCommit==HEAD early-return saw GitNexus's own auto-generated
  outputs (.claude/, .cursor/, AGENTS.md, CLAUDE.md) as dirty,
  perpetually defeating the up-to-date fast path. Extended the
  pathspec exclusion to cover all auto-gen outputs, not just
  .gitnexus/.
- ruby field-type disambig: my chunk-stability sort exposed a
  pre-existing order-dependency in Ruby cross-file resolution
  (`user.address.save -> Address#save` only resolves correctly when
  user.rb parses before address.rb in some configurations). Removed
  the sort. Filesystem ordering is stable enough in practice that
  the parse cache still hits the common case; the pre-existing
  fragility is left for a separate fix.
- pipeline-graph-golden: regenerated. Seeded Leiden RNG produces a
  partition different from the previous Math.random snapshot.
- staleness `parallel calls` was a CI timing flake; passes locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(incremental): re-insert cached embeddings on incremental path

Bugbot re-review caught: deleteNodesForFile cascades to the
CodeEmbedding table (DELETE WHERE e.nodeId STARTS WITH ...), so
changed-file embedding rows are wiped along with their nodes. The
previous fix gated re-insert on `!isIncremental`, which silently
dropped those embeddings — a regression versus the full-rebuild path's
"preserve embeddings by default" guarantee.

Remove the `!isIncremental` gate. The per-batch try/catch already
handles the unchanged-file PK-conflict case ("some may fail if node
was removed, that's fine") with the same semantics, so re-inserting
the full cached set on incremental works:

  - changed-file rows: deleted, then re-inserted from cache (preserved)
  - unchanged-file rows: still in DB, re-insert PK-conflicts and is
    silently ignored (existing rows are correct)

Cost: re-inserting ~24K embeddings on incremental when only a few
files changed — most are no-op conflicts. Bounded by batch size of
200; ~3-5s overhead. Worth it for correctness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(incremental): address Claude+Bugbot review findings + remove design doc

Addresses CHANGES_REQUESTED review on PR #1479:

1. Remove docs/superpowers/specs/2026-05-10-incremental-indexing-design.md
   per maintainer request.

2. BLOCKER (Claude Finding 1, Bugbot Round 3): Stale cross-file edges
   between unchanged files. extractChangedSubgraph excluded edges where
   both endpoints were unchanged-file nodes — when a barrel/re-export
   file changes, cross-file resolution may update CALLS edges between
   two unchanged files that would then be silently lost.

   Fix: 1-hop importer-closure expansion of the writable set in
   run-analyze.ts. Before deleting/rewriting rows, query DB for
   importers of every changed/deleted file and add them to the writable
   set. Their nodes get deleted+rewritten too, so cross-file's refined
   edges land in the DB. Re-added queryImporters to lbug-adapter.ts.

3. BLOCKER (Claude Finding 3): Parse cache key omitted parser version.
   After a GitNexus upgrade, the cache silently replays pre-upgrade
   ParseWorkerResults against the new schema → wrong CALLS/IMPORTS/
   scope edges with no visible signal.

   Fix: PARSE_CACHE_VERSION now embeds the gitnexus npm package
   version (read at module load via createRequire on package.json).
   Format: `${SCHEMA_BUMP}+${PKG_VERSION}` e.g. "1+1.6.4". Any release
   that bumps package.json automatically invalidates the on-disk cache.
   Mismatched versions fall through to an empty cache (next save
   overwrites with the new version baked in).

4. BLOCKER (Claude Finding 2): No automated tests for incremental
   behavior. Added 28 unit tests across 3 files:

     - incremental-file-hash.test.ts (10 tests)
       diffFileHashes classification, computeFileHash determinism,
       computeFileHashes batch / missing-file tolerance, sorted output.

     - incremental-parse-cache.test.ts (12 tests)
       computeChunkHash stability and order-independence, version
       prefix format, pruneCache, load/save round-trip on empty /
       missing / corrupt / version-mismatched files, AND a Map/Set
       round-trip test that pins the JSON replacer/reviver behaviour
       (without it, ParsedFile.scopes[*].typeBindings collapses to
       {} and downstream `.get()` / iteration throws).

     - incremental-subgraph-extract.test.ts (6 tests)
       writable-set node inclusion, Community/Process always kept,
       edge inclusion when at least one endpoint is writable, MEMBER_OF
       edges via graph-wide endpoints, empty subgraph case.

5. Medium (Claude Finding 6): AGENTS.md "Keeping the Index Fresh"
   said "only changed files are re-parsed." Imprecise — the pipeline
   parses every file every run; the cache skips tree-sitter for chunks
   whose contents haven't changed. Reworded to match the design doc.

Test plan still expects:
  [x] Typecheck clean
  [x] All 28 new unit tests pass
  [x] All previously-failing tests still pass on the rebased branch
  [x] Equivalence verified locally (incremental ≡ --force, byte-identical
      stats on this repo)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(incremental): round 3 review feedback — bounded BFS, atomic meta, integration test, docs

Addresses remaining findings on PR #1479 from Claude's re-review of
commit ad7bd31 + verifies the outstanding Bugbot HIGH severity.

1. F1 — Transitive importer expansion (Claude, was Medium-but-noted).
   Previous 1-hop importer expansion missed barrel re-export chains
   (A imports C, C re-exports B; when B changes, only C was pulled in
   — A was left with potentially-stale CALLS edges to refined targets).
   Replaced the single pass with a bounded BFS over the IMPORTS graph
   (depth ≤ 4). Catches nested barrel pyramids without ballooning into
   a near-full rebuild on monorepos with deep re-export trees. `--force`
   remains the escape hatch documented in GUARDRAILS.md for cases that
   exceed the bound.

2. F2 — Integration test for incremental orchestration (Claude, BLOCKER,
   DoD §2.7). The unit tests added in ad7bd31 covered `diffFileHashes`,
   `extractChangedSubgraph`, `computeChunkHash`, `pruneCache`, and the
   Map/Set JSON round-trip — but none of them exercised the real
   `runFullAnalysis` orchestration. Added gitnexus/test/unit/
   incremental-orchestration.test.ts with four end-to-end tests against
   a real git-initialized fixture repo + real LadybugDB:

     a. First run populates fileHashes + schemaVersion and clears
        incrementalInProgress on success.
     b. Second run on unchanged state takes the alreadyUpToDate fast
        path (early-return).
     c. Second run after a source edit takes the incremental path
        (not full rebuild) and rotates fileHashes for the touched file
        while keeping the dirty flag cleared.
     d. A pre-set incrementalInProgress flag forces a full rebuild
        that clears it (crash-recovery wire).

   These would catch any regression that wires `isIncremental` from a
   pre-pipeline prediction (the Bugbot finding from commit 5eb0597) or
   accidentally re-gates the embedding re-insert on `!isIncremental`
   (the Bugbot finding from commit 60c10f1).

3. F3 — GUARDRAILS.md docs accuracy (Claude, Low). Line 33 still said
   "only changed files are re-parsed" — AGENTS.md was already corrected
   in ad7bd31 but GUARDRAILS.md was missed. Reworded to match.

4. F5 — Atomic saveMeta (Claude, Medium; vvladescu-tb fork). The dirty
   flag (`incrementalInProgress`) travels through meta.json. A crash
   mid-write would leave a corrupt meta.json that `loadMeta` would
   silently treat as "no prior index", losing the flag and skipping
   recovery. Switched to tmp-file + rename matching saveParseCache.

5. Bugbot's "Subgraph edges reference nodes absent from subgraph"
   (HIGH severity). Verified as FALSE POSITIVE: `getNodeLabel` in
   lbug-adapter.ts derives labels from the node-ID string (parses
   the table prefix), not from the in-memory graph. The CSV
   generator writes (src_id, dst_id, type) rows without consulting
   node objects; `splitRelCsvByLabelPair` routes by ID-derived label;
   `COPY ... (from=X, to=Y)` resolves both endpoints against the live
   LadybugDB where unchanged-file nodes still exist. No fix needed.

All 213 tests pass locally (including the 4 new integration tests
and the previously-failing CI tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(incremental): address Bugbot round-4 findings (added-file shadow seed + dedupe)

Bugbot review on commit e23e4400 surfaced two new findings against the
incremental writeback in run-analyze.ts:

  HIGH — Incremental BFS misses importers of newly added files.
    queryImporters() reads the pre-pipeline DB. For a NEWLY ADDED
    file there are no IMPORTS rows pointing to it yet, so unchanged
    files whose pre-existing import statements now resolve to the
    newcomer keep stale CALLS edges pointing at the OLD resolution
    target.

  LOW — Deleted files double-counted in filesToDelete.
    hashDiff.deleted entries can reappear in writableFiles via the
    BFS expansion (queryImporters can return a now-deleted path),
    so deleteNodesForFile() ran twice for the same file.

Fixes:

  - Add gitnexus/src/core/incremental/shadow-candidates.ts: derive
    the pre-existing file paths whose JS/TS module-resolution claim
    an added file can steal. Pattern catalogue: same-basename/
    different-extension, bare-file-beats-directory-index, and
    directory-index-beats-bare-file. Emit both POSIX and Windows
    separators because the prior fileHashes map may have been
    written from either OS.

  - In run-analyze.ts, seed the BFS frontier with shadow candidates
    that exist in the prior meta.fileHashes. Their importers — found
    via queryImporters — get pulled into the writable set so their
    CALLS edges re-resolve against the new file.

  - Dedupe filesToDelete via Set to avoid the double-call.

Tests: gitnexus/test/unit/incremental-shadow-candidates.test.ts —
8 cases covering each shadow pattern, separator handling, .d.ts as
a single extension token, deduplication, and the no-self-shadow
invariant. All 40 incremental tests (file-hash, parse-cache,
subgraph-extract, shadow-candidates, orchestration) pass locally.

Note on the third Bugbot finding ("Subgraph edges reference nodes
absent from subgraph"): re-anchored from a prior review pass — the
code at subgraph-extract.ts:48 is unchanged. Already verified as a
false positive: getNodeLabel parses labels from ID strings, CSV
write is by ID, and COPY resolves against the live DB.

* chore(autofix): apply prettier + eslint fixes via /autofix command

* test(incremental): exact-equality stats invariant + analyze ≡ analyze --force

Addresses the only remaining Claude production-readiness review finding
on PR #1479 (Low-Medium, test-quality only — Claude itself said it does
NOT block merge, but the central PR claim "incremental ≡ full rebuild"
deserves explicit CI coverage rather than implicit trust).

Changes to gitnexus/test/unit/incremental-orchestration.test.ts:

1) Tighten the existing "comment-only edit takes incremental path" test.
   - Replace toBeGreaterThan(0) bounds assertions on stats.files and
     stats.nodes with exact toBe(firstMeta) per-field equality across
     files / nodes / edges / communities / processes. DoD §2.7 calls
     out bounds-only assertions as masking regressions that drop half
     the graph; this swap closes that gap.
   - Rationale: a comment-only edit must change the file content hash
     (driving the incremental path) without changing any graph data.
     Therefore every stat MUST be identical to the first run. Anything
     else is a regression.

2) New test: incremental output is byte-equivalent to a full rebuild.
   - Run analyze → comment-only edit → analyze (incremental writeback)
     → analyze --force (full rebuild from same on-disk state).
   - Assert files / nodes / edges / communities / processes are exactly
     equal across the incremental and the --force passes.
   - This is the PR's central correctness contract, now proven by a
     test that exercises the real runtime path end-to-end against a
     real on-disk LadybugDB.

All 5 orchestration tests pass locally (52s), including the new
equivalence test — every stat field matches exactly between incremental
and --force on the mini-repo fixture.

tsc --noEmit clean.

* fix(incremental): F1 cross-file edge consistency + F4 stable chunk sort + unit coverage (#1511)

Patch addressing two of the still-open changes-requested findings on PR
#1479, rebased onto the current feat/incremental-indexing head. F3
(parser fingerprint in the cache key), F5 (atomic saveMeta), and F6
(AGENTS.md phrasing) were already handled on the branch, so the
corresponding parts of the original patch were dropped as redundant.

  F1 (Blocker) — Cross-file edges between unchanged files
    Adds `computeEffectiveWriteSet(graph, toWriteSet)` to
    subgraph-extract.ts: a single pass over the new graph's edges that
    pulls the unchanged-side file of every writable-boundary-crossing
    edge into the write set. run-analyze composes it ON TOP of the
    existing importer-BFS expansion and feeds the combined set to BOTH
    `deleteNodesForFile` and `extractChangedSubgraph`, so the delete
    cascade and the writeback subgraph cover identical files (asymmetry
    would leave stale rows or PK-conflict at COPY time). The BFS reads
    IMPORTS from the pre-pipeline DB (catches files that *stopped*
    importing a changed file); the edge walk reads the new graph
    (catches refined CALLS edges the pre-run DB couldn't predict, e.g.
    a barrel re-export shifting a symbol from B to D). `extractChangedSubgraph`
    stays a pure filter — all expansion is the orchestrator's job.

  F4 (Medium) — Restore alphabetical chunk sort
    `parseableScanned` is sorted before chunking. Filesystem-scan order
    isn't stable enough across runs/platforms (notably macOS APFS) to
    keep chunk hashes consistent, so the parse cache thrashes without
    it. The pre-existing Ruby cross-file resolution order-dependency the
    old comment cited is independent — the sort surfaces it but doesn't
    cause it; tracked separately rather than leaving the cache cold.

  Tests — incremental-subgraph-extract.test.ts
    Locks the F1 invariants: `extractChangedSubgraph` is a pure filter
    (includes only the set it's given, plus graph-wide nodes; edges
    fire on one writable endpoint), and `computeEffectiveWriteSet`
    covers the barrel-re-export scenario, the symmetric edge-into-
    changed-file case, the no-boundary-crossed no-op, graph-wide-node
    edges, and input-immutability. Supersedes the prior
    extractChangedSubgraph-only test file on the branch.

Co-authored-by: Val Vladescu <vvladescu-tb@users.noreply.github.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(call-processor): register properties in pre-pass to fix order-dependent field type disambiguation + regenerate golden snapshot

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2d66666f-861c-432e-a4b0-11f2aefca98a

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(call-processor): port worker-path property enrichment into the sequential pre-pass

Copilot's pre-pass in 8184439 fixed the Ruby attr_accessor order-dependence,
but it copied the OLD in-loop registration logic, not the canonical worker
path in parse-worker.ts. That left the sequential and worker paths emitting
non-identical Property nodes/symbols for the same source — silently breaking
the `incremental ≡ --force` invariant the moment a repo crosses the worker
threshold between runs.

Two concrete divergences are closed here:

  * Node id: worker keys Property as `${file}:${className}.${propName}`
    (qualified). Pre-pass was using `${file}:${propName}` (unqualified).
    Same source produced different graph ids depending on which path ran.

  * Field metadata: worker enriches each routed property with
    `provider.fieldExtractor` + `getFieldInfo`, falling back to
    `routedFieldInfo.type` for `declaredType` when the routing payload
    lacks one (e.g. types discovered from `@address = Address.new`
    ctor assignments rather than YARD `@return [Type]`), and propagates
    `visibility` / `isStatic` / `isReadonly`. Pre-pass did none of this,
    so on the sequential path `resolveFieldAccessType` failed to walk
    chains where the type only came from the FieldExtractor.

The pre-pass now mirrors parse-worker.ts:1803-1898 verbatim, with one
deliberate difference: the FieldInfo cache is scoped to a single
`processCalls` invocation rather than module-level (the worker process
is short-lived; the main thread is not, and a module-level cache would
leak state between analyze runs).

Also drops the now-stale "Defer resolution: Ruby attr_accessor properties
are registered during this same loop" comment on `pendingWrites.push` —
the rationale is no longer accurate after Copilot's pre-pass, but the
deferral is still needed so write-access tracking sees inference that
completes during the main loop. Comment updated to reflect that.

Verification:
  * `tsc --noEmit`: 0 errors
  * test/unit (call-processor, call-routing, field-extraction, ruby-self-call): 224 passing
  * test/integration (ruby, ruby-sequential-mixin, pipeline-graph-golden): 137 passing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(call-processor): key fieldInfoCache by filePath:startIndex, not raw byte offset

Claude's review of 255bdf6 caught a real collision in the FieldInfoCache I
added: keying by `classNode.startIndex` alone is a per-file byte offset, so
two files that both begin with a class at byte 0 — extremely common in Ruby /
Python, where files frequently open with `class Foo`, `module Foo` — collide
on the same cache entry. The second file's `getFieldInfo` then returns the
first file's FieldInfo map, producing wrong `declaredType` / `visibility` /
`isReadonly` on its properties.

Same shape as the bug that already exists in parse-worker.ts:377 (also keyed
by `classNode.startIndex` in a module-level map, persistent across files
processed by the same worker). Fixing the symmetric pre-existing leak in
parse-worker.ts is a separate, scoped follow-up — left out of this commit to
keep the fix minimal and reviewable.

Cache map and key are now both string-typed. Composite key
`${context.filePath}:${classNode.startIndex}` keeps the within-file hit rate
(one FieldExtractor.extract() per class regardless of how many
`attr_accessor` lines it has) while eliminating cross-file aliasing.

Verification on the patched HEAD:
  * `tsc --noEmit`: 0 errors
  * test/unit (call-processor, call-routing, field-extraction, ruby-self-call): 224 passing
  * test/integration (ruby, ruby-sequential-mixin, pipeline-graph-golden): 137 passing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Val Vladescu <val.vladescu@thirdbridge.com>
Co-authored-by: Val Vladescu <vvladescu-tb@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
2026-05-12 13:14:56 +01:00
d4f34905bc feat: migrate Java to scope-based registry resolution (RFC #909 Ring 3) (#1482)
* Initial plan

* feat: implement Java scope-based resolution (RFC #909 Ring 3)

Add scope-resolution pipeline for Java, following the C# pattern:

- query.ts: tree-sitter query for scopes, declarations, imports,
  type bindings, and references against tree-sitter-java grammar
- captures.ts: orchestrator synthesizing import decomposition,
  receiver bindings (this/super), arity metadata, and reference arity
- import-decomposer.ts: decompose import_declaration nodes into
  kind/source/name markers (named, wildcard, static, static-wildcard)
- interpret.ts: convert captures to ParsedImport/ParsedTypeBinding
- receiver-binding.ts: synthesize this/super type-bindings on instance
  methods with superclass support
- arity-metadata.ts: extract parameter count/types using javaMethodConfig
- arity.ts: Java arity compatibility check with varargs support
- merge-bindings.ts: Java shadowing precedence (local > import > wildcard)
- simple-hooks.ts: bindingScopeFor, importOwningScope, receiverBinding
- import-target.ts: package path to file path resolution
- scope-resolver.ts: ScopeResolver implementation registered in registry

Wire scope hooks into javaProvider (java.ts) and register
javaScopeResolver in SCOPE_RESOLVERS registry. Add createResolverParityIt
wrapper to java.test.ts for parity testing.

All 172 existing Java tests pass. Java is NOT added to
MIGRATED_LANGUAGES — the resolver sits idle until the migration flag
is flipped.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix: address review findings 1-4 — varargs arity, static import resolution, importOwningScope, stripGeneric

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/22308da3-59c9-47e6-8e52-738305b1b80a

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* docs: document registry-primary parity status and CI visibility gap in scope-resolver

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/22308da3-59c9-47e6-8e52-738305b1b80a

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: add generic type erasure fallback in stripGeneric + update scope-resolver docs

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/223f77ac-59a7-4487-9316-f2be05eac5d3

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: improve stripGeneric fallback regex — use valid Java identifier chars and handle nested generics

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/223f77ac-59a7-4487-9316-f2be05eac5d3

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: address adversarial review findings 1-6 — flaky test, wildcard import fixture, varargs fixed-prefix test, qualified generic stripping, JSDoc updates

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/172c8a1a-cdf3-4de8-9142-f2c12c14b0a6

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* docs: add inline comment explaining stripQualifier/stripGeneric call order

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/172c8a1a-cdf3-4de8-9142-f2c12c14b0a6

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test: add varargs 0-arg fixture and strengthen wildcard import assertions

Finding 1: Added `badCall()` method with 0-arg `fmt.format()` call to the
varargs fixture. Test documents that legacy mode still resolves this call
(arity rejection is registry-primary only). The fixture now exercises both
the success path (2-arg, 3-arg) and the undersupplied path (0-arg).

Finding 2: Strengthened wildcard import test to assert `targetFilePath`
on the CALLS edge (`com/example/models/User.java`), confirming the call
resolved through the wildcard-imported type to the correct file.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2b4e5602-9833-485c-ab48-e1d54fdf8465

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-12 09:37:44 +01:00
Abhigyan Patwari f33efa8714 Merge pull request #1523 from magyargergo/fix/claude-review-skip-permissions-ci
ci(claude): fix /review PR comments blocked by Bash approval in Actions
2026-05-12 09:12:46 +01:00
Gergo MagyarandCursor 2bf6d078aa ci(claude): allow Bash in code-review job without interactive approval
Claude Code defaults to prompting for Bash approval. In GitHub Actions there
is no human to approve, so gh pr comment and similar commands fail and the
PR receives no review comment. Pass --dangerously-skip-permissions for the
code-review step only (headless CI; token and checkout are already scoped).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 09:07:47 +01:00
Abhigyan Patwari ba1ac3989d Merge pull request #1522 from abhigyanpatwari/fix/claude-review-pr-comment
fix(ci): make /review reliably post PR comments
2026-05-12 08:29:39 +01:00
Gergo Magyar c5c42a9649 fix(ci): make /review reliably post PR comments
The Run Claude Code Review step passed an invalid PR ref
(owner/repo/pull/N) which gh interprets as a branch name, causing
early gh pr view failures. More importantly, the prompt omitted
--comment, so the code-review plugin only displayed findings in
terminal output and never invoked gh pr comment to post to the PR.

Switch to a full PR URL and add --comment so the plugin posts the
review during the session, which also routes around upstream bugs
anthropics/claude-code-action#1061 and #1087 where the action's
post-step capture can silently drop output on issue_comment triggers.
2026-05-12 08:28:42 +01:00
Abhigyan Patwari a445d38e0b Merge pull request #1521 from abhigyanpatwari/remove_flaky_test
chore(tests): remove flaky regression test for resource exhaustion
2026-05-12 08:00:04 +01:00
Gergo Magyar 0e2c0c77ec chore(tests): remove flaky regression test for resource exhaustion 2026-05-12 07:58:58 +01:00
AntheurusandGergő Magyar fcab1e2e82 fix(augment): add CONTAINS fallback when FTS indexes unavailable (#1476)
* fix(augment): add CONTAINS fallback when FTS indexes unavailable

When the MCP server holds the KuzuDB write lock, the augment CLI opens
the DB read-only. FTS indexes cannot be created in read-only mode, so
searchFTSFromLbug returns ftsAvailable=false and an empty results array.
The existing early-return path silently produced no enrichment.

Add a Cypher name CONTAINS fallback that fires only when ftsAvailable is
false and BM25 produced no symbol matches. This covers the read-only DB
case (concurrent MCP server) and the first-run case (indexes not yet
built). The fallback is wrapped in .catch(() => []) and cannot throw.

When FTS indexes exist, this branch is never reached — behaviour is
unchanged for users without a concurrent MCP server.

* fix(augment): guard against CONTAINS '' and add no-FTS test coverage

Blocker 1 — CONTAINS '' on whitespace-leading patterns:
pattern.split(/\s+/)[0] returns "" when the input has leading whitespace
(e.g. "   ".split(/\s+/) → ["", ""]). In Kuzu, CONTAINS '' matches every
node with a name property, injecting arbitrary graph nodes into LLM context.

Fix: trim() before split, then guard on !firstWord || firstWord.length < 2.
No behaviour change for normal non-empty patterns.

Blocker 2 — zero test coverage on the FTS-unavailable code path:
The new CONTAINS fallback block (engine.ts lines 146-166) was exercised by
no existing test — all existing tests run with FTS indexes built. A second
withTestLbugDB fixture is added with no ftsIndexes, forcing searchFTSFromLbug
to return ftsAvailable: false, and asserts:
1. augment('login', ...) returns non-empty enrichment (fallback works)
2. augment('   ', ...) returns '' (CONTAINS '' guard holds)
3. augment('nxyz_notfound', ...) returns '' (no matching nodes)
4. executeQuery throwing returns '' (.catch(() => []) path)

* fix(augment): extend CONTAINS '' guard to FTS happy path and consolidate

The same split(/\s+/)[0] bug existed at line 125 (BM25 symbol filter,
FTS-available path) — a leading-whitespace pattern produced CONTAINS ''
there too, matching every node in BM25-matched files.

Fix: hoist patternFirstWord computation with trim() and the length guard
to the top of augment(), before any DB interaction. Both CONTAINS sites
(BM25 symbol filter and CONTAINS fallback) now use the single pre-validated
value. No behaviour change for normal patterns; the guard fires once for
all callers instead of being duplicated.

Also tighten the whitespace test in the no-FTS suite from 3 spaces to
4 spaces so it unambiguously exercises the patternFirstWord guard rather
than straddling the outer pattern.length < 3 boundary.

* test(augment): negative-safety test for ftsAvailable=true gate

Asserts the CONTAINS fallback does NOT fire when FTS is available but
BM25 returns zero results. Pins the safety property promised by the PR
description: behavior is unchanged for users without the read-only-DB
condition.

If anyone later loosens the gate to `symbolMatches.length === 0` alone,
this test fails.

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-11 16:39:10 +01:00
fdf1effb2a feat(cli): add --skip-skills and --index-only flags to analyze (resubmit of #742) (#1485)
* feat(cli): add --skip-skills and --index-only flags to analyze command

The `installSkills()` call in `generateAIContextFiles()` runs
unconditionally, injecting 6 skill files into `.claude/skills/gitnexus/`
even when `--skip-agents-md` is passed. This is problematic for bulk
indexing operations on read-only mirrors or third-party repos.

Add two new flags:
- `--skip-skills`: suppress standard GitNexus skill file injection
- `--index-only`: pure index mode that suppresses all file injection
  (AGENTS.md, CLAUDE.md, and skills), writing only to `.gitnexus/`

This gives users three levels of control:
- `--skip-agents-md` — suppress only root context files
- `--skip-skills` — suppress only skill injection
- `--index-only` — suppress everything (pure indexing)

Discovery context: while bulk-indexing 176 repos with
`--skip-agents-md`, all 144 indexed repos were contaminated with
`.claude/skills/gitnexus/` files requiring manual cleanup.

* fix(cli): address PR #742 review — gate community skills, drop dangling refs, add tests

Bot review (#742) flagged three issues with the original commit:

1. `--index-only --skills` still wrote community-derived skill files
   to `.claude/skills/generated/`. The `--skills` branch in analyze.ts
   was not gated by `skipAll`, so the "skip all file injection" contract
   was violated. Gate `generateSkillFiles()` with `!skipAll` so
   `--index-only` truly wins over `--skills`.

2. `--skip-skills` without `--skip-agents-md` produced AGENTS.md /
   CLAUDE.md that still referenced `.claude/skills/gitnexus/*/SKILL.md`
   files that were never installed — every agent load incurred 6
   failed reads. Pass `skipSkills` through to `generateGitNexusContent()`
   and omit the standard-skill rows (and the entire `## CLI` heading
   when the table is empty). Community skills, when present via
   `--skills`, are unaffected.

3. No filesystem tests for `skipSkills` / `indexOnly`. Add three
   regression guards to `test/unit/ai-context.test.ts`:
   - `.claude/skills/gitnexus/` is NOT created when skipSkills=true
   - Nothing is written when both skipAgentsMd and skipSkills are true
     (the resolved-flag state from --index-only)
   - AGENTS.md/CLAUDE.md routing table omits standard skill references
     when skipSkills=true, but preserves the load-bearing imperative
     sections (Always Do / Never Do / Resources)

* test(cli): PR 1485 review follow-ups (help text, gate test, --skip-skills docs)

- Assert --skip-skills and --index-only in analyze --help (skip-git-cli.test.ts).

- Export shouldGenerateCommunitySkillFiles; unit-test index-only+skills gate.

- Clarify --skip-skills does not suppress --skills community files; --index-only for full skip.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): warn when --index-only silently overrides --skills

Address review findings on PR 1485 follow-ups:
- analyze.ts emits a one-line note when both --index-only and --skills
  are set, so users see why a pipeline re-index ran with no skill files
  written.
- index.ts --skills help text now flags the --index-only override.
- shouldGenerateCommunitySkillFiles JSDoc documents the dual role of
  the gate (community skills + AGENTS.md/CLAUDE.md re-generation).
- skip-git-cli.test.ts pins the override-warning surface end-to-end.

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 15:00:58 +01:00
622f98ade5 feat(embeddings): forward dimensions param in HTTP embedding requests (#1498)
* feat(embeddings): forward GITNEXUS_EMBEDDING_DIMS as dimensions in HTTP request body

When GITNEXUS_EMBEDDING_DIMS is set, include it as the `dimensions` field
in the /v1/embeddings request body. This enables Matryoshka-capable models
(OpenAI text-embedding-3-*, Cohere embed-v3, Voyage) to return truncated
vectors at the requested size.

When the env var is unset, the request body remains `{ input, model }` —
no breaking change for backends that reject unknown fields.

Adds 4 unit tests covering both paths (with/without dimensions) on both
the batch embed and single-query embed code paths.

* fix(embeddings): address review findings — strict parseInt, multi-batch test, comment wording

1. Strict parseInt validation: reject non-numeric strings like '1024abc'
   by checking /^\d+$/ before parseInt (Finding 1).
2. Add multi-batch test asserting dimensions is forwarded in every fetch
   call when inputs exceed batch size (Finding 2).
3. Soften JSDoc comment: backends may ignore or reject the dimensions
   field rather than universally ignoring it (Finding 3).
4. Add test for invalid GITNEXUS_EMBEDDING_DIMS values.

---------

Co-authored-by: henry <zhangwei2017@unipus.cn>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-11 13:56:10 +01:00
juyua9andGergő Magyar 55b7a79beb fix(group): detect httpx async consumers (#1408)
* fix(group): detect httpx async consumers

* test(group): tighten httpx consumer coverage

* test(group): create extractor temp dirs safely

* fix(group): scope httpx async client tracking

* fix(group): tighten httpx module-scope tracking

Prevent module-scope httpx.AsyncClient tracking from matching same-name local variables inside functions.

Also documents the intentionally unsupported direct-import, alias, and typed-assignment forms, and extends the httpx extractor regression fixture to cover module-scope shadowing while keeping module-scope calls detected.

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-11 13:13:49 +01:00
uwence a3af0dcce2 fix(docker): include duckdb installer script in runtime image (#1502) 2026-05-11 11:38:59 +01:00
RinandGergő Magyar 6a8947217c fix(server): sanitize repo name to prevent argument injection (#1305)
* fix(server): sanitize repo name to prevent argument injection

Sanitizes the extracted repository name to prevent argument injection during git clone operations and ensures compatibility with various file systems.

1. Strips leading dashes to prevent git command-line argument injection.

2. Replaces unsafe directory characters with underscores.

3. Blocks path traversal segments ('.' and '..') and Windows reserved names.

4. Fixes ReDoS vulnerability in parseRepoNameFromUrl regex.

5. Added unit tests for sanitization and path traversal edge cases.

* fix(server): expand Windows reserved name check to include extensions

- Updated sanitizeRepoName to block Windows reserved names (CON, NUL, etc.) even when they have extensions (e.g., CON.txt).
- Corrected regex and added unit tests for these edge cases to resolve CI failures on Windows.
- Ref: https://github.com/abhigyanpatwari/GitNexus/pull/1305#issuecomment-4407200914

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-11 09:38:07 +01:00
e412d292fe feat: migrate C to scope-based resolution (RFC #909 Ring 3) (#1481)
* Initial plan

* feat: add C scope resolution files for language migration (RFC #909)

Add 11 C language scope resolution files following the Go pattern:
- query.ts: tree-sitter-c query and parser for C constructs
- captures.ts: emit scope captures with arity enrichment
- import-decomposer.ts: decompose #include into structured captures
- arity-metadata.ts: C function declaration/call arity computation
- interpret.ts: interpret C imports and type bindings
- import-target.ts: resolve #include paths via suffix matching
- arity.ts: C arity compatibility (variadic detection)
- merge-bindings.ts: first-wins binding merge by tier
- simple-hooks.ts: null hooks (no receivers/methods in C)
- index.ts: barrel re-exports
- scope-resolver.ts: ScopeResolver implementation for C

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* feat: migrate C to scope-based resolution (RFC #909 Ring 3)

Add C ScopeResolver with:
- tree-sitter-c scope query (structs, unions, enums, functions, macros, variables, includes)
- emitCScopeCaptures with arity enrichment and typedef-struct dedup
- interpretCImport for #include directives (system headers filtered)
- resolveCImportTarget with suffix matching
- cArityCompatibility with variadic detection
- cMergeBindings (first-wins by tier)
- Header file scanning for cross-language #include resolution
- Register in SCOPE_RESOLVERS and MIGRATED_LANGUAGES
- Integration test with 4 passing test cases

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/ddcbc075-2999-492c-a0ac-47cddd401a4b

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: update registry-primary-flag test and add C legacy parity expected failures

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/ddcbc075-2999-492c-a0ac-47cddd401a4b

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* refactor: improve arity-metadata readability per review feedback

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/ddcbc075-2999-492c-a0ac-47cddd401a4b

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: address CI failures — unused import, Dirent types, null comparison, lint, formatting

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/f4b6e20d-8d56-4834-8296-db82af27f8e1

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: replace loose comparisons with strict equality, remove optional chaining from childForFieldName

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/20628629-9dc7-45ec-8ae4-f3a14ad29d93

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* Potential fix for pull request finding 'CodeQL / Comparison between inconvertible types'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* fix: remove unnecessary optional chaining on non-null decl in findFuncDeclarator

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9b012c35-7494-4180-8c6a-b83da8d8abb9

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: address 5 production readiness review findings

Finding 1: Enforce static functions as file-local via expandsWildcardTo hook
- Add static-linkage.ts tracking module with markStaticName/isStaticName/expandCWildcardNames
- Update captures.ts to detect storage_class_specifier static on functions
- Wire expandsWildcardTo in scope-resolver.ts

Finding 2: Expand test coverage to ≥30 cases (74 unit tests added)
- c-captures.test.ts: 55 tests (scopes, structs, unions, enums, functions, typedef, field, variable, macro, imports, references, type bindings, arity, static)
- c-imports.test.ts: 12 tests (decomposition, interpretation, target resolution, determinism, edge cases)
- c-arity.test.ts: 18 tests (declaration arity, call arity, compatibility)

Finding 3: Deterministic #include resolution on depth ties
- Add lexicographic tiebreak in import-target.ts when candidates tie on path depth

Finding 4: Revert unexplained package-lock.json change
- Restored to pre-PR state (node >=20.0.0)

Finding 5: Planning artifact commit acknowledged (squash on merge)

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/22ee780c-2b44-4e69-b9c4-8843ad6ec1ee

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: address code review feedback — Set-based dedup, SyntaxNode type alias

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/22ee780c-2b44-4e69-b9c4-8843ad6ec1ee

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix: address second review findings 1-4 — isFileLocalDef hook, singleton docs, fn-ptr typedef, static isolation test

Finding 1: Added `isFileLocalDef` hook to ScopeResolver contract + implementation
in free-call-fallback.ts to filter C static functions from global free-call
fallback. Threads caller filePath through pickUniqueGlobalCallable so static
defs in other files are excluded.

Finding 2: Documented single-invocation assumption on staticNames Map. Added
clearStaticNames() call in loadResolutionConfig to prevent cross-repo
contamination in server-mode scenarios.

Finding 3: Added tree-sitter query pattern for function pointer typedef aliases
(typedef void (*callback)(int, int)) in query.ts. Added unit test.

Finding 4: Added c-static-isolation integration fixture (a.c with static helper,
b.c with non-static helper, caller.c) and test asserting no CALLS edge from
caller to a.c's static helper.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/5b948327-9f59-4ca2-9d8c-8c8087feb510

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix: skip static isolation integration test in legacy parity mode

The `caller.c calls b:helper via include, NOT a:static helper` test
requires scope-based wildcard import binding + isFileLocalDef filtering
which is only available in the registry-primary path. The legacy DAG
path does not resolve cross-file calls through #include → prototype
chains. Added to LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/3be48249-b375-4446-973b-657400f530fb

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: address 3 review findings — static leakage in Phase 2, build-dir skip list, same-directory preference

Finding 1: Apply isFileLocalDef filtering in Phase 2 of pickUniqueGlobalCallable
so cross-file static defs cannot leak through the SemanticModel fallback path.

Finding 2: Expand scanHeaderFiles skip list with dist, build, out, target,
_build, .next, cmake-build-* to avoid generated headers shadowing source ones.

Finding 3: Implement same-directory sibling preference in resolveCImportTarget,
matching C compiler #include "…" relative-lookup semantics. Sibling check now
runs before exact match and suffix fallback.

Tests: 11 new header-scan tests, 4 new import-target tests (96 total C tests).

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/5236f2b8-72a0-476d-bf39-cca041781014

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* refactor: skip redundant sibling path computation when targetRaw has no backslashes

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/5236f2b8-72a0-476d-bf39-cca041781014

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix: normalize header-scan paths to forward slashes for Windows compatibility

On Windows, path.relative() produces backslash-separated paths (e.g. src\foo.h)
which caused test failures when comparing against forward-slash expectations.
Normalize output with .replace(/\\/g, '/') matching the convention used
throughout the scope-resolution pipeline.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/88420759-8a8c-4e6d-affa-ecb5458a235a

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: address 4 findings — K&R arity, function-pointer docs, prototype docs, _filePath rename

Finding 1: K&R empty parameter list `int foo()` now returns unknown arity `{}`
instead of `{parameterCount:0}`. Distinguishes from explicit `int foo(void)`.
3 unit tests cover K&R definition, prototype, and void comparison.

Finding 2: Added code comment documenting function-pointer-variable call
capture as known architectural trade-off (same as Go resolver).

Finding 3: Added code comment documenting prototype/definition duplication
as graph-quality concern (no false CALLS edges).

Finding 4: Renamed `_filePath` → `filePath` in captures.ts since it is
actively used in markStaticName().

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/ab2164f7-972f-4ea8-81fa-a14ce20d7cce

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-11 09:02:00 +01:00
Hector PratsandGergő Magyar d69eadfb7f fix(windows): 32767-char tree-sitter crash + VECTOR extension SIGSEGV (#1433)
* fix(windows): 32767-char tree-sitter crash + VECTOR extension SIGSEGV

tree-sitter 0.21.x on Windows crashes with SIGSEGV when parsing source
strings longer than 32 767 chars (signed 16-bit integer overflow in the
native binding). Five call sites passed raw file content without any
length guard:

  - captures.ts (C# scope extraction)
  - namespace-siblings.ts (extractFileStructure)
  - parse-worker.ts (worker thread parse path)
  - parsing-processor.ts (sequential parse fallback)

Fix: truncate at the last newline before the limit so the fragment stays
syntactically coherent. Files truncated mid-class produce ERROR roots;
captures.ts returns [] for any ERROR-root tree so the legacy DAG handles
the file silently without orphaned scope errors.

Additional C# scope fixes:
  - scope-tree.ts: Module scopes may share the same range as a top-level
    namespace_declaration (files with no leading `using` directives). The
    rangeStrictlyContains check rejects equal ranges. Added
    rangeNonStrictlyContains for Module parents.
  - scope-extractor.ts: pass1BuildScopes stack-pop used strict containment;
    same Module == Namespace range case caused orphaned scopes. Added
    moduleAwareContains helper.
  - scope-extractor-bridge.ts: empty captures from ERROR-root files still
    called extractScope -> "no Module scope found" warning. Added early
    return for empty/non-array captures.
  - namespace-siblings.ts: three sites pushed onto binding arrays frozen by
    finalize-algorithm. Fixed with spread-copy before mutation.

lbug-adapter.ts: INSTALL VECTOR in loadVectorExtension calls the KuzuDB
native extension installer, which crashes with SIGSEGV on Windows via an
unhandled error path in native code. JS try/catch cannot intercept native
signals. Skip extension loading on win32 — vector/embedding search is
unavailable on Windows but all graph index queries work correctly.

Verified on: Windows 11, Node.js 24, gitnexus 1.6.3, pcf8-game codebase
(61 757 nodes / 111 796 edges / 300 flows after fix).

* fix(windows): skip FTS extension load in pool-adapter on Windows to prevent SIGSEGV

LOAD EXTENSION fts crashes the process with SIGSEGV on Windows when the
FTS extension binary is not installed locally. This is an @ladybugdb/core
native bug — the extension loader hits an unhandled error path that raises
a native signal instead of a JS exception, so try/catch cannot protect here.

Add a process.platform === 'win32' guard in both doInitLbug and
initLbugWithDb. When skipped, bm25-index.js catches the resulting
Kuzu catalog errors (CREATE_FTS_INDEX not defined) and returns empty
BM25 results gracefully. All graph queries (cypher, context, impact)
are unaffected.

This is patch 9 of the Windows fix series for gitnexus on Windows:
patch 8 (same PR) already fixed INSTALL VECTOR SIGSEGV in lbug-adapter.ts.
pool-adapter.ts is the separate MCP-server code path that was not covered.

* fix: address codeql findings on PR #1433

The four `lastIndexOf('\n', ...)` calls were committed with a literal
newline inside the single-quoted string instead of the `\n` escape, so
the files do not parse — `tsc` and CodeQL both flagged them. Replace
the embedded newline with `'\n'`.

Also remove the two helpers that were superseded during review and
became dead code: `rangeNonStrictlyContains` in scope-tree.ts (the
equal-range carve-out is handled by `rangeStrictlyContains` +
`rangesEqual` in `canParentScope`) and `moduleAwareContains` in
scope-extractor.ts (`pass1BuildScopes` calls `canParentScope` directly).

* fix(windows): replace 32767-char truncation with chunked-input parsing

The tree-sitter 0.21.x Node binding crashes (SIGSEGV) on Windows when
parser.parse(string, ...) is handed a JS string longer than 32 767 chars.
The crash is in the bindings V8 string-to-buffer conversion and cannot
be intercepted from JS. Previous mitigation truncated source at the last
newline before that boundary, silently losing the file tail and producing
ERROR-root trees from mid-class cuts.

Switch to the callback (Parser.Input) overload via a new parseSourceSafe
helper. tree-sitter pulls source in 16 KiB chunks via repeated callback
invocations, bypassing the broken conversion path. Files are parsed in
full, no data loss, no platform-specific code path.

Removes the now-unnecessary ERROR-root short-circuit in csharp/captures.ts
and the empty-captures shim in scope-extractor-bridge.ts; both existed only
to swallow truncation-induced parse failures.

* fix(windows): cover all parse sites and correct vector-extension state

Address adversarial review on PR #1433:

1. Extend parseSourceSafe to all remaining parser.parse() call sites that
   handle full file content. The first commit only converted the four
   sites with active truncation hacks; cache-miss paths in
   call-processor (x2), heritage-processor (x2), import-processor, and
   the Go/Python/TypeScript captures + Go range-binding still called
   parser.parse() directly. On Windows those would still SIGSEGV for
   files > 32767 chars.

2. Stop setting vectorExtensionLoaded = true on the win32 short-circuit
   in lbug-adapter.ts. The flag means "successfully loaded" and is
   checked by an early-return at the top of loadVectorExtension; setting
   it on the skip path made the second call return true and let
   QUERY_VECTOR_INDEX run against a DB without the extension.

3. Drop the placeholder issues/... URL in the same comment.

4. Add unit tests for parseSourceSafe at boundary values: 16 KiB
   (direct/callback boundary), the 32 767 Windows crash boundary,
   single-line > chunk size, CRLF near boundary, and large all-Chinese
   source. Confirms the callback path is correct for non-ASCII content,
   which is also exercised by the existing csharp-captures large-file
   test.

Researched the chunking concern: tree-sitter Node binding sets
TSInputEncodingUTF16 and divides byte_index by 2 in ByteCountToJS before
calling the JS callback, so the index argument is a UTF-16 code-unit
offset — matching String.prototype.slice. Splitting tokens across chunks
is safe by API contract; the lexer is chunk-agnostic.

* fix(windows): extend parseSourceSafe to group/embeddings + lint enforcement

Closes the remaining Windows SIGSEGV exposure flagged by the Codex
adversarial review on PR #1433. Six pre-existing parser.parse(content)
call sites bypassed parseSourceSafe and could crash the process on
Windows when a contract IDL, route file, or embedding-target source
exceeded 32 767 chars. Adds a lint rule so the regression vector closes
permanently.

Production code:
- Relocate parseSourceSafe from ingestion/utils/ to core/tree-sitter/
  so group/ and embeddings/ can import without crossing into ingestion
  internals. core/tree-sitter/ already houses parser-loader.ts and is
  the natural shared facade. All 11 existing importers updated; no shim
  left behind in the old location.
- Route through parseSourceSafe in 5 group extractors (grpc, thrift,
  http-route, include, tree-sitter-scanner) and the embeddings
  ensureAndParse helper.
- The seventh direct .parse() call in grpc-patterns/proto.ts:49 is a
  module-load grammar smoke test parsing a 36-char literal. Trivially
  safe by inspection, intentionally direct, filtered out by the lint
  rule via the string-literal-arg skip.

Tests:
- 5 caller-side regression tests with a vi.spyOn assertion on
  parseSourceSafe. The spy is what catches a regression: parser.parse
  on a 40 000-char input succeeds on Linux/macOS, so a "no throw"
  assertion alone would silently pass with the bypass reintroduced.
- The vi.mock boilerplate is centralised in
  gitnexus/test/helpers/parse-source-safe-mock.ts, dynamic-imported
  inside each mock factory so vitest's hoister does not race the
  static import binding.

Lint:
- New custom ESLint rule gitnexus/require-safe-parse, scoped to
  gitnexus/src/core/**, fails on direct <parser>.parse(<non-literal>,
  ...) calls and auto-fixes them to parseSourceSafe(<parser>, ...).
  Skips JSON/URL/marked/Number/Math, string-literal first args
  (smoke tests), test files, and the helper itself. Auto-fix rewrites
  the call site only; the developer adds the import after tsc
  surfaces the missing identifier — same tradeoff as
  unused-imports/no-unused-imports.

Plan: docs/plans/2026-05-10-001-fix-windows-parse-safety-group-and-embeddings-plan.md

* fix(test): use mkdtempSync in http-route-extractor regression test

Address CodeQL js/insecure-temporary-file warning on the new Windows-
SIGSEGV regression test. The test was using path.join(tmpDir, "large-input")
which, when nested inside a Date.now()-based parent tmpDir, lets CodeQL flag
the directory as a predictable-name temp file with race-condition risk.
Switch to fs.mkdtempSync(path.join(tmpDir, "large-input-")) so the suffix
is a secure unique random string.

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-10 16:00:36 +01:00
2620b704e0 feat(cursor): upgrade hooks to Cursor 2.4 postToolUse for Read/Grep/Shell coverage (#1467)
* feat(cursor): upgrade hooks to Cursor 2.4 postToolUse for Read/Grep/Shell coverage

Cursor 2.4 (released 2026-01-22) shipped generic preToolUse/postToolUse hooks
matching `Shell|Read|Write|Grep|Delete|Task|MCP:<tool>`, replacing the
2.3-era beforeShellExecution hook that only fired on shell commands. The
existing integration only intercepted the shell path, so Cursor users got
graph augmentation roughly 10% as often as Claude Code users — only when
the agent dropped to rg/grep instead of using its native Read/Grep tools.

This swaps the integration over to postToolUse and ports the bash+jq
hook script to cross-platform Node:

- gitnexus-cursor-integration/hooks/hooks.json: registers a single
  postToolUse hook matching Shell|Read|Grep that invokes the new
  gitnexus-hook.cjs.
- gitnexus-cursor-integration/hooks/gitnexus-hook.cjs: new Node hook
  mirroring the safety patterns from the Claude hook (absolute-cwd
  validation, .gitnexus discovery with linked-worktree fallback,
  npx.cmd on Windows, end-of-options `--` marker, debug truncation,
  graceful failure). Extracts the search pattern per tool kind:
  Grep -> toolInput.query; Read -> file basename stripped to identifier
  chars; Shell -> existing rg/grep arg parser. Emits Cursor-shape
  `{ "additional_context": "..." }` on stdout — no shell, no jq.
- gitnexus-cursor-integration/hooks/augment-shell.sh: removed (Windows
  incompatible, narrower coverage).
- gitnexus/test/unit/cursor-hook.test.ts: 33 regression tests covering
  manifest wiring, source-level invariants (no shell:true, npx.cmd,
  isAbsolute, additional_context output shape, end-of-options marker),
  extractPattern coverage per tool, and behavioral early-exit paths
  (empty/invalid stdin, relative cwd, no .gitnexus, unknown tool name,
  short patterns, non-search shell commands, case-insensitive matching).
- README.md / gitnexus/README.md: editor-support table now lists Cursor
  as Full / hooks=Yes (postToolUse), matching reality.
- gitnexus/src/cli/augment.ts and gitnexus/src/core/augmentation/engine.ts:
  doc-strings updated from `Cursor beforeShellExecution` to
  `Cursor postToolUse`.

Closes #1466.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cursor): hook timeout is in seconds, not milliseconds

Cursor's `timeout` field in hooks.json is in seconds (per
https://cursor.com/docs/agent/hooks and the original integration's
`"timeout": 5`). I'd written `10000` after blindly copying the issue
body's example — that resolves to ~2.8 hours, not 10 seconds. If the
script ever hangs before reaching its inner spawnSync timeouts (e.g.
during stdin read), Cursor would have waited that long before killing
it.

Drop to `10` (seconds), matching the Claude plugin's hooks.json and
giving plenty of headroom over the inner 7s augment-CLI timeout.

Add a regression-guard assertion in cursor-hook.test.ts so a future
ms/s mixup fails fast.

Reported by Cursor Bugbot on PR #1467.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cursor): address Claude review findings — payload aliases, debug, install docs

Resolves three findings from Claude reviewer on PR #1467:

1. Cursor payload field-name uncertainty (SIGNIFICANT)
   Claude flagged that the Grep `query` field is an unverified assumption
   per Cursor 2.4 docs (https://cursor.com/docs/agent/hooks). Mitigated:
   - Expanded Grep aliases: query | pattern | regex | q | search | searchQuery
   - Added pickLongestStringValue() last-resort fallback so the hook
     extracts *something* even if Cursor renames every documented field
   - Added GITNEXUS_DEBUG=1 stderr logging of the raw stdin payload so
     users can capture Cursor's actual contract when diagnosing silent
     no-ops, and report it back if aliases drift
   - Added Read alias `filePath` (camelCase variant alongside `file_path`)
   - Inline comment block citing the docs URL and the uncertainty

2. Hook command path resolution + install docs (SIGNIFICANT)
   Claude flagged `node ./hooks/gitnexus-hook.cjs` as relative without
   documented install path. Added gitnexus-cursor-integration/README.md
   with explicit install steps:
   - .cursor/hooks.json + hooks/gitnexus-hook.cjs at project root
   - Confirms Cursor's project-root CWD convention with doc link
   - Verify steps including GITNEXUS_DEBUG capture
   - Pattern-extraction contract table per tool
   - Troubleshooting: not-firing, npx fallback, wrong-pattern diagnosis

3. README "Full" overclaim for Cursor (MODERATE)
   Both README rows now read `Yes (postToolUse, manual install)` linking
   to the new install README, accurately signaling that hooks aren't
   automated by `gitnexus setup` like they are for Claude Code.

4. Shell quoted-pattern parser limitation (MINOR, documented)
   Added inline comment in gitnexus-hook.cjs documenting the known
   `rg "User Service"` -> `User` truncation, plus regression tests in
   cursor-hook.test.ts pinning the behavior so a future change is
   visible.

Test additions (33 -> 41):
- Wide-alias source coverage for Grep (query / pattern / regex / q /
  search / searchQuery) plus pickLongestStringValue fallback
- Read alias coverage including camelCase filePath
- GITNEXUS_DEBUG behavioral test: stderr quiet by default, payload
  echoed when env var set, stdout output contract preserved either way
- Shell quoted-pattern documented behavior tests
- Install README presence + content (.cursor/hooks.json, hooks/, debug
  diagnostics)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-10 13:29:06 +01:00
Gergő Magyar 5d670a530d ci(release): skip rc build on release PRs (#1474)
* ci(release): skip rc build on release PRs

Suppress the auto-fired Release Candidate workflow when:
  1. The HEAD commit subject matches `chore: release vX.Y.Z` (the canonical
     release-PR title), or
  2. The squash-merged PR carries the `release` label.

Either match short-circuits the guard to should_run=false. This prevents the
rc cycle from racing publish.yml on the v-tag (as happened on v1.6.4 where
we had to manually cancel the auto-fired RC run after merging PR #1473).

Adds pull-requests: read to the guard job for the label lookup. A failed
gh API call falls through to the existing dedup logic rather than silently
suppressing rc builds.

* ci(release): address PR #1474 review — anchor regex + sanitise log echo

Two minor follow-ups from Claude's review:

1. End-anchor the release-subject regex. The previous shape
   ^chore: release vX.Y.Z would match noisy variants like
   chore: release v1.0.0 (something unrelated). The new shape
   requires either the bare title or the canonical squash-merge
   (#NNNN) suffix exactly.

2. Sanitise HEAD_SUBJECT before echoing to logs. git %s strips
   newlines so LF injection is impossible, but a hypothetical
   subject containing ::error:: or ::set-output:: could otherwise
   forge GitHub Actions annotation entries. Defence-in-depth.

Both findings flagged minor / does not block merge — applying
anyway since they are trivial.
2026-05-10 09:50:58 +01:00
Gergő Magyar 4848dce9ea test(u8): de-flake regex linearity assertions (#1475)
* test(u8): de-flake regex linearity assertions

The single-trial 2x input + 3x ratio bound was razor-thin: a real macOS
CI run failed at ratio 3.01x with small=7.41ms / large=22.31ms - both
above the 5ms noise floor but close enough that single-shot scheduler
jitter pushed the ratio over.

Replace the methodology with four stacked techniques:
  1. Warmup runs before timing (let the JIT tier up)
  2. Median of 5 trials per measurement (eliminates GC + jitter)
  3. 4x input ratio (was 2x) - linear gives ~4x, O(n^2) gives ~16x
  4. 8x ratio bound with a 20ms noise floor on the LARGE measurement

Headroom: linear is expected at ~4x, bound is 8x = 2x safety margin.
A real O(n^2) regression on a 4x input would clock 16x, well outside.
Catastrophic backtracking is still caught by the absolute <500ms cap.

Verified: 10 consecutive local runs all passed.

* test(u8): address PR #1475 review — tighten floor + rename for accuracy

Two follow-ups from Claude's review:

1. Floor semantics: revert to 'skip when BOTH measurements below floor'
   (AND, not single-check) and lower threshold from 20ms back to 5ms.
   Median-of-5 makes 5ms reliably resolvable above performance.now()'s
   ~10-100us band, so the higher floor was unnecessary defense.
   Closes the gap where an O(n^2) regression on a fast runner could
   stay under 500ms AND below 20ms-large to escape both detectors.

2. Rename assertSubLinearRatio -> assertNearLinearScaling. The bound
   is SIZE_RATIO * 2 = 8x on a 4x input = sub-quadratic with 2x
   headroom over linear, not strict sub-linearity. New name reflects
   the actual semantics.
2026-05-10 09:31:47 +01:00
Gergő Magyar 936a3bed6f chore: release v1.6.4 (#1473)
* chore: release v1.6.4

* chore: expand v1.6.4 CHANGELOG with additional crash-issue closes
2026-05-10 08:33:47 +01:00
dependabot[bot] 874d2aa95b chore(deps)(deps): bump langchain from 1.3.4 to 1.3.5 in /gitnexus-web (#1462) 2026-05-10 06:15:27 +01:00
dependabot[bot] 262ad8b859 chore(deps)(deps-dev): bump @types/dompurify in /gitnexus-web (#1461) 2026-05-10 05:55:44 +01:00
dependabot[bot]andGergő Magyar a2517e050d chore(deps)(deps): bump lucide-react in /gitnexus-web (#1460)
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 1.11.0 to 1.14.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.14.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-09 19:51:12 +01:00
dependabot[bot]andGergő Magyar a5c582f547 chore(deps): bump actions/checkout from 5.0.0 to 6.0.2 (#1459)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5.0.0 to 6.0.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...de0fac2e4500dabe0009e67214ff5f5447ce83dd)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-09 19:26:53 +01:00
CopilotandGergő Magyar 6f1cfffdd7 fix(security): Harden CI permissions (#1454)
* Initial plan

* chore(security): harden workflow permissions and pin Docker base image digests

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2ddc8f2b-7355-48cf-9a0b-c06df66c3f47

* fix(security): restore permissions: {} on publish + release-candidate workflows

These two release-publishing workflows had permissions: {} (the strictest valid form) before PR #1454, which replaced it with permissions: read-all. Every job in both files already declares its own permissions block, so the workflow-level default is only the safety net for future jobs added without one — read-all weakens that net for no benefit. Restore {} and the explanatory comment.

Scorecard's TokenPermissions check accepts both forms, so this preserves U9 compliance.

* fix(security): narrow permissions: read-all to contents: read on 13 workflows

PR #1454 added permissions: read-all to 13 workflows that previously had no top-level permissions block. read-all is Scorecard-compliant but unnecessarily broad — every job in scope only needs contents:read at the workflow level (job-level blocks already grant the writes that any job actually performs).

Snapshot of every job in the 13 workflows confirms contents:read is sufficient:

- ci.yml: quality/tests/scope-parity have explicit contents:read job blocks; save-pr-meta uses upload-artifact only (no token scopes needed); ci-status is pure shell.
- ci-e2e.yml, ci-quality.yml, ci-scope-parity.yml, ci-tests.yml: all jobs do checkout + npm + tsc/vitest/playwright/upload-artifact only; no API token scopes required.
- claude.yml, codeql.yml, dependency-review.yml, docker.yml, gitleaks.yml, pr-labeler.yml, trivy.yml, workflow-lint.yml: all jobs already declare their own job-level blocks (security-events:write, pull-requests:write, packages:write, etc.) so the workflow-level default does not gate them.

zizmor (--min-severity high) is clean on the resulting tree. Pre-existing medium findings (secrets-inherit, artipacked) are in unrelated workflows and untouched by this commit.

scorecard.yml also uses read-all but pre-existed PR #1454 and is deferred to a follow-up PR per the plan's scope boundary.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-09 17:58:22 +01:00
666041d608 fix(security): log-injection, http-to-file-access, client-side-request-forgery (#1456)
* fix(security): U11 log-injection, http-to-file-access, client-side-request-forgery

U11.1: Add validateLLMBaseUrl() in llm-client.ts; called at the top of
callLLM() to reject non-http/https schemes and http:// to non-loopback
hosts before any fetch that writes LLM output to disk.

U11.2: Strip CRLF from groupDir in bridge-db.ts openBridgeDbReadOnly
before logging (defence-in-depth on top of pino's JSON escaping).

U11.3: Replace console.log with logger.debug and sanitize normalizedName
/ job.id in api.ts resolveRepo to close js/log-injection alerts.

U11.4: Add validateBackendUrl() in backend-client.ts; called inside
setBackendUrl() to reject non-http/https schemes before the URL is
stored as a fetch target, closing js/client-side-request-forgery alerts.

U11.5: Tests added:
- wiki-llm-client.test.ts: validateLLMBaseUrl happy/error paths
- server-connection.test.ts: validateBackendUrl and setBackendUrl
  rejection paths

All new tests pass (30/30 wiki-llm-client, 18/18 server-connection,
30/30 bridge-db).

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/0452a6ce-711f-4203-9ae6-5dd0b77fb157

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: correct IPv6 loopback check in validateLLMBaseUrl

Node's URL parser preserves brackets in hostname for IPv6 addresses
(e.g. http://[::1]:11434 yields hostname '[::1]'), so strip them
before comparing against '::1'. Add a test to cover this case.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/0452a6ce-711f-4203-9ae6-5dd0b77fb157

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: also sanitize error message in bridge-db log call

Sanitize lastErr.message (which may contain a file path from ENOENT
errors) alongside groupDir to prevent CRLF injection from error
message content. Addressed code review feedback.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/0452a6ce-711f-4203-9ae6-5dd0b77fb157

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: address security review findings — credential hygiene and test coverage

[LOW] Redact credentials from URL validation error messages:
- validateLLMBaseUrl: malformed URL no longer echoes raw input;
  scheme error shows protocol only; http-non-loopback error uses
  parsed.origin (scheme+host+port) instead of full URL
- validateBackendUrl: same treatment — no raw input in any error path

[INFO] Add state-preservation test for setBackendUrl:
- Proves _backendUrl is unchanged after a rejected call, covering the
  validation-before-assignment ordering.

[INFO] Expand validateLLMBaseUrl adversarial test coverage:
- LOCALHOST uppercase (case-fold path)
- RFC 1918 / IMDS IPs (10.x, 169.254.x)
- Hostname-spoofing (localhost.evil.com, 127.0.0.1.evil.com, localhost.)
- Non-loopback IPv6 (fe80::1, ::ffff:127.0.0.1)
- ftp:// scheme
- Credential-hygiene assertion (sk-secret not in error message)

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/7bb18fa2-3e66-4fe0-949f-6d493fbd351b

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* style: prettier autoformat U11 security fix files

Fixes the failing 'quality / format' check on PR #1456 by running 'prettier --write' over the 6 files touched by the security fix. Formatting only — no logic change.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-05-09 17:26:32 +01:00
e02c56f653 fix(security): Pin Docker Node base images, remove runtime package-manager CVE surface, verify Trivy on PRs, and harden Dependabot policy (#1455)
* fix: pin Docker node base images and remediate bundled npm CVEs

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e0605c79-296e-4b3a-b6c3-4ad375950935

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: run trivy on docker PR changes and remove corepack

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/4d714047-4fc1-4af1-9734-91400a15568f

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* chore: add docker digest updates and normalize dockerfile comments

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/7d980908-a823-4c28-b074-9134ec672e84

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: add dependabot cooldown policies

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a8531b8d-384b-4c54-84dd-a98b31993c44

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: remove unsupported dependabot cooldown keys

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/166df50e-c2fe-4d7f-ab41-e94c703338f6

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* chore(node): bump CI + engines to Node 22; centralize NPM_VERSION via build ARG

Closes the LOW findings from Claude Final Re-Review on PR #1455:

- Bump engines.node to >=22.0.0 and align all CI workflows (ci-quality,
  pr-autofix, publish, release-candidate) and the composite setup
  actions on Node 22. Node 20 reached EOL on 2026-04-30; the test
  Docker image was already on 22.
- Centralize the bootstrapped npm version in a single ARG NPM_VERSION
  per Dockerfile (cli, web, gitnexus/Dockerfile.test) so a security
  bump only requires updating one default per file with a clear
  cross-reference comment.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-05-09 16:55:31 +01:00
Gergő Magyar 6906be3695 feat(autofix): replace inline reviewdog with /autofix ChatOps button (#1458)
* fix(autofix): verify reviewdog actually posted before claiming "click Apply"

The sticky summary comment was stating "Posted formatting suggestions
inline. Click Apply suggestion on each" even when reviewdog landed zero
inline review comments — typical case: the formatter touched lines
outside the PR's added range, so `-filter-mode=added` (correctly)
filtered everything out. The script unconditionally set `posted=true`
after running reviewdog regardless of whether any comments were
actually created, leaving the user staring at a sticky that promised
buttons that didn't exist.

The publish job now snapshots the count of `github-actions[bot]` review
comments before and after reviewdog. If the delta is zero, surface a
new `diff-no-overlap` UI state that tells the user plainly:

  "Formatter found fixable issues, but they're on lines outside this
  PR's added range — there's nothing to click here. Run locally:
  npm run lint:fix && npm run format."

Plus a matching `gitnexus/autofix` Check Run conclusion (still neutral,
distinct title) so agents reading `gh pr checks` see the same signal.

Three states are now machine-distinguishable in the sticky's
gitnexus-autofix JSON block: suggestions-posted (delta > 0),
diff-no-overlap (delta == 0), skipped-too-large (>3k lines).

* feat(autofix): replace inline reviewdog with /autofix ChatOps button

Pivot the PR autofix UX from per-line reviewdog suggestions to a single
slash-command button. Contributors comment `/autofix` on the PR; a new
trusted workflow downloads the existing autofix patch artifact, applies
it to the PR head, and pushes a commit back.

Why:
- 3K+ diffs hit GitHub's review-comment API 406 limit -> dead end.
- Diffs where the formatter touches lines outside the PR's added range
  ("no-overlap") get filtered by reviewdog's -filter-mode=added -> dead
  end (PR #1457 patched the lying sticky but the underlying UX gap
  remained).
- Per-line click-Apply-suggestion is high-friction for big diffs and
  easy to apply unevenly.
- A single `git apply` + push works at any size and lands fixes
  atomically.

Changes:
- pr-autofix-publish.yml: remove `Install reviewdog` and
  `Post inline suggestions` steps. Collapse three sticky states
  (suggestions-posted, diff-no-overlap, skipped-too-large) into one
  (fixes-available). Bump JSON schema v1 -> v2 with `apply_command`
  field; all v1 fields preserved.
- pr-autofix-apply.yml (new): triggers on issue_comment with body
  `/autofix`, validates body via strict regex, validates commenter
  has write/admin/maintain or is the PR author, locates latest
  successful pr-autofix run for PR head SHA, downloads artifact,
  applies patch, pushes commit. Reacts +1/-1/eyes on triggering
  comment per outcome. Idempotent (`git apply --check --reverse`
  detects already-applied state).
- CONTRIBUTING.md: document v2 schema and the /autofix flow,
  including the maintainer-edit requirement for fork PR pushes.

Trust posture: apply workflow runs from default-branch code only,
under issue_comment trigger. Comment body and author login flow
through env vars and pattern-matched, never interpolated into shell.
Permission gate (write/admin/maintain OR PR author) before any
artifact fetch. Fork PRs require "Allow edits by maintainers"
(GitHub-native; we don't bypass).

Net YAML: -139 lines in publish.yml, +260 in apply.yml. Removes
reviewdog binary pin and the entire review-comment API surface.

* fix(autofix): address Codex adversarial findings on PR #1458

Two findings from the Codex adversarial review of the autofix ChatOps
pivot. Both are localized YAML changes that close trust gaps the pivot
inherited from the original PR #1446 design.

U1 — Cross-verify metadata against workflow_run authority
(.github/workflows/pr-autofix-publish.yml):
  Previously the trusted publisher accepted pr_number, head_sha, and
  head_repo from metadata.json after only an allowlist regex. A
  fork-controlled `npm run lint:fix` could have written a syntactically
  valid metadata.json referencing another PR/SHA, redirecting the
  write-scoped sticky/check-run onto an attacker-chosen target.

  New `Verify metadata against workflow_run authority` step compares
  artifact-claimed identity against:
    - github.event.workflow_run.head_sha
    - github.event.workflow_run.head_repository.full_name
    - workflow_run.pull_requests[].number (within-repo PRs)
    - gh api commits/{sha}/pulls fallback (fork PRs, where
      pull_requests[] is empty)
  Fail closed on mismatch — no sticky, no check-run, no override.

U2 — Lease-protected push in apply workflow
(.github/workflows/pr-autofix-apply.yml):
  Previously the apply step pushed `HEAD:${HEAD_REF}` plain. A force-
  push between resolve (Step 5) and push (Step 9) would silently
  fast-forward an older commit graph over the contributor's newer
  state.

  Push now uses `--force-with-lease=refs/heads/${HEAD_REF}:${HEAD_SHA}`
  against the SHA resolved earlier. Distinct `lease-failed` result code
  + retry-message reply, separated from `push-failed` (fork without
  maintainer-edit) so contributors can diagnose the actual cause.

Plan: docs/plans/2026-05-09-005-fix-autofix-codex-adversarial-findings-plan.md
(local-only per repo convention).

Trust posture preserved: no new permissions, no new workflows, no
contract change. JSON v2 schema unchanged. CodeQL js/server-side-
request-forgery and template-injection posture unchanged — all new
inputs flow via env vars and pattern-matched.

* fix(autofix): close zizmor credential-persistence finding on apply checkout

actions/checkout's default behavior writes the GITHUB_TOKEN into
.git/config as an extraheader. The token then sits on disk in the
checkout directory — an actions/upload-artifact step on that
directory would leak it. We don't upload, but zizmor's
credential-persistence lint correctly flags the latent risk.

Set persist-credentials: false on the Checkout PR head step. Provide
push auth inline via `git -c http.extraheader="Authorization: Basic
<base64-of-x-access-token:TOKEN>"` so the credential never lands on
disk and never appears in process listings (the URL form
https://x-access-token:TOKEN@… is rejected here because it leaks via
ps and git remote -v).

Push lease semantics from U2 unchanged — same --force-with-lease
against the resolved HEAD_SHA, same lease-failed/push-failed/stale
result codes.

* fix(review): apply autofix feedback

ce-code-review surfaced 15 findings on PR #1458; this commit applies
the 7 with concrete fixes (#1, #2, #3, #4, #5, #9, #13). Five P2
findings (#6, #7, #8, #10, #12) are recorded as residual actionable
work for follow-up; two advisory items (#11, #14) skipped.

#1 — applied_run_id schema drift (CONTRIBUTING.md):
  v2 docs claimed `state: applied` enum value and an `applied_run_id`
  field that no code path emits. Trimmed docs to match what the
  workflow actually writes (state: fixes-available; v1 field set as
  superset). Implementing the apply-side sticky upsert that would
  populate `applied_run_id` is deferred — cleaner than carrying a
  contract claim with no code.

#2 — result= unset between idempotency probe and lease push
(pr-autofix-apply.yml):
  After `git apply --check` passed, an early non-zero exit from
  `git config` / `git apply` / `git add` / `git commit` left
  `result=` unset, sending the user to the `*` "unexpected state
  (`unknown`)" arm. Wrapped the apply/commit phase in a single
  if-test that sets `result=apply-failed` on any failure. New
  React-and-reply branch surfaces an actionable message.

#3 — permission lookup conflated transient API failures with denial
(pr-autofix-apply.yml):
  `gh api … 2>/dev/null || echo "none"` swallowed 5xx, 429 secondary
  rate-limit, and network failures, surfacing them as a public 👎
  refusal to legitimate maintainers. Now distinguishes 404
  (genuine non-collaborator) from other API failures via stderr
  match. New `allowed=api-failed` state triggers a 😕 reaction with
  a "transient API failure, retry" reply instead of a misleading
  refusal.

#4 — lease-failure grep missed git's "remote rejected" / branch-
deleted phrasings (pr-autofix-apply.yml):
  Real lease failures got classified as `push-failed` →
  user told to enable maintainer-edit, which won't help. Expanded
  regex to match `remote rejected` and `! [rejected]`.

#5 — broken bullet continuation in CONTRIBUTING.md release-candidate
section: rejoined the split bullet so it renders correctly.

#9 — base64 GITHUB_TOKEN bypassed GitHub's secret-masker
(pr-autofix-apply.yml):
  Added `::add-mask::${auth_header}` immediately after construction
  so any subsequent log line (set -x, GIT_TRACE) gets *** redacted.

#13 — misleading schema-bump comment in pr-autofix-publish.yml:
  Comment claimed all v1 fields preserved exactly, but the `state`
  enum was redefined v1→v2. Updated to make the migration path
  explicit (v1 readers see unfamiliar schema, fall back to prose).

Residual actionable work (deferred to follow-up):
  #6 locate step gh api retry; #7 artifact-expired graceful fallback;
  #8 re-entrancy comment-spam guard; #10 producer-still-running UX;
  #12 gh_retry wrapper for apply.yml.

Validations: yaml.safe_load OK, check-workflow-concurrency.py OK.

* fix(autofix): apply remaining ce-code-review residual findings (#6, #7, #8, #10, #12)

Pulls the deferred items from the previous review pass into this PR so
the workflow ships with full reliability + UX coverage rather than
follow-up debt.

#6 + #12 — gh_retry wrapper on idempotent GETs in apply.yml:
  Permission lookup, PR metadata fetch, and workflow-run lookup are now
  wrapped in the same gh_retry helper publish.yml uses (3 attempts,
  linear backoff). Reaction/comment POSTs remain unwrapped (retrying
  POST would dupe the resource).

#10 — producer-still-running UX:
  The locate step now distinguishes three cases via `found_status`
  output: success (proceed), in-progress / queued / pending / waiting
  (reply ⏳ "wait for autofix run to finish"), not-found (reply 🤔
  "push a commit"), api-failed (reply ⚠️ "transient API failure"). The
  "no successful autofix run" message no longer fires immediately after
  a fresh push while the producer is still mid-run.

#7 — artifact-expired graceful fallback:
  actions/download-artifact gains `continue-on-error: true`. The apply
  step distinguishes patch-file-missing (artifact expired, 1-day
  retention elapsed) from patch-file-zero-bytes (formatter found
  nothing). New `result=artifact-expired` case + ⏳ "push a new commit
  to regenerate" reply.

#8 — re-entrancy loop guard:
  After checkout but before applying, check if HEAD itself is a
  github-actions[bot] `chore(autofix)` commit. If so, refuse to
  re-apply (`result=loop-prevented`) with a 🔁 reply telling the user
  to push a human-authored commit or revert before retrying. Prevents
  formatter-config-drift loops where an automated agent watching the
  sticky could pump arbitrary apply commits.

Net effect: every code path in apply.yml now sets a meaningful `result=`
that maps to a specific user-facing reaction + reply. The `*` "unexpected
state (unknown)" arm becomes truly unreachable in normal operation.

Validations: yaml.safe_load OK, check-workflow-concurrency.py OK.

* fix(autofix): refresh stale reviewdog comments + reject patches touching .github/

Two follow-up findings on PR #1458:

#1 — Stale reviewdog references in workflow header comments:
  pr-autofix-publish.yml's header still described the removed inline-
  suggestion path ("posts inline review-comment suggestions to the PR
  using `reviewdog`", "Reviewdog reporter: github-pr-review reads
  $REVIEWDOG_GITHUB_API_TOKEN…"). The Check Run permissions comment
  enumerated the old outcomes (clean / suggestions-posted /
  skipped-too-large) instead of the current set (clean / fixes-
  available). pr-autofix.yml's header described the trusted job as
  posting "inline review-comment suggestions" and the changed_lines
  comment referenced the dead 3000-line cap. Refreshed all three to
  describe the actual sticky + Check Run + /autofix flow.

#2 — Reject patches touching .github/ (sensitive-paths guard):
  Theoretical supply-chain vector: a malicious PR could ship a custom
  prettier/ESLint config that reformats workflow YAML, dependabot.yml,
  or CODEOWNERS. The producer would capture those edits in
  autofix.patch; a maintainer running `/autofix` would push them under
  `contents: write` without human review. The default GITHUB_TOKEN
  lacks the `workflows` scope so workflow-file pushes would fail at
  the platform layer anyway, but as a generic `push-failed` (which
  misleads users into enabling maintainer-edit). Reject early with
  a specific reason.

  Match runs against the patch with grep on `^(diff --git|---|+++)
  [ab]?/?\.github/`. New `result=sensitive-paths` case + 🛑 reply
  telling the user to apply .github/ formatter changes manually.

  Documented the constraint in CONTRIBUTING.md under the /autofix
  section so contributors aren't surprised when the workflow refuses
  a patch that includes formatter changes to workflow files.

Validations: yaml.safe_load OK, check-workflow-concurrency.py OK.
2026-05-09 16:32:38 +01:00
Gergő Magyar 152a0506c9 feat: shared resilient-fetch (retries + circuit breaker) (#1448)
* feat: shared resilient-fetch (retries + circuit breaker)

Add a small, runtime-agnostic resilience layer in gitnexus-shared and
migrate every backend HTTP outbound call (CLI, MCP, wiki LLM, web → backend)
through it.

Helpers (gitnexus-shared/src/integrations/):

- retry.ts            — withRetry(fn, opts) with caller-supplied
                        retryability classification and full-jitter
                        exponential backoff.
- circuit-breaker.ts  — closed/open/half-open per-process breaker with
                        injectable clock, plus a keyed registry so
                        callers targeting the same endpoint share state.
- resilient-fetch.ts  — composed wrapper: retries 5xx + 429 + retryable
                        network throws, treats AbortSignal.timeout()
                        and 4xx (other than 429) as terminal, honors
                        Retry-After (capped at 30s), throws
                        CircuitOpenError when the breaker opens.

Migrations (no behaviour regression — all existing tests pass):

- gitnexus/src/core/embeddings/http-client.ts (covers analyze + MCP
  query path) — replaces inline linear-backoff retry.
- gitnexus/src/core/wiki/llm-client.ts — preserves Azure content-filter
  branch; resilientFetch handles 5xx/429.
- gitnexus-web/src/services/backend-client.ts (fetchWithTimeout helper)
  — small retry budget (2 attempts, 250–1500 ms) so a dead local
  backend still fails fast for the user.
- gitnexus-web/src/core/llm/settings-service.ts (OpenRouter model list).

Deliberately not migrated:

- gitnexus-web/src/services/backend-client.ts streamJob() — Server-Sent
  Events stream; the existing reconnect-with-Last-Event-ID logic is
  not unary-fetch shaped.
- gitnexus-web/src/components/SettingsPanel.tsx checkOllamaStatus() —
  one-shot health probe; retrying delays the "Ollama not running"
  error rather than improving UX.

41 new helper tests cover backoff math, breaker state transitions,
Retry-After parsing (delta-seconds + HTTP-date), 401/422 terminal
classification, and breaker fail-fast on three exhausted retry batches.

* fix(review): apply autofix feedback

Address Claude's two MEDIUM blocking findings on PR #1448 plus the
CodeQL SSRF false-positive flag.

- backend-client `fetchWithTimeout` now uses `AbortSignal.timeout()`
  merged with the caller's signal via `AbortSignal.any()`. Timer-fired
  aborts surface as `DOMException(name='TimeoutError')` so
  resilientFetch routes them through the terminal-network branch
  (no retry, no breaker hit), instead of incrementing the breaker
  for user-side network slowness.
- Method-aware retry budget in `fetchWithTimeout`: idempotent verbs
  (GET/HEAD/OPTIONS) keep the 2-attempt budget; POST/PATCH/PUT/DELETE
  default to single-attempt so a 5xx on `startAnalyze` cannot start
  a duplicate job. New `forceRetry` parameter for callers that
  know-idempotent mutations (e.g. DELETE of a known-deleted resource).
- `resilient-fetch.ts` carries a documented suppression for CodeQL
  js/server-side-request-forgery on the inner fetch call. Every
  concrete caller passes a hardcoded URL constant or a value from
  configuration (env vars, saved settings); user request input never
  flows into the URL parameter.
- New test file `backend-client-retry.test.ts` covers all three
  paths: GET retries on 503, POST does not retry, timeout does not
  increment the breaker.

* fix(resilient-fetch): address Codex adversarial findings

Closes the three blocking issues from Codex's review on PR #1448.

U1 — Add `recordNeutral()` to CircuitBreaker.
  Third outcome path that's an explicit no-op for state and the
  consecutive-failure counter. Distinct from `recordSuccess` (closes
  the breaker) and `recordFailure` (may open it). Used for outcomes
  that are neither evidence of backend health nor evidence of
  backend failure.

U2 — Route terminal-client / terminal-network through `recordNeutral`.
  Previously a 401 or local timeout called `recordSuccess`, which
  reset `consecutiveFailures` to 0. A 5xx → 401 → 5xx → 401 → 5xx
  sequence would NEVER trip the breaker because each 4xx in between
  erased the running count. Also classify external `AbortError` as
  terminal-network (was retryable-network), so caller-driven
  cancellation no longer retries against an already-aborted signal
  or counts toward breaker failures on exhaustion.

U3 — Per-origin breaker key in web `fetchWithTimeout`.
  Was hardcoded to `'web-backend'` even though `_backendUrl` is
  mutable via `setBackendUrl`. Switching backend URLs after a
  circuit tripped on host-A would strand the user during the full
  cooldown. Key is now `web-backend:<origin>`, so each backend URL
  gets its own breaker state.

Tests: +5 recordNeutral, +4 resilient-fetch (interleaved 4xx/5xx,
external AbortError, prior-state preservation), +1 web switch-backend
regression. All 70 gitnexus integration tests + 15 web tests green.

* fix(resilient-fetch): tolerate header-less fetch mocks on 429

`classifyOutcome` called `resp.headers.get('Retry-After')` directly,
which crashed when a test stubs `fetch` with a plain object like
`{ ok: false, status: 429 }` (no `headers` field). Real `Response`
always has Headers, so this surfaces only in test setups, but the
helper has no business assuming caller-side correctness on this — the
defensive guard is cheap and a missing `Retry-After` falls through to
exponential-backoff retry like any 429 without the header.

Surfaced by `gitnexus/test/unit/http-embedder.test.ts > retries on
rate limit`, which the embeddings migration exercises against a
plain-object 429 stub. Locked in with a new
`classifies 429 from a header-less fetch mock without throwing` case.

* fix(review): apply autofix feedback

Closes findings from the third multi-agent review pass on PR #1448.

#1 (P1) callLLM had no per-attempt timeout
  Wiki LLM calls passed no `signal` to resilientFetch; each of three
  retry attempts could hang indefinitely on a frozen TCP connection.
  Add `signal: AbortSignal.timeout(60_000)` so the per-attempt budget
  matches what http-client.ts and backend-client.ts already provide.

#2 (P2) drop dead `lastRetryableResp` post-loop fallback
  Variable was set in one switch arm but only read in unreachable code
  after the loop. The retry loop always returns/throws on every
  iteration. Keep only the defensive `throw` so TypeScript's
  control-flow analysis still sees `Promise<Response>` as the return.

#5 (P2) gate test-only exports behind a subpath
  `__resetBreakerRegistry__` and `classifyOutcome` were reachable from
  the main `gitnexus-shared` barrel — production code calling
  `__resetBreakerRegistry__` from a tool implementation would silently
  nuke every circuit breaker process-wide. Move to a new
  `gitnexus-shared/test-helpers` subpath export. Production callers
  see the cleaner public API; tests import via the explicit
  `gitnexus-shared/test-helpers` path.

#6 (P2) exhaustiveness guard on Outcome switch
  Add a `default: const _: never = outcome` arm so a future sixth
  `Outcome.kind` won't compile silently — it'll surface at the switch
  site rather than fall through to a retry/no-retry default.

#9 (P3) document cumulative wall-clock budget
  Add a "Cumulative wall-clock budget" paragraph to resilientFetch's
  JSDoc explaining the worst-case total wait (`maxAttempts × (per-attempt
  timeout + capDelayMs)` ≈ 60s with defaults) and pointing callers at
  outer `AbortSignal.timeout()` when they want a tighter bound.

Deferred to follow-up PRs (per review's Auto-resolve recommendation):
  - #3 idempotency knob to shared API (forceRetry into ResilientFetchOptions)
  - #4 publish.ts migration to resilientFetch
  - #7 parseRetryAfter past-HTTP-date / negative-seconds asymmetry
  - #8 recordNeutral counter time-decay (documented breaker semantic)

* fix(circuit-breaker): gate half-open to a single in-flight probe

Closes the Codex adversarial-review finding on PR #1448 that flagged a
recovery-time thundering herd: when cooldown expired, every concurrent
caller transitioned the breaker to half-open and probed the still-
recovering dependency in lockstep, defeating the breaker's "fail fast"
promise.

U1 — probe-permit gate in CircuitBreaker.check()
  Added a `probeInFlight: boolean` field. After cooldown expires, the
  first `check()` admits the probe and consumes the permit; subsequent
  callers throw `CircuitOpenError` with a configurable
  `halfOpenRetryAfterMs` (default 1000ms) until the probe resolves.

  Critical design point: `recordNeutral` now RELEASES the permit but
  does NOT transition state. Without that split, a single `TimeoutError`
  from per-attempt `AbortSignal.timeout` (which routes through neutral
  classification) would permanently park the breaker in half-open. By
  separating permit-release from state-resolution, we keep the
  "neutral doesn't claim health" semantic without creating that wedge.

  Other changes:
  - `halfOpenRetryAfterMs` is now a constructor option for consumers
    with long-running protected ops (LLM streaming, large uploads).
  - `getState()` is documented as a pure read; the implicit
    Open -> Half-Open transition lives in `check()` only, so tests
    that inspect state never inadvertently consume a probe permit.
  - `isProbeInFlight()` test-only accessor for assertion clarity.
  - JSDoc on `check()` records the JS event-loop atomicity dependency
    and the load-bearing `try/finally` pairing invariant.

U2 — End-to-end concurrency regression through resilientFetch
  Three new scenarios in resilient-fetch.test.ts (26 -> 29):
  - 3 concurrent calls + probe gets 200 -> 1 hits fetch, 2 throw
    CircuitOpenError, breaker closes.
  - 3 concurrent calls + probe gets 503 -> ResilientFetchExhaustedError
    on probe; concurrent callers see halfOpenRetryAfterMs (1000ms);
    fresh caller after probe resolves sees the FULL new cooldown
    (10000ms), not the probe-in-flight default.
  - Probe cancelled mid-flight via AbortError -> permit released,
    state stays half-open, next caller becomes the new probe and
    succeeds.

Plus 9 new circuit-breaker unit tests (16 -> 25) covering the permit
gate, recordNeutral-releases-permit semantic, fresh-cooldown distinction,
default vs configurable halfOpenRetryAfterMs, getState() purity, and
the three-probes-via-neutrals chain.

Total integration test count: 70 -> 82. All 106 gitnexus + 15 web
tests pass; both packages typecheck.

Maintainer decisions (deferred per plan 003 Open Questions):
  - Plan 002's deferral judgement was reversed on Codex's argument
    without new measurement / incident data. The reversal is defensible
    on principle (Hystrix / Resilience4j alignment) but lacks workload-
    driven evidence.
  - Probe-blocked callers throw silently (no log / event hook). R4's
    "no new public API" prevents adding observability; loosen if a
    debug log on probe-blocked is wanted.

* refactor(embeddings): replace bespoke HF breaker with shared CircuitBreaker

Deleted the local `HfDownloadCircuitBreaker` class and the manual
retry loop in `withHfDownloadRetry`. Both are now backed by the
shared `gitnexus-shared` primitives:

- `hfDownloadCircuit` is `new CircuitBreaker({ failureThreshold,
  cooldownMs, key: 'hf-download' })` — same state machine as before
  PLUS the single-permit half-open gate that prevents recovery-time
  stampedes when CLI + MCP embedders concurrently re-load the model.
- `withHfDownloadRetry` delegates the loop to `withRetry` from the
  shared package. Per-attempt timeout (`withDownloadTimeout`),
  network-vs-non-network classification, circuit recording, and the
  `onRetry` callback wire through `withRetry`'s `isRetryable`
  callback.

Behaviour preserved:
- Pre-flight `CIRCUIT_OPEN_TAG` rejection when the breaker is open.
- Mid-loop `CIRCUIT_OPEN_TAG` "opened after N consecutive failures"
  when a network error trips the threshold.
- Non-network errors (e.g. CUDA unavailable) bypass retry and go
  through `recordNeutral` instead of resetting the breaker's
  failure-count progress.
- `onRetry(attempt+1, max, err)` fires only when there's a next
  attempt, matching the prior semantic.

Generic CircuitBreaker gained two inspection accessors:
- `getOpenedAt(): number | null`
- `getCooldownMs(): number`
Used by `withHfDownloadRetry` to compute `secsUntilReset` without
consuming a probe permit (which `check()` would do).

Test consolidation: the 7 bespoke `HfDownloadCircuitBreaker`
state-machine tests in hf-env.test.ts were 1:1 duplicates of
existing tests in `circuit-breaker.test.ts` and were deleted.
Remaining 42 hf-env tests all pass; full integration sweep (148
gitnexus + 15 web) green.
2026-05-09 15:18:09 +01:00
CopilotandGergő Magyar 248cb1e634 Add regression coverage for .gitnexusignore behavior with --skip-git (#1450)
* Initial plan

* test: cover --skip-git with .gitnexusignore regression

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/7ca9afd5-10b9-4f39-a260-60e60bde6874

* test: reuse cli path constant in skip-git tests

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/7ca9afd5-10b9-4f39-a260-60e60bde6874

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-09 14:26:06 +01:00
dependabot[bot]andGergő Magyar f26a35b17f chore(deps)(deps): bump @anthropic-ai/sdk (#1442)
Bumps the npm_and_yarn group with 1 update in the /gitnexus-web directory: [@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript).


Updates `@anthropic-ai/sdk` from 0.90.0 to 0.91.1
- [Release notes](https://github.com/anthropics/anthropic-sdk-typescript/releases)
- [Changelog](https://github.com/anthropics/anthropic-sdk-typescript/blob/main/CHANGELOG.md)
- [Commits](https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.90.0...sdk-v0.91.1)

---
updated-dependencies:
- dependency-name: "@anthropic-ai/sdk"
  dependency-version: 0.91.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-09 13:53:07 +01:00
Gergő Magyar b5627f27d8 ci: add fork-safe PR autofix pipeline (#1446)
* ci: add fork-safe PR autofix pipeline

Two-workflow split posts prettier + eslint --fix output as inline
review-comment suggestions on PRs (including fork PRs) without running
fork-controlled ESLint plugins under a privileged token.

- pr-autofix.yml: untrusted, runs lint:fix/format with permissions: {},
  uploads diff artifact. paths-ignore on lockfiles/snapshots/dist to
  avoid reviewdog 406 on >3k-line diffs.
- pr-autofix-publish.yml: trusted workflow_run consumer. Validates every
  metadata.json field with regex allowlists before exporting to
  GITHUB_OUTPUT (closes head_ref newline-injection vector). Concurrency
  keyed on PR number with fork fallback to head-repo+branch. Reviewdog
  pinned to v0.21.0. Sticky comment posts only when patch is non-empty
  (no noise on clean PRs); body carries a fenced gitnexus-autofix JSON
  block under a stable HTML marker for agent parsing. gh API calls go
  through a small retry helper for transient 5xx.

Branch protection should enable merge queue + 'require branches up to
date' to handle PR freshness; chinthakagodawita/autoupdate is dropped
(unmaintained since 2023).

* ci(autofix): close zizmor template-injection findings

Move fork-controlled values (head.ref, head.repo.full_name, head.sha,
pr.number, github.repository) into the step's env: block instead of
interpolating them with `${{ }}` directly into the bash run body. The
job has permissions:{} today so this is defence-in-depth, but a future
scope grant on the untrusted half would otherwise turn a malicious
branch name into shell injection.

Add pr-autofix-publish.yml to the documented dangerous-triggers ignore
list — workflow_run is required to post sticky comments on fork PRs
and the file's structural defences (no fork checkout, allowlist on
metadata.json, base_repo equality check) match the existing
ci-report.yml exemption.

* ci(autofix): close remaining review findings

- Add an actionlint job to workflow-lint.yml. Catches YAML syntax,
  expression typing, shellcheck-inside-run, and deprecated runner
  labels on every .github/** PR — closes the gap that let pr-autofix's
  YAML literal-block bug reach review on this branch.
- pr-autofix-publish.yml emits a `gitnexus/autofix` Check Run on the
  PR head SHA: conclusion `success` for clean, `neutral` (with
  distinct output titles) for suggestions-posted vs.
  skipped-too-large. Stable name lets agents read the outcome via
  `gh pr checks` without parsing the sticky comment.
- Document the autofix signal contract in CONTRIBUTING.md — sticky
  marker, fenced gitnexus-autofix JSON schema, Check Run name. One
  source of truth so the marker / schema fields don't drift across
  the workflow files and consumers.

* ci: fix actionlint/shellcheck findings on PR #1446

Closes the actionlint warnings the new lint job (workflow-lint.yml's
actionlint runner) surfaced once it was wired into CI. Mostly
shellcheck-style cleanups across three workflows.

pr-autofix-publish.yml
  - SC2170: `[ "${{ steps.meta.outputs.changed_lines }}" -gt 3000 ]`
    interpolates a literal string into bash, breaking shellcheck's
    arithmetic-comparison parse. Move `changed_lines` through env: as
    `CHANGED_LINES` and reference as `$CHANGED_LINES` inside bash.

ci-report.yml (Read PR metadata step)
  - SC2002 ×2: `cat file | tr` -> `tr < file`.
  - SC2129: three consecutive `>> "$GITHUB_OUTPUT"` redirects collapsed
    into one `{ ...; } >> "$GITHUB_OUTPUT"` group.

ci-report.yml (Build report step)
  - SC2162 ×2: `read VAR1 VAR2` -> `read -r VAR1 VAR2` so backslashes
    in test-results.json output aren't mangled.
  - SC2034: drop unused `SUITES` aggregate. The per-framework suite
    counts (CLI_SU, WEB_SU) are now read into `_` placeholders since
    the report doesn't surface them anywhere.

release-candidate.yml
  - SC2129 ×2: collapse consecutive `>> "$GITHUB_OUTPUT"` redirects in
    the rc-version computation step and the tag-push step into one
    grouped block each.
2026-05-09 13:35:04 +01:00
KareemandGergő Magyar 3daf8c9984 feat(extractors): strip Unreal Engine reflection macros before C++ parsing (#1439)
* feat(extractors): strip Unreal Engine reflection macros before C++ parsing

Tree-sitter does not expand C preprocessor macros, so Unreal Engine reflection markers (UCLASS, UFUNCTION, UPROPERTY, MODULENAME_API, GENERATED_BODY, ...) are parsed verbatim. The result is mis-parsed UE class/function declarations: in 'class BRAWLUI_API UMyClass : public UObject', tree-sitter-cpp captures BRAWLUI_API as the class name, leaving the actual class without an entry in the graph.

This patch adds an optional 'preprocessSource' hook to LanguageProvider and implements it for C++ via a new 'stripUeMacros' module. The transform is length-preserving (each elided byte becomes a space, newlines preserved) so byte offsets and line/column positions tree-sitter reports remain identical to the original file -- symbol locations in the graph stay accurate.

A cheap detection guard short-circuits files that don't look like UE sources, so non-UE C++ codebases pay no cost (single regex test then bail).

27 unit tests cover the detection guard, length preservation across multiple UE samples, macro removal for UCLASS/UFUNCTION/UPROPERTY/USTRUCT/GENERATED_BODY/MODULE_API/DECLARE_*_DELEGATE/UE_DEPRECATED, false-positive guards (substring matches, balanced parens inside string literals, Qt macros left alone), and class-name extraction sanity. Full unit suite still passes (5337 tests, 0 regressions). Verified end-to-end against an Unreal Engine 5.7 game project (Brawl).

* fix(extractors): address PR review findings on UE macro preprocessor

Resolves three blocking issues raised by automated review:

1. Prettier format: ran prettier --write on call-processor.ts, heritage-processor.ts, import-processor.ts (the three sites where the cache-miss reparse hook insertion landed unformatted).

2. Byte-length contract narrowed: language-provider.ts docblock now states the contract precisely (UTF-16 .length + newline-position preservation, not UTF-8 byte length). Notes that startIndex byte offsets only match the original file when the elided range is pure ASCII -- which is the practical UE case (reflection macros and module-export tokens are ASCII-only).

3. Tree-sitter extraction tests added: new end-to-end tests parse the preprocessed source with tree-sitter-cpp and assert the captured class/struct name is the real UClass identifier (UMyClass, FMyData), never the MODULE_API export macro. Also asserts source positions (startPosition.row) survive the transform.

Plus one moderate fix:

4. _API stripping is now scoped to UE files only. The HAS_UE_HINT guard previously included [A-Z]_API tokens, which would fire on non-UE codebases that use REST_API / HTTP_API / MY_LIB_API as constants or enum values, silently erasing them. The guard now requires a strong UE marker (UCLASS|UFUNCTION|UPROPERTY|USTRUCT|UENUM|UINTERFACE|GENERATED_BODY|UE_DEPRECATED|DECLARE_*_DELEGATE) to be present before any stripping runs. Two new tests confirm REST_API and DECLARE_HANDLER style identifiers in non-UE files are left untouched.

Plus one minor fix:

5. stripUeMacros signature now accepts (source, _filePath?) to match the LanguageProvider.preprocessSource hook contract exactly. The filePath argument is unused; UE detection is purely content-based.

Verification: 34/34 preprocessor tests pass (was 27, +7 new for non-ASCII preservation, REST_API safety, tree-sitter extraction, struct extraction, source position preservation). Full unit suite 5349 pass, 0 regressions. Typecheck clean. Prettier --check clean on all 9 changed files.

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-09 12:28:32 +01:00
Alex Macdonald-SmithandGergő Magyar d91428ad9d feat(cli): add gitnexus publish for opt-in understand-quickly registry (#1425)
* feat(cli): add `gitnexus publish` for opt-in understand-quickly registry

Adds a small, opt-in command that fires a single `repository_dispatch`
event at `looptech-ai/understand-quickly` to ask the registry for an
instant resync of the current repo's entry. No graph file is uploaded;
the registry pulls from raw.githubusercontent.com per the protocol at
https://github.com/looptech-ai/understand-quickly/blob/main/docs/integrations/protocol.md.

  - Pure helpers (id parsing, payload construction, validation) live in
    `gitnexus-shared/src/integrations/understand-quickly.ts` so the
    package stays Node-free and the same logic is testable in isolation.
  - The CLI command lives in `gitnexus/src/cli/publish.ts`. Without
    `UNDERSTAND_QUICKLY_TOKEN` it is a no-op (exits 0 with one
    informational line); with the token it POSTs the dispatch and
    surfaces 204 / 401 / 404 / 5xx distinctly.
  - The id defaults to `<owner>/<repo>` parsed from the `origin` remote
    and can be overridden with `--id`.
  - Refuses to publish when no `.gitnexus/` index exists, with a
    `gitnexus analyze` hint.

Tests: a new vitest unit covers the pure helpers (8 + 8 + 2 cases) and
the no-token no-op path with a `fetch` spy that fails the test if the
network is touched. README gets a one-paragraph "Publishing to
understand-quickly" section near the existing CLI docs.

* fix(uq-publish): address review blockers + high-severity items

Addresses CodeQL polynomial-regex (HIGH), token-gate ordering, distinct
401/403/404/422 response branches, fetch timeout, expanded test coverage,
tightened owner/repo validation, and non-GitHub remote rejection.

See response thread on PR #1425 for the per-finding rationale.

Signed-off-by: amacsmith <alex.mac@looptech.ai>

* fix(publish): address Claude review on PR #1425

- AbortError → TimeoutError: AbortSignal.timeout() throws a
  DOMException with name 'TimeoutError', not Error{name:'AbortError'}.
  Match the pattern used in core/embeddings/http-client.ts so the
  user-facing "timed out after 15000ms" message actually fires. Update
  the regression test to throw a real DOMException — the previous fake
  was a false-green.
- isValidOwnerRepo: forbid trailing hyphen in the owner segment.
  GitHub rejects this at account-creation time; allowing it here meant
  hand-typed --id values like 'my-org-/repo' would pass our regex and
  422 from GitHub.
- Add publish-command coverage to cli-index-help.test.ts (asserts on
  --id, --skip-git, the registry name, and the token env var) and
  cli-commands.test.ts (asserts publishCommand is exported as a
  function). Catches accidental command-registration deletion.

---------

Signed-off-by: amacsmith <alex.mac@looptech.ai>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-09 09:52:26 +01:00
32b5c0e3fc feat: add IncludeExtractor for C++ cross-repo include tracking (group) (#1156)
* feat: add IncludeExtractor for C++ cross-repo include tracking (group)

* fix: address CodeQL warnings on include-extractor

- Remove unused HEADER_GLOB constant in include-extractor.ts
- Use fs.mkdtempSync for secure temp dir creation in tests
  (CodeQL: 'Insecure temporary file')

* fix(group): close missing ); in manifest-extractor include branch

The 'include' branch in ManifestExtractor.resolveSymbol was missing
the closing ); for the executor() call, causing a syntax error that
broke ESLint, Prettier, and the full test CI on all platforms.

Reported by Claude PR review on #1156.

* chore: drop test/global-setup.ts + test/vitest.d.ts

Upstream removed these in commit 3f0c74fe (ladybugdb 0.16.0 upgrade).
Commit 3f5d21c5 accidentally restored them during a rebase dance.

* style(group): reformat VALID_CONTRACT_TYPES array to satisfy prettier

Adding 'include' pushed the array over prettier's 100-char limit,
so prettier prefers multi-line. Apply the reformat to unbreak
ci-quality/format job.

* fix(include-extractor): address PR #1156 Claude review findings #3-#7

Claude Deep Review raised 7 findings on the IncludeExtractor. #1/#2
(BLOCKERs) were fixed earlier. This commit closes the remaining five.

#3 HIGH  case-sensitive FS -> provider contract-id collision
  Document the deliberate case-folding trade-off on normalizeIncludePath
  (matches C/C++ convention on Windows/macOS; collapses Foo.h & foo.h on
  Linux). Add a unit test pinning the behavior.

#4 HIGH  suffixResolve short-suffix match silently drops cross-repo include
  When a local file ends with the same basename as an external include
  (e.g. local internal/api.h vs. #include "ext/api.h"), suffixResolve
  returned a bogus local hit and suppressed the cross-repo consumer.
  Replace the suffixResolve lookup inside include-extractor with a
  strict isLocalInclude() that only accepts full-path hits via
  SuffixIndex.get / getInsensitive. Callers of suffixResolve elsewhere
  are unaffected. Add 3 unit tests covering the regression.

#5 MEDIUM regex fallback matched #include inside /* ... */
  Strip block comments before running the fallback regex scan.
  Add a unit test.

#6 MEDIUM meta.source was hard-coded to 'tree_sitter'
  Track the actual extraction path with an extractionSource local and
  write it into meta.source so downstream audits can distinguish
  tree-sitter parses from regex fallbacks. Add 2 unit tests.

#7 MEDIUM missing end-to-end coverage
  Add test/integration/group/include-extractor-sync.test.ts with 3
  cases exercising extractor -> syncGroup -> CrossLink (mocked
  contracts, mixed-case/backslash normalization, real temp repos).

Tests: 21 unit + 3 integration, all green.

* fix(lbug): robust Windows lock acquisition for CI integration tests

LadybugDB's `new Database()` raises `Could not set lock on file` from
local_file_system.cpp synchronously inside the constructor — before any
query is issued, so `withLbugDb`'s query-time retry never sees it. On
Windows CI this surfaces as flaky integration tests due to AV-scanner
holds, libuv handle-release lag, and stale `.wal` sidecars from aborted
prior runs.

This change closes the gap at *open time*:

- `openLbugConnection` now wraps `new lbug.Database()` in a bounded
  busy-retry (5x100ms back-off) inside `lbug-config.ts`. Errors that
  exhaust the budget are tagged via `LBUG_OPEN_RETRY_EXHAUSTED` so
  `withLbugDb`'s outer 3x retry skips re-retrying a freshly-exhausted
  path (eliminates the 3x5=15-attempt / ~6s tail latency).
- For recognized test fixtures only (immediate-parent dir matches a
  known prefix AND resolves under `os.tmpdir()`), one final stale-
  sidecar sweep removes `.wal`/`.lock` and retries once. Production
  paths never enter this branch.
- `safeClose` on Windows runs a bounded `fs.open` probe to absorb
  native handle-release lag; logs a warning if the probe exhausts so
  operators can spot AV interference.
- `isDbBusyError` is now defined in `lbug-config.ts` as the single
  source of truth, re-exported from `lbug-adapter.ts` for compatibility.
- New tests cover open-time retry (happy/retry/exhaust/non-busy/tag),
  stale-sidecar sweep (test-fixture-only, production-rejection,
  preserves-original-error), `isTestFixturePath` direct unit suite
  (accept/reject/traversal/nested/trailing-sep), and
  `waitForWindowsHandleRelease` (openable/ENOENT/no-leak).
- The two new test files are added to vitest's existing serialized
  `lbug-db` project (already `fileParallelism: false`).

Closes the chronic Windows CI flake on lbug-touching integration tests
while preserving the existing single-writable-Database-per-process
LadybugDB contract. No public API surface changed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(lbug): drop isDbBusyError re-export, import from lbug-config directly

The re-export from lbug-adapter.ts was a transitional convenience — with
the matcher now living in lbug-config.ts, having two import paths for the
same symbol invites future drift. Updated the two real consumers
(lbug-lock-retry.test.ts, lbug-open-retry.test.ts) to import from
lbug-config directly, removed the re-export equality test (now vacuous),
and refreshed the explanatory comment so it no longer references a
re-export pattern that doesn't exist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(lbug): silence benign LadybugDB v0.16.1 schema-init lock warnings on Windows

doInitLbug logs "⚠️ Schema creation warning: ... Could not set lock on
file" on every CREATE NODE TABLE call after the first init on a given
dbPath, on Windows. The lock is internal to LadybugDB v0.16.1 and is
resolved before the table is created — same tolerance pattern as the
existing "already exists" filter. Genuine cross-process lock contention
still surfaces on the next operation through withLbugDb's retry, so
filtering at the schema-init catch only suppresses noise, not signal.

Also extend the safeClose Windows handle-release probe to cover the
.wal sidecar (the previous Database's WAL handle was the slowest to
release, surfacing as the schema-query lock contention) and switch the
probe back to 'r+' so it actually detects exclusive locks.

Test loop in lbug-close-handle-release.test.ts simplified to 10 plain
iterations now that the underlying noise is filtered upstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(lbug): isDbBusyError review fixes

- Drop redundant `could not set lock` term — already subsumed by `lock`.
- Document the intentionally-broad matcher: graph-DB lock-shaped errors
  ("deadlock", "unlock failed", "lock contention", "could not open lock
  file") are all treated as transient. If a non-transient surfaces,
  tighten the matcher rather than raise the retry budget.
- Add positive test cases covering those lock-shaped strings so the
  intent is visible and a future tightening would deliberately break
  these.
- Fix the open-retry back-off comment: max sleep is 100+200+300+400 =
  1000ms (no sleep after the final attempt), not 1.5s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(group): address PR #1156 follow-up review findings

Addresses two blockers and two mediums from the deep review.

BLOCKER 1: Windows CI ENOTEMPTY in sync.test.ts
  After this PR added writeBridge() to syncGroup, the existing test
  "writes registry to groupDir when skipWrite is false" fails on
  windows-latest. LadybugDB's checkpoint thread briefly outlives
  closeBridgeDb, holding a Win32 lock on bridge.lbug; the test's
  fs.rmSync then fails with ENOTEMPTY. Switched the test cleanup to
  cleanupTempDir from test/helpers/test-db.ts which already tolerates
  EBUSY/EPERM/EACCES/ENOTEMPTY with bounded retries — same pattern
  used elsewhere for LadybugDB-touching tests.

BLOCKER 2: Graph provider absolute-path bug
  extractProvidersGraph queried File.filePath from the LadybugDB graph
  but never stripped the repo root, so provider contract IDs ended up
  as include::/abs/path/foo.h while consumers emitted include::foo.h.
  These never matched through runExactMatch — silently producing 0
  cross-links for any indexed C++ repo (the primary use case).
  Now passes repoPath into extractProvidersGraph and applies
  path.relative(); rows that resolve outside repoPath (stale absolute
  paths from another machine, system headers somehow indexed) are
  dropped instead of polluting the registry.

MEDIUM: `../` relative includes produce spurious noise
  `#include "../foo.h"` is almost always intra-repo, but the suffix
  index can never match a `..`-prefixed path so it became a consumer
  contract no provider could satisfy. Now skipped before matching;
  covers both forward-slash and backslash forms.

MEDIUM: writeBridge error in sync.ts propagates uncaught
  contracts.json is the canonical source of truth and was just written
  successfully when writeBridge runs. A bridge-only failure (disk full,
  schema error, permission denied) shouldn't mask the registry. Wrapped
  writeBridge in try/catch with a logger.warn surfacing the path and
  recovery instructions.

Tests added:
  - extractProvidersGraph repo-relative ID generation (stub Cypher
    executor returns absolute paths)
  - extractProvidersGraph drops rows whose path resolves outside repo
  - `../foo.h` forward-slash skip
  - `..\foo.h` backslash-form skip

Skipped findings:
  - canExtract() removal (#5, low): canExtract is part of the
    ContractExtractor interface; every other extractor implements the
    same `return true` shape. Removing it from IncludeExtractor would
    break the interface contract — keeping for consistency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(group): close PR #1156 Codex adversarial findings

Two HIGH findings from the Codex adversarial review on
feat/group-include-extractor:

1. Default-on extraction silently changes existing groups (BLOCKER)
   DEFAULT_DETECT.includes was true, so any pre-existing group.yaml
   that omits the new field would gain a wave of include::* contracts
   on the next sync after upgrade. Flipped to false (opt-in). The
   integration test already declares includes: true explicitly so it
   survives unchanged; the unit extractor tests bypass parseGroupConfig
   entirely; the sync test uses extractorOverride. Only config-parser
   needed regression tests covering omitted/explicit/false variants.

2. IncludeExtractor scans outside the indexed file universe (BLOCKER)
   The extractor was running glob('**/*', { ignore: STANDARD_IGNORES })
   twice with a hand-rolled 9-pattern list, no .gitignore/.gitnexusignore
   honoring, and no max-file-size cap. That meant File:<path> contracts
   could appear for files ingestion would never index, producing
   cross-links group impact cannot fan out to (silent false-negatives).
   Refactored to a single discoverIndexableFiles() helper that mirrors
   walkRepositoryPaths exactly: createIgnoreFilter + getMaxFileSizeBytes,
   one discovery pass shared by provider and consumer paths. Dropped
   STANDARD_IGNORES and SOURCE_GLOB entirely.

   third_party and 3rdparty (the C/C++ vendored-deps conventions) were
   in the local ignore list but not in the canonical DEFAULT_IGNORE_LIST
   used by ingestion. Folded both into the canonical set rather than
   keep a parallel list — the whole point of the Codex finding is that
   two file-discovery implementations drift. Single source of truth.

Tests: 5 new regression tests for the discovery alignment (.gitignore,
.gitnexusignore, max-file-size on both provider and consumer paths)
plus 4 for the opt-in default. All 30 include-extractor tests + the
494-test group suite + ignore-service tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): apply autofix feedback

ce-code-review surfaced 6 safe_auto findings on commit a9936a9b:

- T1 (testing, P2): the sync.ts:174 gate was untested with includes:false.
  Added a sync-level test mirroring the existing thrift-off pattern at
  sync.test.ts:545, asserting zero include contracts when the gate is
  disabled in a real syncGroup call.

- T3 (testing, P3): third_party and 3rdparty entries in DEFAULT_IGNORE_LIST
  had no regression test. Added both to ignore-service.test.ts's
  dependency-directories it.each block.

- M1 (maintainability, P3): discoverIndexableFiles JSDoc lacked a
  fork-warning relative to walkRepositoryPaths. Added a MAINTENANCE
  note explaining why the duplication is tolerated and the contract
  the two implementations must keep.

- M2 (maintainability, P3): thrift-extractor still hand-rolls its
  ignore array with no signal that DEFAULT_IGNORE_LIST additions
  silently do not apply there. Added TODO(#1156-followup) comments
  above both call sites.

- M3 (maintainability, P3): SOURCE_EXTENSIONS duplicated the four
  HEADER_EXTENSIONS entries with no expressed subset relationship.
  Spread HEADER_EXTENSIONS into SOURCE_EXTENSIONS so future header-
  extension additions propagate.

- C1+T4 (correctness+testing, P3, cross-reviewer corroborated):
  discoverIndexableFiles swallowed all fs.stat errors silently,
  including EACCES/EMFILE/EIO. Narrowed the catch to ENOENT (the
  documented benign glob/stat race) and added a logger.warn for
  any other code so operators can spot permission/resource issues.

All 629 tests pass; typecheck + prettier clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(group): use retryRename in writeContractRegistry to absorb Windows EPERM

`storage.ts:62` used raw `fsp.rename` for the contracts.json atomic swap.
On Windows, AV scanners and concurrent renames briefly hold the
destination handle between rename calls, surfacing as EPERM/EBUSY.
The `insecure-tempfile.test.ts > concurrent writes do not collide`
test was flaking with `EPERM: operation not permitted, rename` on
windows-latest CI.

`bridge-db.ts` already has a battle-tested `retryRename(src, dst, 3)`
helper used at six call sites for exactly this pattern. Reusing it
here keeps the Windows-rename policy single-source-of-truth across
the group package.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(group): drop macro-style #include from consumer contracts

Tree-sitter's `(_) @import.source` wildcard matches the identifier node
of `#include PLATFORM_HEADER`, so the cleaned value `PLATFORM_HEADER`
slipped past the system-header / `..` filters and was emitted as a
permanently orphaned consumer contract (no file is named after a macro
identifier, so no provider can ever match). Add a shape guard that
skips cleaned values lacking both a path separator and an extension
dot, plus regression tests for single and multi-macro files.

Also document `IncludeExtractor.canExtract()` as unused by sync.ts
(gated via `config.detect.includes` instead) and kept solely for
ContractExtractor interface uniformity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: HuangWenjie <zhoudeng.hwj@alibaba-inc.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:31:59 +01:00
30e0c7c726 fix(csharp): include generic typed properties in context and impact (#1399)
* Fix C# context and impact for generic typed properties

* Address C# typed-property review feedback

---------

Co-authored-by: Richard Carmel <rcarmel@seitel.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-09 09:07:24 +01:00
dependabot[bot]andGergő Magyar 98addbd6c4 chore(deps)(deps-dev): bump @types/node in /gitnexus (#1436)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.6.1 to 25.6.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.6.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-09 07:30:05 +01:00
dependabot[bot]andGergő Magyar f29147864e chore(deps)(deps): bump fast-uri from 3.1.0 to 3.1.2 in /gitnexus (#1441)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-09 07:09:10 +01:00
dependabot[bot]andGergő Magyar b89ec5b5df chore(deps)(deps): bump hono from 4.12.16 to 4.12.18 in /gitnexus (#1443)
Bumps [hono](https://github.com/honojs/hono) from 4.12.16 to 4.12.18.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.16...v4.12.18)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.12.18
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-09 06:49:32 +01:00
dependabot[bot] 5bfe0c5222 chore(deps)(deps): bump onnxruntime-node in /gitnexus (#1435) 2026-05-09 05:53:16 +01:00
azizur100389 5497079ab2 fix(search): surface warning when FTS indexes are missing (#1418) 2026-05-08 17:05:18 +01:00
301 changed files with 22413 additions and 1403 deletions
@@ -1,5 +1,5 @@
name: Setup GitNexus Web
description: Setup Node.js 20.19+ (vite 7 floor), build gitnexus-shared, install web dependencies
description: Setup Node.js 22, build gitnexus-shared, install web dependencies
runs:
using: composite
@@ -7,9 +7,7 @@ runs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
# Vite 7 requires Node ^20.19.0 || >=22.12.0 (require(esm) support).
# Pin explicitly so we don't depend on the floating "20" alias resolving
# to a high enough patch version on every runner image.
node-version: '20.19.0'
node-version: 22
cache: npm
cache-dependency-path: gitnexus-web/package-lock.json
+2 -2
View File
@@ -1,5 +1,5 @@
name: Setup GitNexus
description: Setup Node.js 20, install dependencies, and optionally build
description: Setup Node.js 22, install dependencies, and optionally build
inputs:
build:
@@ -12,7 +12,7 @@ runs:
steps:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
node-version: 22
cache: npm
cache-dependency-path: gitnexus/package-lock.json
+47
View File
@@ -7,6 +7,8 @@ updates:
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
open-pull-requests-limit: 5
commit-message:
prefix: chore
@@ -15,6 +17,36 @@ updates:
- dependencies
- ci
# Keep pinned Docker base-image digests current for the root Dockerfiles.
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
open-pull-requests-limit: 5
commit-message:
prefix: chore(deps)
include: scope
labels:
- dependencies
- ci
# Keep the nested test-image Docker base digest current as well.
- package-ecosystem: docker
directory: /gitnexus
schedule:
interval: weekly
cooldown:
default-days: 7
open-pull-requests-limit: 5
commit-message:
prefix: chore(deps)
include: scope
labels:
- dependencies
- ci
# Gitnexus npm deps — tree-sitter grammars checked daily so we catch
# new releases that unblock the tree-sitter 0.25 upgrade ASAP. Grammars
# are grouped so lockstep bumps produce a single PR. The tree-sitter
@@ -25,6 +57,11 @@ updates:
directory: /gitnexus
schedule:
interval: daily
cooldown:
default-days: 7
semver-major-days: 30
semver-minor-days: 7
semver-patch-days: 3
open-pull-requests-limit: 10
commit-message:
prefix: chore(deps)
@@ -54,6 +91,11 @@ updates:
directory: /gitnexus-web
schedule:
interval: weekly
cooldown:
default-days: 7
semver-major-days: 30
semver-minor-days: 7
semver-patch-days: 3
open-pull-requests-limit: 5
commit-message:
prefix: chore(deps)
@@ -67,6 +109,11 @@ updates:
directory: /gitnexus-shared
schedule:
interval: weekly
cooldown:
default-days: 7
semver-major-days: 30
semver-minor-days: 7
semver-patch-days: 3
open-pull-requests-limit: 5
commit-message:
prefix: chore(deps)
+3
View File
@@ -3,6 +3,9 @@ name: E2E Tests
on:
workflow_call:
permissions:
contents: read
jobs:
check-changes:
name: Check web module changes
+5 -2
View File
@@ -3,6 +3,9 @@ name: Quality Checks
on:
workflow_call:
permissions:
contents: read
jobs:
format:
runs-on: ubuntu-latest
@@ -11,7 +14,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
node-version: 22
cache: npm
cache-dependency-path: package-lock.json
- run: npm ci
@@ -24,7 +27,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
node-version: 22
cache: npm
cache-dependency-path: package-lock.json
- run: npm ci
+15 -10
View File
@@ -95,31 +95,33 @@ jobs:
# Validate PR number is a positive integer (artifact comes from
# untrusted fork code, so treat contents defensively).
PR_NUM=$(cat "$DIR/pr_number" | tr -d '[:space:]')
PR_NUM=$(tr -d '[:space:]' < "$DIR/pr_number")
if ! [[ "$PR_NUM" =~ ^[0-9]+$ ]]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "::error::Invalid PR number in artifact: '$PR_NUM'"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "pr_number=$PR_NUM" >> "$GITHUB_OUTPUT"
# Validate job-result strings against known GitHub Actions values.
# Artifact contents come from the PR workflow (potentially untrusted
# fork code), so we whitelist to prevent newline injection into
# GITHUB_OUTPUT.
validate_result() {
local val
val=$(cat "$1" | tr -d '[:space:]')
val=$(tr -d '[:space:]' < "$1")
case "$val" in
success|failure|cancelled|skipped) echo "$val" ;;
*) echo "unknown" ;;
esac
}
echo "quality=$(validate_result "$DIR/quality_result")" >> "$GITHUB_OUTPUT"
echo "tests=$(validate_result "$DIR/tests_result")" >> "$GITHUB_OUTPUT"
echo "e2e=$(validate_result "$DIR/e2e_result")" >> "$GITHUB_OUTPUT"
{
echo "skip=false"
echo "pr_number=$PR_NUM"
echo "quality=$(validate_result "$DIR/quality_result")"
echo "tests=$(validate_result "$DIR/tests_result")"
echo "e2e=$(validate_result "$DIR/e2e_result")"
} >> "$GITHUB_OUTPUT"
- name: Checkout (for vitest config)
if: steps.meta.outputs.skip != 'true'
@@ -279,14 +281,17 @@ jobs:
fi
}
read CLI_T CLI_P CLI_F CLI_S CLI_SU CLI_D <<< "$(sum_results "$RESULTS_FILE")"
read WEB_T WEB_P WEB_F WEB_S WEB_SU WEB_D <<< "$(sum_results "$WEB_RESULTS_FILE")"
# `_` placeholder for the suite-count column — positional
# readability for sum_results' 6-field output, but the value
# isn't surfaced in the report (suites are tracked per-test
# framework, not as a top-line metric).
read -r CLI_T CLI_P CLI_F CLI_S _ CLI_D <<< "$(sum_results "$RESULTS_FILE")"
read -r WEB_T WEB_P WEB_F WEB_S _ WEB_D <<< "$(sum_results "$WEB_RESULTS_FILE")"
TOTAL=$((CLI_T + WEB_T))
PASSED=$((CLI_P + WEB_P))
FAILED=$((CLI_F + WEB_F))
SKIPPED=$((CLI_S + WEB_S))
SUITES=$((CLI_SU + WEB_SU))
DURATION=$((CLI_D > WEB_D ? CLI_D : WEB_D))
# ── Status helpers ──
+3
View File
@@ -28,6 +28,9 @@ name: Scope Resolution Parity
on:
workflow_call:
permissions:
contents: read
jobs:
discover:
name: Discover migrated languages
+3
View File
@@ -3,6 +3,9 @@ name: Tests
on:
workflow_call:
permissions:
contents: read
jobs:
tests:
name: ubuntu / coverage
+3
View File
@@ -6,6 +6,9 @@ on:
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
workflow_call:
permissions:
contents: read
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
# Hardcoded `CI-` prefix (not `${{ github.workflow }}`) because this workflow is
# invoked as a reusable workflow from publish.yml and release-candidate.yml. In
+6 -1
View File
@@ -19,6 +19,9 @@ on:
pull_request_review:
types: [submitted]
permissions:
contents: read
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
# Serialize per-PR/issue to avoid racing comments.
concurrency:
@@ -155,6 +158,8 @@ jobs:
github_token: ${{ secrets.GITHUB_TOKEN }}
allowed_non_write_users: '*'
show_full_output: true
# Review posts use Bash (`gh`, etc.); default mode asks for approval — impossible in CI.
claude_args: '--dangerously-skip-permissions'
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ steps.pr.outputs.number }}'
prompt: '/code-review:code-review https://github.com/${{ github.repository }}/pull/${{ steps.pr.outputs.number }} --comment'
+3
View File
@@ -17,6 +17,9 @@ on:
# already-merged code without waiting for the next PR.
- cron: '0 6 * * 1'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+3
View File
@@ -10,6 +10,9 @@ on:
pull_request:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
+3
View File
@@ -26,6 +26,9 @@ on:
required: true
type: string
permissions:
contents: read
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
# Tag refs are unique per release, so distinct tags run in parallel.
# Re-pushes of the same tag serialize. cancel-in-progress: false — never cancel a publish mid-flight.
+3
View File
@@ -12,6 +12,9 @@ on:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+595
View File
@@ -0,0 +1,595 @@
name: PR Autofix (apply)
# CHATOPS HALF of the autofix pipeline.
#
# Triggered when a contributor comments `/autofix` on a PR. Validates
# permission, locates the most recent successful `pr-autofix.yml`
# artifact for the PR's current head SHA, applies the patch to the PR
# head, and pushes a commit back to the PR branch.
#
# This workflow runs from the default branch's copy of the file
# regardless of where the comment originates -- that's the trust
# anchor. Comment body and author login are untrusted; both flow
# through env vars and pattern-matched, never interpolated into shell.
#
# Fork PR support: `git push` with the GITHUB_TOKEN succeeds against
# fork branches only when the contributor enabled "Allow edits by
# maintainers" on the PR (the default). When they disabled it, we
# fail loud with a 👎 reaction and an explanation comment.
on:
issue_comment:
types: [created]
concurrency:
# Per-PR scope. issue_comment events expose `github.event.issue.number`
# for both PR and Issue comments; the `pull_request != null` guard on
# the job ensures we only run on PRs, so this number is the PR number.
# cancel-in-progress: false — a second `/autofix` should wait for the
# first to finish (idempotency check on the second invocation handles
# the no-op case).
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: false
permissions: {}
jobs:
apply:
name: apply-autofix
# Pre-filter at the workflow level so non-PR comments and unrelated
# comments don't even spawn a runner. The job-level body re-check
# below (Step 1) is the strict gate.
if: >-
github.event.issue.pull_request != null
&& startsWith(github.event.comment.body, '/autofix')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
# React on the triggering comment + post reply comments.
pull-requests: write
# Push the apply commit to the PR head branch.
contents: write
# Required by actions/download-artifact to fetch artifacts produced
# by a different workflow run.
actions: read
steps:
- name: Validate comment body precisely
id: body
env:
BODY: ${{ github.event.comment.body }}
shell: bash
run: |
set -euo pipefail
# Whole-line, case-sensitive match: `^/autofix\s*$`. The
# workflow-level startsWith guard is coarse — `please don't
# /autofix this code` would pass that filter but fail this one.
# We exit silently (no reaction) on body mismatch so quoted
# text in unrelated discussions doesn't get a visible response.
if [[ ! "${BODY}" =~ ^/autofix[[:space:]]*$ ]]; then
echo "Body did not match strict /autofix regex — exiting silently."
echo "match=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "match=true" >> "$GITHUB_OUTPUT"
- name: Validate commenter permission
id: perm
if: steps.body.outputs.match == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
COMMENTER: ${{ github.event.comment.user.login }}
PR_AUTHOR: ${{ github.event.issue.user.login }}
shell: bash
run: |
set -euo pipefail
# Retry wrapper for transient 5xx / 429 / network blips.
# Mirrors the helper in pr-autofix-publish.yml. Used on
# idempotent GETs only; reactions/comment-POSTs are NOT
# wrapped (retrying a POST would dupe the resource).
gh_retry() {
local n=0 max=3
while true; do
if gh "$@"; then return 0; fi
n=$((n+1))
if [ "$n" -ge "$max" ]; then return 1; fi
sleep $((n * 2))
done
}
# Allowlist the commenter login before it flows into a URL.
# GitHub usernames: alphanumeric + dashes, max 39 chars.
if ! [[ "${COMMENTER}" =~ ^[A-Za-z0-9-]{1,39}$ ]]; then
echo "::error::Invalid commenter login format: $(printf '%q' "${COMMENTER}")"
echo "allowed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Self-comparison: PR author can always /autofix their own PR.
if [ "${COMMENTER}" = "${PR_AUTHOR}" ]; then
echo "Commenter is PR author — granting access."
echo "allowed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Repo permission lookup. admin/write/maintain are sufficient.
# Distinguish API failure (5xx, 429, network) from genuine
# permission denial (404 = not a collaborator). Conflating them
# would silently refuse a legitimate maintainer with a public
# 👎 every time GitHub blips. gh_retry handles transient blips;
# the stderr-grep distinguishes 404 from persistent failure.
perm_stderr=$(mktemp)
if permission=$(gh_retry api "repos/${GH_REPO}/collaborators/${COMMENTER}/permission" \
--jq '.permission' 2>"$perm_stderr"); then
echo "Commenter permission: ${permission}"
case "${permission}" in
admin|write|maintain)
echo "allowed=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "allowed=false" >> "$GITHUB_OUTPUT"
;;
esac
else
err=$(cat "$perm_stderr")
echo "Permission lookup stderr: ${err}" >&2
# 404 (not a collaborator) is a genuine deny.
# Anything else is a transient API/network failure.
if grep -qE "HTTP 404|Not Found" "$perm_stderr"; then
echo "allowed=false" >> "$GITHUB_OUTPUT"
else
echo "::error::Permission lookup failed transiently — refusing to act."
echo "allowed=api-failed" >> "$GITHUB_OUTPUT"
fi
fi
- name: React 😕 on transient permission-API failure
if: steps.body.outputs.match == 'true' && steps.perm.outputs.allowed == 'api-failed'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
COMMENT_ID: ${{ github.event.comment.id }}
PR: ${{ github.event.issue.number }}
RUN_ID: ${{ github.run_id }}
shell: bash
run: |
set -euo pipefail
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
-f content="confused" >/dev/null
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
-f body="⚠️ Couldn't verify your repo permission (transient GitHub API failure). Please comment \`/autofix\` again. ([apply run](https://github.com/${GH_REPO}/actions/runs/${RUN_ID}))" \
>/dev/null
exit 1
- name: React 👎 on permission denial
if: steps.body.outputs.match == 'true' && steps.perm.outputs.allowed == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
COMMENT_ID: ${{ github.event.comment.id }}
PR: ${{ github.event.issue.number }}
shell: bash
run: |
set -euo pipefail
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
-f content="-1" >/dev/null
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
-f body="🚫 \`/autofix\` is restricted to users with write access or the PR author. Comment ignored." \
>/dev/null
# Hard exit so the rest of the job is skipped.
exit 1
- name: React 👀 to acknowledge
if: steps.perm.outputs.allowed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
COMMENT_ID: ${{ github.event.comment.id }}
shell: bash
run: |
set -euo pipefail
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
-f content="eyes" >/dev/null
- name: Resolve PR head and locate autofix run
id: locate
if: steps.perm.outputs.allowed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
PR: ${{ github.event.issue.number }}
shell: bash
run: |
set -euo pipefail
# Same retry wrapper used in the permission step, repeated
# because each YAML `run:` block is a fresh bash session.
gh_retry() {
local n=0 max=3
while true; do
if gh "$@"; then return 0; fi
n=$((n+1))
if [ "$n" -ge "$max" ]; then return 1; fi
sleep $((n * 2))
done
}
# Fetch PR metadata. All fields here are server-controlled API
# output, but we still allowlist before exporting so anything
# weird short-circuits before $GITHUB_OUTPUT. Wrapped in
# gh_retry so transient blips don't surface as "no autofix run
# found" with a wrong remediation.
if ! pr_json=$(gh_retry api "repos/${GH_REPO}/pulls/${PR}"); then
echo "::error::PR metadata fetch failed after retries."
echo "found_status=api-failed" >> "$GITHUB_OUTPUT"
exit 0
fi
head_sha=$(jq -r '.head.sha' <<< "${pr_json}")
head_ref=$(jq -r '.head.ref' <<< "${pr_json}")
head_repo=$(jq -r '.head.repo.full_name' <<< "${pr_json}")
[[ "${head_sha}" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::Bad head_sha"; exit 1; }
[[ "${head_ref}" =~ ^[A-Za-z0-9._/-]+$ ]] || { echo "::error::Bad head_ref"; exit 1; }
[[ "${head_repo}" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || { echo "::error::Bad head_repo"; exit 1; }
# Find the latest successful pr-autofix.yml run for this head SHA.
if ! runs_json=$(gh_retry api "repos/${GH_REPO}/actions/workflows/pr-autofix.yml/runs?head_sha=${head_sha}&per_page=10"); then
echo "::error::Workflow run lookup failed after retries."
echo "found_status=api-failed" >> "$GITHUB_OUTPUT"
exit 0
fi
run_id=$(jq -r '[.workflow_runs[] | select(.conclusion == "success")] | .[0].id // empty' <<< "${runs_json}")
if [ -n "${run_id}" ] && [[ "${run_id}" =~ ^[0-9]+$ ]]; then
echo "found_status=success" >> "$GITHUB_OUTPUT"
{
echo "found=true"
echo "head_sha=${head_sha}"
echo "head_ref=${head_ref}"
echo "head_repo=${head_repo}"
echo "run_id=${run_id}"
} >> "$GITHUB_OUTPUT"
exit 0
fi
# No successful run. Distinguish "still running" (producer in
# flight after a recent push) from "never ran / all failed".
# in_progress / queued / pending / waiting cover the GitHub
# workflow-run lifecycle states that precede success/failure.
in_progress=$(jq -r '[.workflow_runs[] | select(.status == "in_progress" or .status == "queued" or .status == "pending" or .status == "waiting")] | length' <<< "${runs_json}")
if [ "${in_progress:-0}" -gt 0 ]; then
echo "::warning::pr-autofix run is still in progress for head ${head_sha}."
echo "found_status=in-progress" >> "$GITHUB_OUTPUT"
else
echo "::warning::No successful pr-autofix run found for head ${head_sha}."
echo "found_status=not-found" >> "$GITHUB_OUTPUT"
fi
# Existing `found` boolean is preserved so downstream gates
# (`steps.locate.outputs.found == 'true'`) still work.
echo "found=false" >> "$GITHUB_OUTPUT"
- name: Reply when locate did not yield a usable run
if: steps.perm.outputs.allowed == 'true' && steps.locate.outputs.found != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
COMMENT_ID: ${{ github.event.comment.id }}
PR: ${{ github.event.issue.number }}
FOUND_STATUS: ${{ steps.locate.outputs.found_status }}
RUN_ID: ${{ github.run_id }}
shell: bash
run: |
set -euo pipefail
run_url="https://github.com/${GH_REPO}/actions/runs/${RUN_ID}"
case "${FOUND_STATUS}" in
in-progress)
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
-f content="confused" >/dev/null
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
-f body="⏳ A pr-autofix run is still in progress for this PR's current head SHA. Wait for it to finish, then comment \`/autofix\` again. ([apply run](${run_url}))" \
>/dev/null
;;
api-failed)
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
-f content="confused" >/dev/null
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
-f body="⚠️ Couldn't reach the GitHub API to look up the autofix run (transient failure after retries). Please comment \`/autofix\` again. ([apply run](${run_url}))" \
>/dev/null
;;
*)
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
-f content="-1" >/dev/null
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
-f body="🤔 No successful autofix run found for this PR's current head SHA. Push a new commit to trigger one, then comment \`/autofix\` again." \
>/dev/null
;;
esac
exit 1
# Pinned to v8.0.1. Same SHA as pr-autofix-publish.yml.
# `continue-on-error: true` lets the workflow proceed when the
# artifact is expired or pruned (1-day retention). The apply
# step distinguishes "patch file missing entirely" (artifact-
# expired) from "patch file zero bytes" (genuinely empty patch).
- name: Download autofix artifact
if: steps.locate.outputs.found == 'true'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
name: autofix
run-id: ${{ steps.locate.outputs.run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
path: autofix-in
# Pinned to v5.0.4. Verify SHA via:
# gh api repos/actions/checkout/git/refs/tags/v5.0.4
#
# `persist-credentials: false` disables the default behavior where
# actions/checkout writes the GITHUB_TOKEN into `.git/config` as an
# extraheader. That default is convenient (subsequent git commands
# auth automatically) but it means the token is sitting on disk in
# the checkout directory — an `actions/upload-artifact` step on
# this directory would leak the token. We don't upload, but
# zizmor's `credential-persistence` lint flags it defensively.
# Push auth is provided inline at push time via the URL.
- name: Checkout PR head
if: steps.locate.outputs.found == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5.0.4
with:
repository: ${{ steps.locate.outputs.head_repo }}
ref: ${{ steps.locate.outputs.head_sha }}
token: ${{ secrets.GITHUB_TOKEN }}
persist-credentials: false
# Fetch full history so the push doesn't hit shallow-clone errors.
fetch-depth: 0
path: pr-checkout
- name: Apply patch and push
id: apply
if: steps.locate.outputs.found == 'true'
env:
HEAD_REF: ${{ steps.locate.outputs.head_ref }}
HEAD_REPO: ${{ steps.locate.outputs.head_repo }}
# The SHA we resolved earlier in `locate` — this is what the
# remote ref MUST still equal at push time. If the contributor
# force-pushed between resolve and now, the lease fails and
# we surface that distinctly from a fork-without-maintainer
# -edit push failure.
HEAD_SHA: ${{ steps.locate.outputs.head_sha }}
# Auth for the push only — never persisted to disk. Provided
# via env to avoid interpolating into the shell command line.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
working-directory: pr-checkout
run: |
set -euo pipefail
patch="../autofix-in/autofix.patch"
# Distinguish artifact-expired (file missing entirely, because
# actions/download-artifact ran with continue-on-error and the
# 1-day retention had elapsed) from genuinely empty patch
# (file present, zero bytes, formatter found nothing).
if [ ! -e "$patch" ]; then
echo "::warning::Patch file does not exist — autofix artifact likely expired."
echo "result=artifact-expired" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ ! -s "$patch" ]; then
echo "::warning::Empty patch — nothing to apply."
echo "result=empty-patch" >> "$GITHUB_OUTPUT"
exit 0
fi
# Sensitive-paths guard: refuse to apply patches that touch
# `.github/` — workflow files, action definitions, CODEOWNERS,
# dependabot config, etc. A malicious PR could ship a custom
# prettier/ESLint config that reformats workflow YAML; the
# producer would then capture those edits in autofix.patch,
# and a maintainer running `/autofix` would push them under
# `contents: write`. The default GITHUB_TOKEN lacks `workflows`
# scope so the platform would reject workflow-file pushes
# anyway, but that surfaces as a generic `push-failed` and
# misleads users into enabling maintainer-edit. Reject early
# with a specific reason. CODEOWNERS and dependabot.yml live
# under .github/ but outside .github/workflows/ — the broader
# match is intentional (they all govern trust boundaries).
if grep -qE '^(diff --git|---|\+\+\+) [ab]?/?\.github/' "$patch"; then
echo "::warning::Patch touches .github/ — refusing to apply (sensitive paths)."
echo "result=sensitive-paths" >> "$GITHUB_OUTPUT"
exit 0
fi
# Re-entrancy guard: if HEAD itself is an autofix bot commit,
# refuse to apply again. Without this, lint/formatter config
# drift between runs could pump arbitrary apply commits into
# the same PR if an automated agent watches the sticky and
# re-fires `/autofix` on each new "fixes-available" surface.
# The contributor can still get out by force-pushing a
# human-authored commit to revert the autofix and re-trigger.
head_author=$(git log -1 --format='%ae' HEAD)
head_subject=$(git log -1 --format='%s' HEAD)
if [ "${head_author}" = "41898282+github-actions[bot]@users.noreply.github.com" ] \
&& [[ "${head_subject}" =~ ^chore\(autofix\) ]]; then
echo "::warning::HEAD is an autofix bot commit — refusing to re-apply (loop guard)."
echo "result=loop-prevented" >> "$GITHUB_OUTPUT"
exit 0
fi
# Idempotency probe: does the forward apply work?
if git apply --check "$patch" 2>/dev/null; then
echo "Patch applies cleanly — proceeding."
elif git apply --check --reverse "$patch" 2>/dev/null; then
# Reverse-check passes => the patch is already applied to
# the current tree. Treat as success no-op.
echo "Patch is already applied (reverse-check passed) — no-op."
echo "result=already-applied" >> "$GITHUB_OUTPUT"
exit 0
else
echo "::error::Patch does not apply (stale or conflicting)."
echo "result=stale" >> "$GITHUB_OUTPUT"
exit 0
fi
# Wrap the apply/commit phase so any non-zero exit sets a
# meaningful `result=` instead of leaving it unset (which would
# send the user to the `*` "unexpected state" arm with a
# non-actionable confused-emoji reply).
if ! {
git config user.email "41898282+github-actions[bot]@users.noreply.github.com" &&
git config user.name "github-actions[bot]" &&
git apply "$patch" &&
git add -A &&
git commit -m "chore(autofix): apply prettier + eslint fixes via /autofix command"
}; then
echo "::error::git apply / config / commit failed after idempotency probe passed."
echo "result=apply-failed" >> "$GITHUB_OUTPUT"
exit 0
fi
# Push to the PR head branch with a lease against the resolved
# SHA. The lease ensures the remote ref still points at HEAD_SHA
# when the push lands — if the contributor force-pushed in the
# window between resolve and now, the lease fails and we return
# `lease-failed` (NOT `push-failed`, which would mislead users
# into enabling maintainer-edit). For fork PRs, the push still
# requires "Allow edits by maintainers" to be enabled.
#
# Auth is supplied inline via `-c http.<base>.extraheader` (NOT
# via a `https://x-access-token:TOKEN@…` URL — those leak into
# process listings and `git remote -v` output). The header is
# set per-invocation; it never lands in `.git/config` on disk.
# The token is base64-encoded for the Basic auth header per
# GitHub's documented pattern for this scope.
push_url="https://github.com/${HEAD_REPO}.git"
auth_header="Authorization: Basic $(printf 'x-access-token:%s' "${GITHUB_TOKEN}" | base64 -w0)"
# GitHub's secret-masker only masks the raw token, not its
# base64-encoded form. Mask the encoded value so any subsequent
# log line (set -x, GIT_TRACE, error spew) gets ***-redacted.
echo "::add-mask::${auth_header}"
push_stderr=$(mktemp)
if git -c http.extraheader="${auth_header}" \
push --force-with-lease="refs/heads/${HEAD_REF}:${HEAD_SHA}" \
"${push_url}" "HEAD:${HEAD_REF}" 2>"$push_stderr"; then
echo "result=applied" >> "$GITHUB_OUTPUT"
else
cat "$push_stderr" >&2
# `--force-with-lease` reports "stale info" when the remote
# ref has moved past the expected SHA. Other lease-failure
# phrases git emits include "remote rejected" (server-side
# reject), "non-fast-forward", and the literal flag name. Match
# any of those to distinguish from auth/network/maintainer-
# edit failures.
if grep -qE "stale info|force-with-lease|rejected.*non-fast-forward|remote rejected|! \[rejected\]" "$push_stderr"; then
echo "::error::git push lease failed — branch moved during apply."
echo "result=lease-failed" >> "$GITHUB_OUTPUT"
else
echo "::error::git push failed — likely fork without maintainer-edit enabled."
echo "result=push-failed" >> "$GITHUB_OUTPUT"
fi
exit 0
fi
- name: React and reply on outcome
if: always() && steps.locate.outputs.found == 'true' && steps.apply.outcome != 'skipped'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
COMMENT_ID: ${{ github.event.comment.id }}
PR: ${{ github.event.issue.number }}
RESULT: ${{ steps.apply.outputs.result }}
RUN_ID: ${{ github.run_id }}
shell: bash
run: |
set -euo pipefail
run_url="https://github.com/${GH_REPO}/actions/runs/${RUN_ID}"
case "${RESULT}" in
applied)
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
-f content="+1" >/dev/null
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
-f body="✅ Applied autofix and pushed a commit. ([apply run](${run_url}))" \
>/dev/null
;;
already-applied)
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
-f content="+1" >/dev/null
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
-f body="✅ Autofix is already applied — no changes needed." \
>/dev/null
;;
empty-patch)
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
-f content="+1" >/dev/null
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
-f body="✅ No autofix to apply — formatter found nothing." \
>/dev/null
;;
artifact-expired)
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
-f content="confused" >/dev/null
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
-f body="⏳ The autofix artifact for this PR's head SHA has expired (1-day retention). Push a new commit to regenerate it, then comment \`/autofix\` again. ([apply run](${run_url}))" \
>/dev/null
exit 1
;;
loop-prevented)
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
-f content="confused" >/dev/null
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
-f body="🔁 Refusing to re-apply autofix on top of an existing autofix commit. If formatter rules drifted and you genuinely need another pass, push a human-authored commit (or revert the existing autofix commit) before commenting \`/autofix\` again. ([apply run](${run_url}))" \
>/dev/null
exit 1
;;
sensitive-paths)
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
-f content="-1" >/dev/null
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
-f body="🛑 Refusing to apply: the autofix patch touches files under \`.github/\` (workflow / CODEOWNERS / dependabot config). Apply formatter changes to those files manually in a regular commit so they get human review. ([apply run](${run_url}))" \
>/dev/null
exit 1
;;
stale)
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
-f content="-1" >/dev/null
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
-f body="⚠️ The autofix patch is stale or conflicts with the current head — push a new commit to regenerate, then comment \`/autofix\` again. ([apply run](${run_url}))" \
>/dev/null
exit 1
;;
apply-failed)
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
-f content="-1" >/dev/null
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
-f body="⚠️ Autofix applied cleanly in the dry run, but \`git apply\` / \`git commit\` failed when actually landing the patch. This usually means a race with concurrent edits or a corrupt patch. See logs: ${run_url}" \
>/dev/null
exit 1
;;
push-failed)
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
-f content="-1" >/dev/null
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
-f body="⚠️ Couldn't push the autofix commit. If this is a fork PR, please tick **Allow edits by maintainers** in the PR sidebar, then comment \`/autofix\` again. ([apply run](${run_url}))" \
>/dev/null
exit 1
;;
lease-failed)
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
-f content="-1" >/dev/null
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
-f body="⚠️ The PR head moved while autofix was applying — a new commit landed in the window between resolve and push. Comment \`/autofix\` again to retry against the latest head. ([apply run](${run_url}))" \
>/dev/null
exit 1
;;
*)
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
-f content="confused" >/dev/null
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
-f body="❓ Autofix run finished in an unexpected state (\`${RESULT:-unknown}\`). See logs: ${run_url}" \
>/dev/null
exit 1
;;
esac
+316
View File
@@ -0,0 +1,316 @@
name: PR Autofix (publish)
# TRUSTED HALF of the autofix pipeline.
#
# Triggered by `pr-autofix.yml` completing on a PR (including fork PRs).
# Downloads the diff artifact produced by the untrusted job, verifies
# its claimed PR identity against the workflow_run authority, then
# posts (or edits) a single sticky summary comment plus a
# `gitnexus/autofix` Check Run. This job NEVER checks out fork code —
# it only consumes the diff (data) and calls the GitHub API. That
# isolation is what makes it safe to run under `pull-requests: write`
# on fork-triggered events.
#
# The sticky comment is the contributor signal: heading
# "## :sparkles: PR Autofix" in the PR's top-level comments, with a
# fenced `gitnexus-autofix` JSON block carrying machine-readable state
# for AI agents. Contributors apply the patch by commenting `/autofix`
# on the PR — handled by the separate `pr-autofix-apply.yml` workflow.
on:
workflow_run:
workflows: ['PR Autofix']
types: [completed]
concurrency:
# Key on PR identity, NOT workflow_run.id — workflow_run.id is per-run
# unique, which would defeat serialization and let two parallel
# publishes both POST a sticky summary comment. CONTRIBUTING.md
# § GitHub Actions — Concurrency Convention names this anti-pattern
# explicitly. For fork PRs, `pull_requests[]` is empty in the
# workflow_run payload, so we fall back to head-repo + head-branch.
group: ${{ github.workflow }}-${{ github.event.workflow_run.pull_requests[0].number || format('{0}/{1}', github.event.workflow_run.head_repository.full_name, github.event.workflow_run.head_branch) }}
cancel-in-progress: false
permissions: {}
jobs:
publish:
name: publish-autofix
if: >-
github.event.workflow_run.event == 'pull_request'
&& github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
pull-requests: write
# Required by actions/download-artifact to fetch artifacts produced
# by a different workflow run.
actions: read
# Required to create the `gitnexus/autofix` Check Run that reports
# the outcome (clean / fixes-available) to the PR's Checks tab.
# Branch protection or agents can grep the conclusion + output
# title without parsing the sticky comment.
checks: write
steps:
# Pinned to v8.0.1. Verify SHA via:
# gh api repos/actions/download-artifact/git/refs/tags/v8.0.1
- name: Download autofix artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: autofix
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
path: autofix-in
- name: Read and validate metadata
id: meta
shell: bash
run: |
set -euo pipefail
test -f autofix-in/metadata.json
jq . autofix-in/metadata.json
# The artifact comes from the untrusted half running fork code.
# Every field is allowlist-validated before it can flow into
# $GITHUB_OUTPUT. A newline in head_ref would otherwise let a
# malicious branch name inject a second `pr_number=N` line and
# redirect this job's reviewdog suggestions / sticky summary
# comment onto a victim PR under github-actions[bot] with
# pull-requests: write.
assert_field() {
local key="$1" pattern="$2" value
value=$(jq -r ".${key} // empty" autofix-in/metadata.json)
if [ -z "$value" ] || ! [[ "$value" =~ $pattern ]]; then
echo "::error::metadata.${key} failed allowlist (got: $(printf '%q' "$value"))"
exit 1
fi
printf '%s' "$value"
}
SCHEMA=$(assert_field schema '^gitnexus\.pr-autofix/v[0-9]+$')
PR_NUMBER=$(assert_field pr_number '^[0-9]+$')
HEAD_SHA=$(assert_field head_sha '^[0-9a-f]{40}$')
HEAD_REF=$(assert_field head_ref '^[A-Za-z0-9._/-]+$')
HEAD_REPO=$(assert_field head_repo '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$')
BASE_REPO=$(assert_field base_repo '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$')
CHANGED=$(assert_field changed_lines '^[0-9]+$')
# Defence-in-depth: refuse to act if the artifact claims to
# belong to a different repo than the one that triggered us.
if [ "$BASE_REPO" != "${GITHUB_REPOSITORY}" ]; then
echo "::error::Artifact base_repo does not match \$GITHUB_REPOSITORY — refusing to publish."
exit 1
fi
{
echo "schema=${SCHEMA}"
echo "pr_number=${PR_NUMBER}"
echo "head_sha=${HEAD_SHA}"
echo "head_ref=${HEAD_REF}"
echo "head_repo=${HEAD_REPO}"
echo "base_repo=${BASE_REPO}"
echo "changed_lines=${CHANGED}"
} >> "$GITHUB_OUTPUT"
# Cross-verify the artifact's claimed identity against the
# GitHub-controlled workflow_run event. The previous step's
# allowlist only proves the fields are well-formed — not that
# they refer to the PR/SHA that actually triggered this run.
# A fork-controlled `npm run lint:fix` could plausibly mutate
# metadata.json to reference another PR or SHA, redirecting our
# write-scoped sticky/check-run onto an attacker-chosen target.
#
# Authority sources are all server-controlled GitHub event fields:
# - workflow_run.head_sha
# - workflow_run.head_repository.full_name
# - workflow_run.pull_requests[].number (within-repo PRs only;
# empty array on fork PRs — fall back to commits/{sha}/pulls)
#
# Mismatch => fail loud BEFORE any sticky/check-run side effect.
- name: Verify metadata against workflow_run authority
id: verify
if: steps.meta.outputs.changed_lines != '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
META_PR_NUMBER: ${{ steps.meta.outputs.pr_number }}
META_HEAD_SHA: ${{ steps.meta.outputs.head_sha }}
META_HEAD_REPO: ${{ steps.meta.outputs.head_repo }}
WF_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
WF_HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }}
WF_PR_NUMBERS: ${{ toJSON(github.event.workflow_run.pull_requests.*.number) }}
shell: bash
run: |
set -euo pipefail
# 1) head_sha must match exactly. workflow_run.head_sha is the
# commit GitHub actually ran the producer against — definitive.
if [ "${META_HEAD_SHA}" != "${WF_HEAD_SHA}" ]; then
echo "::error::Artifact head_sha (${META_HEAD_SHA}) does not match workflow_run.head_sha (${WF_HEAD_SHA}) — refusing to publish."
exit 1
fi
# 2) head_repo must match exactly. Same authority anchor.
if [ "${META_HEAD_REPO}" != "${WF_HEAD_REPO}" ]; then
echo "::error::Artifact head_repo (${META_HEAD_REPO}) does not match workflow_run.head_repository (${WF_HEAD_REPO}) — refusing to publish."
exit 1
fi
# 3) pr_number must reference an open PR with this head SHA.
# Within-repo PRs: workflow_run.pull_requests[] is populated.
# Fork PRs: that array is empty by GitHub design — fall back
# to the REST commit-to-PRs lookup. Fail closed if the lookup
# finds no matching open PR (avoids attacker-forged PR ids).
allowed_numbers=$(jq -c '.' <<< "${WF_PR_NUMBERS}")
if [ "${allowed_numbers}" = "[]" ]; then
echo "workflow_run.pull_requests is empty (fork PR) — falling back to commits/{sha}/pulls."
allowed_numbers=$(gh api "repos/${GH_REPO}/commits/${WF_HEAD_SHA}/pulls" \
--jq '[.[] | select(.state == "open") | .number]' 2>/dev/null || echo "[]")
if [ "${allowed_numbers}" = "[]" ]; then
echo "::error::No open PR found for head ${WF_HEAD_SHA} via commits/{sha}/pulls — refusing to publish."
exit 1
fi
fi
if ! jq -e --argjson n "${META_PR_NUMBER}" 'index($n) != null' <<< "${allowed_numbers}" >/dev/null; then
echo "::error::Artifact pr_number (${META_PR_NUMBER}) is not in the authoritative PR list (${allowed_numbers}) — refusing to publish."
exit 1
fi
echo "Verified: metadata identity matches workflow_run authority (PR=${META_PR_NUMBER}, head_sha=${META_HEAD_SHA}, head_repo=${META_HEAD_REPO})."
- name: Upsert sticky summary comment
# Only post when ci-quality found something fixable (= the
# autofix patch is non-empty). When prettier/eslint are clean
# the patch is zero bytes and the sticky comment is pure noise,
# so we skip it.
if: >-
always()
&& steps.meta.outputs.pr_number != ''
&& steps.meta.outputs.changed_lines != '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
PR: ${{ steps.meta.outputs.pr_number }}
CHANGED: ${{ steps.meta.outputs.changed_lines }}
HEAD_SHA: ${{ steps.meta.outputs.head_sha }}
RUN_ID: ${{ github.run_id }}
shell: bash
run: |
set -euo pipefail
# Stable heading + marker — agents grep for these exact strings.
marker="<!-- gitnexus:pr-autofix-summary -->"
heading="## :sparkles: PR Autofix"
# Single state. The /autofix slash command works for any diff
# size — there's no 3K cap and no no-overlap dead-end because
# the apply workflow uses `git apply` + push, not the GitHub
# review-comment API.
ui_state="fixes-available"
prose="Found fixable formatting / unused-import issues across **${CHANGED}** changed lines. **Comment \`/autofix\` on this PR to apply them**, or run \`npm run lint:fix && npm run format\` locally."
# Machine-readable JSON block — agents parse this instead of
# regexing English. Fenced code-block info string is
# `gitnexus-autofix` so agents can locate it without ambiguity.
# Schema bumped from v1 -> v2: adds `apply_command`. The v1
# field set is preserved as a superset, but the `state` enum
# is redefined (v1: suggestions-posted | skipped-too-large |
# diff-no-overlap; v2: fixes-available). v1 readers checking
# `schema == 'gitnexus.pr-autofix/v1'` see an unfamiliar version
# and fall back to prose, which is the intended migration path.
json=$(jq -n -c \
--arg state "${ui_state}" \
--argjson pr_number "${PR}" \
--argjson changed_lines "${CHANGED}" \
--arg head_sha "${HEAD_SHA}" \
--arg run_id "${RUN_ID}" \
'{schema:"gitnexus.pr-autofix/v2", state:$state, pr_number:$pr_number, changed_lines:$changed_lines, head_sha:$head_sha, run_id:$run_id, apply_command:"/autofix"}')
# Multi-line quoted string instead of a column-0 heredoc — YAML's
# `run: |` block ends as soon as a content line dedents below the
# block's first-line indent, which would mis-parse the workflow.
body="${marker}
${heading}
${prose}
\`\`\`gitnexus-autofix
${json}
\`\`\`"
# Strip the leading 10-space indent that the YAML block requires
# so the rendered comment body starts at column 0.
body="$(printf '%s\n' "$body" | sed 's/^ //')"
# Small retry wrapper for transient 5xx / rate-limit responses
# on the GitHub REST API. Three tries with linear backoff. We
# only retry GET (idempotent) and PATCH on a known comment id
# (idempotent). POST is NOT wrapped — retrying a comment-create
# would create duplicates if the first attempt actually landed.
gh_retry() {
local n=0 max=3
while true; do
if gh "$@"; then return 0; fi
n=$((n+1))
if [ "$n" -ge "$max" ]; then return 1; fi
sleep $((n * 2))
done
}
# Find existing bot comment by the marker and edit-in-place; else create.
# CRITICAL: filter by `.user.login == "github-actions[bot]"`. A regular
# user posting a comment containing the marker would otherwise be the
# `head -n1` match; PATCH on someone else's comment 403s, `set -e`
# aborts, and the bot is permanently DoS'd for that PR.
existing=$(gh_retry api "repos/${GH_REPO}/issues/${PR}/comments" \
--paginate --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | contains(\"${marker}\"))) | .id" \
| head -n1 || true)
if [ -n "${existing}" ]; then
gh_retry api -X PATCH "repos/${GH_REPO}/issues/comments/${existing}" \
-f body="${body}" >/dev/null
echo "Updated comment ${existing}."
else
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
-f body="${body}" >/dev/null
echo "Created summary comment."
fi
- name: Emit gitnexus/autofix Check Run
# Stable check name `gitnexus/autofix` so PR-watching agents can
# `gh pr checks <pr>` and read the conclusion + title without
# parsing the sticky comment. Two outcomes:
# clean → conclusion: success
# fixes-available → conclusion: neutral
# `neutral` does not block branch-protection required-checks but
# is visually distinct from a green pass.
if: always() && steps.meta.outputs.head_sha != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
HEAD_SHA: ${{ steps.meta.outputs.head_sha }}
CHANGED: ${{ steps.meta.outputs.changed_lines }}
shell: bash
run: |
set -euo pipefail
if [ "${CHANGED}" = "0" ]; then
conclusion="success"
title="Formatting clean"
summary="Prettier and ESLint --fix produced no changes."
else
conclusion="neutral"
title="Autofix available — comment /autofix to apply"
summary="Comment \`/autofix\` on this PR to apply formatter + unused-import fixes (works at any diff size). Or run \`npm run lint:fix && npm run format\` locally."
fi
gh api -X POST "repos/${GH_REPO}/check-runs" \
-f name="gitnexus/autofix" \
-f head_sha="${HEAD_SHA}" \
-f status="completed" \
-f conclusion="${conclusion}" \
-f "output[title]=${title}" \
-f "output[summary]=${summary}" \
>/dev/null
echo "Posted check-run gitnexus/autofix=${conclusion} (${title})"
+146
View File
@@ -0,0 +1,146 @@
name: PR Autofix
# UNTRUSTED HALF of the autofix pipeline.
#
# Runs `npm run lint:fix` + `npm run format` against the PR head
# (including fork heads) and uploads the resulting diff as an artifact.
# This job has NO privileged token and CANNOT post to the PR. The trusted
# `pr-autofix-publish.yml` workflow downloads the artifact via
# `workflow_run` and posts a sticky summary comment + Check Run.
# Contributors apply the patch by commenting `/autofix` on the PR —
# handled by the separate `pr-autofix-apply.yml` ChatOps workflow.
#
# Why the split:
# ESLint loads plugins from fork-controlled `node_modules`, so running
# it in a job with `pull-requests: write` would let a malicious fork PR
# ship a poisoned eslint plugin and execute arbitrary code under that
# token. By keeping fork code execution in this job (token: read-only)
# and posting from a separate trusted job that never touches fork
# code, we get the autofix UX for fork PRs without the supply-chain
# hole. (See autofix.ci for the same pattern.)
#
# Removes unused imports via `eslint-plugin-unused-imports`, already in
# devDependencies and wired into the `lint` config.
on:
pull_request:
types: [opened, synchronize, reopened]
# Skip lockfile / generated-file PRs entirely — `action-suggester`
# cannot post on diffs > ~3k lines (GitHub returns 406) and these
# paths produce massive diffs no human wants suggested back inline.
paths-ignore:
- '**/package-lock.json'
- '**/*.snap'
- '**/dist/**'
- '**/node_modules/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
# Don't cancel in-flight runs; the publish workflow may already be
# downloading the artifact and a cancelled untrusted run produces no
# signal at all (worse DX than waiting).
cancel-in-progress: false
# This workflow runs untrusted fork code. Top-level deny-all and NO
# job-level grants — the job can only read its own checkout.
permissions: {}
jobs:
autofix:
name: autofix
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# PR head commit (not the synthetic merge ref) — we need the
# exact tree the contributor pushed so suggestions line up.
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: npm
cache-dependency-path: package-lock.json
# `--ignore-scripts` blocks pre/postinstall lifecycle hooks. ESLint
# plugins still load from node_modules (that is the actual escape
# hatch on a typical fork), but this job has no token to abuse —
# which is the whole point of the split.
- run: npm ci --ignore-scripts
- name: ESLint --fix (removes unused imports)
run: npm run lint:fix
# Lint errors that --fix can't auto-resolve must not block the
# diff artifact — partial fixes are still useful as suggestions.
continue-on-error: true
- name: Prettier --write
run: npm run format
continue-on-error: true
- name: Capture diff and metadata
id: capture
# Pass GitHub-context values via env: rather than `${{ }}`
# interpolated directly into the bash body. `head.ref` and
# `head.repo.full_name` are fork-controlled strings; expanding
# them into shell source is the canonical template-injection
# vector zizmor flags. Even though this job has `permissions: {}`,
# routing through env: makes it impossible for a future scope
# grant to turn into RCE. Inside bash, reference as `$HEAD_REF`
# etc. — the values are then plain strings, not code.
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
BASE_REPO: ${{ github.repository }}
shell: bash
run: |
set -euo pipefail
mkdir -p autofix-out
# Produce a unified diff of the working tree vs. the PR head.
# Empty diff => nothing to suggest; the publish job short-circuits.
git diff --no-color > autofix-out/autofix.patch
# NOTE: `changed_lines` is the line-count of the patch file,
# (hunk headers + context lines + added/removed). Surfaced in
# the sticky comment so contributors and AI agents have a
# quick size hint before invoking `/autofix`.
changed_lines=$(wc -l < autofix-out/autofix.patch | tr -d ' ')
echo "changed_lines=${changed_lines}" >> "$GITHUB_OUTPUT"
# Carry PR identity over to the trusted job. workflow_run
# context is base-repo-only, so the publish job needs these
# to call the GitHub PR API on the right resource.
# CONTRACT: keep this schema in sync with pr-autofix-publish.yml's
# `assert_field` validators and the agent-facing JSON block in
# the sticky comment. Bump `schema` when changing field names.
jq -n \
--arg schema 'gitnexus.pr-autofix/v1' \
--argjson pr_number "${PR_NUMBER}" \
--arg head_sha "${HEAD_SHA}" \
--arg head_ref "${HEAD_REF}" \
--arg head_repo "${HEAD_REPO}" \
--arg base_repo "${BASE_REPO}" \
--argjson changed_lines "${changed_lines}" \
'{schema:$schema, pr_number:$pr_number, head_sha:$head_sha, head_ref:$head_ref, head_repo:$head_repo, base_repo:$base_repo, changed_lines:$changed_lines}' \
> autofix-out/metadata.json
echo "--- metadata ---"
cat autofix-out/metadata.json
echo "--- diff (head) ---"
head -c 2000 autofix-out/autofix.patch || true
# Pinned to v7.0.1. Verify SHA via:
# gh api repos/actions/upload-artifact/git/refs/tags/v7.0.1
- name: Upload autofix artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: autofix
path: autofix-out/
retention-days: 1
if-no-files-found: error
+3
View File
@@ -35,6 +35,9 @@ on:
pull_request_target:
types: [opened, edited, reopened]
permissions:
contents: read
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
# Include `github.event_name` so `pull_request` (validate-title) and
# `pull_request_target` (autolabel) runs for the same PR do NOT share a slot
+2 -1
View File
@@ -6,6 +6,7 @@ on:
- 'v*'
# No workflow-level permissions — scoped per job below.
permissions: {}
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
# Tag refs are unique per release, so distinct tags run in parallel. Re-pushes of the
@@ -35,7 +36,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
node-version: 22
registry-url: https://registry.npmjs.org
# Hermetic install for the published artifact — no cache carry-over
# from non-tag contexts. setup-node v5+ caches by default when a
+57 -7
View File
@@ -58,6 +58,7 @@ jobs:
timeout-minutes: 5
permissions:
contents: read
pull-requests: read # read PR labels on the merge commit
outputs:
should_run: ${{ steps.decide.outputs.should_run }}
head_sha: ${{ steps.decide.outputs.head_sha }}
@@ -74,6 +75,8 @@ jobs:
FORCE: ${{ inputs.force }}
BUMP_INPUT: ${{ inputs.bump }}
EVENT_NAME: ${{ github.event_name }}
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
HEAD_SHA=$(git rev-parse HEAD)
@@ -96,6 +99,49 @@ jobs:
exit 0
fi
# ── Skip when the merge commit corresponds to a release ─────────
# Two complementary checks (belt-and-suspenders):
# 1. The HEAD commit subject matches `chore: release vX.Y.Z`
# (the canonical release-PR title in this repo). Anchored
# at both ends to require the bare title or the squash-merge
# `(#NNNN)` suffix exactly — rejects noisy variants like
# `chore: release v1.0.0 (something unrelated)`.
# 2. The squash-merged PR carries the `release` label.
# Either match suppresses the rc build — stable releases publish
# via publish.yml on the v-tag, so the rc cycle should pause for
# them rather than racing the npm publish.
HEAD_SUBJECT="$(git log -1 --pretty=%s HEAD)"
# Sanitise GitHub-Actions annotation prefixes before logging the
# raw subject — defence-in-depth so a hypothetical commit subject
# containing `::error::` or `::set-output::` cannot forge log
# annotations even though %s strips newlines.
HEAD_SUBJECT_SAFE="${HEAD_SUBJECT//::/__}"
RELEASE_SUBJECT_RE='^chore:[[:space:]]*release[[:space:]]+v[0-9]+\.[0-9]+\.[0-9]+([[:space:]]+\(#[0-9]+\))?$'
if [[ "$HEAD_SUBJECT" =~ $RELEASE_SUBJECT_RE ]]; then
echo "HEAD commit subject matches a release commit — skipping rc."
echo " subject (sanitised): $HEAD_SUBJECT_SAFE"
echo "should_run=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Squash-merge commits include `(#NNNN)` at the end of the subject.
if [[ "$HEAD_SUBJECT" =~ \(#([0-9]+)\)[[:space:]]*$ ]]; then
PR_NUM="${BASH_REMATCH[1]}"
echo "Detected squash-merge of PR #$PR_NUM — checking labels."
if LABELS_JSON="$(gh pr view "$PR_NUM" --repo "$REPO" --json labels 2>/dev/null)"; then
if printf '%s' "$LABELS_JSON" | jq -e '.labels[] | select(.name == "release")' >/dev/null; then
echo "PR #$PR_NUM has the 'release' label — skipping rc."
echo "should_run=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "PR #$PR_NUM has no 'release' label — proceeding."
else
# Lookup failure is not fatal — fall through to the dedup check
# so a transient GH API hiccup doesn't silently suppress rc builds.
echo "::warning::Could not read labels for PR #${PR_NUM} — falling through."
fi
fi
# Dedup: is there already an rc/<HEAD_SHA> marker pointing at HEAD?
MARKER="rc/${HEAD_SHA}"
if git rev-parse "refs/tags/$MARKER" >/dev/null 2>&1; then
@@ -149,7 +195,7 @@ jobs:
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
node-version: 22
registry-url: https://registry.npmjs.org
# Hermetic install — release-candidate produces shipped artifacts.
# setup-node v5+ caches by default when a packageManager field is
@@ -294,9 +340,11 @@ jobs:
fi
fi
echo "base=$BASE" >> "$GITHUB_OUTPUT"
echo "rc_n=$NEXT_N" >> "$GITHUB_OUTPUT"
echo "rc_version=$RC_VERSION" >> "$GITHUB_OUTPUT"
{
echo "base=$BASE"
echo "rc_n=$NEXT_N"
echo "rc_version=$RC_VERSION"
} >> "$GITHUB_OUTPUT"
- name: Apply rc version in-CI
shell: bash
@@ -354,9 +402,11 @@ jobs:
# remote ref, the push fails and we stop before npm publish.
git push --atomic origin "refs/tags/$VTAG" "refs/tags/$MARKER"
echo "vtag=$VTAG" >> "$GITHUB_OUTPUT"
echo "marker=$MARKER" >> "$GITHUB_OUTPUT"
echo "release_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT"
{
echo "vtag=$VTAG"
echo "marker=$MARKER"
echo "release_sha=$RELEASE_SHA"
} >> "$GITHUB_OUTPUT"
- name: Publish to npm (rc dist-tag)
run: npm publish --provenance --access public --tag rc
+13 -4
View File
@@ -1,19 +1,28 @@
name: Trivy Image Scan
# Builds Dockerfile.cli and Dockerfile.web, then scans the resulting images
# for OS-package and language-package CVEs at HIGH/CRITICAL severity.
# for OS-package and language-package CVEs at MEDIUM+ severity.
# Findings upload to the Security tab; record-only (does not block merges).
#
# NOT triggered on PRs — image builds are slow and base-image CVE churn
# shouldn't gate feature delivery.
# Trigger on Dockerfile changes in PRs so base-image/npm-layer remediation can
# be verified before merge without running image scans on every PR.
on:
pull_request:
paths:
- 'Dockerfile.cli'
- 'Dockerfile.web'
- 'gitnexus/Dockerfile.test'
- '.github/workflows/trivy.yml'
push:
branches: [main]
schedule:
- cron: '0 8 * * 1'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
@@ -61,7 +70,7 @@ jobs:
image-ref: scan-target:${{ matrix.image.name }}
format: sarif
output: trivy-${{ matrix.image.name }}.sarif
severity: HIGH,CRITICAL
severity: MEDIUM,HIGH,CRITICAL
# Hides CVEs with no available fix in the base image.
ignore-unfixed: true
exit-code: '0'
+32 -5
View File
@@ -1,10 +1,13 @@
name: Workflow Lint (zizmor)
name: Workflow Lint
# Lints .github/workflows/** for known GitHub Actions security misconfigurations:
# unpinned Actions, dangerous ${{ ... }} interpolation in run: blocks,
# missing per-job permissions:, etc.
# Lints .github/workflows/** for both:
# - actionlint: YAML syntax, expression typing, shellcheck inside `run:`
# blocks, unknown contexts, deprecated runner labels.
# - zizmor: security misconfigurations — unpinned actions, dangerous
# `${{ }}` interpolation, missing per-job permissions, etc.
#
# Scoped to PRs that touch .github/** only — keeps off the typical PR critical path.
# Scoped to PRs that touch .github/** only — keeps off the typical PR
# critical path.
on:
pull_request:
@@ -12,11 +15,35 @@ on:
paths:
- '.github/**'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
actionlint:
name: actionlint
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# Pinned to v2.1.2. Verify SHA via:
# gh api repos/raven-actions/actionlint/git/refs/tags/v2.1.2
# The action wraps the upstream `rhysd/actionlint` binary and emits
# GitHub-annotation-formatted findings on PRs.
- name: Run actionlint
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2.1.2
with:
fail-on-error: true
zizmor:
runs-on: ubuntu-latest
timeout-minutes: 10
+9
View File
@@ -14,6 +14,15 @@ rules:
# no checkout of fork code occurs. Header comment in the file documents.
- ci-report.yml
# workflow_run is the trusted half of the autofix pipeline. The
# untrusted half (pr-autofix.yml) runs fork code with permissions:{}
# and produces only a diff artifact (data, not executable code). The
# publish job consumes the artifact, allowlist-validates every field
# of metadata.json before exporting to $GITHUB_OUTPUT, never checks
# out fork code, and never executes anything fork-controlled. Header
# comment in the file documents the split.
- pr-autofix-publish.yml
# pull_request_target needed by claude-code-action to access secrets
# and post review comments on fork PRs. Mitigated by: PR checkouts pin
# the fork's HEAD SHA (not the branch ref) to prevent TOCTOU races,
+6 -1
View File
@@ -149,11 +149,16 @@ Indexed as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows)
## Keeping the Index Fresh
```bash
npx gitnexus analyze # basic refresh; preserves any existing embeddings
npx gitnexus analyze # incremental by default; preserves embeddings
npx gitnexus analyze --force # full rebuild from scratch (opt out of incremental)
npx gitnexus analyze --embeddings # also generate embeddings for new/changed nodes
npx gitnexus analyze --drop-embeddings # explicit opt-in to wipe existing embeddings
```
`analyze` runs **incrementally by default**. The pipeline still parses every file every run (cross-file resolution requires it), but tree-sitter parsing is **served from a content-addressed cache** at `.gitnexus/parse-cache.json` for chunks whose file contents haven't changed since the last run. Only changed-file rows (and their importers) are rewritten in LadybugDB; unchanged-file rows are preserved. Output is byte-equivalent to a full rebuild. Pass `--force` to wipe and re-index from scratch (e.g., to recover from a corrupt index, or after upgrading GitNexus).
The parse cache key is **content-addressed and version-tagged**: it survives `--force` runs, and is automatically invalidated by a `gitnexus` package upgrade (so a new tree-sitter grammar doesn't silently replay stale parse output). Safe to delete `.gitnexus/parse-cache.json` at any time — it'll be rebuilt on the next analyze.
Check `.gitnexus/meta.json` `stats.embeddings` (0 = none). A plain `analyze` no longer drops existing vectors — pass `--drop-embeddings` to wipe.
> Claude Code: PostToolUse hook detects a stale index after `git commit` and `git merge` and prompts the agent to run `analyze`. The hook does not invoke `analyze` itself.
+58 -24
View File
@@ -30,17 +30,17 @@ Format: `<type>[(scope)][!]: <subject>`
Allowed types and the release-notes section each one lands in (defined in `.github/release.yml`):
| Type | Label applied | Release-notes section |
|------|---------------|-----------------------|
| `feat` | `enhancement` | 🚀 Features |
| `fix` | `bug` | 🐛 Bug Fixes |
| `perf` | `performance` | 🏎️ Performance |
| `refactor` | `refactor` | 🔄 Refactoring |
| `test` | `test` | 🧪 Tests |
| `ci` | `ci` | 👷 CI/CD |
| `build` / `deps` | `dependencies` | 📦 Dependencies |
| `docs` | `documentation` | (grouped under Other Changes unless a Docs section is added) |
| `chore` / `revert` | `chore` | (excluded from release notes) |
| Type | Label applied | Release-notes section |
| ------------------ | --------------- | ------------------------------------------------------------ |
| `feat` | `enhancement` | 🚀 Features |
| `fix` | `bug` | 🐛 Bug Fixes |
| `perf` | `performance` | 🏎️ Performance |
| `refactor` | `refactor` | 🔄 Refactoring |
| `test` | `test` | 🧪 Tests |
| `ci` | `ci` | 👷 CI/CD |
| `build` / `deps` | `dependencies` | 📦 Dependencies |
| `docs` | `documentation` | (grouped under Other Changes unless a Docs section is added) |
| `chore` / `revert` | `chore` | (excluded from release notes) |
Append `!` to the type (e.g. `feat(api)!: drop /v1 endpoint`) or include `BREAKING CHANGE:` in the PR body to flag a breaking change — the labeler then adds the `breaking` label and the 💥 Breaking Changes section is rendered first.
@@ -81,17 +81,17 @@ Every workflow under `.github/workflows/` MUST declare a top-level `concurrency:
- **Merge queue (`merge_group`)**: when this event is added, use `${{ github.workflow }}-${{ github.event.merge_group.head_ref }}` with `cancel-in-progress: false` (every queue entry is a distinct ref; never cancel).
- **`cancel-in-progress` policy:**
| Event | `cancel-in-progress` | Why |
|-------|----------------------|-----|
| `pull_request` CI run | `true` | New push supersedes old run |
| `push` to `main` | `false` | Every main commit gets validated |
| Tag push (`v*` publish) | `false` | Never cancel mid-publish |
| `push` to `main` for release-candidate | `false` | Never cancel mid-RC publish |
| `workflow_dispatch` (release/publish) | `false` | Manual runs are intentional |
| `workflow_run` (sticky-comment reports) | `false` | Serialize, don't race |
| Per-PR bot workflows (`@claude`, review) | `false` | Serialize comments per PR |
| PR-meta re-checks (pr-description-check) | `true` | Cheap, latest wins |
| Single-slot utilities (triage sweep) | `true` | Latest dispatch supersedes |
| Event | `cancel-in-progress` | Why |
| ---------------------------------------- | -------------------- | -------------------------------- |
| `pull_request` CI run | `true` | New push supersedes old run |
| `push` to `main` | `false` | Every main commit gets validated |
| Tag push (`v*` publish) | `false` | Never cancel mid-publish |
| `push` to `main` for release-candidate | `false` | Never cancel mid-RC publish |
| `workflow_dispatch` (release/publish) | `false` | Manual runs are intentional |
| `workflow_run` (sticky-comment reports) | `false` | Serialize, don't race |
| Per-PR bot workflows (`@claude`, review) | `false` | Serialize comments per PR |
| PR-meta re-checks (pr-description-check) | `true` | Cheap, latest wins |
| Single-slot utilities (triage sweep) | `true` | Latest dispatch supersedes |
- For workflows that serve multiple events at once (e.g. `ci.yml` handles `pull_request`, `push`, and `workflow_call`), make `cancel-in-progress` event-aware:
@@ -103,6 +103,41 @@ Every workflow under `.github/workflows/` MUST declare a top-level `concurrency:
- When adding a new workflow, copy the concurrency block from an existing workflow of the same event shape.
## CI automation contracts
Two workflows produce machine-readable signals on every PR. Coding agents and humans alike can rely on the names and shapes below — change them with intent.
### `gitnexus/autofix`
`pr-autofix.yml` (untrusted) + `pr-autofix-publish.yml` (trusted) run `prettier --write` and `eslint --fix` against the PR head and surface a single ChatOps button on the PR. Three signals are emitted:
| Surface | Where | Notes |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| Sticky PR comment | Top-level comment with the HTML marker `<!-- gitnexus:pr-autofix-summary -->` and heading `## :sparkles: PR Autofix`. Only posted when there is something to fix; clean PRs stay silent. | Edit-in-place via marker; one comment per PR. |
| Fenced JSON block | Inside the sticky, fenced as `gitnexus-autofix`. Schema `gitnexus.pr-autofix/v2` with fields `state` (`fixes-available`), `pr_number`, `head_sha`, `changed_lines`, `run_id`, and `apply_command` (literal `/autofix`). | Parseable signal — preferred over regexing prose. v1 fields preserved as a superset. |
| Check Run | Stable name `gitnexus/autofix` on the PR head SHA. Conclusion: `success` (clean) or `neutral` (`fixes-available`). The neutral title is `Autofix available — comment /autofix to apply`. | Surfaced under PR Checks; readable via `gh pr checks <pr>`. |
To detect outcome from an agent: `gh pr checks <pr> --json name,conclusion,output | jq '.[] | select(.name == "gitnexus/autofix")'`.
Forks are supported. The untrusted half runs fork code with `permissions: {}` and ships the diff as an artifact; the trusted publish job consumes only the diff (data, not code) and posts the comment + check run.
#### Applying autofix
Comment `/autofix` on the PR (whole-line, no arguments). The `pr-autofix-apply.yml` workflow:
1. Validates the comment body matches `^/autofix\s*$` exactly. Quoted or inline mentions are silently ignored.
2. Validates the commenter has `admin`, `write`, or `maintain` permission on the repo, OR is the PR author. Other commenters get a 👎 reaction and a refusal reply.
3. Locates the most recent successful `pr-autofix.yml` run for the PR's current head SHA, downloads its `autofix` artifact, applies the patch, and pushes a `chore(autofix): ...` commit back to the PR head branch.
4. Reacts ✅ on success, 👎 on stale-patch / push-failure, and posts a short reply with the apply-run URL in either case.
The apply workflow runs from the default branch's copy of the file regardless of where the comment originates — that's the trust anchor. There is no diff-size cap (the apply workflow uses `git apply` + push, not the GitHub review-comment API).
For fork PRs, the push succeeds only when the contributor has **Allow edits by maintainers** enabled on the PR (the default). When they have disabled it, the workflow fails loud with a 👎 reaction and an explanation comment.
Re-invoking `/autofix` after a successful apply is a safe no-op — the workflow detects the already-applied state via `git apply --check --reverse` and reacts ✅ without pushing.
**Sensitive paths.** The apply workflow refuses any patch that touches `.github/` (workflow files, CODEOWNERS, dependabot config). A malicious PR could ship a custom prettier or ESLint config that reformats workflow YAML; if accepted, those edits would be pushed under `contents: write` without human review. Apply formatter changes to files under `.github/` manually in a normal commit so they get the same review every other workflow change gets.
## AI-assisted contributions
If you use coding agents, follow project context files (e.g. `AGENTS.md`, `CLAUDE.md`) and avoid drive-by refactors unrelated to the issue. Prefer incremental, test-backed changes.
@@ -164,8 +199,7 @@ Two publish workflows ship `gitnexus` to npm:
the Docker build.
- Manually run `docker build` + `docker push` locally and sign with Cosign
against the same digest.
- Delete `rc/<HEAD_SHA>` and `v<RC>` tags, then redispatch with `force:
true` to re-run the full RC pipeline (cuts a new RC number).
- Delete `rc/<HEAD_SHA>` and `v<RC>` tags, then redispatch with `force: true` to re-run the full RC pipeline (cuts a new RC number).
The rc workflow never moves `latest`. To verify after a change, inspect dist-tags:
+21 -8
View File
@@ -1,24 +1,32 @@
ARG BUILDPLATFORM
ARG TARGETPLATFORM
# Pinned npm version used to replace the bundled npm in the upstream Node
# image. Bumping requires a coordinated update in Dockerfile.web and
# gitnexus/Dockerfile.test so all images bootstrap the same npm.
ARG NPM_VERSION=11.14.1
# ── Builder ────────────────────────────────────────────────────────────
# -- Builder -----------------------------------------------------------
# Native modules (tree-sitter-*, onnxruntime-node, node-gyp builds for
# tree-sitter-proto / tree-sitter-swift) require python3 + a C/C++ toolchain.
FROM node:22-trixie-slim AS builder
# node:22-bookworm-slim
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS builder
ARG NPM_VERSION
WORKDIR /app
RUN npx --yes npm@${NPM_VERSION} install -g npm@${NPM_VERSION}
# Toolchain for node-gyp / native builds.
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ git && rm -rf /var/lib/apt/lists/*
# Build gitnexus-shared first — gitnexus depends on it as a workspace.
# Build gitnexus-shared first - gitnexus depends on it as a workspace.
COPY gitnexus-shared/package.json gitnexus-shared/package-lock.json ./gitnexus-shared/
RUN npm ci --prefix gitnexus-shared
COPY gitnexus-shared ./gitnexus-shared
RUN rm -f gitnexus-shared/tsconfig.tsbuildinfo
RUN npm run build --prefix gitnexus-shared
# Copy the full gitnexus package before installing — `npm ci` triggers
# Copy the full gitnexus package before installing - `npm ci` triggers
# `postinstall` (patches tree-sitter-swift, builds the vendored
# tree-sitter-proto) and `prepare` (compiles TypeScript via scripts/build.js),
# both of which need the source tree.
@@ -28,11 +36,15 @@ RUN npm ci --prefix gitnexus
# Drop dev dependencies for a smaller runtime layer.
RUN npm prune --omit=dev --prefix gitnexus
# ── Runtime ────────────────────────────────────────────────────────────
FROM node:22-trixie-slim AS runtime
# -- Runtime -----------------------------------------------------------
# node:22-bookworm-slim
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime
# curl for the healthcheck; git so `gitnexus` can clone repos at runtime.
RUN apt-get update && apt-get install -y --no-install-recommends curl git && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends curl git && rm -rf /var/lib/apt/lists/* \
&& rm -rf /usr/local/lib/node_modules/npm \
&& rm -rf /usr/local/lib/node_modules/corepack \
&& rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack
WORKDIR /app
@@ -43,11 +55,12 @@ RUN mkdir -p /data/gitnexus && chown -R node:node /data
COPY --from=builder --chown=node:node /app/gitnexus/dist ./gitnexus/dist
COPY --from=builder --chown=node:node /app/gitnexus/node_modules ./gitnexus/node_modules
COPY --from=builder --chown=node:node /app/gitnexus/package.json ./gitnexus/package.json
COPY --from=builder --chown=node:node /app/gitnexus/scripts/install-duckdb-extension.mjs ./gitnexus/scripts/install-duckdb-extension.mjs
COPY --from=builder --chown=node:node /app/gitnexus/vendor ./gitnexus/vendor
USER node
# The web UI defaults to http://localhost:4747 — keep that contract.
# The web UI defaults to http://localhost:4747 - keep that contract.
ENV GITNEXUS_HOME=/data/gitnexus \
NODE_ENV=production \
PORT=4747
+14 -3
View File
@@ -1,10 +1,17 @@
ARG BUILDPLATFORM
ARG TARGETPLATFORM
# Pinned npm version — keep in sync with Dockerfile.cli and
# gitnexus/Dockerfile.test.
ARG NPM_VERSION=11.14.1
FROM --platform=$BUILDPLATFORM node:22-alpine AS builder
# node:22-bookworm-slim
FROM --platform=$BUILDPLATFORM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS builder
ARG NPM_VERSION
WORKDIR /app
RUN npx --yes npm@${NPM_VERSION} install -g npm@${NPM_VERSION}
COPY gitnexus-shared/package.json gitnexus-shared/package-lock.json ./gitnexus-shared/
RUN npm ci --prefix gitnexus-shared
@@ -19,9 +26,13 @@ RUN npm ci --prefix gitnexus-web
COPY gitnexus-web ./gitnexus-web
RUN npm run build --prefix gitnexus-web
FROM node:22-alpine AS runtime
# node:22-bookworm-slim
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime
RUN apk add --no-cache curl
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* \
&& rm -rf /usr/local/lib/node_modules/npm \
&& rm -rf /usr/local/lib/node_modules/corepack \
&& rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack
WORKDIR /app
+7 -1
View File
@@ -30,9 +30,15 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
### Stale graph after edits
- **Trigger:** MCP warns index is behind `HEAD`, or search doesn't match latest commit.
- **Do:** `npx gitnexus analyze` (plus `--embeddings` if used).
- **Do:** `npx gitnexus analyze` (plus `--embeddings` if used). Runs incrementally by default — the pipeline parses every file every run (cross-file resolution requires it), but tree-sitter dispatch is skipped for unchanged file chunks via the content-addressed cache, and only changed-file rows (plus their importers, transitively) are rewritten in LadybugDB.
- **Why:** Tools query LadybugDB from last analyze; git changes are invisible until re-indexed.
### Index seems corrupt or "incremental" is misbehaving
- **Trigger:** `analyze` produces unexpected results, or `meta.json.incrementalInProgress` is set, or the index is in a half-state after a crash.
- **Do:** `npx gitnexus analyze --force` to rebuild from scratch. The dirty-flag check forces this automatically when a previous incremental run didn't complete cleanly, but `--force` is the manual escape hatch. Safe to delete `.gitnexus/parse-cache.json` at any time — content-addressed, will be regenerated.
- **Why:** Incremental writeback is selective DB row replacement; if the on-disk state is inconsistent for any reason, a full rebuild is the cheapest path back to a known-good index.
### Embeddings vanished after analyze
- **Trigger:** Semantic search quality drops; `stats.embeddings` in `meta.json` is 0 after refresh.
+8 -1
View File
@@ -120,7 +120,7 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
| --------------------- | --- | ------ | -------------------- | -------------- |
| **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** |
| **Cursor** | Yes | Yes | — | MCP + Skills |
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
| **Codex** | Yes | Yes | — | MCP + Skills |
| **Windsurf** | Yes | — | — | MCP |
| **OpenCode** | Yes | Yes | — | MCP + Skills |
@@ -214,6 +214,7 @@ gitnexus clean --all --force # Delete all indexes
gitnexus wiki [path] # Generate repository wiki from knowledge graph
gitnexus wiki --model <model> # Wiki with custom LLM model (default: gpt-4o-mini)
gitnexus wiki --base-url <url> # Wiki with custom LLM API base URL
gitnexus publish # Notify the understand-quickly registry (opt-in, see below)
# Repository groups (multi-repo / monorepo service tracking)
gitnexus group create <name> # Create a repository group
@@ -228,6 +229,12 @@ gitnexus group status <name> # Check staleness of repos in a group
If `analyze` reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use `gitnexus analyze --worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget.
#### Publishing to understand-quickly (opt-in)
[`looptech-ai/understand-quickly`](https://github.com/looptech-ai/understand-quickly) is a public registry of code-knowledge graphs that lists `gitnexus@1` as a first-class format. After registering your repo once (`npx @understand-quickly/cli add` or the [wizard](https://looptech-ai.github.io/understand-quickly/add.html)), `gitnexus publish` fires a single `repository_dispatch` event so the registry resyncs your entry on demand instead of waiting for the nightly job.
It is opt-in and a no-op without `UNDERSTAND_QUICKLY_TOKEN` — a fine-grained GitHub PAT with `Repository dispatches: write` on the registry repo. Nothing else happens; no graph file is uploaded. See the [protocol spec](https://github.com/looptech-ai/understand-quickly/blob/main/docs/integrations/protocol.md) for the full contract.
### What Your AI Agent Gets
**16 tools** exposed via MCP (11 per-repo + 5 group):
+95
View File
@@ -0,0 +1,95 @@
/**
* Custom ESLint rule: require `parseSourceSafe(parser, content, ...)` instead
* of direct `<parser>.parse(<content>, ...)` calls.
*
* Background: tree-sitter's Node.js native binding crashes with SIGSEGV on
* Windows when handed a JS string longer than 32 767 chars. The crash happens
* inside the binding's V8 string-to-buffer conversion and cannot be intercepted
* by JavaScript `try/catch`. `parseSourceSafe` (in
* `gitnexus/src/core/tree-sitter/safe-parse.ts`) routes large inputs through
* the chunked-callback overload of `parser.parse(input, ...)` which bypasses
* the broken conversion path. PR #1433 fixed every direct call site at the
* time; this rule prevents new direct calls from creeping in.
*
* The rule is auto-fixable for the call-site rewrite. It does NOT auto-add the
* import (computing the correct relative path per file is brittle); after the
* call rewrite runs, the consumer file's `tsc` will complain about an
* undefined identifier and the developer adds the import. This is the same
* tradeoff `unused-imports/no-unused-imports` makes in the opposite direction.
*
* False-positive suppression:
* - Skips calls whose receiver is a known non-tree-sitter library (`JSON`,
* `URL`, `marked`, `Number`).
* - Skips calls whose first argument is a string-literal (grammar-load smoke
* tests like `_testParser.parse('service X { rpc Y (R) returns (R); }')`).
* - Skips test files (`.test.ts`/`.test.tsx`/`.spec.ts`).
* - Skips the `safe-parse.ts` helper itself.
*/
const SKIPPED_RECEIVERS = new Set(['JSON', 'URL', 'marked', 'Number', 'Math']);
export default {
meta: {
type: 'problem',
docs: {
description:
'Require parseSourceSafe instead of direct tree-sitter `<parser>.parse(content, ...)` calls (Windows SIGSEGV protection)',
recommended: true,
},
fixable: 'code',
schema: [],
messages: {
useSafeParse:
'Direct `{{receiver}}.parse(...)` can SIGSEGV on Windows for inputs > 32 767 chars (uncatchable from JS). Use `parseSourceSafe({{receiver}}, ...)` from `core/tree-sitter/safe-parse.js`. Auto-fix rewrites the call; add the missing import yourself.',
},
},
create(context) {
const filename = context.filename ?? context.getFilename();
// Don't lint the helper itself or test files.
if (filename.includes('safe-parse')) return {};
if (/[.](?:test|spec)\.tsx?$/.test(filename)) return {};
const sourceCode = context.sourceCode ?? context.getSourceCode();
return {
CallExpression(node) {
const callee = node.callee;
if (callee.type !== 'MemberExpression') return;
if (callee.computed) return;
if (callee.property.type !== 'Identifier') return;
if (callee.property.name !== 'parse') return;
// Skip known non-tree-sitter receivers.
if (callee.object.type === 'Identifier' && SKIPPED_RECEIVERS.has(callee.object.name)) {
return;
}
// Smoke tests pass a string literal directly; those are trivially safe.
const firstArg = node.arguments[0];
if (!firstArg) return;
if (firstArg.type === 'Literal' && typeof firstArg.value === 'string') return;
if (firstArg.type === 'TemplateLiteral' && firstArg.expressions.length === 0) return;
const receiverText = sourceCode.getText(callee.object);
// Receiver-text-shape skip: anything matching well-known JS APIs that
// happen to have a `.parse(<expr>)` shape but aren't tree-sitter.
if (
/^(JSON|URL|marked|Number|Math|Date|globalThis\.JSON)\b/.test(receiverText) ||
/\bjson\.parse\b/i.test(receiverText)
) {
return;
}
context.report({
node,
messageId: 'useSafeParse',
data: { receiver: receiverText },
fix(fixer) {
const argsText = node.arguments.map((arg) => sourceCode.getText(arg)).join(', ');
return fixer.replaceText(node, `parseSourceSafe(${receiverText}, ${argsText})`);
},
});
},
};
},
};
+26
View File
@@ -3,6 +3,15 @@ import tsParser from '@typescript-eslint/parser';
import unusedImports from 'eslint-plugin-unused-imports';
import reactHooks from 'eslint-plugin-react-hooks';
import prettierConfig from 'eslint-config-prettier';
import requireSafeParse from './eslint-rules/require-safe-parse.mjs';
// Local plugin hosting custom rules that enforce GitNexus-specific invariants
// (currently: the Windows-SIGSEGV-safe parser entrypoint).
const gitnexusLocalPlugin = {
rules: {
'require-safe-parse': requireSafeParse,
},
};
// Selectors that protect MCP-reachable code from corrupting the JSON-RPC
// stdio frame stream. The MCP-reachable block below uses these directly;
@@ -135,6 +144,23 @@ export default [
},
},
// Windows SIGSEGV protection: every tree-sitter parse in `core/` must route
// through parseSourceSafe. Direct `<parser>.parse(content, ...)` crashes on
// Windows for inputs > 32 767 chars (V8 string-conversion bug, uncatchable
// from JS). The rule auto-fixes the call site; the developer adds the
// missing import after the fix runs. Out of scope: tests (skipped by the
// rule), the helper itself (`safe-parse.ts`), and the `grpc-patterns/proto.ts`
// grammar-load smoke test (filtered by string-literal-arg skip in the rule).
{
files: ['gitnexus/src/core/**/*.ts'],
plugins: {
gitnexus: gitnexusLocalPlugin,
},
rules: {
'gitnexus/require-safe-parse': 'error',
},
},
// React-specific rules for gitnexus-web
{
files: ['gitnexus-web/src/**/*.{ts,tsx}'],
Generated
+3 -3
View File
@@ -2278,11 +2278,11 @@ wheels = [
[[package]]
name = "urllib3"
version = "2.6.3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]
[[package]]
@@ -14,6 +14,7 @@
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { acquireHookSlot } = require('./hook-lock.js');
/**
* Read JSON input from stdin synchronously.
@@ -217,7 +218,8 @@ function sendHookResponse(hookEventName, message) {
function handlePreToolUse(input) {
const cwd = input.cwd || process.cwd();
if (!path.isAbsolute(cwd)) return;
if (!findGitNexusDir(cwd)) return;
const gitNexusDir = findGitNexusDir(cwd);
if (!gitNexusDir) return;
const toolName = input.tool_name || '';
const toolInput = input.tool_input || {};
@@ -227,6 +229,9 @@ function handlePreToolUse(input) {
const pattern = extractPattern(toolName, toolInput);
if (!pattern || pattern.length < 3) return;
const release = acquireHookSlot(gitNexusDir);
if (!release) return;
let result = '';
try {
const child = runGitNexusCli(['augment', '--', pattern], cwd, 7000);
@@ -235,6 +240,8 @@ function handlePreToolUse(input) {
}
} catch {
/* graceful failure */
} finally {
release();
}
if (result && result.trim()) {
+119
View File
@@ -0,0 +1,119 @@
const fs = require('fs');
const path = require('path');
const HOOK_LOCK_SUBDIR = '.hook-locks';
const HOOK_LOCK_MAX_INFLIGHT = 3;
const HOOK_LOCK_STALE_MS = 30000;
function acquireHookSlot(gitNexusDir) {
const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR);
try {
fs.mkdirSync(lockDir, { recursive: true });
} catch {
// Cannot create lock dir (read-only fs, cross-user perm denial, out of
// inodes, etc.) — fail closed by returning null. Caller skips augment.
// Fail-open here would let N concurrent hooks all proceed unguarded and
// reintroduce the #1486 fan-out the guard exists to prevent.
return null;
}
const myPidStr = String(process.pid);
for (let slot = 0; slot < HOOK_LOCK_MAX_INFLIGHT; slot++) {
const slotPath = path.join(lockDir, `slot-${slot}.lock`);
for (let attempt = 0; attempt < 2; attempt++) {
try {
fs.writeFileSync(slotPath, myPidStr, { flag: 'wx' });
let released = false;
const release = () => {
if (released) return;
released = true;
try {
// Only unlink if we still own the slot. If we appeared stale and
// another hook took over, the file now belongs to it — leave alone.
const content = fs.readFileSync(slotPath, 'utf-8').trim();
if (content === myPidStr) fs.unlinkSync(slotPath);
} catch {
/* already removed or unreadable */
}
};
process.on('exit', release);
return release;
} catch {
// Slot exists. Decide whether to take it over.
// Open once and inspect mtime + content via the same fd so there's
// no TOCTOU between the metadata check and the content read
// (codeql js/file-system-race).
let fd;
try {
fd = fs.openSync(slotPath, 'r');
} catch {
continue; // Vanished between EEXIST and open — retry this slot.
}
let isLive = false;
let mtimeMs = Date.now();
try {
mtimeMs = fs.fstatSync(fd).mtimeMs;
const buf = Buffer.alloc(32);
const n = fs.readSync(fd, buf, 0, 32, 0);
const ownerStr = buf.slice(0, n).toString('utf-8').trim();
if (ownerStr === '') {
// Owner created the file but hasn't written its PID yet. The
// wx open+write window is microseconds; give it the benefit
// of the doubt and treat as live.
isLive = true;
} else {
const owner = Number.parseInt(ownerStr, 10);
if (Number.isFinite(owner) && owner > 0) {
try {
process.kill(owner, 0);
isLive = true;
} catch (e) {
// ESRCH = process gone → treat as dead. EPERM = process exists
// but owned by another user (cross-user lock dir) → still alive,
// keep the slot. Anything else: be conservative, assume alive.
if (e && e.code === 'ESRCH') {
isLive = false;
} else {
isLive = true;
}
}
}
}
} catch {
/* unreadable — treat as dead */
} finally {
try {
fs.closeSync(fd);
} catch {
/* already closed */
}
}
// For slots younger than HOOK_LOCK_STALE_MS, PID-liveness wins —
// a slow-but-alive hook is never wrongly evicted. For older slots,
// age is the final arbiter as a defense against PID reuse on long-
// abandoned slots. 30s >> the 7s augment timeout, so a healthy run
// never crosses this threshold.
if (isLive && Date.now() - mtimeMs > HOOK_LOCK_STALE_MS) {
isLive = false;
}
if (isLive) break; // Try the next slot.
try {
fs.unlinkSync(slotPath);
} catch {
/* another hook beat us to it — retry will hit EEXIST */
}
// Loop and retry this slot.
}
}
}
return null;
}
module.exports = {
HOOK_LOCK_SUBDIR,
HOOK_LOCK_MAX_INFLIGHT,
HOOK_LOCK_STALE_MS,
acquireHookSlot,
};
+91
View File
@@ -0,0 +1,91 @@
# GitNexus — Cursor integration
Static config that adds GitNexus knowledge-graph augmentation and skill files to Cursor.
> **Hooks require Cursor 2.4+.** Earlier versions don't expose `postToolUse` and the hook will silently no-op.
## What you get
| Layer | What it does | How it's installed |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| **MCP** | `gitnexus` MCP server with 16 tools (`query`, `context`, `impact`, `detect_changes`, `rename`, …) | `npx gitnexus setup` writes `~/.cursor/mcp.json` automatically. |
| **Skills** | `/gitnexus-exploring`, `/gitnexus-debugging`, `/gitnexus-impact-analysis`, `/gitnexus-refactoring`, `/gitnexus-pr-review` markdown skills | `npx gitnexus setup` copies them to `~/.cursor/skills/gitnexus/`. |
| **Hooks** _(this README)_ | `postToolUse` hook that enriches `Shell` / `Read` / `Grep` tool calls with graph context — same augmentation Claude Code gets | **Manual** — copy the files described below into your project's `.cursor/`. |
## Hook install
Cursor 2.4+ reads `.cursor/hooks.json` from the project root and runs hook commands with the project root as the working directory ([docs](https://cursor.com/docs/agent/hooks)).
From this repo's `gitnexus-cursor-integration/hooks/`, copy the files below into your **project root**:
```text
<your-project>/
├── .cursor/
│ └── hooks.json ← from gitnexus-cursor-integration/hooks/hooks.json
└── hooks/
├── gitnexus-hook.cjs ← from gitnexus-cursor-integration/hooks/gitnexus-hook.cjs
└── hook-lock.cjs ← from gitnexus-cursor-integration/hooks/hook-lock.cjs
```
Equivalent shell commands (run from your project root, with `$GITNEXUS_REPO` pointing at a clone of this repo):
```bash
mkdir -p .cursor hooks
cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/hooks.json" .cursor/hooks.json
cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs" hooks/gitnexus-hook.cjs
cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/hook-lock.cjs" hooks/hook-lock.cjs
```
If you already have a `.cursor/hooks.json`, merge the `hooks.postToolUse` array rather than overwriting.
### Verify
1. Index the project: `npx gitnexus analyze`
2. Reload the Cursor window so it picks up the new hook config.
3. Ask the agent something that triggers `Read` / `Grep` / `Shell rg`. You should see a `[GitNexus]` block appended to the tool result.
4. Diagnose silent no-ops by setting `GITNEXUS_DEBUG=1` in your shell environment — the hook will write Cursor's raw event payload to stderr so you can verify field names.
### What's installed manually vs. automated
| Step | Automated by `gitnexus setup`? |
| -------------------------------------------------------------------- | ------------------------------ |
| `~/.cursor/mcp.json` | ✅ |
| `~/.cursor/skills/gitnexus/*` | ✅ |
| `<project>/.cursor/hooks.json` + `<project>/hooks/gitnexus-hook.cjs` + `<project>/hooks/hook-lock.cjs` | ❌ — copy manually (see above) |
Hook install is per-project (Cursor scopes hooks to a project root); skills and MCP config are global.
## Hook contract
The hook receives a JSON event on stdin matching Cursor 2.4's `postToolUse` shape:
```json
{
"tool_name": "Grep" | "Read" | "Shell",
"tool_input": { /* tool-specific */ },
"tool_output": { /* optional */ },
"cwd": "/absolute/path/to/project"
}
```
It writes augmentation context to stdout as:
```json
{ "additional_context": "[GitNexus] …" }
```
Empty stdout means "no augmentation, continue normally" — the hook never blocks the tool.
### Pattern extraction per tool
| Tool | Pattern source | Notes |
| ------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `Grep` | `tool_input.query` (also `pattern`, `regex`, `q`, `search`, `searchQuery`) | Last-resort fallback: longest string value in `tool_input` (≥ 3 chars). |
| `Read` | basename of `tool_input.target_file` (also `file_path`, `filePath`, `path`, `file`), stripped to identifier characters | `auth/handler.ts` → `handler`. |
| `Shell` | First positional argument after `rg` / `grep` in `tool_input.command` | Best-effort tokenizer; quoted multi-word patterns (`rg "User Service"`) extract the first word only. |
## Troubleshooting
- **Nothing happens** — Confirm Cursor is on 2.4+ and the project root has `.cursor/hooks.json` plus both hook files at `hooks/gitnexus-hook.cjs` and `hooks/hook-lock.cjs`. Then `npx gitnexus list` to confirm the project is indexed.
- **`gitnexus` not found** — The hook prefers a locally-resolvable `gitnexus/dist/cli/index.js` and falls back to `npx -y gitnexus`. Install globally with `npm i -g gitnexus` to skip the npx cold-start latency.
- **Wrong pattern extracted** — Set `GITNEXUS_DEBUG=1` and run a tool call. The raw stdin payload is logged to stderr; use it to confirm Cursor's actual `tool_input` field names against the table above. If they differ, file an issue with the captured payload.
@@ -1,50 +0,0 @@
#!/bin/bash
# GitNexus beforeShellExecution hook for Cursor
# Receives JSON on stdin with { command, cwd, timeout }
# Returns JSON on stdout with { permission, agent_message }
#
# Extracts search pattern from grep/rg commands, runs gitnexus augment,
# and injects the enriched context via agent_message.
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.command // empty' 2>/dev/null)
if [ -z "$COMMAND" ]; then
echo '{"permission":"allow"}'
exit 0
fi
# Skip non-search commands
case "$COMMAND" in
cd\ *|npm\ *|yarn\ *|pnpm\ *|git\ commit*|git\ push*|git\ pull*|mkdir\ *|rm\ *|cp\ *|mv\ *|echo\ *|cat\ *)
echo '{"permission":"allow"}'
exit 0
;;
esac
# Extract search pattern from rg/grep commands
PATTERN=""
if echo "$COMMAND" | grep -qE '\brg\b'; then
PATTERN=$(echo "$COMMAND" | sed -n "s/.*\brg\s\+\(--[^ ]*\s\+\)*['\"]\\?\([^'\";\| >]*\\).*/\2/p")
elif echo "$COMMAND" | grep -qE '\bgrep\b'; then
PATTERN=$(echo "$COMMAND" | sed -n "s/.*\bgrep\s\+\(-[^ ]*\s\+\)*['\"]\\?\([^'\";\| >]*\\).*/\2/p")
fi
if [ -z "$PATTERN" ] || [ ${#PATTERN} -lt 3 ]; then
echo '{"permission":"allow"}'
exit 0
fi
# Run gitnexus augment
RESULT=$(npx -y gitnexus augment "$PATTERN" 2>/dev/null)
if [ -n "$RESULT" ]; then
# Escape for JSON
ESCAPED=$(echo "$RESULT" | jq -Rs .)
echo "{\"permission\":\"allow\",\"agent_message\":$ESCAPED}"
else
echo '{"permission":"allow"}'
fi
exit 0
@@ -0,0 +1,266 @@
#!/usr/bin/env node
/**
* GitNexus Cursor postToolUse Hook
*
* Receives a JSON event on stdin describing a finished tool call, derives a
* search pattern (Grep query, Read file basename, or rg/grep arg from a Shell
* command), runs `gitnexus augment <pattern>`, and emits the enriched context
* back as `{ additional_context: "..." }` so the agent sees it alongside the
* tool result.
*
* Replaces the legacy beforeShellExecution / augment-shell.sh pipeline:
* - Cross-platform (no bash, no jq — runs on Windows out of the box)
* - Covers Read and Grep, not just Shell rg/grep
*
* Cursor 2.4+ generic hooks: https://cursor.com/docs/agent/hooks
*/
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { acquireHookSlot } = require('./hook-lock.cjs');
function readInput() {
try {
const data = fs.readFileSync(0, 'utf-8');
return JSON.parse(data);
} catch {
return {};
}
}
function isGlobalRegistryDir(candidate) {
if (fs.existsSync(path.join(candidate, 'meta.json'))) return false;
return (
fs.existsSync(path.join(candidate, 'registry.json')) ||
fs.existsSync(path.join(candidate, 'repos'))
);
}
function walkForGitNexusDir(startDir) {
let dir = startDir;
for (let i = 0; i < 5; i++) {
const candidate = path.join(dir, '.gitnexus');
if (fs.existsSync(candidate)) {
if (!isGlobalRegistryDir(candidate)) return candidate;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
function findCanonicalRepoRoot(cwd) {
try {
const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
encoding: 'utf-8',
timeout: 2000,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
});
if (result.error || result.status !== 0) return null;
const commonDir = (result.stdout || '').trim();
if (!commonDir || !path.isAbsolute(commonDir)) return null;
return path.dirname(commonDir);
} catch {
return null;
}
}
function findGitNexusDir(startDir) {
const cwd = startDir || process.cwd();
const fromCwd = walkForGitNexusDir(cwd);
if (fromCwd) return fromCwd;
const canonicalRoot = findCanonicalRepoRoot(cwd);
if (canonicalRoot && canonicalRoot !== cwd) {
return walkForGitNexusDir(canonicalRoot);
}
return null;
}
function parseRgGrepPattern(cmd) {
const tokens = cmd.split(/\s+/);
let foundCmd = false;
let skipNext = false;
const flagsWithValues = new Set([
'-e',
'-f',
'-m',
'-A',
'-B',
'-C',
'-g',
'--glob',
'-t',
'--type',
'--include',
'--exclude',
]);
for (const token of tokens) {
if (skipNext) {
skipNext = false;
continue;
}
if (!foundCmd) {
if (/\brg$|\bgrep$/.test(token)) foundCmd = true;
continue;
}
if (token.startsWith('-')) {
if (flagsWithValues.has(token)) skipNext = true;
continue;
}
const cleaned = token.replace(/['"]/g, '');
return cleaned.length >= 3 ? cleaned : null;
}
return null;
}
/**
* Extract a search pattern from the tool input. Cursor 2.4 docs at
* https://cursor.com/docs/agent/hooks list the tool *matchers* but do not
* formally specify the per-tool tool_input field names, so we probe a
* generous set of MCP-style aliases. As a last-resort fallback for Grep
* (the highest-frequency search path) we also accept the longest plausible
* string value in tool_input. Set GITNEXUS_DEBUG=1 to log the raw payload
* to stderr if Cursor changes the contract and aliases stop matching.
*/
function pickLongestStringValue(obj) {
let best = null;
if (!obj || typeof obj !== 'object') return null;
for (const v of Object.values(obj)) {
if (typeof v === 'string' && v.length >= 3 && (!best || v.length > best.length)) {
best = v;
}
}
return best;
}
function extractPattern(toolName, toolInput) {
const t = (toolName || '').toLowerCase();
if (t === 'grep') {
const aliases = [
toolInput.query,
toolInput.pattern,
toolInput.regex,
toolInput.q,
toolInput.search,
toolInput.searchQuery,
];
for (const a of aliases) {
if (typeof a === 'string' && a.length >= 3) return a;
}
// Last resort: scan tool_input for any reasonable-looking string value.
return pickLongestStringValue(toolInput);
}
if (t === 'read') {
const filePath =
toolInput.target_file ||
toolInput.file_path ||
toolInput.filePath ||
toolInput.path ||
toolInput.file ||
'';
if (!filePath) return null;
const base = path.basename(String(filePath), path.extname(String(filePath)));
const cleaned = base.replace(/[^a-zA-Z0-9_]/g, '');
return cleaned.length >= 3 ? cleaned : null;
}
if (t === 'shell') {
const cmd = toolInput.command || '';
if (!/\brg\b|\bgrep\b/.test(cmd)) return null;
// NOTE: parseRgGrepPattern uses split(/\s+/) and cannot handle shell
// quoting. `rg "User Service" src/` returns "User" (the first token
// after the rg/grep arg, with surrounding quotes stripped) — the
// multi-word pattern is intentionally not reconstructed since BM25 is
// already token-tolerant. Quoted single tokens (`rg "validateUser"`)
// work fine.
return parseRgGrepPattern(cmd);
}
return null;
}
function resolveCliPath() {
try {
return require.resolve('gitnexus/dist/cli/index.js');
} catch {
return '';
}
}
function runGitNexusCli(cliPath, args, cwd, timeout) {
const isWin = process.platform === 'win32';
if (cliPath) {
return spawnSync(process.execPath, [cliPath, ...args], {
encoding: 'utf-8',
timeout,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
});
}
return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
encoding: 'utf-8',
timeout: timeout + 5000,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
});
}
function main() {
try {
const input = readInput();
if (process.env.GITNEXUS_DEBUG) {
// Echo the payload so users can capture Cursor's actual contract when
// diagnosing why augmentation isn't firing. Stderr only — stdout is
// reserved for the JSON response Cursor consumes.
try {
process.stderr.write(
`GitNexus Cursor hook stdin: ${JSON.stringify(input).slice(0, 500)}\n`,
);
} catch {
/* never let debug logging break the hook */
}
}
const cwd = input.cwd || process.cwd();
if (!path.isAbsolute(cwd)) return;
const gitNexusDir = findGitNexusDir(cwd);
if (!gitNexusDir) return;
const toolName = input.tool_name || '';
const toolInput = input.tool_input || {};
const pattern = extractPattern(toolName, toolInput);
if (!pattern || pattern.length < 3) return;
const release = acquireHookSlot(gitNexusDir);
if (!release) return;
const cliPath = resolveCliPath();
let result = '';
try {
const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
if (!child.error && child.status === 0) {
result = child.stderr || '';
}
} catch {
/* graceful failure */
} finally {
release();
}
if (result && result.trim()) {
console.log(JSON.stringify({ additional_context: result.trim() }));
}
} catch (err) {
if (process.env.GITNEXUS_DEBUG) {
console.error('GitNexus Cursor hook error:', (err.message || '').slice(0, 200));
}
}
}
main();
@@ -0,0 +1,119 @@
const fs = require('fs');
const path = require('path');
const HOOK_LOCK_SUBDIR = '.hook-locks';
const HOOK_LOCK_MAX_INFLIGHT = 3;
const HOOK_LOCK_STALE_MS = 30000;
function acquireHookSlot(gitNexusDir) {
const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR);
try {
fs.mkdirSync(lockDir, { recursive: true });
} catch {
// Cannot create lock dir (read-only fs, cross-user perm denial, out of
// inodes, etc.) — fail closed by returning null. Caller skips augment.
// Fail-open here would let N concurrent hooks all proceed unguarded and
// reintroduce the #1486 fan-out the guard exists to prevent.
return null;
}
const myPidStr = String(process.pid);
for (let slot = 0; slot < HOOK_LOCK_MAX_INFLIGHT; slot++) {
const slotPath = path.join(lockDir, `slot-${slot}.lock`);
for (let attempt = 0; attempt < 2; attempt++) {
try {
fs.writeFileSync(slotPath, myPidStr, { flag: 'wx' });
let released = false;
const release = () => {
if (released) return;
released = true;
try {
// Only unlink if we still own the slot. If we appeared stale and
// another hook took over, the file now belongs to it — leave alone.
const content = fs.readFileSync(slotPath, 'utf-8').trim();
if (content === myPidStr) fs.unlinkSync(slotPath);
} catch {
/* already removed or unreadable */
}
};
process.on('exit', release);
return release;
} catch {
// Slot exists. Decide whether to take it over.
// Open once and inspect mtime + content via the same fd so there's
// no TOCTOU between the metadata check and the content read
// (codeql js/file-system-race).
let fd;
try {
fd = fs.openSync(slotPath, 'r');
} catch {
continue; // Vanished between EEXIST and open — retry this slot.
}
let isLive = false;
let mtimeMs = Date.now();
try {
mtimeMs = fs.fstatSync(fd).mtimeMs;
const buf = Buffer.alloc(32);
const n = fs.readSync(fd, buf, 0, 32, 0);
const ownerStr = buf.slice(0, n).toString('utf-8').trim();
if (ownerStr === '') {
// Owner created the file but hasn't written its PID yet. The
// wx open+write window is microseconds; give it the benefit
// of the doubt and treat as live.
isLive = true;
} else {
const owner = Number.parseInt(ownerStr, 10);
if (Number.isFinite(owner) && owner > 0) {
try {
process.kill(owner, 0);
isLive = true;
} catch (e) {
// ESRCH = process gone → treat as dead. EPERM = process exists
// but owned by another user (cross-user lock dir) → still alive,
// keep the slot. Anything else: be conservative, assume alive.
if (e && e.code === 'ESRCH') {
isLive = false;
} else {
isLive = true;
}
}
}
}
} catch {
/* unreadable — treat as dead */
} finally {
try {
fs.closeSync(fd);
} catch {
/* already closed */
}
}
// For slots younger than HOOK_LOCK_STALE_MS, PID-liveness wins —
// a slow-but-alive hook is never wrongly evicted. For older slots,
// age is the final arbiter as a defense against PID reuse on long-
// abandoned slots. 30s >> the 7s augment timeout, so a healthy run
// never crosses this threshold.
if (isLive && Date.now() - mtimeMs > HOOK_LOCK_STALE_MS) {
isLive = false;
}
if (isLive) break; // Try the next slot.
try {
fs.unlinkSync(slotPath);
} catch {
/* another hook beat us to it — retry will hit EEXIST */
}
// Loop and retry this slot.
}
}
}
return null;
}
module.exports = {
HOOK_LOCK_SUBDIR,
HOOK_LOCK_MAX_INFLIGHT,
HOOK_LOCK_STALE_MS,
acquireHookSlot,
};
+4 -4
View File
@@ -1,11 +1,11 @@
{
"version": 1,
"hooks": {
"beforeShellExecution": [
"postToolUse": [
{
"command": "./hooks/augment-shell.sh",
"timeout": 5,
"matcher": "\\brg\\b|\\bgrep\\b"
"matcher": "Shell|Read|Grep",
"command": "node ./hooks/gitnexus-hook.cjs",
"timeout": 10
}
]
}
+4
View File
@@ -10,6 +10,10 @@
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./test-helpers": {
"types": "./dist/test-helpers.d.ts",
"default": "./dist/test-helpers.js"
}
},
"scripts": {
+28
View File
@@ -143,6 +143,34 @@ export type { ScopeTree } from './scope-resolution/scope-tree.js';
export { buildPositionIndex } from './scope-resolution/position-index.js';
export type { PositionIndex } from './scope-resolution/position-index.js';
// Resilient fetch primitives — bounded retries + per-process circuit breaker.
// Test-only helpers (`__resetBreakerRegistry__`, `classifyOutcome`) are
// reachable via the separate `gitnexus-shared/test-helpers` subpath; do
// NOT add them here. Production consumers must not call them.
export { withRetry, computeBackoffMs } from './integrations/retry.js';
export type { RetryOptions, RetryDecision } from './integrations/retry.js';
export { CircuitBreaker, CircuitOpenError, getBreaker } from './integrations/circuit-breaker.js';
export type { CircuitBreakerOptions } from './integrations/circuit-breaker.js';
export {
resilientFetch,
ResilientFetchExhaustedError,
RETRY_AFTER_CAP_MS,
parseRetryAfter,
} from './integrations/resilient-fetch.js';
export type { ResilientFetchOptions } from './integrations/resilient-fetch.js';
// Understand-Quickly registry integration (opt-in)
export {
UNDERSTAND_QUICKLY_DISPATCH_URL,
UNDERSTAND_QUICKLY_EVENT_TYPE,
UNDERSTAND_QUICKLY_TOKEN_ENV,
buildUqDispatchPayload,
isValidOwnerRepo,
parseOwnerRepoFromRemote,
stripGitSuffix,
} from './integrations/understand-quickly.js';
export type { UqDispatchPayload } from './integrations/understand-quickly.js';
// Shadow-mode diff + aggregation (RFC §6.3; Ring 2 SHARED #918)
export { diffResolutions } from './scope-resolution/shadow/diff.js';
export type {
@@ -0,0 +1,273 @@
/**
* Per-process circuit breaker.
*
* Closed -> Open transition fires after `failureThreshold` consecutive
* failures. While Open, `check` throws `CircuitOpenError` until
* `cooldownMs` has elapsed since the breaker tripped. The first call
* after the cooldown enters Half-Open and consumes the *probe permit*:
* a recorded success returns to Closed; a recorded failure flips back
* to Open with a fresh timestamp.
*
* Half-open admits exactly one in-flight probe at a time. Concurrent
* callers attempting `check()` while a probe is outstanding receive
* `CircuitOpenError` with `retryAfterMs = halfOpenRetryAfterMs` (default
* 1000ms; configurable). This prevents the recovery-time thundering
* herd that defeats the breaker's "fail fast" promise.
*
* Outcome reporting splits permit-release from state-resolution:
* - `recordSuccess` — releases the probe permit, resets the failure
* counter, transitions to Closed. Reserved for true 2xx/3xx outcomes.
* - `recordFailure` — releases the probe permit, increments the
* consecutive-failure counter, transitions to Open with a fresh
* `openedAt` (when called from Half-Open or when the threshold
* trips from Closed).
* - `recordNeutral` — releases the probe permit, BUT leaves state and
* counter untouched. Used for outcomes that are neither evidence of
* backend health nor evidence of backend failure (caller-driven
* cancellation, local timeout, terminal 4xx client errors). Critical
* design point: if `recordNeutral` did not release the permit, a
* single `TimeoutError` from per-attempt `AbortSignal.timeout` would
* route through `recordNeutral` and permanently park the breaker in
* half-open until process restart. Releasing the permit while leaving
* state half-open keeps the "neutral doesn't claim health" semantic
* without creating that wedge.
*
* Pairing invariant: every successful `check()` MUST be paired with
* exactly one `record*()` on every code path including throws. Direct
* consumers should wrap the protected operation in `try/finally`:
*
* breaker.check();
* try {
* const result = await operation();
* breaker.recordSuccess();
* return result;
* } catch (err) {
* // classify err and call recordFailure / recordNeutral / etc.
* throw err;
* }
*
* `resilientFetch`'s catch-all on `fetchImpl` already satisfies this
* for that consumer.
*
* Atomicity model: the half-open gate relies on JavaScript event-loop
* single-threadedness within a synchronous `check()` body. There is no
* `await` inside `check()`; concurrent callers serialize on microtask
* order, and exactly one observes `probeInFlight === false`. Do not
* introduce `await` inside `check()` without revisiting the gate. If
* this code is ever ported to a runtime with shared-memory threads
* (Node `worker_threads` with `SharedArrayBuffer`, Web Workers with
* shared registries), the boolean must become an atomic CAS — Resilience4j
* and Hystrix use atomic permits *because* they run in JVM thread pools.
*
* Runtime-agnostic: depends only on a `now()` clock and standard JS —
* no Node-only imports. Tests inject `now` to advance the clock
* deterministically without `vi.useFakeTimers()`.
*/
export class CircuitOpenError extends Error {
override readonly name = 'CircuitOpenError';
/** Approximate wait time before the breaker may transition to Half-Open
* (or before the in-flight probe is expected to resolve). */
readonly retryAfterMs: number;
constructor(retryAfterMs: number, key?: string) {
super(
key
? `Circuit '${key}' is open; retry in ${Math.ceil(retryAfterMs / 1000)}s`
: `Circuit is open; retry in ${Math.ceil(retryAfterMs / 1000)}s`,
);
this.retryAfterMs = retryAfterMs;
}
}
export interface CircuitBreakerOptions {
/** Consecutive failures required to trip Closed -> Open. */
failureThreshold?: number;
/** Milliseconds Open before the next call may probe (Half-Open). */
cooldownMs?: number;
/**
* Milliseconds to suggest in `CircuitOpenError.retryAfterMs` when the
* breaker is Half-Open with the probe permit consumed. Default 1000ms.
* Consumers with long-running protected ops (LLM streaming, large
* uploads) should raise this — the cooldown clock is no longer the
* right answer because cooldown has elapsed. Returning 0 invites
* retry storms; returning the full cooldown misleads about wait.
*/
halfOpenRetryAfterMs?: number;
/** Optional key for error messages and registry lookups. */
key?: string;
/** Clock override — defaults to `Date.now`. Tests inject deterministic time. */
now?: () => number;
}
type State = 'closed' | 'open' | 'half-open';
export class CircuitBreaker {
private readonly failureThreshold: number;
private readonly cooldownMs: number;
private readonly halfOpenRetryAfterMs: number;
private readonly key: string | undefined;
private readonly now: () => number;
private state: State = 'closed';
private consecutiveFailures = 0;
private openedAt: number | null = null;
/**
* True between a successful `check()` and the next `record*()` call
* during Half-Open. Gates concurrent callers from stampeding a still-
* recovering dependency. Boolean rather than counter — single-permit
* is the conservative end of the Hystrix/Resilience4j spectrum.
*/
private probeInFlight = false;
constructor(opts: CircuitBreakerOptions = {}) {
this.failureThreshold = opts.failureThreshold ?? 3;
this.cooldownMs = opts.cooldownMs ?? 30_000;
this.halfOpenRetryAfterMs = opts.halfOpenRetryAfterMs ?? 1_000;
this.key = opts.key;
this.now = opts.now ?? (() => Date.now());
}
/**
* Throw `CircuitOpenError` if the breaker won't admit this call.
* Otherwise consume the half-open probe permit (if applicable) and
* return so the caller can attempt the protected work.
*
* Three rejection paths:
* 1. Open and still in cooldown → throws with `retryAfterMs` =
* remaining cooldown.
* 2. Open with cooldown elapsed AND a probe is already in flight
* (race: another caller transitioned to half-open and grabbed
* the permit on a microtask before us) → throws with
* `halfOpenRetryAfterMs`.
* 3. Half-Open with probe in flight → throws with `halfOpenRetryAfterMs`.
*
* **Pairing invariant**: every successful return from `check()` MUST
* be paired with exactly one `recordSuccess` / `recordFailure` /
* `recordNeutral` on every code path including thrown exceptions.
* Failing to pair leaves the probe permit consumed forever and
* wedges the breaker. See file-header JSDoc for the canonical
* try/finally pattern.
*/
check(): void {
if (this.state === 'open' && this.openedAt !== null) {
const elapsed = this.now() - this.openedAt;
if (elapsed < this.cooldownMs) {
throw new CircuitOpenError(this.cooldownMs - elapsed, this.key);
}
// Cooldown elapsed — transition to Half-Open. The very next
// `probeInFlight` check below decides whether THIS caller gets
// the permit or hits the gate.
this.state = 'half-open';
}
if (this.state === 'half-open') {
if (this.probeInFlight) {
throw new CircuitOpenError(this.halfOpenRetryAfterMs, this.key);
}
this.probeInFlight = true;
}
// Closed state falls through silently.
}
recordSuccess(): void {
this.probeInFlight = false;
this.consecutiveFailures = 0;
this.state = 'closed';
this.openedAt = null;
}
recordFailure(): void {
this.probeInFlight = false;
this.consecutiveFailures += 1;
if (this.state === 'half-open' || this.consecutiveFailures >= this.failureThreshold) {
this.state = 'open';
this.openedAt = this.now();
}
}
/**
* Releases the probe permit BUT leaves state and counter untouched.
* Use when an attempt produced a response or error that should not
* influence breaker health in either direction — caller-driven aborts,
* local AbortSignal timeouts, terminal 4xx client errors.
*
* Why permit-release-without-state-resolution: if `recordNeutral` did
* not clear `probeInFlight`, a single `TimeoutError` from per-attempt
* `AbortSignal.timeout` (which routes through neutral classification)
* would permanently park the breaker in half-open. Since timeouts are
* an *expected* outcome under flaky-dependency conditions, the cited
* "per-attempt timeout bounds the stuck state" mitigation would itself
* be the trigger for a permanent wedge. Releasing the permit closes
* that loop while keeping the "neutral doesn't claim dependency
* health" semantic.
*
* Calling `recordSuccess` for these would erase legitimate prior
* failure signal; calling `recordFailure` would trip the breaker for
* outcomes the backend isn't responsible for.
*/
recordNeutral(): void {
this.probeInFlight = false;
// State and consecutiveFailures are preserved by design.
}
/**
* Pure read — no state mutation, no permit accounting. Returns the
* *would-be* state at the current instant: 'half-open' if the breaker
* is open with cooldown elapsed (regardless of whether a probe is in
* flight), 'open' if open and still in cooldown, 'closed' otherwise.
*
* Inspection-only; safe to call from tests without consuming a probe
* permit. The implicit Open -> Half-Open transition that mutates
* `state` lives in `check()` only.
*/
getState(): State {
if (this.state === 'open' && this.openedAt !== null) {
const elapsed = this.now() - this.openedAt;
if (elapsed >= this.cooldownMs) return 'half-open';
}
return this.state;
}
getConsecutiveFailures(): number {
return this.consecutiveFailures;
}
/** Inspection-only test accessor for the half-open probe permit. */
isProbeInFlight(): boolean {
return this.probeInFlight;
}
/** Timestamp (ms since epoch) when the breaker last transitioned to Open,
* or `null` if it's currently Closed. Useful for computing remaining
* cooldown without consuming a probe permit via `check()`. */
getOpenedAt(): number | null {
return this.openedAt;
}
/** Configured cooldown duration in milliseconds. */
getCooldownMs(): number {
return this.cooldownMs;
}
}
// ─── Per-process registry ────────────────────────────────────────────
//
// Single shared map keyed on caller-chosen strings. Used by
// `resilient-fetch.ts` so multiple call sites targeting the same logical
// endpoint share breaker state. Per-process only — not persisted.
const registry = new Map<string, CircuitBreaker>();
export function getBreaker(key: string, opts?: CircuitBreakerOptions): CircuitBreaker {
let breaker = registry.get(key);
if (!breaker) {
breaker = new CircuitBreaker({ ...opts, key });
registry.set(key, breaker);
}
return breaker;
}
/**
* Test-only: clear all registered breakers. Tests must call this in
* `beforeEach` to prevent breaker state from leaking across test cases.
*/
export function __resetBreakerRegistry__(): void {
registry.clear();
}
@@ -0,0 +1,279 @@
/**
* `resilientFetch` — fetch wrapped in retry + circuit breaker, with
* GitHub-flavoured retry classification baked in (Retry-After parsing,
* 401/403/404/422 treated as terminal client errors).
*
* Designed for the `gitnexus publish` GitHub `repository_dispatch`
* call, but the classification rules apply to any GitHub REST endpoint.
* Runtime-agnostic — no Node-only imports.
*/
import {
CircuitBreaker,
CircuitOpenError,
getBreaker,
type CircuitBreakerOptions,
} from './circuit-breaker.js';
import { computeBackoffMs, type RetryOptions } from './retry.js';
export { CircuitOpenError };
export interface ResilientFetchOptions {
/** Optional fetch implementation override. Defaults to `globalThis.fetch`. */
fetchImpl?: typeof fetch;
/**
* Logical key for the breaker. Defaults to `<host><pathname>` of the
* request URL — call sites targeting the same endpoint share breaker
* state regardless of query-string differences.
*/
breakerKey?: string;
/** Per-call breaker override. Used for tests and one-off configuration. */
breaker?: CircuitBreaker;
/** Tuning knobs for the breaker registered under `breakerKey`. */
breakerOptions?: CircuitBreakerOptions;
/** Tuning knobs for the retry helper. */
retry?: Partial<Pick<RetryOptions, 'maxAttempts' | 'baseDelayMs' | 'capDelayMs'>> & {
sleep?: RetryOptions['sleep'];
random?: RetryOptions['random'];
};
/** Clock override propagated into Retry-After HTTP-date math and breaker. */
now?: () => number;
}
/** Cap on any single Retry-After wait — protects CLI from a buggy registry. */
export const RETRY_AFTER_CAP_MS = 30_000;
const DEFAULT_RETRY = {
maxAttempts: 3,
baseDelayMs: 500,
capDelayMs: 5_000,
};
/**
* Parse a `Retry-After` header value into milliseconds.
* Accepts either a delta-seconds integer (`"30"`) or an HTTP-date.
* Returns null on parse failure or negative deltas.
*/
export function parseRetryAfter(value: string | null, now: () => number = Date.now): number | null {
if (!value) return null;
const trimmed = value.trim();
if (trimmed === '') return null;
if (/^[0-9]+$/.test(trimmed)) {
const seconds = parseInt(trimmed, 10);
if (Number.isNaN(seconds) || seconds < 0) return null;
return seconds * 1000;
}
const target = Date.parse(trimmed);
if (Number.isNaN(target)) return null;
const delta = target - now();
return delta >= 0 ? delta : 0;
}
/** Internal: outcome classification used by the resilientFetch loop. */
type Outcome =
| { kind: 'success'; resp: Response }
| { kind: 'terminal-client'; resp: Response } // 4xx other than 429: no retry, breaker neutral
| { kind: 'retryable-status'; resp: Response; afterMs: number | undefined } // 5xx, 429
| { kind: 'terminal-network'; err: unknown } // TimeoutError or AbortError: no retry, breaker neutral
| { kind: 'retryable-network'; err: unknown }; // DNS, ECONNRESET, etc.
/** Exported for unit tests. */
export function classifyOutcome(
result: { kind: 'error'; err: unknown } | { kind: 'response'; resp: Response },
now: () => number,
): Outcome {
if (result.kind === 'error') {
// Both timer-fired aborts (`AbortSignal.timeout()` → `TimeoutError`)
// and caller-driven aborts (`AbortController.abort()` → `AbortError`)
// are terminal: retrying against an already-aborted signal would
// fail again immediately, and neither outcome reflects backend
// health. They route through the breaker's neutral path.
if (
result.err instanceof DOMException &&
(result.err.name === 'TimeoutError' || result.err.name === 'AbortError')
) {
return { kind: 'terminal-network', err: result.err };
}
return { kind: 'retryable-network', err: result.err };
}
const resp = result.resp;
if (resp.status >= 200 && resp.status < 400) return { kind: 'success', resp };
if (resp.status === 429) {
// `resp.headers` is always present on a real `Response`, but tests
// sometimes stub `fetch` with a plain `{ ok, status }` object. Be
// defensive — a missing `Retry-After` falls through to exponential
// backoff, which is the correct behaviour anyway.
const retryAfterHeader =
typeof resp.headers?.get === 'function' ? resp.headers.get('Retry-After') : null;
const parsed = parseRetryAfter(retryAfterHeader, now);
return {
kind: 'retryable-status',
resp,
afterMs: parsed !== null ? Math.min(parsed, RETRY_AFTER_CAP_MS) : undefined,
};
}
if (resp.status >= 500) return { kind: 'retryable-status', resp, afterMs: undefined };
return { kind: 'terminal-client', resp };
}
const defaultSleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
function defaultBreakerKey(input: string | URL): string {
try {
const url = typeof input === 'string' ? new URL(input) : input;
return `${url.host}${url.pathname}`;
} catch {
return String(input);
}
}
/** Final error thrown when retries are exhausted on a 5xx / 429. */
export class ResilientFetchExhaustedError extends Error {
override readonly name = 'ResilientFetchExhaustedError';
constructor(public readonly response: Response) {
super(`Request failed after retries (HTTP ${response.status})`);
}
}
/**
* Wrap `fetch` with bounded retries and a per-process circuit breaker.
*
* Semantics:
* - 5xx and 429 responses are retried; 429 honors `Retry-After` (capped).
* - Network throws are retried unless they are `TimeoutError` DOMExceptions.
* - Timeouts and 4xx (other than 429) are returned/thrown without retry
* AND without incrementing the breaker — they reflect caller config
* or local network state, not registry health.
* - Each `fetch` call carries the caller-supplied `signal` (e.g. an
* `AbortSignal.timeout()`) — that timeout bounds each individual
* attempt, not the whole retry sequence.
* - When the breaker is open, throws `CircuitOpenError` synchronously
* without invoking `fetch`.
* - When retries are exhausted on a 5xx / 429, throws
* `ResilientFetchExhaustedError` carrying the last response.
*
* Cumulative wall-clock budget:
* maxAttempts × (per-attempt-timeout + capDelayMs)
* With defaults (3, 500ms base, 5000ms cap) and a typical 15s per-attempt
* timeout from the caller's signal, worst case is ~3 × (15s + 5s) = 60s.
* Callers that want a tighter total bound should reduce `maxAttempts` or
* wrap `resilientFetch` in their own outer `AbortSignal.timeout()`.
*/
export async function resilientFetch(
input: string | URL,
init: RequestInit | undefined,
opts: ResilientFetchOptions = {},
): Promise<Response> {
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
const now = opts.now ?? (() => Date.now());
const breaker =
opts.breaker ?? getBreaker(opts.breakerKey ?? defaultBreakerKey(input), opts.breakerOptions);
const retryConfig = {
maxAttempts: opts.retry?.maxAttempts ?? DEFAULT_RETRY.maxAttempts,
baseDelayMs: opts.retry?.baseDelayMs ?? DEFAULT_RETRY.baseDelayMs,
capDelayMs: opts.retry?.capDelayMs ?? DEFAULT_RETRY.capDelayMs,
};
const sleep = opts.retry?.sleep ?? defaultSleep;
const random = opts.retry?.random ?? Math.random;
// Fail fast on an open breaker, before invoking fetch.
breaker.check();
for (let attempt = 0; attempt < retryConfig.maxAttempts; attempt++) {
let result: { kind: 'error'; err: unknown } | { kind: 'response'; resp: Response };
try {
// CodeQL js/server-side-request-forgery — flagged because `input`
// is caller-supplied. Suppressed: every concrete caller passes
// either a hardcoded URL constant (UNDERSTAND_QUICKLY_DISPATCH_URL,
// OpenRouter base URL) or a value derived from configuration
// (env vars, saved settings, the local backend URL). User-input
// request fields (e.g. PR title, repo name) never flow into
// `input`. Validating URL shape here would push false-positive
// rejection onto every caller — wrong layer for the check.
// lgtm[js/server-side-request-forgery]
// codeql[js/server-side-request-forgery]
const resp = await fetchImpl(input, init);
result = { kind: 'response', resp };
} catch (err) {
result = { kind: 'error', err };
}
const outcome = classifyOutcome(result, now);
switch (outcome.kind) {
case 'success':
breaker.recordSuccess();
return outcome.resp;
case 'terminal-client':
// 4xx: do not count as breaker failure (the server is healthy
// and rejecting our request — auth, scope, or routing). But
// also do NOT call recordSuccess: a 401 sandwiched between
// 5xx responses would otherwise erase the running outage
// signal. The breaker's neutral path leaves state untouched.
breaker.recordNeutral();
return outcome.resp;
case 'terminal-network':
// Either `AbortSignal.timeout()` fired locally OR an external
// caller cancelled the request via AbortController. The server
// never had a chance to answer; this reflects the user's
// network or an explicit cancel, not registry health. Don't
// punish the breaker AND don't reset its outage signal.
breaker.recordNeutral();
throw outcome.err;
case 'retryable-status':
if (attempt + 1 >= retryConfig.maxAttempts) {
breaker.recordFailure();
throw new ResilientFetchExhaustedError(outcome.resp);
}
await sleep(
computeBackoffMs(
attempt,
retryConfig.baseDelayMs,
retryConfig.capDelayMs,
outcome.afterMs,
random,
),
);
break;
case 'retryable-network':
if (attempt + 1 >= retryConfig.maxAttempts) {
breaker.recordFailure();
throw outcome.err;
}
await sleep(
computeBackoffMs(
attempt,
retryConfig.baseDelayMs,
retryConfig.capDelayMs,
undefined,
random,
),
);
break;
default: {
// Exhaustiveness guard. If a sixth `Outcome` kind is added in
// future, TypeScript will refuse to assign it to `never` and
// this line forces the maintainer to add an explicit arm
// rather than silently fall through to retry/no-retry behaviour.
const _exhaustive: never = outcome;
throw new Error(`resilientFetch: unhandled outcome ${JSON.stringify(_exhaustive)}`);
}
}
}
// Unreachable: every iteration of the loop either returns (success
// / terminal-client) or throws (terminal-network / retry exhaustion).
// The throw is here purely so TypeScript's control-flow analysis sees
// the function never falls off the end without producing `Promise<Response>`.
/* c8 ignore next 2 */
throw new Error('resilientFetch: retry loop terminated unexpectedly');
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Bounded retry helper with full-jitter exponential backoff.
*
* Runtime-agnostic: depends only on `setTimeout`, `Math.random`, and the
* Promise machinery — no Node-only imports. Safe to consume from CLI,
* server, or browser callers.
*
* Pattern reference: gitnexus/src/core/embeddings/http-client.ts. This
* helper is the upgraded form: classification is caller-supplied (so
* 4xx-vs-5xx-vs-timeout decisions live with the protocol that knows
* them), backoff is exponential with full jitter, and an optional
* `afterMs` lets callers honor `Retry-After` headers.
*/
export interface RetryOptions {
/** Initial delay before the first retry attempt, in milliseconds. */
baseDelayMs: number;
/** Upper bound on any single delay, in milliseconds. */
capDelayMs: number;
/** Total attempts including the first call. Must be >= 1. */
maxAttempts: number;
/**
* Decide whether to retry after a thrown error.
* Return `{retry:false}` to terminate immediately and rethrow.
* Return `{retry:true}` to retry with exponential-backoff jitter.
* Return `{retry:true, afterMs}` to wait at least `afterMs` (still
* subject to `capDelayMs`) — used by callers parsing `Retry-After`.
*/
isRetryable: (err: unknown, attempt: number) => RetryDecision;
/** Sleep override — defaults to `setTimeout`. Tests inject fake timers. */
sleep?: (ms: number) => Promise<void>;
/** Random override — defaults to `Math.random`. Tests inject seeded values. */
random?: () => number;
}
export type RetryDecision = { retry: false } | { retry: true; afterMs?: number };
const defaultSleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
/**
* Compute the delay before the next retry attempt.
*
* - When the caller specifies `afterMs` (e.g., from `Retry-After`), use
* `min(afterMs, capDelayMs)` so a misbehaving server can't pin the
* client for an arbitrarily long wait.
* - Otherwise compute full-jitter exponential backoff:
* `random() * min(cap, base * 2^attempt)`. Full jitter (rather than
* "equal jitter") avoids retry-storm thundering herd, per AWS
* guidance on backoff strategies.
*/
export function computeBackoffMs(
attempt: number,
baseDelayMs: number,
capDelayMs: number,
afterMs: number | undefined,
random: () => number,
): number {
if (afterMs !== undefined) {
return Math.min(Math.max(0, afterMs), capDelayMs);
}
const exponential = baseDelayMs * Math.pow(2, attempt);
const upper = Math.min(capDelayMs, exponential);
return Math.floor(random() * upper);
}
/**
* Execute `fn` with bounded retries.
*
* The classification of "retryable" is the caller's responsibility — see
* `resilient-fetch.ts` for the GitHub-dispatch-specific rules. This
* helper is the mechanical retry loop only.
*/
export async function withRetry<T>(
fn: (attempt: number) => Promise<T>,
opts: RetryOptions,
): Promise<T> {
if (opts.maxAttempts < 1) {
throw new Error(`withRetry: maxAttempts must be >= 1, got ${opts.maxAttempts}`);
}
const sleep = opts.sleep ?? defaultSleep;
const random = opts.random ?? Math.random;
let lastError: unknown;
for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
try {
return await fn(attempt);
} catch (err) {
lastError = err;
const decision = opts.isRetryable(err, attempt);
if (!decision.retry) throw err;
// Don't sleep after the final attempt.
if (attempt + 1 >= opts.maxAttempts) break;
const delayMs = computeBackoffMs(
attempt,
opts.baseDelayMs,
opts.capDelayMs,
decision.afterMs,
random,
);
if (delayMs > 0) await sleep(delayMs);
}
}
throw lastError;
}
@@ -0,0 +1,151 @@
/**
* Understand-Quickly registry integration helpers.
*
* Pure, runtime-agnostic logic for opting in to publishing a GitNexus
* index to the [`looptech-ai/understand-quickly`](https://github.com/looptech-ai/understand-quickly)
* registry. Lives in `gitnexus-shared` so both the Node CLI and any
* future browser-side surface can construct identical dispatch payloads.
*
* Network I/O lives in the CLI command (`gitnexus/src/cli/publish.ts`)
* to keep this module free of Node-only imports — see the comment at
* the top of `gitnexus-shared/src/graph/types.ts`.
*
* The protocol contract (single dispatch event, no graph upload) is
* documented at:
* https://github.com/looptech-ai/understand-quickly/blob/main/docs/integrations/protocol.md
*/
/**
* URL of the registry repo's repository_dispatch endpoint. Hardcoded
* because the registry is the canonical home for this integration —
* users who want a private registry can fork and patch.
*/
export const UNDERSTAND_QUICKLY_DISPATCH_URL =
'https://api.github.com/repos/looptech-ai/understand-quickly/dispatches';
/**
* Event type the registry's sync workflow listens for.
* See `looptech-ai/understand-quickly/.github/workflows/sync.yml`.
*/
export const UNDERSTAND_QUICKLY_EVENT_TYPE = 'sync-entry';
/** Environment variable that gates the dispatch. */
export const UNDERSTAND_QUICKLY_TOKEN_ENV = 'UNDERSTAND_QUICKLY_TOKEN';
export interface UqDispatchPayload {
event_type: typeof UNDERSTAND_QUICKLY_EVENT_TYPE;
client_payload: {
/** `<owner>/<repo>` shape — must match the registered entry. */
id: string;
};
}
/**
* Build the JSON body for the `repository_dispatch` ping. Pure — no
* env reads, no network. Validates that `id` looks like `owner/repo`
* (one slash, no whitespace, both halves non-empty) so a misconfigured
* caller fails loudly before the round-trip.
*/
export function buildUqDispatchPayload(id: string): UqDispatchPayload {
if (!isValidOwnerRepo(id)) {
throw new Error(
`[understand-quickly] expected id of the form "owner/repo", got "${id}". ` +
`The registry uses this string to look up your entry in registry.json — ` +
`it must match the GitHub owner/repo of the source code, not a local path.`,
);
}
return {
event_type: UNDERSTAND_QUICKLY_EVENT_TYPE,
client_payload: { id },
};
}
/**
* `owner/repo` validation. Conservative on purpose: GitHub's actual
* naming rules are looser, but we want to catch local paths
* (`/Users/...`), bare slugs (`my-repo`), and accidental whitespace.
*
* Matches GitHub's published slug rules:
* owner: starts with alnum, then alnum/hyphen only, must end with
* alnum (no trailing hyphen — GitHub rejects this at account
* creation, so a `my-org-/repo` input would otherwise pass us
* and 422 from GitHub). No underscore, no dot. Length cap 39.
* repo: any of alnum/dot/hyphen/underscore. Length cap 100.
*/
export function isValidOwnerRepo(id: string): boolean {
return /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?\/[A-Za-z0-9._-]{1,100}$/.test(id);
}
/**
* Strip a single trailing `.git` (case-insensitive) and any trailing
* slashes from a URL-ish string. Bounded linear: each character is
* visited at most twice, no backtracking.
*
* Replaces `s.replace(/\.git\/*$/i, '').replace(/\/+$/, '')` which
* CodeQL's polynomial-regex check (codeql/js/polynomial-redos) flags as
* a worst-case O(n²) on adversarial input like "////.../x".
*/
export function stripGitSuffix(input: string): string {
let end = input.length;
// Trim trailing '/'.
while (end > 0 && input.charCodeAt(end - 1) === 0x2f) end--;
// Drop one trailing '.git' (case-insensitive).
if (end >= 4) {
const tail = input.slice(end - 4, end).toLowerCase();
if (tail === '.git') end -= 4;
}
// Trim trailing '/' that may have sat between '.git' and the rest.
while (end > 0 && input.charCodeAt(end - 1) === 0x2f) end--;
return input.slice(0, end);
}
/**
* Parse `owner/repo` out of a git remote URL. Mirrors the heuristic in
* `gitnexus/src/storage/git.ts:parseRepoNameFromUrl` but keeps both
* halves so we can build a registry id. Returns `null` on shapes we
* don't recognise.
*
* Examples:
* git@github.com:looptech-ai/understand-quickly.git
* https://github.com/looptech-ai/understand-quickly
* ssh://git@github.com/looptech-ai/understand-quickly.git
*/
export function parseOwnerRepoFromRemote(url: string | null | undefined): string | null {
if (!url) return null;
const trimmed = url.trim();
if (!trimmed) return null;
// Strip a trailing `.git` (case-insensitive) and any trailing slashes
// so https://h/o/r and https://h/o/r.git collapse to the same id.
// Bounded-linear helper avoids the polynomial-regex CodeQL alert.
const stripped = stripGitSuffix(trimmed);
// SCP-form SSH (`git@host:owner/repo`). Capture host so we can reject
// non-GitHub remotes — a GitLab origin like
// `https://gitlab.example.com/group/sub/project.git` would otherwise
// silently dispatch the wrong id (LOW 9).
const ssh = stripped.match(/^[^@]+@([^:]+):([^/]+)\/([^/]+)$/);
if (ssh) {
const host = ssh[1].toLowerCase();
if (host !== 'github.com' && host !== 'www.github.com') return null;
return `${ssh[2]}/${ssh[3]}`;
}
// URL forms (https://, ssh://, git://, file://) — last two path segments.
const url2 = stripped.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/([^/]+)\/(.+)$/);
if (url2) {
// Strip optional `userinfo@` (e.g. `ssh://git@github.com/...`).
const authority = url2[1];
const atIdx = authority.lastIndexOf('@');
const hostAndPort = atIdx >= 0 ? authority.slice(atIdx + 1) : authority;
// Strip `:port` suffix if present.
const colonIdx = hostAndPort.indexOf(':');
const host = (colonIdx >= 0 ? hostAndPort.slice(0, colonIdx) : hostAndPort).toLowerCase();
if (host !== 'github.com' && host !== 'www.github.com') return null;
const segments = url2[2].split('/').filter(Boolean);
if (segments.length >= 2) {
const [owner, repo] = segments.slice(-2);
return `${owner}/${repo}`;
}
}
return null;
}
@@ -40,11 +40,27 @@ export interface MethodDispatchIndex {
readonly mroByOwnerDefId: ReadonlyMap<DefId, readonly DefId[]>;
/** Interfaces / traits → classes that implement them. */
readonly implsByInterfaceDefId: ReadonlyMap<DefId, readonly DefId[]>;
/**
* Optional parallel MRO view that EXCLUDES mixin-like augmentation
* (e.g., PHP traits). Populated only when the input supplies
* `computeExtendsOnlyMro`. Used by the super-branch dispatch in
* `receiver-bound-calls` so that `parent::method()` walks the
* inheritance chain only, not the trait-augmented one. Undefined for
* languages without mixin-like semantics — callers should fall back
* to `mroFor` when this is missing.
*/
readonly extendsOnlyMroByOwnerDefId?: ReadonlyMap<DefId, readonly DefId[]>;
/** `mroByOwnerDefId.get`, with an empty frozen array on miss. */
mroFor(ownerDefId: DefId): readonly DefId[];
/** `implsByInterfaceDefId.get`, with an empty frozen array on miss. */
implementorsOf(interfaceDefId: DefId): readonly DefId[];
/**
* `extendsOnlyMroByOwnerDefId.get`, with an empty frozen array on miss.
* Undefined when `extendsOnlyMroByOwnerDefId` was not populated; callers
* should treat this as equivalent to `mroFor` for non-mixin languages.
*/
readonly extendsOnlyMroFor?: (ownerDefId: DefId) => readonly DefId[];
}
export interface MethodDispatchInput {
@@ -81,12 +97,25 @@ export interface MethodDispatchInput {
* write-wins policy and fires at most once per unique owner.
*/
readonly implementsOf: (ownerDefId: DefId) => readonly DefId[];
/**
* Optional: return the EXTENDS-only ancestor chain for `ownerDefId`,
* excluding the owner itself AND any mixin-like augmentation (e.g.,
* PHP traits). Languages without mixin semantics leave this undefined
* and the index's `extendsOnlyMroByOwnerDefId` stays unpopulated.
*
* Same contract as `computeMro`: pure, deterministic, `[]` on no parents.
* Called at most once per unique owner (first-write-wins).
*/
readonly computeExtendsOnlyMro?: (ownerDefId: DefId) => readonly DefId[];
}
// ─── Builder ────────────────────────────────────────────────────────────────
export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDispatchIndex {
const mroByOwnerDefId = new Map<DefId, readonly DefId[]>();
const extendsOnlyByOwnerDefId = input.computeExtendsOnlyMro
? new Map<DefId, readonly DefId[]>()
: undefined;
const implsBuilding = new Map<DefId, DefId[]>();
const implsSeen = new Map<DefId, Set<DefId>>();
@@ -97,6 +126,14 @@ export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDisp
const chain = input.computeMro(ownerId);
mroByOwnerDefId.set(ownerId, Object.freeze(chain.slice()));
}
if (
input.computeExtendsOnlyMro !== undefined &&
extendsOnlyByOwnerDefId !== undefined &&
!extendsOnlyByOwnerDefId.has(ownerId)
) {
const extOnly = input.computeExtendsOnlyMro(ownerId);
extendsOnlyByOwnerDefId.set(ownerId, Object.freeze(extOnly.slice()));
}
for (const ifaceId of input.implementsOf(ownerId)) {
let seen = implsSeen.get(ifaceId);
@@ -121,7 +158,7 @@ export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDisp
implsByInterfaceDefId.set(ifaceId, Object.freeze(owners.slice()));
}
return wrapIndex(mroByOwnerDefId, implsByInterfaceDefId);
return wrapIndex(mroByOwnerDefId, implsByInterfaceDefId, extendsOnlyByOwnerDefId);
}
// ─── Internal ───────────────────────────────────────────────────────────────
@@ -131,8 +168,9 @@ const EMPTY: readonly DefId[] = Object.freeze([]);
function wrapIndex(
mroByOwnerDefId: Map<DefId, readonly DefId[]>,
implsByInterfaceDefId: Map<DefId, readonly DefId[]>,
extendsOnlyMroByOwnerDefId: Map<DefId, readonly DefId[]> | undefined,
): MethodDispatchIndex {
return {
const base: MethodDispatchIndex = {
mroByOwnerDefId,
implsByInterfaceDefId,
mroFor(ownerDefId: DefId): readonly DefId[] {
@@ -142,4 +180,14 @@ function wrapIndex(
return implsByInterfaceDefId.get(interfaceDefId) ?? EMPTY;
},
};
if (extendsOnlyMroByOwnerDefId !== undefined) {
return {
...base,
extendsOnlyMroByOwnerDefId,
extendsOnlyMroFor(ownerDefId: DefId): readonly DefId[] {
return extendsOnlyMroByOwnerDefId.get(ownerDefId) ?? EMPTY;
},
};
}
return base;
}
@@ -423,13 +423,30 @@ function applyArityFilter(
}
let anyCompatible = false;
let anyUnknown = false;
for (const state of perCandidate.values()) {
const verdict = arityFn(callsite, state.def);
state.signals.arityVerdict = verdict;
if (verdict === 'compatible') anyCompatible = true;
else if (verdict === 'unknown') anyUnknown = true;
}
if (!anyCompatible) return;
// When ALL candidates are 'incompatible' (none compatible, none unknown),
// the call is genuinely arity-broken — drop every candidate so the
// registry returns no resolution. This matches the PHP variadic case
// f(int $req, ...$rest) called with zero args: every candidate definitively
// rejects, and emitting an edge to a definitively-rejected callable is
// a false positive. When some candidates are 'unknown' (missing metadata),
// keep the set so downstream evidence can break the tie — that's the
// original safety-fallback behavior.
if (!anyCompatible) {
if (!anyUnknown) {
for (const defId of perCandidate.keys()) {
perCandidate.delete(defId);
}
}
return;
}
// Filter: when at least one compatible candidate exists, drop incompatibles.
for (const [defId, state] of perCandidate) {
+13
View File
@@ -0,0 +1,13 @@
/**
* Test-only helpers.
*
* Symbols here are reachable from `gitnexus-shared/test-helpers` so test
* suites can reset shared registries or exercise internal classifiers,
* but they are deliberately NOT re-exported from the main `gitnexus-shared`
* barrel. Production consumers should never import this module — calling
* `__resetBreakerRegistry__()` from a tool implementation would silently
* nuke every circuit breaker process-wide.
*/
export { __resetBreakerRegistry__ } from './integrations/circuit-breaker.js';
export { classifyOutcome } from './integrations/resilient-fetch.js';
+53 -195
View File
@@ -8,7 +8,7 @@
"name": "gitnexus",
"version": "0.0.0",
"dependencies": {
"@langchain/anthropic": "^1.3.28",
"@langchain/anthropic": "^1.3.29",
"@langchain/core": "^1.1.44",
"@langchain/google-genai": "^2.1.28",
"@langchain/langgraph": "^1.2.9",
@@ -26,10 +26,10 @@
"graphology-layout-forceatlas2": "^0.10.1",
"graphology-layout-noverlap": "^0.4.2",
"graphology-utils": "^2.3.0",
"langchain": "^1.3.4",
"langchain": "^1.3.5",
"lru-cache": "^11.2.4",
"lucide-react": "^1.11.0",
"mermaid": "^11.14.0",
"lucide-react": "^1.14.0",
"mermaid": "^11.15.0",
"mnemonist": "^0.39.0",
"pandemonium": "^2.4.0",
"react": "^19.2.5",
@@ -49,7 +49,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/dompurify": "^3.0.5",
"@types/dompurify": "^3.2.0",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -95,9 +95,9 @@
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.90.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.90.0.tgz",
"integrity": "sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg==",
"version": "0.91.1",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz",
"integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1"
@@ -528,41 +528,10 @@
"integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==",
"license": "MIT"
},
"node_modules/@chevrotain/cst-dts-gen": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz",
"integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/gast": "12.0.0",
"@chevrotain/types": "12.0.0"
}
},
"node_modules/@chevrotain/gast": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz",
"integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/types": "12.0.0"
}
},
"node_modules/@chevrotain/regexp-to-ast": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz",
"integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==",
"license": "Apache-2.0"
},
"node_modules/@chevrotain/types": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz",
"integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==",
"license": "Apache-2.0"
},
"node_modules/@chevrotain/utils": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz",
"integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==",
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz",
"integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
"license": "Apache-2.0"
},
"node_modules/@cspotcode/source-map-support": {
@@ -1396,25 +1365,25 @@
}
},
"node_modules/@langchain/anthropic": {
"version": "1.3.28",
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.3.28.tgz",
"integrity": "sha512-gOF8oXJL8xDdYes2KXNI9vFm/9TldBBBHOjuCdt27kganVaQKzLvTw5kV6R4mjbnFagV5CWteNH7APLZYCpdwg==",
"version": "1.3.29",
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.3.29.tgz",
"integrity": "sha512-ep1qBIcV07bajsg3fDqMd39rYwoRLOEK/6lk+MCxlm1YB5SRoKKJAZANrblQ/4RYhZJnxf95c6BSQu8VoNbVAQ==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.90.0",
"@anthropic-ai/sdk": "^0.91.1",
"zod": "^3.25.76 || ^4"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@langchain/core": "^1.1.42"
"@langchain/core": "^1.1.45"
}
},
"node_modules/@langchain/core": {
"version": "1.1.44",
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.44.tgz",
"integrity": "sha512-RePW1IjGCHr9ua2vcby3aE8mOOz3EnwDZxMEGbNDT91kf14eqkJqxDXvaZFviGdcN9DTrxM5RPQNAHmwSm4tbg==",
"version": "1.1.45",
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.45.tgz",
"integrity": "sha512-Y/wvuglLTMKJahkl4QD9dBIdF/z/CxZJWdTfHJF/q2jtlJtoFf6Mb5JpGxZfsi3mBY6NSG941FSLTcqhCKrhBA==",
"license": "MIT",
"dependencies": {
"@cfworker/json-schema": "^4.0.2",
@@ -1679,12 +1648,12 @@
}
},
"node_modules/@mermaid-js/parser": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz",
"integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz",
"integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==",
"license": "MIT",
"dependencies": {
"langium": "^4.0.0"
"@chevrotain/types": "~11.1.1"
}
},
"node_modules/@napi-rs/wasm-runtime": {
@@ -2800,13 +2769,14 @@
"license": "MIT"
},
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz",
"integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==",
"deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
"dompurify": "*"
}
},
"node_modules/@types/estree": {
@@ -2909,8 +2879,8 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"devOptional": true,
"license": "MIT"
"license": "MIT",
"optional": true
},
"node_modules/@types/unist": {
"version": "3.0.3",
@@ -3642,34 +3612,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chevrotain": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz",
"integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/cst-dts-gen": "12.0.0",
"@chevrotain/gast": "12.0.0",
"@chevrotain/regexp-to-ast": "12.0.0",
"@chevrotain/types": "12.0.0",
"@chevrotain/utils": "12.0.0"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/chevrotain-allstar": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.1.tgz",
"integrity": "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==",
"license": "MIT",
"dependencies": {
"lodash-es": "^4.17.21"
},
"peerDependencies": {
"chevrotain": "^12.0.0"
}
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@@ -4627,6 +4569,16 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.46.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz",
"integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz",
@@ -5643,53 +5595,21 @@
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
},
"node_modules/langchain": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.3.4.tgz",
"integrity": "sha512-umrD+ZC6vr0Q0U1lC5PoIMMQqgnP+7QhaIdAXCeZiSX2GPVBiVR7Ed2ZR+3MPvz1yWrRtIm17wPaovkND+wOXg==",
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.3.5.tgz",
"integrity": "sha512-QSB8TEo6G1tWupgNt1Osm8ylLLoOMq1lLw5NeijnIwRPI5BqdBjUn4/U8usbjEJJcQnbXSK2qTKgTx7zvblnBw==",
"license": "MIT",
"dependencies": {
"@langchain/langgraph": "^1.2.9",
"@langchain/langgraph-checkpoint": "^1.0.1",
"langsmith": ">=0.5.0 <1.0.0",
"uuid": "^11.1.0",
"zod": "^3.25.76 || ^4"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@langchain/core": "^1.1.41"
}
},
"node_modules/langchain/node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/langium": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/langium/-/langium-4.2.2.tgz",
"integrity": "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==",
"license": "MIT",
"dependencies": {
"@chevrotain/regexp-to-ast": "~12.0.0",
"chevrotain": "~12.0.0",
"chevrotain-allstar": "~0.4.1",
"vscode-languageserver": "~9.0.1",
"vscode-languageserver-textdocument": "~1.0.11",
"vscode-uri": "~3.1.0"
},
"engines": {
"node": ">=20.10.0",
"npm": ">=10.2.3"
"@langchain/core": "^1.1.42"
}
},
"node_modules/langsmith": {
@@ -6041,9 +5961,9 @@
}
},
"node_modules/lucide-react": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.11.0.tgz",
"integrity": "sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g==",
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
"integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -6435,14 +6355,14 @@
}
},
"node_modules/mermaid": {
"version": "11.14.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz",
"integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==",
"version": "11.15.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz",
"integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==",
"license": "MIT",
"dependencies": {
"@braintree/sanitize-url": "^7.1.1",
"@iconify/utils": "^3.0.2",
"@mermaid-js/parser": "^1.1.0",
"@mermaid-js/parser": "^1.1.1",
"@types/d3": "^7.4.3",
"@upsetjs/venn.js": "^2.0.0",
"cytoscape": "^3.33.1",
@@ -6453,27 +6373,14 @@
"dagre-d3-es": "7.0.14",
"dayjs": "^1.11.19",
"dompurify": "^3.3.1",
"es-toolkit": "^1.45.1",
"katex": "^0.16.25",
"khroma": "^2.1.0",
"lodash-es": "^4.17.23",
"marked": "^16.3.0",
"roughjs": "^4.6.6",
"stylis": "^4.3.6",
"ts-dedent": "^2.2.0",
"uuid": "^11.1.0"
}
},
"node_modules/mermaid/node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
"uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0"
}
},
"node_modules/micromark": {
@@ -8888,55 +8795,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/vscode-jsonrpc": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vscode-languageserver": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
"integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
"license": "MIT",
"dependencies": {
"vscode-languageserver-protocol": "3.17.5"
},
"bin": {
"installServerIntoExtension": "bin/installServerIntoExtension"
}
},
"node_modules/vscode-languageserver-protocol": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
"license": "MIT",
"dependencies": {
"vscode-jsonrpc": "8.2.0",
"vscode-languageserver-types": "3.17.5"
}
},
"node_modules/vscode-languageserver-textdocument": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
"license": "MIT"
},
"node_modules/vscode-languageserver-types": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
"license": "MIT"
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+5 -5
View File
@@ -19,7 +19,7 @@
},
"dependencies": {
"gitnexus-shared": "file:../gitnexus-shared",
"@langchain/anthropic": "^1.3.28",
"@langchain/anthropic": "^1.3.29",
"@langchain/core": "^1.1.44",
"@langchain/google-genai": "^2.1.28",
"@langchain/langgraph": "^1.2.9",
@@ -36,10 +36,10 @@
"graphology-layout-forceatlas2": "^0.10.1",
"graphology-layout-noverlap": "^0.4.2",
"graphology-utils": "^2.3.0",
"langchain": "^1.3.4",
"langchain": "^1.3.5",
"lru-cache": "^11.2.4",
"lucide-react": "^1.11.0",
"mermaid": "^11.14.0",
"lucide-react": "^1.14.0",
"mermaid": "^11.15.0",
"mnemonist": "^0.39.0",
"pandemonium": "^2.4.0",
"react": "^19.2.5",
@@ -59,7 +59,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/dompurify": "^3.0.5",
"@types/dompurify": "^3.2.0",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -20,6 +20,7 @@ import {
ProviderConfig,
} from './types';
import { DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OLLAMA_BASE_URL } from '../../config/ui-constants';
import { resilientFetch } from 'gitnexus-shared';
const STORAGE_KEY = 'gitnexus-llm-settings';
@@ -407,7 +408,10 @@ export const getAvailableModels = (provider: LLMProvider): string[] => {
*/
export const fetchOpenRouterModels = async (): Promise<Array<{ id: string; name: string }>> => {
try {
const response = await fetch(`${DEFAULT_OPENROUTER_BASE_URL}/models`);
const response = await resilientFetch(`${DEFAULT_OPENROUTER_BASE_URL}/models`, undefined, {
breakerKey: 'openrouter-models',
retry: { maxAttempts: 2, baseDelayMs: 500, capDelayMs: 2_000 },
});
if (!response.ok) throw new Error('Failed to fetch models');
const data = await response.json();
return data.data.map((model: any) => ({
+98 -13
View File
@@ -7,6 +7,7 @@
*/
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from 'gitnexus-shared';
// ── Types ──────────────────────────────────────────────────────────────────
@@ -204,8 +205,32 @@ export function streamSSE<T = unknown>(url: string, handlers: SSEHandlers<T>): A
let _backendUrl = 'http://localhost:4747';
/**
* Validate that a backend URL is a safe http:// or https:// origin before
* storing it as the fetch target base (CodeQL js/client-side-request-forgery).
*
* Throws if the URL uses a non-HTTP scheme (e.g. javascript:, data:, file://).
* All other well-formed http/https URLs are accepted — the client intentionally
* supports connecting to remote GitNexus servers, not just localhost.
*/
export function validateBackendUrl(url: string): void {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
// Do not echo raw input — it may contain credentials.
throw new Error('Invalid backend URL: must be a well-formed http:// or https:// URL');
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
// Use parsed.protocol only (scheme), not the full URL, to avoid leaking credentials.
throw new Error(`Backend URL must use http:// or https:// (got ${parsed.protocol})`);
}
}
export const setBackendUrl = (url: string): void => {
_backendUrl = url.replace(/\/$/, '');
const trimmed = url.replace(/\/$/, '');
validateBackendUrl(trimmed);
_backendUrl = trimmed;
};
export const getBackendUrl = (): string => _backendUrl;
@@ -237,29 +262,91 @@ export function normalizeServerUrl(input: string): string {
const DEFAULT_TIMEOUT_MS = 30_000;
const PROBE_TIMEOUT_MS = 2_000;
/** Idempotent HTTP methods. Other verbs (POST, PATCH, PUT, DELETE) get
* a single-attempt retry budget by default to avoid duplicate side
* effects on retry — a POST that 5xx'd may have already executed
* server-side. Callers that have idempotency keys or otherwise know
* their mutation is safe to retry can opt in via `forceRetry`. */
const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
const fetchWithTimeout = async (
url: string,
init: RequestInit = {},
timeoutMs: number = DEFAULT_TIMEOUT_MS,
/**
* Force a retry budget on non-idempotent methods. Default false.
* Pass true only when the endpoint is known-idempotent (e.g. DELETE
* of a known-deleted resource — second call is a 404 / no-op) AND
* the duplicate-side-effect window is acceptable.
*/
forceRetry = false,
): Promise<Response> => {
const controller = new AbortController();
// Merge external signal if provided
// Merge the external caller signal (if any) with an
// `AbortSignal.timeout()` so a timer-fired abort produces a
// `DOMException` with `name === 'TimeoutError'` — which
// `resilientFetch` correctly classifies as terminal-network (no
// retry, no breaker hit). A manual `AbortController.abort()` would
// produce `name === 'AbortError'` and route through the
// retryable-network branch, which mis-penalizes the breaker for
// user-side network slowness.
const timeoutSignal = AbortSignal.timeout(timeoutMs);
const externalSignal = init.signal;
if (externalSignal) {
externalSignal.addEventListener('abort', () => controller.abort());
const signal = externalSignal ? AbortSignal.any([timeoutSignal, externalSignal]) : timeoutSignal;
const method = (init.method ?? 'GET').toUpperCase();
const isIdempotent = IDEMPOTENT_METHODS.has(method);
const maxAttempts = isIdempotent || forceRetry ? 2 : 1;
// Key the breaker by the current backend origin so switching backend
// URLs (e.g. recovering from a flapping local server by pointing at
// a different host) gives the new origin a fresh breaker state. A
// single shared `'web-backend'` key would otherwise leave a user
// locked out for the full cooldown after one bad host trips the
// circuit. The malformed-URL fallback is defensive — `setBackendUrl`
// normalizes input, so this branch shouldn't fire in practice.
let breakerKey: string;
try {
breakerKey = `web-backend:${new URL(_backendUrl).origin}`;
} catch {
breakerKey = 'web-backend:invalid';
}
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { ...init, signal: controller.signal });
// Bounded retries + 5xx/429 handling are delegated to resilientFetch.
// Method-aware budget: idempotent verbs retry once on transient
// backend failures; mutations (POST/PATCH/PUT/DELETE) default to
// single-attempt to avoid duplicate side effects.
const response = await resilientFetch(
url,
{ ...init, signal },
{
breakerKey,
retry: { maxAttempts, baseDelayMs: 250, capDelayMs: 1500 },
},
);
return response;
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'AbortError') {
if (externalSignal?.aborted) {
throw new BackendError('Request aborted', 0, 'network');
}
if (error instanceof CircuitOpenError) {
throw new BackendError(
`GitNexus backend at ${_backendUrl} is unhealthy; retry in ${Math.ceil(error.retryAfterMs / 1000)}s`,
0,
'network',
);
}
if (error instanceof ResilientFetchExhaustedError) {
// Fall through to caller — surface the raw response so assertOk
// can craft the BackendError with the right code.
return error.response;
}
if (error instanceof DOMException && error.name === 'TimeoutError') {
throw new BackendError(`Request to ${url} timed out after ${timeoutMs}ms`, 0, 'timeout');
}
if (error instanceof DOMException && error.name === 'AbortError') {
// External caller-driven cancellation — `timeoutSignal` would
// have surfaced as TimeoutError above, so this branch covers
// only the externally-aborted case.
throw new BackendError('Request aborted', 0, 'network');
}
if (error instanceof TypeError) {
throw new BackendError(
`Network error reaching GitNexus backend at ${_backendUrl}: ${error.message}`,
@@ -268,8 +355,6 @@ const fetchWithTimeout = async (
);
}
throw error;
} finally {
clearTimeout(timer);
}
};
@@ -0,0 +1,110 @@
/**
* Method-aware retry budget + timeout-as-TimeoutError verification for
* backend-client's `fetchWithTimeout`.
*
* Closes review findings on PR #1448:
* - Non-idempotent POST/DELETE must NOT be retried by default —
* a 5xx on `startAnalyze` could otherwise start a duplicate job.
* - Timer-fired timeout must surface as `DOMException(name='TimeoutError')`,
* not `AbortError`, so resilientFetch routes it through the
* terminal-network branch (no retry, no breaker hit).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getBreaker } from 'gitnexus-shared';
import { __resetBreakerRegistry__ } from 'gitnexus-shared/test-helpers';
import { fetchRepos, setBackendUrl, startAnalyze } from '../../src/services/backend-client';
const BASE = 'http://localhost:4747';
describe('backend-client retry budget (method-aware)', () => {
beforeEach(() => {
__resetBreakerRegistry__();
setBackendUrl(BASE);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('GET retries once on transient 503 (idempotent verb)', async () => {
let n = 0;
const fetchMock = vi.fn(async () => {
n += 1;
if (n === 1) return new Response('boom', { status: 503 });
return new Response('[]', {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
});
vi.stubGlobal('fetch', fetchMock);
const repos = await fetchRepos();
expect(repos).toEqual([]);
// 1 retry budget on idempotent GET → 2 total fetch calls.
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('POST does NOT retry on 503 by default (non-idempotent verb)', async () => {
const fetchMock = vi.fn(async () => new Response('boom', { status: 503 }));
vi.stubGlobal('fetch', fetchMock);
await expect(startAnalyze({ path: '/tmp/repo' })).rejects.toBeTruthy();
// Single attempt — never duplicates a job-start POST.
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('switching backend URL after a circuit opens reaches a fresh breaker (U3)', async () => {
// Pre-open the breaker for host-A by directly recording 3 failures.
setBackendUrl('http://host-a.test:4747');
const aKey = 'web-backend:http://host-a.test:4747';
const breakerA = getBreaker(aKey);
breakerA.recordFailure();
breakerA.recordFailure();
breakerA.recordFailure();
expect(breakerA.getState()).toBe('open');
// Switch to host-B and make a request — must succeed against the
// new origin without tripping the host-A circuit. Under the old
// single-key behaviour the call would throw CircuitOpenError.
setBackendUrl('http://host-b.test:4747');
const fetchMock = vi.fn(
async () =>
new Response('[]', {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
const repos = await fetchRepos();
expect(repos).toEqual([]);
expect(fetchMock).toHaveBeenCalledTimes(1);
// Host-A's breaker is still open in cooldown.
expect(breakerA.getState()).toBe('open');
// Host-B has its own (fresh) breaker.
const bKey = 'web-backend:http://host-b.test:4747';
expect(getBreaker(bKey).getState()).toBe('closed');
expect(getBreaker(bKey).getConsecutiveFailures()).toBe(0);
});
it('breaker not incremented when timeout fires (TimeoutError, not AbortError)', async () => {
// Reject directly with a TimeoutError DOMException, mimicking what
// `fetch` produces when its `AbortSignal.timeout()`-wired signal
// fires. The real-fetch path goes signal.reason → reject(reason);
// we shortcut that here so the test doesn't have to wait the
// 30-second default timeout.
const fetchMock = vi.fn(async () => {
throw new DOMException('aborted by timeout', 'TimeoutError');
});
vi.stubGlobal('fetch', fetchMock);
await expect(fetchRepos()).rejects.toMatchObject({ code: 'timeout' });
// The breaker must not have been penalized for a local timeout.
expect(getBreaker(`web-backend:${BASE}`).getConsecutiveFailures()).toBe(0);
// Timeout is terminal — no retry attempted.
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
@@ -1,5 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fetchGraph, normalizeServerUrl, setBackendUrl } from '../../src/services/backend-client';
import {
fetchGraph,
getBackendUrl,
normalizeServerUrl,
setBackendUrl,
validateBackendUrl,
} from '../../src/services/backend-client';
describe('normalizeServerUrl', () => {
it('adds http:// to localhost', () => {
@@ -165,3 +171,61 @@ describe('fetchGraph', () => {
});
});
});
describe('validateBackendUrl', () => {
it('allows http:// URLs', () => {
expect(() => validateBackendUrl('http://localhost:4747')).not.toThrow();
expect(() => validateBackendUrl('http://127.0.0.1:4747')).not.toThrow();
});
it('allows https:// URLs', () => {
expect(() => validateBackendUrl('https://gitnexus.example.com')).not.toThrow();
expect(() => validateBackendUrl('https://my-server.internal:4747')).not.toThrow();
});
it('rejects non-http schemes', () => {
expect(() => validateBackendUrl('javascript:alert(1)')).toThrow('must use http:// or https://');
expect(() => validateBackendUrl('file:///etc/passwd')).toThrow('must use http:// or https://');
expect(() => validateBackendUrl('data:text/plain,evil')).toThrow(
'must use http:// or https://',
);
});
it('rejects malformed URLs', () => {
expect(() => validateBackendUrl('not-a-url')).toThrow('Invalid backend URL');
});
it('does not include the raw URL in error messages (credential hygiene)', () => {
const urlWithCreds = 'javascript:alert("sk-secret")';
let msg = '';
try {
validateBackendUrl(urlWithCreds);
} catch (e) {
msg = (e as Error).message;
}
expect(msg).not.toContain('sk-secret');
expect(msg).not.toContain(urlWithCreds);
});
});
describe('setBackendUrl', () => {
it('accepts valid http URLs', () => {
expect(() => setBackendUrl('http://localhost:4747')).not.toThrow();
});
it('accepts valid https URLs', () => {
expect(() => setBackendUrl('https://my-server.example.com')).not.toThrow();
});
it('rejects non-http/https schemes', () => {
expect(() => setBackendUrl('javascript:alert(1)')).toThrow('must use http:// or https://');
expect(() => setBackendUrl('file:///etc/passwd')).toThrow('must use http:// or https://');
});
it('does not mutate _backendUrl when validation fails', () => {
setBackendUrl('http://localhost:4747');
expect(() => setBackendUrl('javascript:alert(1)')).toThrow();
// State must be preserved — validation must happen before the assignment
expect(getBackendUrl()).toBe('http://localhost:4747');
});
});
+67
View File
@@ -4,6 +4,73 @@ All notable changes to GitNexus will be documented in this file.
## [Unreleased]
## [1.6.4] - 2026-05-10
### Added
- **`gitnexus publish`** — opt-in command to push your indexed graph to the understand-quickly registry for shareable browsing (#1425)
- **`IncludeExtractor` for C++** — cross-repo include tracking joins the group contract pipeline (#1156)
- **Unreal Engine C++ support** — strips reflection macros (`UCLASS`, `UFUNCTION`, `UPROPERTY`, etc.) before tree-sitter parses, so UE projects index cleanly (#1439)
- **Thrift contracts extractor** — group-mode contract detection for Apache Thrift IDL (#1234)
- **Workspace extractors for Node, Python, Go, Java, Elixir** — group-mode auto-discovery of cross-package boundaries (#1260)
- **Rust workspace cross-crate contracts** — auto-discovery of `[workspace]` member crates and their cross-crate links (#1256)
- **Go scope-resolution hooks** — Go joins Python / C# / TypeScript on the registry-primary RFC #909 path (#1302)
- **TypeScript registry-primary scope resolution (Ring 3)** — TypeScript fully migrated to scope-based resolution (#1050)
- **Configurable group cross-link path exclusions** — reduces false-positive contract links in vendored / monorepo trees (#1093)
- **MCP tool safety annotations** — every MCP tool advertises read-only / mutating semantics so hosts can prompt appropriately (#1127)
- **`--embeddings <limit>` opt-in cap** — bound the embeddings pass on huge graphs (closes #382, #1375)
- **Pino structured logger** — replaces ad-hoc console output across the core with structured JSON logs (with pretty-print for TTY) (#1336)
- **Shared resilient-fetch helper** — single retries + circuit breaker module reused by HF / Docker / publish flows (#1448)
- **`/autofix` ChatOps button** — fork-safe PR autofix pipeline replaces the inline reviewdog flow (#1446, #1458)
- **Automated security & vulnerability scans** in CI (#1297, #1455)
### Fixed
- **FTS read-only DB cluster** — hook resolves canonical repo root and guards read-only FTS ensure; missing-FTS warning is now surfaced. Closes #1255, #1287, #1170, #1449, #1440, #1216, #1438 (#1226, #1418, #1107, #1123)
- **WAL corruption recovery** — quarantine corrupted `.wal` files instead of failing analyze; CHECKPOINT before close prevents recurrence; `safeClose` consolidates flush. Closes #1402, #1236, #1273, #1361 (#1417, #1314, #1377)
- **Embedding download failures** — actionable HF_ENDPOINT guidance, retries, timeout, and circuit breaker; bridge `HF_ENDPOINT` to transformers.js; iterative DFS; HF cache via `os.homedir()`. Closes #1378, #1437, #1205 (#1419, #1252, #1078)
- **Windows reliability** — pin tree-sitter-c/cpp to fix segfault, prefer `.cmd`/`.bat` from `where` output, robust LadybugDB lock acquisition for CI integration tests, surface silent finalize-skips so analyze cannot exit 0 without persisting. Closes #1242, #1427, #1447, #1468, #1400; partial #1218 (#1243, #1299, #1430, #1237, #1226, #1235)
- **DuckDB / LadybugDB native** — bumped to 0.16.0 then 0.16.1; prevent extension install hangs; CHECKPOINT before close; WAL quarantine on corruption. Closes #1162, #1160, #273 (#1235, #1326, #1129, #1314, #1417)
- **C# scope-resolution "Cannot add property" crashes** — generic typed properties included in context and impact, fixing crashes on Unity ECS partial structs and on properties whose name matches the class name. Closes #1426, #1465 (#1399)
- **C# frozen-bucket regression** + scope-resolution I8 hardening — closes #1066 (#1082, #1085)
- **Scope resolution** — same-range Module-as-parent for top-level scopes (closes #1086) (#1087); avoid variadic reference-site aggregation (#1112); skip empty scope extraction (#1100); classify Python class methods as Method (#1102)
- **Python** — index repos with empty `__init__.py` and >32 KB files (#1163); walk ancestors for multi-segment dotted imports (#1241); deterministic multi-segment suffix fallback (#1253)
- **TypeScript** — capture missed CALLS edges from HOF callbacks and JSX (#1175); name HOC-wrapped const declarations (`forwardRef` / `memo` / `useCallback` / `useMemo` / `observer`) (#1261); pair-with-arrow `@declaration.function` anchored on inner arrow
- **Go** — loose equality for `Array.find()` null checks (#1384)
- **Swift** — switched to the official prebuilt parser runtime (#1130)
- **Server hardening cluster (U2–U8)** — JS path-injection on `/api/file` + docker-server (U2, #1322); git-clone path/CLI-injection / ReDoS hardening (U3, #1325); per-route rate limiting on FS-touching endpoints (U4, #1327); URL/regex/tag-filter sanitization (U7, #1330); ReDoS in cobol-preprocessor + rust-workspace + cross-impact resource exhaustion (U8, #1331); critical type-confusion + validation helper (#1317); rate-limit `/api/analyze` and `/api/embed` (closes #1328, #1339); IPv6 ipKeyGenerator (closes #1360, #1374); IPv4-compatible IPv6 / NAT64 SSRF bypasses in `validateGitUrl` (closes #1148, 95814847); predictable tempfile names → `crypto.randomBytes` (#1387); log-injection / http-to-file-access / client-side request forgery (#1456); pin Docker Node base images + Trivy verification + Dependabot policy (#1455)
- **Group / contracts** — `runExactMatch` honours `.gitnexusignore` via shared `IgnoreService` (closes #1185, #1247); custom manifest links resolved against graph symbols (#1254); `IgnoreService` EACCES test under uid=0 (#1108)
- **MCP** — close MCP server timeout via stdout discipline + cold-start friction (#1383); avoid `git` from non-repo cwd in sibling-cwd match (closes #1138, #1293); start MCP bridge correctly when using `npx` (#1114); project `tool_map` flows from handlers (#1113); parallelize staleness checks in `list_repos` (#1416)
- **Storage / CLI** — derive registry name from canonical repo root, not worktree slug (closes #1259, #1296); `--skip-git` treats cwd as index root (#1245); keep GitNexus ignores inside `.gitnexus/` (#1248); surface silent finalize-skips so `analyze` cannot exit 0 without persisting (closes #1169, #1237); ignore global registry during staleness checks (#1141); use `os.homedir()` instead of `process.env.HOME` for HF cache dir (#1078); correct OpenCode skills install path in status message (#1386)
- **Docker / server** — dedicated health endpoint for container healthcheck (closes #1147, #1355); HEAD probe so SSE heartbeat doesn't time out healthcheck (#1182); flush WAL after `/api/embed` so search sees new embeddings (closes #1149, #1359); platform-aware semantic fallback (#1150); skip vector index query on unsupported platforms (closes #1178, #1181); serve web UI at root path instead of 404 (#1048)
- **Worker pool** — wait for replacement worker online before dispatch (#1324); prevent premature pool resolution in worker split-and-retry path (#1321); recover worker parse stalls (#1121); widened CI flake-tolerant timeouts (#1323, #1347, #1354)
- **Embeddings storage** — CHECKPOINT before closing DB to prevent WAL corruption (#1314)
- **Performance** — replace O(n³) C3 merge loop with O(n²) head-pointer algorithm (#1316)
- **Install** — vendor tree-sitter-dart source (#1125)
- **Git utils** — suppress stderr leak in `getCurrentCommit` and `getGitRoot` (closes #1172, #1341)
- **Search** — load FTS during core DB init (#1123); create FTS indexes during `analyze` (#1107); surface warning when FTS indexes are missing (#1418)
- **Hooks** — clarify `PostToolUse` hook is notification-only, not auto-reindex (#1070)
- **Docs** — README Web UI section corrected (closes #1110, #1159, #2ff3e64f); Goliath capitalisation typo (#1126)
- **CI** — fork-safe PR autofix pipeline (#1446); consolidated Claude review workflow (#1258); fine-grained PAT for RC tag push (#1407); handle expired artifacts in base coverage fetch (#1410, #1412); allow expected legacy parity failures (#1099); avoid duplicate main push checks; isolate native LadybugDB / CLI e2e flakes; seed e2e with a small fixture repo (#1249); configure e2e GitNexus home at runtime; widen rate-limit test window for Windows CI (#1347)
### Changed
- **`gitnexus publish` artefact contract** — universal opt-in publish format introduced (#1425, #1458)
- **Refactor: per-language patterns consolidated into `LanguageProvider`** (#1279)
- **Refactor: `safeClose` helper** consolidates WAL flush across LadybugDB call sites (#1377)
- **Quality: exclude `test/fixtures` from CodeQL, ESLint, and Prettier** (#1313)
- **Regression coverage** for `.gitnexusignore` behaviour with `--skip-git` (#1450)
### Chore / Dependencies
- `@ladybugdb/core` 0.16.0 → 0.16.1 (#1235, #1326)
- `@anthropic-ai/sdk` (#1442), `@langchain/anthropic` (#1389), `@langchain/core` (#1394), `@langchain/openai` (#1215)
- `hono` 4.12.9 → 4.12.18 + `@hono/node-server` (#1310, #1311, #1443)
- `axios` (#1345), `fast-uri` 3.1.0 → 3.1.2 (#1441), `lru-cache` 11.3.5 → 11.3.6 (#1344), `mnemonist` 0.40.3 → 0.40.4 (#1239), `express-rate-limit` (#1343, #1397), `onnxruntime-node` (#1213, #1435), `uuid` 13 → 14 in /gitnexus-web (#1211, after revert #1222 / re-land #1250 + #1208)
- `react`/`@types/react` (#1210), `react-dom` 19.2.5 → 19.2.6 (#1396), `react-zoom-pan-pinch` (#1214), `jsdom` 29.0.2 → 29.1.1 (#1395)
- npm_and_yarn group bump (#1312), uv group bump (#1315), `python-dotenv` (#1320), `@types/node` (#1212, #1421, #1436)
- GitHub Actions: `docker/build-push-action` 6.19.2 → 7.1.0 (#1391), `github/codeql-action` 3.35.3 → 4.35.3 (#1390)
## [1.6.3] - 2026-04-24
### Added
+11 -2
View File
@@ -1,6 +1,15 @@
FROM node:20-bookworm
# Pinned npm version — keep in sync with the root Dockerfile.cli and
# Dockerfile.web.
ARG NPM_VERSION=11.14.1
# node:22-bookworm-slim
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e
ARG NPM_VERSION
WORKDIR /app
RUN apt-get -o Acquire::Check-Valid-Until=false -o Acquire::Check-Date=false update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
RUN npx --yes npm@${NPM_VERSION} install -g npm@${NPM_VERSION} \
&& apt-get -o Acquire::Check-Valid-Until=false -o Acquire::Check-Date=false update \
&& apt-get install -y python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY . .
RUN npm ci --ignore-scripts \
&& npm rebuild tree-sitter-swift 2>&1 \
+1 -1
View File
@@ -33,7 +33,7 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
|--------|-----|--------|---------------------|---------|
| **Claude Code** | Yes | Yes | Yes (PreToolUse) | **Full** |
| **Cursor** | Yes | Yes | — | MCP + Skills |
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](../gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
| **Codex** | Yes | Yes | — | MCP + Skills |
| **Windsurf** | Yes | — | — | MCP |
| **OpenCode** | Yes | Yes | — | MCP + Skills |
+8 -1
View File
@@ -14,6 +14,7 @@
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { acquireHookSlot } = require('./hook-lock.cjs');
/**
* Read JSON input from stdin synchronously.
@@ -207,7 +208,8 @@ function runGitNexusCli(cliPath, args, cwd, timeout) {
function handlePreToolUse(input) {
const cwd = input.cwd || process.cwd();
if (!path.isAbsolute(cwd)) return;
if (!findGitNexusDir(cwd)) return;
const gitNexusDir = findGitNexusDir(cwd);
if (!gitNexusDir) return;
const toolName = input.tool_name || '';
const toolInput = input.tool_input || {};
@@ -217,6 +219,9 @@ function handlePreToolUse(input) {
const pattern = extractPattern(toolName, toolInput);
if (!pattern || pattern.length < 3) return;
const release = acquireHookSlot(gitNexusDir);
if (!release) return;
const cliPath = resolveCliPath();
let result = '';
try {
@@ -226,6 +231,8 @@ function handlePreToolUse(input) {
}
} catch {
/* graceful failure */
} finally {
release();
}
if (result && result.trim()) {
+119
View File
@@ -0,0 +1,119 @@
const fs = require('fs');
const path = require('path');
const HOOK_LOCK_SUBDIR = '.hook-locks';
const HOOK_LOCK_MAX_INFLIGHT = 3;
const HOOK_LOCK_STALE_MS = 30000;
function acquireHookSlot(gitNexusDir) {
const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR);
try {
fs.mkdirSync(lockDir, { recursive: true });
} catch {
// Cannot create lock dir (read-only fs, cross-user perm denial, out of
// inodes, etc.) — fail closed by returning null. Caller skips augment.
// Fail-open here would let N concurrent hooks all proceed unguarded and
// reintroduce the #1486 fan-out the guard exists to prevent.
return null;
}
const myPidStr = String(process.pid);
for (let slot = 0; slot < HOOK_LOCK_MAX_INFLIGHT; slot++) {
const slotPath = path.join(lockDir, `slot-${slot}.lock`);
for (let attempt = 0; attempt < 2; attempt++) {
try {
fs.writeFileSync(slotPath, myPidStr, { flag: 'wx' });
let released = false;
const release = () => {
if (released) return;
released = true;
try {
// Only unlink if we still own the slot. If we appeared stale and
// another hook took over, the file now belongs to it — leave alone.
const content = fs.readFileSync(slotPath, 'utf-8').trim();
if (content === myPidStr) fs.unlinkSync(slotPath);
} catch {
/* already removed or unreadable */
}
};
process.on('exit', release);
return release;
} catch {
// Slot exists. Decide whether to take it over.
// Open once and inspect mtime + content via the same fd so there's
// no TOCTOU between the metadata check and the content read
// (codeql js/file-system-race).
let fd;
try {
fd = fs.openSync(slotPath, 'r');
} catch {
continue; // Vanished between EEXIST and open — retry this slot.
}
let isLive = false;
let mtimeMs = Date.now();
try {
mtimeMs = fs.fstatSync(fd).mtimeMs;
const buf = Buffer.alloc(32);
const n = fs.readSync(fd, buf, 0, 32, 0);
const ownerStr = buf.slice(0, n).toString('utf-8').trim();
if (ownerStr === '') {
// Owner created the file but hasn't written its PID yet. The
// wx open+write window is microseconds; give it the benefit
// of the doubt and treat as live.
isLive = true;
} else {
const owner = Number.parseInt(ownerStr, 10);
if (Number.isFinite(owner) && owner > 0) {
try {
process.kill(owner, 0);
isLive = true;
} catch (e) {
// ESRCH = process gone → treat as dead. EPERM = process exists
// but owned by another user (cross-user lock dir) → still alive,
// keep the slot. Anything else: be conservative, assume alive.
if (e && e.code === 'ESRCH') {
isLive = false;
} else {
isLive = true;
}
}
}
}
} catch {
/* unreadable — treat as dead */
} finally {
try {
fs.closeSync(fd);
} catch {
/* already closed */
}
}
// For slots younger than HOOK_LOCK_STALE_MS, PID-liveness wins —
// a slow-but-alive hook is never wrongly evicted. For older slots,
// age is the final arbiter as a defense against PID reuse on long-
// abandoned slots. 30s >> the 7s augment timeout, so a healthy run
// never crosses this threshold.
if (isLive && Date.now() - mtimeMs > HOOK_LOCK_STALE_MS) {
isLive = false;
}
if (isLive) break; // Try the next slot.
try {
fs.unlinkSync(slotPath);
} catch {
/* another hook beat us to it — retry will hit EEXIST */
}
// Loop and retry this slot.
}
}
}
return null;
}
module.exports = {
HOOK_LOCK_SUBDIR,
HOOK_LOCK_MAX_INFLIGHT,
HOOK_LOCK_STALE_MS,
acquireHookSlot,
};
+34 -34
View File
@@ -1,12 +1,12 @@
{
"name": "gitnexus",
"version": "1.6.3",
"version": "1.6.5-rc.24",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus",
"version": "1.6.3",
"version": "1.6.5-rc.24",
"hasInstallScript": true,
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
@@ -63,7 +63,7 @@
"vitest": "^4.0.18"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.0.0"
},
"optionalDependencies": {
"node-addon-api": "^8.0.0",
@@ -1600,9 +1600,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
@@ -1628,9 +1628,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz",
"integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
@@ -1646,9 +1646,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
"license": "BSD-3-Clause"
},
"node_modules/@rolldown/binding-android-arm64": {
@@ -2065,9 +2065,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.6.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.1.tgz",
"integrity": "sha512-coJCN8O1q4AGyyqCAUSP06P+SrMTu18BkEj3NVAK07q6QUneD2wzj3CLv9+yP+BMeZQlMvneXqqvDe3w+xcq7g==",
"version": "25.6.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz",
"integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.19.0"
@@ -3110,9 +3110,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"funding": [
{
"type": "github",
@@ -3476,9 +3476,9 @@
"license": "MIT"
},
"node_modules/hono": {
"version": "4.12.16",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.16.tgz",
"integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==",
"version": "4.12.18",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -4282,15 +4282,15 @@
}
},
"node_modules/onnxruntime-common": {
"version": "1.25.1",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.25.1.tgz",
"integrity": "sha512-kKvYQFdos4LWJqhZ+nmKu3NT8NXzw8I5x9fNUKe1rNKcPfNKnYXUtW7JBpcKFsvLtrJashRgVYSbFap4cHxvNg==",
"version": "1.26.0",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.26.0.tgz",
"integrity": "sha512-qVyMR4lcWgbkc4getFV+GQijsTnbg/siteoqcDwa3sI/LxbrMSNw4ePyvCq/ymdQaRomCA7YuWmhzsswxvymdw==",
"license": "MIT"
},
"node_modules/onnxruntime-node": {
"version": "1.25.1",
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.25.1.tgz",
"integrity": "sha512-N0M58CGTiTsLkPpx9bxmRFi24GT6r67Qei/GrBEIiDyntcYdXU5vQZp112ypydG9vEKRFgbgUYQJnEi+jll8dg==",
"version": "1.26.0",
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.26.0.tgz",
"integrity": "sha512-OHl6PiOEOqxaLHL0N9eFrbzS7IGmu3BtJNH3RTEnRAheCIkfc3gjcjl4sGcjp9C22ZC9YTquDOxSdT/stBQ6BQ==",
"hasInstallScript": true,
"license": "MIT",
"os": [
@@ -4301,7 +4301,7 @@
"dependencies": {
"adm-zip": "^0.5.16",
"global-agent": "^4.1.3",
"onnxruntime-common": "1.25.1"
"onnxruntime-common": "1.26.0"
}
},
"node_modules/onnxruntime-web": {
@@ -4543,22 +4543,22 @@
"license": "MIT"
},
"node_modules/protobufjs": {
"version": "7.5.5",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz",
"integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==",
"version": "7.5.8",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz",
"integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.4",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.0",
"@protobufjs/inquire": "^1.1.1",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.0.0"
},
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.6.3",
"version": "1.6.5-rc.24",
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
"author": "Abhigyan Patwari",
"license": "PolyForm-Noncommercial-1.0.0",
@@ -116,6 +116,6 @@
}
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.0.0"
}
}
+6 -2
View File
@@ -21,11 +21,15 @@ const SHARED_DEST = path.join(DIST, '_shared');
// ── 1. Build gitnexus-shared ───────────────────────────────────────
console.log('[build] compiling gitnexus-shared…');
execSync('npx tsc', { cwd: SHARED_ROOT, stdio: 'inherit', timeout: 120_000 });
const tscCmd =
process.platform === 'win32'
? path.join('node_modules', '.bin', 'tsc.cmd')
: path.join('node_modules', '.bin', 'tsc');
execSync(tscCmd, { cwd: SHARED_ROOT, stdio: 'inherit', timeout: 120_000 });
// ── 2. Build gitnexus ──────────────────────────────────────────────
console.log('[build] compiling gitnexus…');
execSync('npx tsc', { cwd: ROOT, stdio: 'inherit', timeout: 120_000 });
execSync(tscCmd, { cwd: ROOT, stdio: 'inherit', timeout: 120_000 });
// ── 3. Copy shared dist ────────────────────────────────────────────
console.log('[build] copying shared module into dist/_shared…');
+33 -10
View File
@@ -28,6 +28,7 @@ interface RepoStats {
export interface AIContextOptions {
skipAgentsMd?: boolean;
noStats?: boolean;
skipSkills?: boolean;
}
const GITNEXUS_START_MARKER = '<!-- gitnexus:start -->';
@@ -94,6 +95,7 @@ function generateGitNexusContent(
generatedSkills?: GeneratedSkillInfo[],
groupNames?: string[],
noStats?: boolean,
skipSkills?: boolean,
): string {
const generatedRows =
generatedSkills && generatedSkills.length > 0
@@ -105,14 +107,26 @@ function generateGitNexusContent(
.join('\n')
: '';
const skillsTable = `| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | \`.claude/skills/gitnexus/gitnexus-exploring/SKILL.md\` |
// Standard skill rows reference files installed by installSkills(). When
// --skip-skills suppresses that install, these rows must be omitted — else
// AGENTS.md/CLAUDE.md would direct agents to read files that don't exist.
// Community skills (generatedRows) live in .claude/skills/generated/ and
// are independent of --skip-skills, so they remain when present.
const standardSkillsRows = skipSkills
? ''
: `| Understand architecture / "How does X work?" | \`.claude/skills/gitnexus/gitnexus-exploring/SKILL.md\` |
| Blast radius / "What breaks if I change X?" | \`.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md\` |
| Trace bugs / "Why is X failing?" | \`.claude/skills/gitnexus/gitnexus-debugging/SKILL.md\` |
| Rename / extract / split / refactor | \`.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md\` |
| Tools, resources, schema reference | \`.claude/skills/gitnexus/gitnexus-guide/SKILL.md\` |
| Index, status, clean, wiki CLI commands | \`.claude/skills/gitnexus/gitnexus-cli/SKILL.md\` |${generatedRows ? '\n' + generatedRows : ''}`;
| Index, status, clean, wiki CLI commands | \`.claude/skills/gitnexus/gitnexus-cli/SKILL.md\` |`;
const tableBody = [standardSkillsRows, generatedRows].filter(Boolean).join('\n');
const skillsTable = tableBody
? `| Task | Read this skill file |
|------|---------------------|
${tableBody}`
: '';
return `${GITNEXUS_START_MARKER}
# GitNexus — Code Intelligence
@@ -153,11 +167,15 @@ This repository is listed under GitNexus **group(s): ${groupNames.join(', ')}**
`
: ''
}## CLI
}${
skillsTable
? `## CLI
${skillsTable}
${GITNEXUS_END_MARKER}`;
`
: ''
}${GITNEXUS_END_MARKER}`;
}
/**
@@ -319,6 +337,7 @@ export async function generateAIContextFiles(
generatedSkills,
groupNames,
options?.noStats,
options?.skipSkills,
);
const createdFiles: string[] = [];
@@ -337,10 +356,14 @@ export async function generateAIContextFiles(
createdFiles.push('CLAUDE.md (skipped via --skip-agents-md)');
}
// Install skills to .claude/skills/gitnexus/
const installedSkills = await installSkills(repoPath);
if (installedSkills.length > 0) {
createdFiles.push(`.claude/skills/gitnexus/ (${installedSkills.length} skills)`);
// Install skills to .claude/skills/gitnexus/ (unless --skip-skills)
if (!options?.skipSkills) {
const installedSkills = await installSkills(repoPath);
if (installedSkills.length > 0) {
createdFiles.push(`.claude/skills/gitnexus/ (${installedSkills.length} skills)`);
}
} else {
createdFiles.push('.claude/skills/gitnexus/ (skipped via --skip-skills)');
}
return { files: createdFiles };
+44 -4
View File
@@ -119,6 +119,10 @@ export interface AnalyzeOptions {
skipAgentsMd?: boolean;
/** Omit volatile symbol/relationship counts from AGENTS.md and CLAUDE.md. */
noStats?: boolean;
/** Skip installing standard GitNexus skill files to .claude/skills/gitnexus/. */
skipSkills?: boolean;
/** Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills). */
indexOnly?: boolean;
/** Index the folder even when no .git directory is present. */
skipGit?: boolean;
/**
@@ -150,6 +154,24 @@ export interface AnalyzeOptions {
embeddingDevice?: string;
}
/**
* Whether the post-index skill step should run.
*
* The gated block does two things in sequence: (1) generates the community
* skill files from `--skills`, and (2) re-runs `generateAIContextFiles` so
* AGENTS.md/CLAUDE.md can reference the freshly written skills. Both are
* suppressed together — `--index-only` drops the entire step, not just the
* community-skill write. Name retained for the test contract; see call site
* in `analyzeCommand` for the AGENTS.md/CLAUDE.md re-generation it also gates.
*
* Kept as a pure helper so the `--index-only --skills` contract is unit-tested
* without booting the full analyze pipeline (#742 review).
*/
export const shouldGenerateCommunitySkillFiles = (
options: Pick<AnalyzeOptions, 'skills' | 'indexOnly'> | undefined,
pipelineResult: unknown,
): boolean => Boolean(options?.skills && pipelineResult && !options?.indexOnly);
export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => {
if (ensureHeap()) return;
@@ -245,6 +267,18 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
console.log('\n GitNexus Analyzer\n');
// `--index-only` is the stronger contract — it suppresses every form of file
// injection, including community skill writes that `--skills` would normally
// produce. Surface the override explicitly so users don't wonder why a
// pipeline re-index ran but no skill files appeared. The pipeline still
// re-runs (see `force: options?.force || options?.skills` below); the warning
// is purely about the dropped post-index write step.
if (options?.indexOnly && options?.skills) {
console.log(
' Note: --index-only overrides --skills; community skill files will not be written.\n',
);
}
let repoPath: string;
if (inputPath) {
repoPath = path.resolve(inputPath);
@@ -399,6 +433,9 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
// ── Run shared analysis orchestrator ───────────────────────────────
try {
const skipAll = options?.indexOnly;
const skipAgentsMd = skipAll || options?.skipAgentsMd;
const skipSkills = skipAll || options?.skipSkills;
const result = await runFullAnalysis(
repoPath,
{
@@ -410,7 +447,8 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
embeddingsNodeLimit,
dropEmbeddings: options?.dropEmbeddings,
skipGit: options?.skipGit,
skipAgentsMd: options?.skipAgentsMd,
skipAgentsMd,
skipSkills,
noStats: options?.noStats,
registryName: options?.name,
// Registry-collision bypass — its own CLI flag, intentionally NOT
@@ -456,8 +494,10 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
// a healthy index.
await assertAnalysisFinalized(repoPath);
// Skill generation (CLI-only, uses pipeline result from analysis)
if (options?.skills && result.pipelineResult) {
// Skill generation (CLI-only, uses pipeline result from analysis).
// Gated so `--index-only --skills` skips community skill writes too
// (`shouldGenerateCommunitySkillFiles` — see unit test).
if (shouldGenerateCommunitySkillFiles(options, result.pipelineResult)) {
updateBar(99, 'Generating skill files...');
try {
const { generateSkillFiles } = await import('./skill-gen.js');
@@ -497,7 +537,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
processes: s.processes,
},
skillResult.skills,
{ skipAgentsMd: options?.skipAgentsMd, noStats: options?.noStats },
{ skipAgentsMd, skipSkills, noStats: options?.noStats },
);
}
} catch {
+1 -1
View File
@@ -2,7 +2,7 @@
* Augment CLI Command
*
* Fast-path command for platform hooks.
* Shells out from Claude Code PreToolUse / Cursor beforeShellExecution hooks.
* Shells out from Claude Code PreToolUse / Cursor postToolUse hooks.
*
* Usage: gitnexus augment <pattern>
* Returns enriched text to stdout.
+24 -1
View File
@@ -33,9 +33,20 @@ program
'Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` ' +
'preserves any embeddings already present in the index.',
)
.option('--skills', 'Generate repo-specific skill files from detected communities')
.option(
'--skills',
'Generate repo-specific skill files from detected communities ' +
'(no-op when --index-only is also set).',
)
.option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
.option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
.option(
'--skip-skills',
'Skip installing standard GitNexus skill files under .claude/skills/gitnexus/. ' +
'Does not suppress community skills from --skills (those use .claude/skills/generated/). ' +
'Use --index-only to skip all AI-context file injection.',
)
.option('--index-only', 'Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills)')
.option(
'--skip-git',
'Treat the provided path/cwd as the index root and skip parent git-root discovery',
@@ -160,6 +171,18 @@ program
.description('Augment a search pattern with knowledge graph context (used by hooks)')
.action(createLazyAction(() => import('./augment.js'), 'augmentCommand'));
program
.command('publish [path]')
.description(
'Notify the understand-quickly registry that this repo has a fresh GitNexus index. ' +
'Opt-in: requires UNDERSTAND_QUICKLY_TOKEN (fine-grained PAT with ' +
'`Repository dispatches: write` on looptech-ai/understand-quickly). ' +
'No-op without the token. See https://github.com/looptech-ai/understand-quickly.',
)
.option('--id <owner/repo>', 'Override the registry id (defaults to the origin remote)')
.option('--skip-git', 'Treat cwd as the repo root and skip parent git-root discovery')
.action(createLazyAction(() => import('./publish.js'), 'publishCommand'));
// ─── Direct Tool Commands (no MCP overhead) ────────────────────────
// These invoke LocalBackend directly for use in eval, scripts, and CI.
+232
View File
@@ -0,0 +1,232 @@
/**
* `gitnexus publish` — opt-in ping to the understand-quickly registry.
*
* Fires a single `repository_dispatch` event at
* `looptech-ai/understand-quickly` so the registry knows to refresh its
* entry for the current repo. Does NOT upload anything: per the
* understand-quickly protocol, the registry pulls the graph from a
* raw-GitHub URL the user controls.
*
* https://github.com/looptech-ai/understand-quickly/blob/main/docs/integrations/protocol.md
*
* Defaults:
* - Without `UNDERSTAND_QUICKLY_TOKEN` in the env, this is a no-op
* (prints one informational line, exit 0). Same shape as the
* `--publish` patterns in sibling tools.
* - With the token, fires the dispatch and reports the response code.
*
* The `id` is derived from the repo's `origin` remote unless the caller
* passes `--id <owner/repo>` explicitly. We deliberately do NOT auto-add
* the repo to the registry — registration is one-time and uses the
* `npx @understand-quickly/cli add` path documented in the protocol.
*/
import path from 'path';
import {
UNDERSTAND_QUICKLY_DISPATCH_URL,
UNDERSTAND_QUICKLY_TOKEN_ENV,
buildUqDispatchPayload,
isValidOwnerRepo,
parseOwnerRepoFromRemote,
} from 'gitnexus-shared';
import { getGitRoot, getRemoteOriginUrl, getCurrentCommit } from '../storage/git.js';
import { hasIndex } from '../storage/repo-manager.js';
import { cliInfo, cliError } from './cli-message.js';
export interface PublishOptions {
/** Override the auto-derived `owner/repo` id. */
id?: string;
/** Treat the cwd as the repo root (skip git-root walk). */
skipGit?: boolean;
}
const REGISTER_HINT =
'Register your repo once with: npx @understand-quickly/cli add\n' +
'Or use the wizard: https://looptech-ai.github.io/understand-quickly/add.html';
/**
* Hard cap on the dispatch fetch to keep CI publish steps from stalling
* for the OS TCP timeout (~2 min) when api.github.com is unreachable.
* Matches the pattern used in `src/core/embeddings/http-client.ts`.
*/
const DISPATCH_TIMEOUT_MS = 15_000;
export const publishCommand = async (
inputPath?: string,
options: PublishOptions = {},
): Promise<void> => {
// ── 0. Token gate FIRST — guarantees true no-op without the token. ──
// The README, CLI --help, and PR body all promise "exit 0 without
// UNDERSTAND_QUICKLY_TOKEN". Doing the index/repo-root checks before
// the token gate would make those promises false for users who haven't
// run `gitnexus analyze` yet but want to verify the command is wired.
const token = process.env[UNDERSTAND_QUICKLY_TOKEN_ENV];
if (!token) {
cliInfo(
`[understand-quickly] ${UNDERSTAND_QUICKLY_TOKEN_ENV} is not set — skipping dispatch.\n` +
`Set it to a fine-grained PAT with "Repository dispatches: write" on ` +
`looptech-ai/understand-quickly to enable instant resync.\n` +
`(Without the token, the registry's nightly sync still picks up your entry.)`,
{ skipped: 'no-token' },
);
return;
}
// ── 1. Resolve the repo root (same precedence as `analyze`) ──────────
let repoPath: string;
if (inputPath) {
repoPath = path.resolve(inputPath);
} else if (options.skipGit) {
repoPath = path.resolve(process.cwd());
} else {
const gitRoot = getGitRoot(process.cwd());
if (!gitRoot) {
cliError(
'[understand-quickly] not inside a git repository.\n' +
'Run from a repo, or pass --skip-git to publish from the current directory.',
);
process.exitCode = 1;
return;
}
repoPath = gitRoot;
}
// ── 2. Confirm a GitNexus index exists ───────────────────────────────
// Publishing without an index is almost always a mistake — the
// registry's nightly sync would fetch a stale or missing graph file
// and mark the entry `missing`. Refuse loudly with a fix-it hint.
if (!(await hasIndex(repoPath))) {
cliError(
`[understand-quickly] no GitNexus index found at ${repoPath}/.gitnexus.\n` +
'Run `gitnexus analyze` first, then re-run `gitnexus publish`.',
);
process.exitCode = 1;
return;
}
// ── 3. Derive the registry id ─────────────────────────────────────────
const id =
options.id ?? parseOwnerRepoFromRemote(getRemoteOriginUrl(repoPath) ?? undefined) ?? null;
if (!id || !isValidOwnerRepo(id)) {
cliError(
`[understand-quickly] could not derive a registry id from this repo.\n` +
`Pass --id <owner/repo> explicitly (e.g. --id looptech-ai/${path.basename(repoPath)}).\n` +
REGISTER_HINT,
);
process.exitCode = 1;
return;
}
// ── 4. Fire the dispatch ─────────────────────────────────────────────
const payload = buildUqDispatchPayload(id);
let response: Response;
try {
response = await fetch(UNDERSTAND_QUICKLY_DISPATCH_URL, {
method: 'POST',
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
'X-GitHub-Api-Version': '2022-11-28',
'Content-Type': 'application/json',
'User-Agent': 'gitnexus-cli',
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(DISPATCH_TIMEOUT_MS),
});
} catch (err) {
// `AbortSignal.timeout()` throws a `DOMException` with `name ===
// 'TimeoutError'` on Node 18.14+ (and on browsers/Bun). It is NOT
// a plain `AbortError`. Match the pattern used in
// gitnexus/src/core/embeddings/http-client.ts so the user sees the
// targeted "timed out" message instead of a generic "operation
// was aborted".
const isTimeout = err instanceof DOMException && err.name === 'TimeoutError';
if (isTimeout) {
cliError(
`[understand-quickly] dispatch timed out after ${DISPATCH_TIMEOUT_MS}ms. ` +
`Check network access to api.github.com and retry.`,
{ id },
);
} else {
const msg = err instanceof Error ? err.message : String(err);
cliError(`[understand-quickly] dispatch network error: ${msg}`, { id });
}
process.exitCode = 1;
return;
}
// GitHub returns 204 on success. Distinct branches for 401/403/404/422
// so users debug without checking the docs.
if (response.status === 204) {
await response.body?.cancel().catch(() => {});
// `getCurrentCommit` is only meaningful in the success path — moving
// it inside this branch removes a wasted child-process spawn on every
// error response (LOW 7).
const commit = getCurrentCommit(repoPath);
cliInfo(
`[understand-quickly] dispatched sync-entry for ${id}` +
(commit ? ` @ ${commit.slice(0, 7)}` : '') +
'.\n' +
`Note: a 204 only confirms GitHub accepted the dispatch. Whether the ` +
`registry workflow finds an entry for "${id}" is logged at ` +
`https://github.com/looptech-ai/understand-quickly/actions/workflows/sync.yml`,
{ id, commit, status: response.status },
);
return;
}
if (response.status === 401) {
cliError(
`[understand-quickly] dispatch returned 401 — the ${UNDERSTAND_QUICKLY_TOKEN_ENV} value is invalid or expired.\n` +
`Regenerate a fine-grained PAT at https://github.com/settings/personal-access-tokens ` +
`with Repository access scoped to looptech-ai/understand-quickly and the ` +
`"Repository dispatches: write" permission, then retry.`,
{ id, status: response.status },
);
process.exitCode = 1;
return;
}
if (response.status === 403) {
cliError(
`[understand-quickly] dispatch returned 403 — the token authenticated but ` +
`lacks the "Repository dispatches: write" permission on ` +
`looptech-ai/understand-quickly. Edit the PAT scopes and retry.`,
{ id, status: response.status },
);
process.exitCode = 1;
return;
}
if (response.status === 404) {
cliError(
`[understand-quickly] dispatch returned 404 — the token cannot reach ` +
`looptech-ai/understand-quickly. Verify the PAT has Repository access to ` +
`that exact repo (not just your own org).`,
{ id, status: response.status },
);
process.exitCode = 1;
return;
}
if (response.status === 422) {
// Malformed event_type / client_payload — a code bug in this CLI,
// not a user mistake. Surface so we get bug reports.
const body422 = await response.text().catch(() => '');
cliError(
`[understand-quickly] dispatch returned 422 (this is a CLI bug; please report).\n` +
`Body: ${body422 || '(empty)'}`,
{ id, status: response.status },
);
process.exitCode = 1;
return;
}
// 5xx and anything else → bubble the body so the user has something to act on.
const body = await response.text().catch(() => '');
cliError(
`[understand-quickly] dispatch failed with HTTP ${response.status}: ${body || '(empty body)'}`,
{ id, status: response.status },
);
process.exitCode = 1;
};
+9
View File
@@ -364,6 +364,15 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
// Script not found in source — skip
}
try {
await fs.copyFile(
path.join(pluginHooksPath, 'hook-lock.cjs'),
path.join(destHooksDir, 'hook-lock.cjs'),
);
} catch {
// Helper not found in source — skip
}
const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/');
// Escape backslashes FIRST, then quotes (CodeQL js/incomplete-sanitization).
// The previous shape `replace(/"/g, '\\"')` alone would let `path\with"quote`
+2
View File
@@ -25,6 +25,8 @@ const DEFAULT_IGNORE_LIST = new Set([
'bower_components',
'jspm_packages',
'vendor', // PHP/Go
'third_party', // C/C++ (Google-style vendored dependencies)
'3rdparty', // C/C++ (alternate spelling, also Qt convention)
// 'packages' removed - commonly used for monorepo source code (lerna, pnpm, yarn workspaces)
'venv',
'.venv',
+30 -6
View File
@@ -2,8 +2,8 @@
* Augmentation Engine
*
* Lightweight, fast-path enrichment of search patterns with knowledge graph context.
* Designed to be called from platform hooks (Claude Code PreToolUse, Cursor beforeShellExecution)
* when an agent runs grep/glob/search.
* Designed to be called from platform hooks (Claude Code PreToolUse, Cursor postToolUse)
* when an agent runs grep/glob/read/search.
*
* Performance target: <500ms cold start, <200ms warm.
*
@@ -86,6 +86,9 @@ async function findRepoForCwd(cwd: string): Promise<{
export async function augment(pattern: string, cwd?: string): Promise<string> {
if (!pattern || pattern.length < 3) return '';
const patternFirstWord = pattern.trim().replace(/'/g, "''").split(/\s+/)[0];
if (!patternFirstWord || patternFirstWord.length < 2) return '';
const workDir = cwd || process.cwd();
try {
@@ -104,9 +107,7 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
}
// Step 1: BM25 search (fast, no embeddings)
const bm25Results = await searchFTSFromLbug(pattern, 10, repoId);
if (bm25Results.length === 0) return '';
const { results: bm25Results, ftsAvailable } = await searchFTSFromLbug(pattern, 10, repoId);
// Step 2: Map BM25 file results to symbols
const symbolMatches: Array<{
@@ -124,7 +125,7 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
repoId,
`
MATCH (n) WHERE n.filePath = '${escaped}'
AND n.name CONTAINS '${pattern.replace(/'/g, "''").split(/\s+/)[0]}'
AND n.name CONTAINS '${patternFirstWord}'
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
LIMIT 3
`,
@@ -143,6 +144,29 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
}
}
// When FTS indexes are unavailable (read-only DB, first run before indexes are built),
// fall back to a direct name CONTAINS query so enrichment still works.
if (symbolMatches.length === 0 && !ftsAvailable) {
const fallbackRows = await executeQuery(
repoId,
`
MATCH (n)
WHERE n.name CONTAINS '${patternFirstWord}'
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
LIMIT 5
`,
).catch(() => []);
for (const sym of fallbackRows) {
symbolMatches.push({
nodeId: sym.id || sym[0],
name: sym.name || sym[1],
type: sym.type || sym[2],
filePath: sym.filePath || sym[3],
score: 1.0,
});
}
}
if (symbolMatches.length === 0) return '';
// Step 3: Batch-fetch callers/callees/processes/cohesion for top matches
+2 -1
View File
@@ -10,6 +10,7 @@ import {
isLanguageAvailable,
resolveLanguageKey,
} from '../tree-sitter/parser-loader.js';
import { parseSourceSafe } from '../tree-sitter/safe-parse.js';
const parserCache = new Map<string, any>();
@@ -29,7 +30,7 @@ export const ensureAndParse = async (content: string, filePath: string): Promise
parserCache.set(parserKey, parserInstance);
}
return parserInstance.parse(content);
return parseSourceSafe(parserInstance, content);
};
const FUNCTION_LIKE_TYPES = new Set([
+75 -104
View File
@@ -1,6 +1,8 @@
import os from 'node:os';
import { join } from 'node:path';
import { CircuitBreaker, withRetry } from 'gitnexus-shared';
// ---------------------------------------------------------------------------
// Download resilience defaults
// ---------------------------------------------------------------------------
@@ -108,70 +110,19 @@ export function isNetworkFetchError(message: string): boolean {
/** @internal Used by `withHfDownloadRetry` to mark a circuit-open rejection. */
export const CIRCUIT_OPEN_TAG = 'hf-circuit-open';
/** Circuit-breaker states. */
type CircuitState = 'closed' | 'open' | 'half-open';
/**
* Circuit breaker for HuggingFace model downloads.
*
* After `failureThreshold` consecutive network failures the circuit opens and
* all subsequent calls to `withHfDownloadRetry` fail immediately without
* issuing any network requests. After `resetTimeoutMs` the circuit enters the
* half-open state and the next call is attempted — if it succeeds the circuit
* closes again; if it fails the circuit re-opens.
*
* Exported for unit-testing; production code should use the module-level
* `hfDownloadCircuit` singleton.
* Module-level singleton shared by both embedder entry points
* (`core/embeddings/embedder.ts` + `mcp/core/embedder.ts`). Per-process
* only — not persisted across restarts. Backed by the shared
* `CircuitBreaker` from `gitnexus-shared` (same state machine, same
* semantics, plus the single-permit half-open gate that prevents
* recovery-time stampedes).
*/
export class HfDownloadCircuitBreaker {
private _state: CircuitState = 'closed';
private _failures = 0;
/** Timestamp of the last recorded failure (ms since epoch). */
lastFailureAt = 0;
constructor(
readonly failureThreshold: number = CB_FAILURE_THRESHOLD,
readonly resetTimeoutMs: number = CB_RESET_TIMEOUT_MS,
) {}
/** Effective state, factoring in the reset-timeout transition. */
get state(): CircuitState {
if (this._state === 'open' && Date.now() - this.lastFailureAt > this.resetTimeoutMs) {
this._state = 'half-open';
}
return this._state;
}
/** Returns true when the circuit is open and calls should be rejected. */
isOpen(): boolean {
return this.state === 'open';
}
/** Record a successful call — resets the failure counter and closes the circuit. */
recordSuccess(): void {
this._failures = 0;
this._state = 'closed';
}
/** Record a failed call — increments the counter and opens the circuit when the threshold is reached. */
recordFailure(): void {
this._failures++;
this.lastFailureAt = Date.now();
if (this._failures >= this.failureThreshold) {
this._state = 'open';
}
}
/** @internal Reset to initial state (used in tests). */
reset(): void {
this._failures = 0;
this._state = 'closed';
this.lastFailureAt = 0;
}
}
/** Module-level singleton shared by both embedder entry points. */
export const hfDownloadCircuit = new HfDownloadCircuitBreaker();
export const hfDownloadCircuit = new CircuitBreaker({
failureThreshold: CB_FAILURE_THRESHOLD,
cooldownMs: CB_RESET_TIMEOUT_MS,
key: 'hf-download',
});
// ---------------------------------------------------------------------------
// Retry + timeout wrapper
@@ -219,11 +170,6 @@ export function withDownloadTimeout<T>(fn: () => Promise<T>, timeoutMs: number):
});
}
/** @internal Async sleep (exposed for testing). */
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export interface HfRetryOptions {
/** Maximum total attempts including the initial one (default: `HF_MAX_ATTEMPTS`). */
maxAttempts?: number;
@@ -235,7 +181,7 @@ export interface HfRetryOptions {
* Circuit-breaker instance to use. Defaults to the module-level
* `hfDownloadCircuit` singleton. Pass a fresh instance in tests.
*/
circuit?: HfDownloadCircuitBreaker;
circuit?: CircuitBreaker;
/**
* Optional callback invoked before each retry (not the initial attempt).
* @param attempt - 1-based retry number
@@ -295,49 +241,74 @@ export async function withHfDownloadRetry<T>(
circuit = hfDownloadCircuit,
onRetry,
} = options;
if (circuit.isOpen()) {
const secsUntilReset = Math.ceil(
(circuit.resetTimeoutMs - (Date.now() - circuit.lastFailureAt)) / 1000,
);
if (circuit.getState() === 'open') {
// Compute remaining cooldown without consuming a probe permit.
const openedAt = circuit.getOpenedAt();
const secsUntilReset =
openedAt !== null ? Math.ceil((circuit.getCooldownMs() - (Date.now() - openedAt)) / 1000) : 0;
throw new Error(
`${CIRCUIT_OPEN_TAG}: HuggingFace download circuit is open after repeated network failures` +
(secsUntilReset > 0 ? ` — will reset in ~${secsUntilReset}s` : ''),
);
}
let lastError: Error = new Error('unknown error');
// Retry budget delegated to `withRetry` from gitnexus-shared. The
// HF-specific bits — per-attempt timeout, network-vs-non-network
// classification, circuit-breaker recording, onRetry callback — wire
// through the `isRetryable` callback. `circuitTripped` is the
// sentinel that lets us replace the final thrown error with a
// CIRCUIT_OPEN_TAG message when the breaker tripped mid-loop.
let circuitTripped = false;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const result = await withDownloadTimeout(fn, timeoutMs);
circuit.recordSuccess();
return result;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (!isNetworkFetchError(lastError.message)) {
// Non-network error (e.g. CUDA unavailable) — propagate without retry
throw lastError;
}
circuit.recordFailure();
if (circuit.isOpen()) {
// Circuit just tripped — fail fast, no more retries
throw new Error(
`${CIRCUIT_OPEN_TAG}: HuggingFace download circuit opened after ${circuit.failureThreshold} consecutive failures`,
);
}
if (attempt < maxAttempts - 1) {
const delay = baseDelayMs * Math.pow(2, attempt);
onRetry?.(attempt + 1, maxAttempts, lastError);
await sleep(delay);
}
try {
return await withRetry(
async () => {
const result = await withDownloadTimeout(fn, timeoutMs);
circuit.recordSuccess();
return result;
},
{
maxAttempts,
baseDelayMs,
// Disable the cap to match the bespoke pure-exponential
// progression. With the default `HF_MAX_ATTEMPTS_CAP = 10` and
// `baseDelayMs = 2000`, the largest possible delay is
// `2000 * 2^9 = ~17 minutes` — bounded enough not to need a cap.
capDelayMs: Number.MAX_SAFE_INTEGER,
isRetryable: (err, attempt) => {
const error = err instanceof Error ? err : new Error(String(err));
if (!isNetworkFetchError(error.message)) {
// Non-network error (e.g. CUDA unavailable) — propagate
// without retry. Use recordNeutral so the breaker's existing
// failure-count progress isn't reset by a non-network failure
// that says nothing about the CDN's health.
circuit.recordNeutral();
return { retry: false };
}
circuit.recordFailure();
if (circuit.getState() === 'open') {
// Circuit just tripped — fail fast, no more retries.
circuitTripped = true;
return { retry: false };
}
// Mirror the bespoke onRetry contract: fire only when there's
// actually a next attempt.
if (attempt + 1 < maxAttempts) {
onRetry?.(attempt + 1, maxAttempts, error);
}
return { retry: true };
},
},
);
} catch (err) {
if (circuitTripped) {
throw new Error(
`${CIRCUIT_OPEN_TAG}: HuggingFace download circuit opened after ${CB_FAILURE_THRESHOLD} consecutive failures`,
);
}
// All retries exhausted — rethrow the last network error so
// isNetworkFetchError patterns in the calling code still match and
// surface HF_ENDPOINT guidance.
throw err;
}
// All retries exhausted — throw the last network error so isNetworkFetchError
// patterns in the calling code still match and surface HF_ENDPOINT guidance.
throw lastError;
}
+75 -29
View File
@@ -3,13 +3,22 @@
*
* Shared fetch+retry logic for OpenAI-compatible /v1/embeddings endpoints.
* Imported by both the core embedder (batch) and MCP embedder (query).
*
* Network resilience is delegated to `resilientFetch` from
* `gitnexus-shared` — bounded retries with exponential-backoff jitter,
* `Retry-After` honored on 429, and an in-process circuit breaker that
* fails fast on a flapping endpoint. Per-attempt timeout is enforced
* via `AbortSignal.timeout` on the underlying fetch.
*/
import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from 'gitnexus-shared';
const HTTP_TIMEOUT_MS = 30_000;
const HTTP_MAX_RETRIES = 2;
const HTTP_RETRY_BACKOFF_MS = 1_000;
const HTTP_BATCH_SIZE = 64;
const DEFAULT_DIMS = 384;
const HTTP_BREAKER_KEY = 'embeddings-http';
interface HttpConfig {
baseUrl: string;
@@ -31,8 +40,11 @@ const readConfig = (): HttpConfig | null => {
const rawDims = process.env.GITNEXUS_EMBEDDING_DIMS;
let dimensions: number | undefined;
if (rawDims !== undefined) {
if (!/^\d+$/.test(rawDims)) {
throw new Error(`GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${rawDims}"`);
}
const parsed = parseInt(rawDims, 10);
if (Number.isNaN(parsed) || parsed <= 0) {
if (parsed <= 0) {
throw new Error(`GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${rawDims}"`);
}
dimensions = parsed;
@@ -82,7 +94,13 @@ interface EmbeddingItem {
* @param model - Model name for the request body
* @param apiKey - Bearer token (only used in Authorization header)
* @param batchIndex - Logical batch number (for error context)
* @param attempt - Current retry attempt (internal)
* @param dimensions - Optional output-vector size. When provided, sent as
* the `dimensions` field in the request body. Endpoints that implement
* Matryoshka truncation (OpenAI text-embedding-3-*, Cohere embed-v3,
* Voyage) return a truncated vector at that size; endpoints that do not
* recognise the field may ignore it or return 400. Leave
* `GITNEXUS_EMBEDDING_DIMS` unset for strict backends that reject
* unknown fields.
*/
const httpEmbedBatch = async (
url: string,
@@ -90,46 +108,60 @@ const httpEmbedBatch = async (
model: string,
apiKey: string,
batchIndex = 0,
attempt = 0,
dimensions?: number,
): Promise<EmbeddingItem[]> => {
const requestBody: { input: string[]; model: string; dimensions?: number } = {
input: batch,
model,
};
if (dimensions !== undefined) {
requestBody.dimensions = dimensions;
}
let resp: Response;
try {
resp = await fetch(url, {
method: 'POST',
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
resp = await resilientFetch(
url,
{
method: 'POST',
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(requestBody),
},
body: JSON.stringify({ input: batch, model }),
});
{
breakerKey: HTTP_BREAKER_KEY,
retry: { maxAttempts: HTTP_MAX_RETRIES + 1, baseDelayMs: HTTP_RETRY_BACKOFF_MS },
},
);
} catch (err) {
// Timeouts should not be retried — the server is unresponsive.
// AbortSignal.timeout() throws DOMException with name 'TimeoutError'.
const isTimeout = err instanceof DOMException && err.name === 'TimeoutError';
if (isTimeout) {
if (err instanceof CircuitOpenError) {
throw new Error(
`Embedding endpoint circuit open (${safeUrl(url)}, batch ${batchIndex}): retry in ${Math.ceil(err.retryAfterMs / 1000)}s`,
);
}
if (err instanceof DOMException && err.name === 'TimeoutError') {
throw new Error(
`Embedding request timed out after ${HTTP_TIMEOUT_MS}ms (${safeUrl(url)}, batch ${batchIndex})`,
);
}
// DNS, connection errors — retry with backoff
if (attempt < HTTP_MAX_RETRIES) {
const delay = HTTP_RETRY_BACKOFF_MS * (attempt + 1);
await new Promise((r) => setTimeout(r, delay));
return httpEmbedBatch(url, batch, model, apiKey, batchIndex, attempt + 1);
if (err instanceof ResilientFetchExhaustedError) {
throw new Error(
`Embedding endpoint returned ${err.response.status} (${safeUrl(url)}, batch ${batchIndex})`,
);
}
const reason = err instanceof Error ? err.message : String(err);
throw new Error(`Embedding request failed (${safeUrl(url)}, batch ${batchIndex}): ${reason}`);
}
if (!resp.ok) {
const status = resp.status;
if ((status === 429 || status >= 500) && attempt < HTTP_MAX_RETRIES) {
const delay = HTTP_RETRY_BACKOFF_MS * (attempt + 1);
await new Promise((r) => setTimeout(r, delay));
return httpEmbedBatch(url, batch, model, apiKey, batchIndex, attempt + 1);
}
throw new Error(`Embedding endpoint returned ${status} (${safeUrl(url)}, batch ${batchIndex})`);
// resilientFetch already retried 5xx/429; any non-OK response here is
// a terminal client error (4xx other than 429).
throw new Error(
`Embedding endpoint returned ${resp.status} (${safeUrl(url)}, batch ${batchIndex})`,
);
}
const data = (await resp.json()) as { data: EmbeddingItem[] };
@@ -155,7 +187,14 @@ export const httpEmbed = async (texts: string[]): Promise<Float32Array[]> => {
for (let i = 0; i < texts.length; i += HTTP_BATCH_SIZE) {
const batch = texts.slice(i, i + HTTP_BATCH_SIZE);
const batchIndex = Math.floor(i / HTTP_BATCH_SIZE);
const items = await httpEmbedBatch(url, batch, config.model, config.apiKey, batchIndex);
const items = await httpEmbedBatch(
url,
batch,
config.model,
config.apiKey,
batchIndex,
config.dimensions,
);
if (items.length !== batch.length) {
throw new Error(
@@ -198,7 +237,14 @@ export const httpEmbedQuery = async (text: string): Promise<number[]> => {
if (!config) throw new Error('HTTP embedding not configured');
const url = `${config.baseUrl}/embeddings`;
const items = await httpEmbedBatch(url, [text], config.model, config.apiKey);
const items = await httpEmbedBatch(
url,
[text],
config.model,
config.apiKey,
0,
config.dimensions,
);
if (!items.length) {
throw new Error(`Embedding endpoint returned empty response (${safeUrl(url)})`);
}
+8 -6
View File
@@ -722,13 +722,15 @@ export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHand
await new Promise((r) => setTimeout(r, delay));
}
}
// Pino's NDJSON serialization is structurally injection-resistant
// (CodeQL js/log-injection): groupDir and err.message are JSON-escaped
// by the serializer, so no manual CRLF / U+2028 / ANSI sanitization is
// needed. Demoted to debug — only fires when the bridge truly gave up
// after retries, and operators only need it at debug verbosity.
// Strip CRLF from user-controlled strings before logging to close
// CodeQL js/log-injection. Pino's NDJSON serialization already
// JSON-escapes all values, but we sanitize here as a defence-in-depth
// measure so CodeQL can see the taint flow is broken.
const safeGroupDir = String(groupDir).replace(/[\r\n]/g, ' ');
const safeErrMsg =
lastErr instanceof Error ? String(lastErr.message).replace(/[\r\n]/g, ' ') : undefined;
bridgeLogger.debug(
{ groupDir, err: lastErr, attempts: LBUG_OPEN_RETRY_ATTEMPTS },
{ groupDir: safeGroupDir, errMsg: safeErrMsg, attempts: LBUG_OPEN_RETRY_ATTEMPTS },
'openBridgeDbReadOnly gave up',
);
return null;
+19 -1
View File
@@ -4,9 +4,26 @@ import type { GroupConfig, GroupManifestLink, ContractType, ContractRole } from
const _require = createRequire(import.meta.url);
const yaml = _require('js-yaml') as typeof import('js-yaml');
const VALID_CONTRACT_TYPES: ContractType[] = ['http', 'grpc', 'thrift', 'topic', 'lib', 'custom'];
const VALID_CONTRACT_TYPES: ContractType[] = [
'http',
'grpc',
'thrift',
'topic',
'lib',
'custom',
'include',
];
const VALID_ROLES: ContractRole[] = ['provider', 'consumer'];
// Defaults matter for backward compatibility: any group.yaml that omits a
// `detect.<type>` key inherits its value from this constant. Adding a new
// extractor that defaults to `true` silently changes the behavior of every
// existing group on the next sync. New extractors must default to `false`
// (opt-in) so operators consciously enable them via group.yaml.
//
// `includes`: opt-in. The C/C++ IncludeExtractor (PR #1156) ships disabled by
// default; enable with `detect.includes: true` for groups containing C/C++
// repos that need cross-repo header tracking.
const DEFAULT_DETECT = {
http: true,
grpc: true,
@@ -14,6 +31,7 @@ const DEFAULT_DETECT = {
topics: true,
shared_libs: true,
embedding_fallback: true,
includes: false,
workspace_deps: false,
};
@@ -5,6 +5,7 @@ import { createIgnoreFilter } from '../../../config/ignore-service.js';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
import { readSafe } from './fs-utils.js';
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
import { logger } from '../../logger.js';
import {
GRPC_SCAN_GLOB,
@@ -428,7 +429,7 @@ export class GrpcExtractor implements ContractExtractor {
let detections: GrpcDetection[] = [];
try {
parser.setLanguage(plugin.language);
const tree = parser.parse(content);
const tree = parseSourceSafe(parser, content);
detections = plugin.scan(tree);
} catch {
continue;
@@ -1,3 +1,4 @@
import type Parser from 'tree-sitter';
import Python from 'tree-sitter-python';
import {
compilePatterns,
@@ -12,6 +13,7 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js';
* - FastAPI `@app.get("/path")` provider decorators
* - `requests.get/post/...("url")` consumer calls
* - Generic `requests.request("METHOD", "url")` consumer calls
* - `httpx.AsyncClient` instances calling `.get/.post/...("url")`
*/
const FASTAPI_VERBS: Record<string, string> = {
@@ -77,11 +79,161 @@ const REQUESTS_GENERIC_PATTERNS = compilePatterns({
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: httpx.AsyncClient assignments ────────────────────────
// NOTE: This targeted detector only tracks explicit `httpx.AsyncClient(...)`
// construction. Direct imports (`from httpx import AsyncClient`) and module
// aliases (`import httpx as hx`) and annotated assignments (`client: httpx.AsyncClient = ...`)
// are intentionally left for a follow-up. Module-scope clients are only matched
// at module scope; calls inside functions require a function/class-local tracked
// client to avoid false positives from same-name local variables.
const HTTPX_ASYNC_CLIENT_ASSIGN_PATTERNS = compilePatterns({
name: 'python-httpx-async-client-assign',
language: Python,
patterns: [
{
meta: {},
query: `
(assignment
left: (_) @client
right: (call
function: (attribute
object: (identifier) @module (#eq? @module "httpx")
attribute: (identifier) @client_class (#eq? @client_class "AsyncClient"))))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: async with httpx.AsyncClient() as client ──────────────
const HTTPX_ASYNC_CLIENT_WITH_ALIAS_PATTERNS = compilePatterns({
name: 'python-httpx-async-client-with-alias',
language: Python,
patterns: [
{
meta: {},
query: `
(as_pattern
(call
function: (attribute
object: (identifier) @module (#eq? @module "httpx")
attribute: (identifier) @client_class (#eq? @client_class "AsyncClient")))
(as_pattern_target (identifier) @client))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
function getScopeKey(node: Parser.SyntaxNode | null, preferClass = false): string {
if (preferClass) {
let current: Parser.SyntaxNode | null = node;
while (current) {
if (current.type === 'class_definition') {
return `class:${current.startIndex}:${current.endIndex}`;
}
current = current.parent;
}
}
let current: Parser.SyntaxNode | null = node;
while (current) {
if (current.type === 'function_definition') {
return `function:${current.startIndex}:${current.endIndex}`;
}
current = current.parent;
}
return 'module';
}
function trackedClientScopeKey(clientNode: Parser.SyntaxNode): string {
return getScopeKey(clientNode.parent, clientNode.text.includes('.'));
}
function callScopeKeys(clientNode: Parser.SyntaxNode): string[] {
const keys = new Set<string>();
const preferClass = clientNode.text.includes('.');
const nearestScope = getScopeKey(clientNode.parent, preferClass);
keys.add(nearestScope);
return [...keys];
}
function collectHttpxAsyncClients(tree: Parser.Tree): Map<string, Set<string>> {
const clients = new Map<string, Set<string>>();
const addClient = (clientNode: Parser.SyntaxNode | undefined) => {
if (!clientNode) return;
const scopeKey = trackedClientScopeKey(clientNode);
const clientText = clientNode.text;
const scopes = clients.get(clientText) ?? new Set<string>();
scopes.add(scopeKey);
clients.set(clientText, scopes);
};
for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_ASSIGN_PATTERNS, tree)) {
addClient(match.captures.client);
}
for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_WITH_ALIAS_PATTERNS, tree)) {
addClient(match.captures.client);
}
return clients;
}
function hasTrackedHttpxAsyncClient(
clients: Map<string, Set<string>>,
clientNode: Parser.SyntaxNode,
): boolean {
const scopes = clients.get(clientNode.text);
if (!scopes) return false;
return callScopeKeys(clientNode).some((scopeKey) => scopes.has(scopeKey));
}
// ─── Consumer: httpx AsyncClient .get/.post/...("url") ──────────────
const HTTPX_ASYNC_CLIENT_VERB_PATTERNS = compilePatterns({
name: 'python-httpx-async-client-verb',
language: Python,
patterns: [
{
meta: {},
query: `
(call
function: (attribute
object: (_) @client
attribute: (identifier) @method (#match? @method "^(get|post|put|delete|patch)$"))
arguments: (argument_list . (string) @path))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: httpx AsyncClient .request("METHOD", "url") ─────────
const HTTPX_ASYNC_CLIENT_GENERIC_PATTERNS = compilePatterns({
name: 'python-httpx-async-client-generic',
language: Python,
patterns: [
{
meta: {},
query: `
(call
function: (attribute
object: (_) @client
attribute: (identifier) @method (#eq? @method "request"))
arguments: (argument_list . (string) @http_method (string) @path))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
name: 'python-http',
language: Python,
scan(tree) {
const out: HttpDetection[] = [];
const httpxAsyncClients = collectHttpxAsyncClients(tree);
// Providers: FastAPI
for (const match of runCompiledPatterns(FASTAPI_PATTERNS, tree)) {
@@ -137,6 +289,45 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
});
}
// Consumers: httpx.AsyncClient.<verb>("url")
for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_VERB_PATTERNS, tree)) {
const clientNode = match.captures.client;
const methodNode = match.captures.method;
const pathNode = match.captures.path;
if (!clientNode || !methodNode || !pathNode) continue;
if (!hasTrackedHttpxAsyncClient(httpxAsyncClients, clientNode)) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
out.push({
role: 'consumer',
framework: 'python-httpx',
method: methodNode.text.toUpperCase(),
path,
name: null,
confidence: 0.7,
});
}
// Consumers: httpx.AsyncClient.request("METHOD", "url")
for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_GENERIC_PATTERNS, tree)) {
const clientNode = match.captures.client;
const methodNode = match.captures.http_method;
const pathNode = match.captures.path;
if (!clientNode || !methodNode || !pathNode) continue;
if (!hasTrackedHttpxAsyncClient(httpxAsyncClients, clientNode)) continue;
const methodRaw = unquoteLiteral(methodNode.text);
const path = unquoteLiteral(pathNode.text);
if (methodRaw === null || path === null) continue;
out.push({
role: 'consumer',
framework: 'python-httpx',
method: methodRaw.toUpperCase(),
path,
name: null,
confidence: 0.7,
});
}
return out;
},
};
@@ -5,6 +5,7 @@ import { createIgnoreFilter } from '../../../config/ignore-service.js';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
import { readSafe } from './fs-utils.js';
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
import { getPluginForFile, HTTP_SCAN_GLOB, type HttpDetection } from './http-patterns/index.js';
/**
@@ -172,7 +173,7 @@ export class HttpRouteExtractor implements ContractExtractor {
}
try {
parser.setLanguage(plugin.language);
const tree = parser.parse(content);
const tree = parseSourceSafe(parser, content);
const detections = plugin.scan(tree);
cachedDetections.set(rel, detections);
return detections;
@@ -0,0 +1,611 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { glob } from 'glob';
import Parser from 'tree-sitter';
import C from 'tree-sitter-c';
import Cpp from 'tree-sitter-cpp';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
import { readSafe } from './fs-utils.js';
import { buildSuffixIndex, type SuffixIndex } from '../../ingestion/import-resolvers/utils.js';
import { createIgnoreFilter } from '../../../config/ignore-service.js';
import { getMaxFileSizeBytes } from '../../ingestion/utils/max-file-size.js';
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
import { logger } from '../../logger.js';
/**
* Cross-repo C/C++ `#include` dependency extractor.
*
* **Provider side:** registers every `.h/.hpp/.hxx/.hh` file in the repo
* as a provider contract with `include::<relative-path>`.
*
* **Consumer side:** parses all C/C++ source/header files for `#include "…"`
* directives, attempts suffix-based resolution against the repo's own file
* list (reusing the same algorithm as the single-repo ingestion pipeline),
* and emits unresolved include paths as consumer contracts.
*
* Matching: a consumer's `include::map/base/dice_map_view.h` in repo A
* matches a provider's `include::map/base/dice_map_view.h` in repo B via
* exact contract-id equality in `runExactMatch`.
*/
// ---------- constants ----------
const HEADER_EXTENSIONS = new Set(['.h', '.hpp', '.hxx', '.hh']);
// Source = headers (provider-eligible) ∪ implementation files (.c/.cpp/.cc/.cxx).
// Spread keeps the subset relationship explicit so a future contributor adding
// a new header extension to HEADER_EXTENSIONS does not have to remember to
// also add it here.
const SOURCE_EXTENSIONS = new Set<string>([...HEADER_EXTENSIONS, '.c', '.cpp', '.cc', '.cxx']);
const INCLUDE_QUERY_SRC = '(preproc_include path: (_) @import.source) @import';
/**
* Well-known C/C++ standard library headers that can appear in `#include "…"`
* form (some projects use quotes for system headers).
*/
const SYSTEM_HEADERS = new Set([
// C standard
'assert.h',
'complex.h',
'ctype.h',
'errno.h',
'fenv.h',
'float.h',
'inttypes.h',
'iso646.h',
'limits.h',
'locale.h',
'math.h',
'setjmp.h',
'signal.h',
'stdalign.h',
'stdarg.h',
'stdatomic.h',
'stdbool.h',
'stddef.h',
'stdint.h',
'stdio.h',
'stdlib.h',
'stdnoreturn.h',
'string.h',
'tgmath.h',
'threads.h',
'time.h',
'uchar.h',
'wchar.h',
'wctype.h',
// C++ standard (extensionless)
'algorithm',
'any',
'array',
'atomic',
'barrier',
'bit',
'bitset',
'cassert',
'cctype',
'cerrno',
'cfenv',
'cfloat',
'charconv',
'chrono',
'cinttypes',
'climits',
'clocale',
'cmath',
'codecvt',
'compare',
'complex',
'concepts',
'condition_variable',
'coroutine',
'csetjmp',
'csignal',
'cstdarg',
'cstddef',
'cstdint',
'cstdio',
'cstdlib',
'cstring',
'ctime',
'cuchar',
'cwchar',
'cwctype',
'deque',
'exception',
'execution',
'expected',
'filesystem',
'format',
'forward_list',
'fstream',
'functional',
'future',
'generator',
'initializer_list',
'iomanip',
'ios',
'iosfwd',
'iostream',
'istream',
'iterator',
'latch',
'limits',
'list',
'locale',
'map',
'mdspan',
'memory',
'memory_resource',
'mutex',
'new',
'numbers',
'numeric',
'optional',
'ostream',
'print',
'queue',
'random',
'ranges',
'ratio',
'regex',
'scoped_allocator',
'semaphore',
'set',
'shared_mutex',
'source_location',
'span',
'spanstream',
'sstream',
'stack',
'stacktrace',
'stdexcept',
'stdfloat',
'stop_token',
'streambuf',
'string',
'string_view',
'strstream',
'syncstream',
'system_error',
'thread',
'tuple',
'type_traits',
'typeindex',
'typeinfo',
'unordered_map',
'unordered_set',
'utility',
'valarray',
'variant',
'vector',
'version',
]);
/** Path prefixes that indicate system/kernel headers. */
const SYSTEM_PATH_PREFIXES = [
'sys/',
'net/',
'netinet/',
'arpa/',
'linux/',
'asm/',
'bits/',
'gnu/',
'mach/',
'machine/',
'xlocale/',
];
/** Regex fallback for files that exceed tree-sitter's 32 KB parse limit. */
const INCLUDE_REGEX = /^[ \t]*#\s*include\s*"([^"]+)"/gm;
// ---------- helpers ----------
/**
* Normalize an include path to a canonical lowercase forward-slash form.
*
* IMPORTANT — case-folding caveat (PR #1156 review finding #3):
* Header paths are lowercased so consumer `#include "Foo/Bar.h"` and
* provider file `Foo/Bar.h` normalize to the same contract-id. This is
* the right trade-off on case-insensitive filesystems (macOS, Windows)
* but on case-sensitive Linux filesystems two distinct headers `Foo.h`
* and `foo.h` in the same repo will collide onto the same provider
* contract-id; only one survives `dedupe()`. The gain (reliable
* cross-platform matching) outweighs the cost (extremely rare header
* casing collisions inside a single repo).
*/
function normalizeIncludePath(raw: string): string {
return raw.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+/g, '/').toLowerCase();
}
/**
* Strip C/C++ block comments from a source blob. Used only by the
* regex-fallback path to avoid emitting consumer contracts for
* commented-out #include directives. Line comments (`// …`) cannot hide
* #include directives because the regex anchors on start-of-line.
* See PR #1156 review finding #5.
*/
function stripBlockComments(src: string): string {
return src.replace(/\/\*[\s\S]*?\*\//g, '');
}
function isAngleBracketInclude(rawNodeText: string): boolean {
const trimmed = rawNodeText.trim();
return trimmed.startsWith('<') && trimmed.endsWith('>');
}
function isSystemHeader(cleanedPath: string): boolean {
// Check well-known standard headers
if (SYSTEM_HEADERS.has(cleanedPath)) return true;
// Check system path prefixes
const lower = cleanedPath.toLowerCase();
return SYSTEM_PATH_PREFIXES.some((prefix) => lower.startsWith(prefix));
}
function isHeaderFile(filePath: string): boolean {
return HEADER_EXTENSIONS.has(path.extname(filePath).toLowerCase());
}
function getLanguageForFile(filePath: string): unknown | null {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case '.c':
case '.h':
return C;
case '.cpp':
case '.cc':
case '.cxx':
case '.hpp':
case '.hxx':
case '.hh':
return Cpp;
default:
return null;
}
}
/**
* Check whether an include path resolves to a file inside the local repo.
*
* Uses *exact full-path* matching on the suffix index — we never accept a
* truncated suffix match. For `#include "foo/bar.h"` this checks:
* (a) a file whose path ends with the full `foo/bar.h`
* (b) if the include omitted the extension, a file whose path ends with
* the include + one of the C/C++ header extensions
*
* Returns `true` when a local file matches — caller should suppress the
* cross-repo consumer contract.
*
* See PR #1156 review finding #4 (suffixResolve ambiguity).
*/
function isLocalInclude(cleaned: string, suffixIndex: SuffixIndex): boolean {
const candidates = [cleaned];
if (!/\.[a-zA-Z0-9]+$/.test(cleaned)) {
for (const ext of ['.h', '.hpp', '.hxx', '.hh']) candidates.push(cleaned + ext);
}
for (const c of candidates) {
if (suffixIndex.get(c) || suffixIndex.getInsensitive(c)) return true;
}
return false;
}
// ---------- main class ----------
export class IncludeExtractor implements ContractExtractor {
type = 'include' as const;
/**
* Always returns `true`. NOT called by `sync.ts`, which gates extraction via
* `config.detect.includes` instead (see `sync.ts:174`). Kept solely to satisfy
* the `ContractExtractor` interface so the type stays uniform across extractors.
*/
async canExtract(_repo: RepoHandle): Promise<boolean> {
return true;
}
async extract(
dbExecutor: CypherExecutor | null,
repoPath: string,
_repo: RepoHandle,
): Promise<ExtractedContract[]> {
// 1. Build the local file list using the same discovery as ingestion
// (createIgnoreFilter + getMaxFileSizeBytes). This guarantees the
// universe of provider/consumer paths matches the universe of File
// nodes in the LadybugDB graph — so no cross-link points at a UID
// that group impact cannot fan out to.
// (PR #1156 Codex follow-up: discovery aligned with ingestion.)
const allFiles = await this.discoverIndexableFiles(repoPath);
const normalizedFiles = allFiles.map((f) => f.replace(/\\/g, '/'));
const suffixIndex = buildSuffixIndex(normalizedFiles, allFiles);
// 2. Provider: register all header files
const providers = await this.extractProviders(dbExecutor, repoPath, allFiles);
// 3. Consumer: filter the shared discovery list for source extensions
// and parse #include directives in those files.
const sourceFiles = allFiles.filter((f) =>
SOURCE_EXTENSIONS.has(path.extname(f).toLowerCase()),
);
const consumers = await this.extractConsumers(repoPath, sourceFiles, suffixIndex);
return this.dedupe([...providers, ...consumers]);
}
/**
* Discover repo-relative file paths using exactly the same rules the
* ingestion pipeline uses (`walkRepositoryPaths` in
* `gitnexus/src/core/ingestion/filesystem-walker.ts`):
* - `createIgnoreFilter` honors `.gitignore`, `.gitnexusignore`, the
* hardcoded ignore list, and `.gitnexusignore` last-match-wins
* negation.
* - `getMaxFileSizeBytes()` drops files larger than the cap so we
* never emit `File:<rel>` UIDs for files ingestion would skip.
*
* Uses sequential stat — there is no `READ_CONCURRENCY` batching here
* because group sync runs at startup-time, not the ingestion hot path,
* and parallelism gains are not worth the import-graph weight.
*
* MAINTENANCE: if `walkRepositoryPaths` changes its glob options, ignore
* filter shape, or size-cap logic, mirror those changes here. The two
* implementations exist because the consumers need different return
* shapes (string[] vs ScannedFile[]) and different concurrency, but
* they MUST agree on which files are reachable — that is what makes
* `File:<rel>` UIDs in cross-links correspond to graph File nodes.
*/
private async discoverIndexableFiles(repoPath: string): Promise<string[]> {
const ignoreFilter = await createIgnoreFilter(repoPath);
const maxFileSizeBytes = getMaxFileSizeBytes();
const candidates = await glob('**/*', {
cwd: repoPath,
nodir: true,
dot: false,
ignore: ignoreFilter,
});
const survivors: string[] = [];
for (const rel of candidates) {
try {
const stat = await fs.stat(path.join(repoPath, rel));
if (stat.size > maxFileSizeBytes) continue;
survivors.push(rel);
} catch (err) {
// ENOENT is the documented benign race (glob enumerated a file
// that was deleted before we stat'd it — same race
// walkRepositoryPaths absorbs via Promise.allSettled). Anything
// else (EACCES, EMFILE, EIO) deserves a warning so an operator
// can spot a permission/resource problem instead of silently
// shipping fewer contracts than expected.
const code = (err as NodeJS.ErrnoException | undefined)?.code;
if (code !== 'ENOENT') {
logger.warn(
{ err: (err as Error).message, file: rel, repoPath },
'⚠️ IncludeExtractor: stat failed during discovery; skipping file',
);
}
}
}
return survivors;
}
// ---------- provider extraction ----------
private async extractProviders(
dbExecutor: CypherExecutor | null,
repoPath: string,
allFiles: string[],
): Promise<ExtractedContract[]> {
// Strategy A: graph-assisted
if (dbExecutor) {
const graphProviders = await this.extractProvidersGraph(dbExecutor, repoPath);
if (graphProviders.length > 0) return graphProviders;
}
// Strategy B: filesystem fallback
return this.extractProvidersFallback(repoPath, allFiles);
}
private async extractProvidersGraph(
db: CypherExecutor,
repoPath: string,
): Promise<ExtractedContract[]> {
try {
const rows = await db(
`MATCH (f:File)
WHERE f.filePath =~ '.*\\\\.(h|hpp|hxx|hh)$'
RETURN f.filePath AS filePath, f.id AS fileId`,
);
// gitnexus analyze stores absolute paths in the File.filePath column.
// Provider contract IDs MUST be repo-relative — otherwise the consumer
// emits `include::map/base/view.h` and the provider emits
// `include::/abs/path/to/repo/map/base/view.h`, which never match
// through runExactMatch and the cross-link silently disappears.
// (PR #1156 follow-up review: graph provider absolute-path bug.)
const normalizedRepoPath = path.resolve(repoPath);
const out: ExtractedContract[] = [];
for (const r of rows) {
if (typeof r.filePath !== 'string' || !r.filePath) continue;
const absolute = r.filePath as string;
const rel = path.relative(normalizedRepoPath, absolute);
// Skip rows that resolve outside the repo (e.g., system headers
// somehow indexed, or stale absolute paths from a different machine).
// path.relative returns a `..`-prefixed path or an absolute path
// when the target is outside the base — both are wrong for our IDs.
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue;
const normalizedRel = rel.replace(/\\/g, '/');
out.push({
contractId: `include::${normalizeIncludePath(normalizedRel)}`,
type: 'include' as const,
role: 'provider' as const,
symbolUid: String(r.fileId ?? ''),
symbolRef: { filePath: normalizedRel, name: path.basename(normalizedRel) },
symbolName: path.basename(normalizedRel),
confidence: 1.0,
meta: { source: 'graph' },
});
}
return out;
} catch {
return [];
}
}
private extractProvidersFallback(_repoPath: string, allFiles: string[]): ExtractedContract[] {
return allFiles
.filter((f) => isHeaderFile(f))
.map((f) => {
const filePath = f.replace(/\\/g, '/');
return {
contractId: `include::${normalizeIncludePath(filePath)}`,
type: 'include' as const,
role: 'provider' as const,
symbolUid: `File:${filePath}`,
symbolRef: { filePath, name: path.basename(filePath) },
symbolName: path.basename(filePath),
confidence: 0.95,
meta: { source: 'filesystem' },
};
});
}
// ---------- consumer extraction ----------
private async extractConsumers(
repoPath: string,
sourceFiles: string[],
suffixIndex: SuffixIndex,
): Promise<ExtractedContract[]> {
const parser = new Parser();
const out: ExtractedContract[] = [];
// Compile the include query once per grammar to avoid re-compilation per file
const queryCache = new Map<unknown, Parser.Query>();
for (const rel of sourceFiles) {
const lang = getLanguageForFile(rel);
if (!lang) continue;
const content = readSafe(repoPath, rel);
if (!content) continue;
let query = queryCache.get(lang);
if (!query) {
try {
query = new Parser.Query(lang, INCLUDE_QUERY_SRC);
queryCache.set(lang, query);
} catch {
continue;
}
}
// Collect raw include paths: tree-sitter first, regex fallback for large files.
// `extractionSource` is stamped on each emitted consumer contract so
// regex-fallback contracts stay auditable post-hoc (PR #1156 review finding #6).
let rawIncludes: string[];
let extractionSource: 'tree_sitter' | 'regex_fallback';
try {
parser.setLanguage(lang);
const tree = parseSourceSafe(parser, content);
let matches: Parser.QueryMatch[];
try {
matches = query.matches(tree.rootNode);
} catch {
matches = [];
}
rawIncludes = [];
extractionSource = 'tree_sitter';
for (const match of matches) {
const sourceNode = match.captures.find((c) => c.name === 'import.source');
if (!sourceNode) continue;
const rawText = sourceNode.node.text;
if (isAngleBracketInclude(rawText)) continue;
const cleaned = rawText.replace(/['"<>]/g, '');
if (cleaned && cleaned.length <= 2048) rawIncludes.push(cleaned);
}
} catch {
// tree-sitter failed (e.g. file > 32 KB) — fall back to regex.
// Strip block comments first so we don't emit a consumer contract
// for a commented-out #include (PR #1156 review finding #5).
rawIncludes = [];
extractionSource = 'regex_fallback';
const scanTarget = stripBlockComments(content);
INCLUDE_REGEX.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = INCLUDE_REGEX.exec(scanTarget)) !== null) {
if (m[1] && m[1].length <= 2048) rawIncludes.push(m[1]);
}
}
for (const cleaned of rawIncludes) {
// Filter: skip known system headers and system path prefixes
if (isSystemHeader(cleaned)) continue;
// Skip relative-up includes: `#include "../include/foo.h"` is
// almost always an intra-repo reference. The suffix index is built
// from repo-relative paths, so isLocalInclude can never match
// `../foo.h`, and emitting it as a consumer contract just pollutes
// the registry with an entry no provider can ever satisfy.
// (PR #1156 follow-up review: `../` relative includes produce
// spurious consumer contracts.)
if (cleaned.startsWith('../') || cleaned.startsWith('..\\')) continue;
// Skip macro-style includes: `#include PLATFORM_HEADER` parses as an
// identifier under tree-sitter's `(_) @import.source` wildcard. The
// identifier text passes the strip/clean step unchanged, so without
// this guard we would emit `include::platform_header` as a consumer
// contract — and no provider in any repo will ever expose a contract
// for a macro identifier (no file is named `PLATFORM_HEADER`). The
// contract would sit permanently orphaned in the registry. Real
// header references always contain a path separator (`/`, `\`) or an
// extension dot (`foo.h`), so an absent both is a reliable signal we
// are looking at a macro identifier. (PR #1156 follow-up review:
// macro includes emit orphaned consumer contracts.)
if (!/[./\\]/.test(cleaned)) continue;
// Local resolution (PR #1156 review finding #4): only accept an
// exact-suffix match on the *full* include path. The generic
// suffixResolve() iterates all truncated suffixes, which would
// silently suppress a cross-repo `#include "map/base/view.h"`
// when the local repo has any `internal/view.h` — a realistic
// false-negative in large C++ codebases. Here we only resolve
// locally if a file path ends with the complete include string
// (optionally re-appending one of the C/C++ header extensions
// when the include already omits it).
if (isLocalInclude(cleaned, suffixIndex)) continue;
// Unresolved: emit as consumer contract
const normalizedRel = rel.replace(/\\/g, '/');
out.push({
contractId: `include::${normalizeIncludePath(cleaned)}`,
type: 'include' as const,
role: 'consumer' as const,
symbolUid: `File:${normalizedRel}`,
symbolRef: { filePath: normalizedRel, name: cleaned },
symbolName: cleaned,
confidence: 0.85,
meta: {
source: extractionSource,
includePath: cleaned,
},
});
}
}
return out;
}
// ---------- deduplication ----------
private dedupe(items: ExtractedContract[]): ExtractedContract[] {
const seen = new Set<string>();
const out: ExtractedContract[] = [];
for (const c of items) {
const k = `${c.contractId}|${c.role}|${c.symbolRef.filePath}`;
if (seen.has(k)) continue;
seen.add(k);
out.push(c);
}
return out;
}
}
@@ -274,6 +274,14 @@ export class ManifestExtractor {
LIMIT 1`,
{ contract: link.contract },
);
} else if (link.type === 'include') {
rows = await executor(
`MATCH (f:File) WHERE f.filePath = $contract
RETURN f.id AS uid, f.name AS name, f.filePath AS filePath
ORDER BY f.filePath ASC
LIMIT 1`,
{ contract: link.contract },
);
} else if (link.type === 'custom') {
// Workspace extractors produce qualified contracts like "mathlex::Expression".
// Graph nodes store the unqualified symbol name ("Expression"), so strip
@@ -358,6 +366,8 @@ export class ManifestExtractor {
return `lib::${contract}`;
case 'custom':
return `custom::${contract}`;
case 'include':
return `include::${contract}`;
default: {
const _exhaustive: never = type;
throw new Error(`Unhandled ContractType: ${String(_exhaustive)}`);
@@ -3,6 +3,7 @@ import Parser from 'tree-sitter';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
import { readSafe } from './fs-utils.js';
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
import {
getPluginForFile,
THRIFT_SCAN_GLOB,
@@ -217,6 +218,10 @@ export async function buildThriftContext(repoPath: string): Promise<ThriftContex
cwd: repoPath,
absolute: false,
nodir: true,
// TODO(#1156-followup): replace this hand-rolled list with createIgnoreFilter
// (the canonical ingestion ignore filter, like include-extractor.ts now uses).
// New entries to DEFAULT_IGNORE_LIST in src/config/ignore-service.ts (e.g.
// third_party, 3rdparty added in commit a9936a9b) silently do not apply here.
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'],
});
const namespacesByThrift = new Map<string, string>();
@@ -290,6 +295,10 @@ export class ThriftExtractor implements ContractExtractor {
cwd: repoPath,
absolute: false,
nodir: true,
// TODO(#1156-followup): replace this hand-rolled list with createIgnoreFilter
// (the canonical ingestion ignore filter, like include-extractor.ts now uses).
// New entries to DEFAULT_IGNORE_LIST in src/config/ignore-service.ts (e.g.
// third_party, 3rdparty added in commit a9936a9b) silently do not apply here.
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'],
});
@@ -303,7 +312,7 @@ export class ThriftExtractor implements ContractExtractor {
let detections: ThriftDetection[] = [];
try {
parser.setLanguage(plugin.language);
const tree = parser.parse(content);
const tree = parseSourceSafe(parser, content);
detections = plugin.scan(tree);
} catch {
continue;
@@ -1,4 +1,5 @@
import Parser from 'tree-sitter';
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
/**
* Shared, language-agnostic tree-sitter scanning utilities used by group
@@ -155,7 +156,7 @@ export function scanFile<TMeta>(
let tree: Parser.Tree;
try {
parser.setLanguage(plugin.language);
tree = parser.parse(content);
tree = parseSourceSafe(parser, content);
} catch {
return [];
}
+2
View File
@@ -107,6 +107,8 @@ export function normalizeContractId(id: string): string {
return `topic::${rest.trim().toLowerCase()}`;
case 'lib':
return `lib::${rest.toLowerCase()}`;
case 'include':
return `include::${rest.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+/g, '/').toLowerCase()}`;
default:
return id;
}
+8 -1
View File
@@ -4,6 +4,7 @@ import * as path from 'node:path';
import * as os from 'node:os';
import { randomBytes } from 'node:crypto';
import type { ContractRegistry } from './types.js';
import { retryRename } from './bridge-db.js';
/**
* Build an unpredictable suffix for atomic-write tmp files. Replaces the
@@ -59,7 +60,13 @@ export async function writeContractRegistry(
} finally {
await handle.close();
}
await fsp.rename(tmpPath, targetPath);
// retryRename absorbs the documented Windows EPERM/EBUSY/EACCES race that
// fires when AV scanners or another concurrent rename briefly hold the
// destination handle between rename calls. Same helper bridge-db.ts uses
// (lines 304, 583, 587, 595, 605, 677) for the bridge.lbug atomic swap —
// single source of truth for the Windows-rename pattern across the group
// package.
await retryRename(tmpPath, targetPath);
}
export async function readContractRegistry(groupDir: string): Promise<ContractRegistry | null> {
+36
View File
@@ -8,12 +8,14 @@ import { HttpRouteExtractor } from './extractors/http-route-extractor.js';
import { GrpcExtractor } from './extractors/grpc-extractor.js';
import { ThriftExtractor } from './extractors/thrift-extractor.js';
import { TopicExtractor } from './extractors/topic-extractor.js';
import { IncludeExtractor } from './extractors/include-extractor.js';
import { ManifestExtractor } from './extractors/manifest-extractor.js';
import { discoverWorkspaceLinks } from './extractors/workspace-extractor.js';
import { buildProviderIndex, runExactMatch, runWildcardMatch } from './matching.js';
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
import type { CypherExecutor } from './contract-extractor.js';
import { writeContractRegistry } from './storage.js';
import { writeBridge } from './bridge-db.js';
import type { ContractRegistry } from './types.js';
import { logger } from '../logger.js';
@@ -100,6 +102,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
const grpcEx = new GrpcExtractor();
const thriftEx = new ThriftExtractor();
const topicEx = new TopicExtractor();
const includeEx = new IncludeExtractor();
dbExecutors = new Map<string, CypherExecutor>();
const openPoolIds: string[] = [];
@@ -168,6 +171,17 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
}
}
if (config.detect.includes) {
const extracted = await includeEx.extract(executor, handle.repoPath, handle);
for (const c of extracted) {
autoContracts.push({
...c,
repo: groupPath,
service: assignService(c.symbolRef.filePath, boundaries),
});
}
}
const metaPath = path.join(handle.storagePath, 'meta.json');
try {
const raw = await fs.readFile(metaPath, 'utf-8');
@@ -270,6 +284,28 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
if (opts?.groupDir && !opts.skipWrite) {
await writeContractRegistry(opts.groupDir, registry);
// writeBridge failure (disk full, schema error, permission denied) must
// not mask the registry — contracts.json was just written successfully
// and is the canonical source of truth. A stale or absent bridge
// degrades impact queries to empty results, which is recoverable on
// the next sync. Surface the failure as a warning so operators can
// act, but do not propagate it.
// (PR #1156 follow-up review: writeBridge error in sync.ts propagates
// uncaught.)
try {
await writeBridge(opts.groupDir, {
contracts: allContracts,
crossLinks,
repoSnapshots,
missingRepos,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(
{ err: msg, groupDir: opts.groupDir },
'⚠️ writeBridge failed; contracts.json is intact but bridge.lbug is stale. Re-run `gitnexus group sync` to retry.',
);
}
}
return {
+2 -1
View File
@@ -1,4 +1,4 @@
export type ContractType = 'http' | 'grpc' | 'thrift' | 'topic' | 'lib' | 'custom';
export type ContractType = 'http' | 'grpc' | 'thrift' | 'topic' | 'lib' | 'custom' | 'include';
export type MatchType = 'exact' | 'manifest' | 'wildcard' | 'bm25' | 'embedding';
export type ContractRole = 'provider' | 'consumer';
@@ -28,6 +28,7 @@ export interface DetectConfig {
topics: boolean;
shared_libs: boolean;
embedding_fallback: boolean;
includes: boolean;
workspace_deps: boolean;
}
@@ -0,0 +1,76 @@
/**
* Shadow-candidate path derivation for incremental indexing.
*
* Background — Bugbot review on PR #1479:
* queryImporters() on a NEWLY ADDED file returns 0 importers in the
* pre-pipeline DB, because the new file's IMPORTS rows haven't been
* written yet. But pre-existing files may have IMPORTS edges that
* *resolved to a sibling path*, and the newcomer can now steal that
* resolution under standard JS/TS module-resolution rules. Without
* pulling those pre-existing files into the writable set, their
* stale CALLS edges remain pointing at the OLD resolution target.
*
* Given an added file path, this helper enumerates the pre-existing
* file paths whose import-resolution claim the newcomer can steal.
* Caller filters the candidates against the prior-run `fileHashes`
* map so we only query importers of paths that actually existed.
*
* Shadow patterns covered (resolution-priority-aware):
*
* (a) Same basename, different extension —
* added `foo/bar.ts` shadows `foo/bar.{tsx,js,jsx,mjs,cjs,d.ts}`.
* (b) Bare-file beats directory-style index —
* added `foo/bar.ts` shadows `foo/bar/index.{ts,tsx,...}`.
* (c) Directory-index beats bare-file —
* added `foo/index.ts` shadows `foo.{ts,tsx,...}` (rare but real,
* e.g. converting a single-file module into a directory module).
*
* Resolution-order priority is conservatively wide: we enumerate ALL
* common extensions because we don't know which the importer actually
* specified, and over-seeding is harmless (extra BFS work, but the
* subgraph extract still gates write-back by file membership).
*
* Cross-platform path separators: candidates are emitted with both `/`
* and `\` for shadow pattern (b), since the caller's prior fileHashes
* map may use either depending on the OS that wrote it.
*/
const SHADOW_EXTS = ['.d.ts', '.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs'];
/**
* Enumerate pre-existing paths whose import-resolution `added` can steal.
*
* @param added — repo-relative path of a newly-added file
* @returns deduplicated list of candidate paths (NOT filtered against
* any known-files set — caller does that)
*/
export const shadowCandidatesFor = (added: string): string[] => {
const ext = SHADOW_EXTS.find((e) => added.endsWith(e));
if (!ext) return [];
const noExt = added.slice(0, -ext.length);
const out = new Set<string>();
// (a) Same basename, different extension.
for (const alt of SHADOW_EXTS) {
if (alt !== ext) out.add(noExt + alt);
}
// (b) Bare file beats sibling directory-style index.
for (const idx of SHADOW_EXTS) {
out.add(`${noExt}/index${idx}`);
out.add(`${noExt}\\index${idx}`);
}
// (c) New `foo/index.ext` shadows old `foo.ext`.
const idxSuffixSlash = '/index';
const idxSuffixBack = '\\index';
let dir: string | null = null;
if (noExt.endsWith(idxSuffixSlash)) dir = noExt.slice(0, -idxSuffixSlash.length);
else if (noExt.endsWith(idxSuffixBack)) dir = noExt.slice(0, -idxSuffixBack.length);
if (dir !== null) {
for (const alt of SHADOW_EXTS) out.add(dir + alt);
}
return [...out];
};
@@ -0,0 +1,123 @@
/**
* Subgraph extraction for incremental DB writeback.
*
* Given the FULL ctx.graph produced by the pipeline (all files parsed,
* all phases run) and the set of file paths whose DB rows must be
* replaced, produce a smaller KnowledgeGraph that contains:
*
* - Every node whose `properties.filePath` is in `toWriteSet`.
* - Every graph-wide node (Community, Process) — these are regenerated
* each run by the communities/processes phases and must be fully
* rewritten.
* - Every relationship where AT LEAST ONE endpoint is in the writable
* set above. Relationships entirely between unchanged-file nodes
* are skipped — their rows are still in the DB and re-inserting
* them would PK-conflict at COPY time.
*
* The resulting subgraph is what gets passed to `loadGraphToLbug` after
* the orchestrator has deleted the corresponding DB rows. Hydrated
* unchanged-file rows are never touched in the DB.
*
* # Cross-file edge consistency (Finding 1)
*
* `extractChangedSubgraph` intentionally does NOT expand the set it is
* given — expansion is the orchestrator's job, so the SAME expanded set
* can be fed to both `deleteNodesForFile` and this function (asymmetry
* between the delete set and the write set silently corrupts the DB).
* `computeEffectiveWriteSet` below performs the boundary-crossing 1-hop
* walk; the orchestrator composes it with its importer-BFS expansion and
* passes the result here.
*
* Why the 1-hop walk is needed: consider a barrel re-export change —
* file C (a barrel) shifts `export { foo } from './b'` to
* `export { foo } from './d'`. After scope resolution, file A's CALLS
* edge to `foo` resolves to D instead of B, even though A's content is
* byte-for-byte identical:
*
* - Old A→B edge survives in DB (neither A nor B is changed → not deleted)
* - New A→D edge is missing (neither A nor D in writable set → skipped)
*
* Pulling the unchanged-side file of every writable-boundary-crossing
* edge into the write set fixes both halves: the orchestrator's
* `DETACH DELETE` cleans up the stale unchanged-side rows, and the new
* cross-file edges land because at least one endpoint is now writable.
*
* Limitation (documented): if a file X *stopped* importing from a
* changed file C, X has no edge to C in the new graph, so this 1-hop
* walk doesn't catch it. The orchestrator's importer-BFS (which reads
* IMPORTS from the pre-pipeline DB) covers that case instead.
*/
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
import { createKnowledgeGraph } from '../graph/graph.js';
import type { KnowledgeGraph } from '../graph/types.js';
const isGraphWide = (label: string): boolean => label === 'Community' || label === 'Process';
/**
* Build a Map<nodeId, filePath> for every File-bound node in the graph.
* Graph-wide nodes (Community/Process) have no filePath and are filtered.
*/
const indexNodeFilePaths = (fullGraph: KnowledgeGraph): Map<string, string> => {
const idx = new Map<string, string>();
fullGraph.forEachNode((n: GraphNode) => {
const fp = n.properties?.filePath as string | undefined;
if (fp) idx.set(n.id, fp);
});
return idx;
};
export const extractChangedSubgraph = (
fullGraph: KnowledgeGraph,
toWriteSet: ReadonlySet<string>,
): KnowledgeGraph => {
const sub = createKnowledgeGraph();
const writableNodeIds = new Set<string>();
fullGraph.forEachNode((n: GraphNode) => {
const filePath = n.properties?.filePath as string | undefined;
const include = (filePath && toWriteSet.has(filePath)) || isGraphWide(n.label);
if (include) {
sub.addNode(n);
writableNodeIds.add(n.id);
}
});
fullGraph.forEachRelationship((r: GraphRelationship) => {
if (writableNodeIds.has(r.sourceId) || writableNodeIds.has(r.targetId)) {
sub.addRelationship(r);
}
});
return sub;
};
/**
* Public — derive the EFFECTIVE write-set: `toWriteSet` expanded by one
* hop along every edge in the new graph that crosses the writable
* boundary (one endpoint in a writable file, the other in an unchanged
* file). The unchanged-side file is pulled in so its stale rows are
* deleted + rewritten in lockstep with the changed side.
*
* Single pass over the edge list. Does NOT mutate `toWriteSet`. The
* orchestrator MUST feed the returned set to both `deleteNodesForFile`
* and `extractChangedSubgraph` — feeding the unexpanded set to either
* one leaves stale rows or PK-conflicts at COPY time.
*/
export const computeEffectiveWriteSet = (
fullGraph: KnowledgeGraph,
toWriteSet: ReadonlySet<string>,
): Set<string> => {
const nodeFilePaths = indexNodeFilePaths(fullGraph);
const expanded = new Set<string>(toWriteSet);
fullGraph.forEachRelationship((r: GraphRelationship) => {
const sourcePath = nodeFilePaths.get(r.sourceId);
const targetPath = nodeFilePaths.get(r.targetId);
if (!sourcePath || !targetPath) return; // skip edges to graph-wide nodes
const sourceWritable = toWriteSet.has(sourcePath);
const targetWritable = toWriteSet.has(targetPath);
if (sourceWritable && !targetWritable) expanded.add(targetPath);
else if (targetWritable && !sourceWritable) expanded.add(sourcePath);
});
return expanded;
};
+186 -49
View File
@@ -40,13 +40,16 @@ import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared';
import { isRegistryPrimary } from './registry-primary-flag.js';
import { isVerboseIngestionEnabled } from './utils/verbose.js';
import { yieldToEventLoop } from './utils/event-loop.js';
import { parseSourceSafe } from '../tree-sitter/safe-parse.js';
import {
CLASS_CONTAINER_TYPES,
FUNCTION_NODE_TYPES,
findEnclosingClassId,
findEnclosingClassInfo,
genericFuncName,
inferFunctionLabel,
} from './utils/ast-helpers.js';
import type { FieldInfo, FieldExtractorContext } from './field-types.js';
import type { LanguageProvider } from './language-provider.js';
import { typeTagForId, constTagForId, buildCollisionGroups } from './utils/method-props.js';
import type { MethodInfo } from './method-types.js';
import {
@@ -76,6 +79,62 @@ import type { LiteralTypeInferrer } from './type-extractors/types.js';
import type { SyntaxNode } from './utils/ast-helpers.js';
import { logger } from '../logger.js';
// ── Property-prepass helpers (parity with parse-worker.ts) ──
// These mirror the sequential-path equivalents in parse-worker.ts so the main-
// thread `processCalls` pre-pass produces byte-identical Property nodes/symbols
// to the worker pool. Drift between the two paths breaks the
// `incremental ≡ --force` invariant the moment a repo crosses the worker
// threshold between runs.
/** Walk up to the nearest enclosing class/struct/interface AST node. */
const findEnclosingClassNode = (node: SyntaxNode): SyntaxNode | null => {
let current = node.parent;
while (current) {
if (CLASS_CONTAINER_TYPES.has(current.type)) return current;
current = current.parent;
}
return null;
};
/** No-op SymbolTable stub for FieldExtractorContext — matches parse-worker. */
const NOOP_SYMBOL_TABLE: SymbolTableReader = {
lookupExact: () => undefined,
lookupExactFull: () => undefined,
lookupExactAll: () => [],
lookupCallableByName: () => [],
getFiles: () => [][Symbol.iterator](),
getStats: () => ({ fileCount: 0 }),
};
/**
* Extract (and cache) field info for a class node. Cache is passed in so it
* stays scoped to a single `processCalls` invocation rather than leaking
* across analyze runs (worker uses module-level caching because each worker
* process is short-lived; the main thread is not).
*
* Cache key is `${filePath}:${classNode.startIndex}` — startIndex alone is a
* per-file byte offset, so almost every Ruby/Python file's leading class lands
* at byte 0 and would collide across files in the shared map.
*/
const getFieldInfo = (
classNode: SyntaxNode,
provider: LanguageProvider,
context: FieldExtractorContext,
cache: Map<string, Map<string, FieldInfo>>,
): Map<string, FieldInfo> | undefined => {
if (!provider.fieldExtractor) return undefined;
const cacheKey = `${context.filePath}:${classNode.startIndex}`;
const cached = cache.get(cacheKey);
if (cached) return cached;
const result = provider.fieldExtractor.extract(classNode, context);
if (!result?.fields?.length) return undefined;
const map = new Map<string, FieldInfo>();
for (const field of result.fields) map.set(field.name, field);
cache.set(cacheKey, map);
return map;
};
/** Per-file resolved type bindings for exported symbols.
* Populated during call processing, consumed by Phase 14 re-resolution pass. */
export type ExportedTypeMap = Map<string, Map<string, string>>;
@@ -769,9 +828,10 @@ export const processCalls = async (
let tree = astCache.get(file.path);
if (!tree) {
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
try {
tree = parser.parse(file.content, undefined, {
bufferSize: getTreeSitterBufferSize(file.content),
tree = parseSourceSafe(parser, parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch (parseError) {
continue;
@@ -858,6 +918,120 @@ export const processCalls = async (
prepared.push({ file, language, provider, tree, matches, parentMap, typeEnv });
}
// ── Property-registration pre-pass ──
// Register all routed properties (e.g. Ruby attr_accessor) BEFORE the
// resolution loop so cross-file field-type lookups (e.g.
// `user.address.save → Address#save`) succeed regardless of file
// processing order. This MUST stay in lockstep with the equivalent
// worker-path block in parse-worker.ts (kind === 'properties') — any
// divergence between the two paths breaks the `incremental ≡ --force`
// invariant once a repo crosses the worker threshold between runs.
const fieldInfoCache = new Map<string, Map<string, FieldInfo>>();
for (const { file, language, provider, matches, typeEnv } of prepared) {
const callRouter = provider.callRouter;
if (!callRouter) continue;
matches.forEach((match) => {
const captureMap: Record<string, any> = {};
match.captures.forEach((c) => (captureMap[c.name] = c.node));
if (!captureMap['call']) return;
const callNameNode = captureMap['call.name'];
if (!callNameNode) return;
const routed = callRouter(callNameNode.text, captureMap['call']);
if (!routed || routed.kind !== 'properties') return;
const propEnclosingInfo = findEnclosingClassInfo(
captureMap['call'],
file.path,
provider.resolveEnclosingOwner,
);
const propEnclosingClassId = propEnclosingInfo?.classId ?? null;
// Enrich routed properties with FieldExtractor metadata so types
// discovered from constructor assignments (e.g. `@address = Address.new`)
// are propagated even when the routing payload itself lacks declaredType.
let routedFieldMap: Map<string, FieldInfo> | undefined;
if (provider.fieldExtractor && typeEnv) {
const classNode = findEnclosingClassNode(captureMap['call']);
if (classNode) {
routedFieldMap = getFieldInfo(
classNode,
provider,
{
typeEnv,
symbolTable: NOOP_SYMBOL_TABLE,
filePath: file.path,
language,
},
fieldInfoCache,
);
}
}
const fileId = generateId('File', file.path);
for (const item of routed.items) {
const routedFieldInfo = routedFieldMap?.get(item.propName);
const propQualifiedName = propEnclosingInfo
? `${propEnclosingInfo.className}.${item.propName}`
: item.propName;
const nodeId = generateId('Property', `${file.path}:${propQualifiedName}`);
graph.addNode({
id: nodeId,
label: 'Property',
properties: {
name: item.propName,
filePath: file.path,
startLine: item.startLine,
endLine: item.endLine,
language,
isExported: true,
description: item.accessorType,
...(item.declaredType
? { declaredType: item.declaredType }
: routedFieldInfo?.type
? { declaredType: routedFieldInfo.type }
: {}),
...(routedFieldInfo?.visibility !== undefined
? { visibility: routedFieldInfo.visibility }
: {}),
...(routedFieldInfo?.isStatic !== undefined
? { isStatic: routedFieldInfo.isStatic }
: {}),
...(routedFieldInfo?.isReadonly !== undefined
? { isReadonly: routedFieldInfo.isReadonly }
: {}),
},
});
ctx.model.symbols.add(file.path, item.propName, nodeId, 'Property', {
...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}),
...(item.declaredType
? { declaredType: item.declaredType }
: routedFieldInfo?.type
? { declaredType: routedFieldInfo.type }
: {}),
});
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
graph.addRelationship({
id: relId,
sourceId: fileId,
targetId: nodeId,
type: 'DEFINES',
confidence: 1.0,
reason: '',
});
if (propEnclosingClassId) {
graph.addRelationship({
id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
sourceId: propEnclosingClassId,
targetId: nodeId,
type: 'HAS_PROPERTY',
confidence: 1.0,
reason: '',
});
}
}
});
}
// ── Resolution loop: verify constructor bindings and resolve calls ──
// The accumulator (if present) is now fully populated from the preparation
// loop above, so verifyConstructorBindings sees all provider bindings
@@ -928,9 +1102,10 @@ export const processCalls = async (
provider,
);
const srcId = enclosing || generateId('File', file.path);
// Defer resolution: Ruby attr_accessor properties are registered during
// this same loop, so cross-file lookups fail if the declaring file hasn't
// been processed yet. Collect now, resolve after all files are done.
// Defer resolution so write-access tracking sees the FINAL graph
// state — properties from the pre-pass are present, but receiver-type
// resolution can still depend on inference that completes during the
// main loop. Resolve after all files have been processed.
pendingWrites.push({ receiverTypeName, propertyName, filePath: file.path, srcId });
}
// Assignment-only capture (no @call sibling): skip the rest of this
@@ -1051,47 +1226,8 @@ export const processCalls = async (
return;
case 'properties': {
const fileId = generateId('File', file.path);
const propEnclosingClassId = findEnclosingClassId(captureMap['call'], file.path);
for (const item of routed.items) {
const nodeId = generateId('Property', `${file.path}:${item.propName}`);
graph.addNode({
id: nodeId,
label: 'Property',
properties: {
name: item.propName,
filePath: file.path,
startLine: item.startLine,
endLine: item.endLine,
language,
isExported: true,
description: item.accessorType,
},
});
ctx.model.symbols.add(file.path, item.propName, nodeId, 'Property', {
...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}),
...(item.declaredType ? { declaredType: item.declaredType } : {}),
});
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
graph.addRelationship({
id: relId,
sourceId: fileId,
targetId: nodeId,
type: 'DEFINES',
confidence: 1.0,
reason: '',
});
if (propEnclosingClassId) {
graph.addRelationship({
id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
sourceId: propEnclosingClassId,
targetId: nodeId,
type: 'HAS_PROPERTY',
confidence: 1.0,
reason: '',
});
}
}
// Properties already registered in the pre-pass above.
// Skip to avoid duplicate nodes/edges.
return;
}
@@ -3280,9 +3416,10 @@ export const extractFetchCallsFromFiles = async (
let tree = astCache.get(file.path);
if (!tree) {
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
try {
tree = parser.parse(file.content, undefined, {
bufferSize: getTreeSitterBufferSize(file.content),
tree = parseSourceSafe(parser, parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch {
continue;
@@ -41,6 +41,24 @@ interface LeidenDetailedResult {
modularity: number;
}
/**
* Deterministic PRNG (mulberry32) seed for the vendored Leiden algorithm.
* Vendored Leiden defaults `rng: Math.random`, which makes community
* assignment non-deterministic across runs. Passing a seeded RNG gives us
* reproducible community/modularity output, which is required for the
* incremental-indexing equivalence test (incremental ≡ full rebuild).
*/
const LEIDEN_SEED = 0xc0de;
function createSeededRng(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ============================================================================
// TYPES
// ============================================================================
@@ -150,6 +168,7 @@ export const processCommunities = async (
leiden.detailed(graph, {
resolution: isLarge ? 2.0 : 1.0,
maxIterations: isLarge ? 3 : 0,
rng: createSeededRng(LEIDEN_SEED),
}),
),
new Promise<never>((_, reject) =>
@@ -0,0 +1,265 @@
/**
* Unreal Engine reflection-macro preprocessor for C++ source.
*
* Tree-sitter does not expand C preprocessor macros, so Unreal's reflection
* markers (`UCLASS(...)`, `UFUNCTION(...)`, `MODULENAME_API`, ...) are parsed
* verbatim. The result is mis-parsed declarations: in `class BRAWLUI_API
* UMyClass : public UObject`, tree-sitter-cpp captures `BRAWLUI_API` as the
* class name and the rest of the declaration becomes structurally wrong.
*
* This module elides those macros from the source text BEFORE tree-sitter
* parses it. Replacement is **length-preserving** (each elided byte becomes
* a space, newlines preserved) so byte offsets and line/column positions
* tree-sitter reports remain identical to the original file. Symbol
* locations in the graph stay accurate.
*
* A cheap detection guard short-circuits files that don't look like UE
* sources, so non-UE C++ codebases pay no cost.
*
* Pure function — no tree-sitter dependency, safe for worker threads.
*/
/**
* Strong UE markers — reflection macros that only Unreal Engine projects use.
* Presence of one of these is sufficient evidence that the file is a UE source
* and that `MODULENAME_API` tokens in it are intended as export macros.
*
* Importantly, `_API` tokens are NOT in this guard — `REST_API`, `HTTP_API`,
* `MY_LIB_API` and similar identifiers appear in plenty of non-UE C++ codebases
* as constants/enums/parameter names. We must not erase them just because the
* file mentions an `_API` token.
*/
const HAS_UE_HINT =
/\b(?:UCLASS|UFUNCTION|UPROPERTY|USTRUCT|UENUM|UINTERFACE|GENERATED_BODY|GENERATED_[A-Z_]+_BODY|UE_DEPRECATED|DECLARE_(?:DYNAMIC_)?(?:MULTICAST_)?DELEGATE)/;
const SIMPLE_MACROS_NO_ARGS: readonly string[] = [
'GENERATED_BODY',
'GENERATED_UCLASS_BODY',
'GENERATED_USTRUCT_BODY',
'GENERATED_UINTERFACE_BODY',
'GENERATED_IINTERFACE_BODY',
'DECLARE_CLASS',
'GENERATED_BODY_LEGACY',
];
const PARENTHESIZED_MACROS: readonly string[] = [
'UCLASS',
'UFUNCTION',
'UPROPERTY',
'USTRUCT',
'UENUM',
'UINTERFACE',
'UMETA',
'UE_DEPRECATED',
];
const DELEGATE_MACRO_RE =
/\bDECLARE_(?:DYNAMIC_)?(?:MULTICAST_)?DELEGATE(?:_(?:RetVal_OneParam|RetVal_TwoParams|RetVal_ThreeParams|RetVal_FourParams|RetVal_FiveParams|RetVal_SixParams|RetVal_SevenParams|RetVal_EightParams|RetVal_NineParams|RetVal|OneParam|TwoParams|ThreeParams|FourParams|FiveParams|SixParams|SevenParams|EightParams|NineParams|TenParams))?(?=\s*\()/g;
/**
* Module export tokens like `BRAWLUI_API`, `ENGINE_API`, `COREUOBJECT_API`.
* Pattern: ALL_CAPS identifier ending in `_API`. The leading word boundary
* (`\b`) prevents matching mid-identifier.
*/
const API_MACRO_RE = /\b[A-Z][A-Z0-9_]*_API\b/g;
/** Replace `[start, end)` of `chars` with spaces, preserving newlines. */
function eraseRange(chars: string[], start: number, end: number): void {
for (let i = start; i < end; i++) {
if (chars[i] !== '\n' && chars[i] !== '\r') {
chars[i] = ' ';
}
}
}
/**
* Find the matching close paren for an opening paren at index `openIdx`.
* Returns the index of `)` (inclusive end), or -1 if unbalanced.
*
* Handles nested parens and string/char literals so commas/parens inside
* strings don't throw off the match. Does not attempt to handle raw string
* literals (`R"(...)"`); UE reflection-macro arguments do not use them in
* practice.
*/
function findMatchingParen(source: string, openIdx: number): number {
if (source.charCodeAt(openIdx) !== 0x28) return -1;
let depth = 1;
let i = openIdx + 1;
const len = source.length;
while (i < len && depth > 0) {
const ch = source.charCodeAt(i);
// String literal
if (ch === 0x22) {
i++;
while (i < len) {
const c = source.charCodeAt(i);
if (c === 0x5c) {
i += 2;
continue;
}
if (c === 0x22) {
i++;
break;
}
i++;
}
continue;
}
// Char literal
if (ch === 0x27) {
i++;
while (i < len) {
const c = source.charCodeAt(i);
if (c === 0x5c) {
i += 2;
continue;
}
if (c === 0x27) {
i++;
break;
}
i++;
}
continue;
}
// Line comment
if (ch === 0x2f && source.charCodeAt(i + 1) === 0x2f) {
while (i < len && source.charCodeAt(i) !== 0x0a) i++;
continue;
}
// Block comment
if (ch === 0x2f && source.charCodeAt(i + 1) === 0x2a) {
i += 2;
while (i < len) {
if (source.charCodeAt(i) === 0x2a && source.charCodeAt(i + 1) === 0x2f) {
i += 2;
break;
}
i++;
}
continue;
}
if (ch === 0x28) depth++;
else if (ch === 0x29) {
depth--;
if (depth === 0) return i;
}
i++;
}
return -1;
}
/** Match a whole-word identifier at `idx`. Returns the byte after the identifier, or -1 on miss. */
function matchIdentifierAt(source: string, idx: number, name: string): number {
if (idx > 0) {
const prev = source.charCodeAt(idx - 1);
if (
(prev >= 0x30 && prev <= 0x39) ||
(prev >= 0x41 && prev <= 0x5a) ||
(prev >= 0x61 && prev <= 0x7a) ||
prev === 0x5f
) {
return -1;
}
}
for (let k = 0; k < name.length; k++) {
if (source.charCodeAt(idx + k) !== name.charCodeAt(k)) return -1;
}
const after = idx + name.length;
if (after < source.length) {
const next = source.charCodeAt(after);
if (
(next >= 0x30 && next <= 0x39) ||
(next >= 0x41 && next <= 0x5a) ||
(next >= 0x61 && next <= 0x7a) ||
next === 0x5f
) {
return -1;
}
}
return after;
}
/** Skip ASCII whitespace forward from `idx`. Returns the next non-whitespace byte index. */
function skipWhitespace(source: string, idx: number): number {
const len = source.length;
while (idx < len) {
const ch = source.charCodeAt(idx);
if (ch === 0x20 || ch === 0x09 || ch === 0x0a || ch === 0x0d) {
idx++;
continue;
}
break;
}
return idx;
}
/**
* Strip Unreal Engine reflection macros from C++ source, length-preserving.
*
* Returns the original string unchanged if no strong UE marker is detected,
* so non-UE C++ files (including ones that contain `*_API`-suffixed
* identifiers like `REST_API` or `HTTP_API`) incur only a single regex test.
*
* The `_filePath` parameter is part of the `LanguageProvider.preprocessSource`
* contract but is unused — UE detection is purely content-based. Accepted and
* ignored here so the function matches the hook signature exactly.
*/
export function stripUeMacros(source: string, _filePath?: string): string {
if (!HAS_UE_HINT.test(source)) return source;
const chars: string[] = source.split('');
for (const macro of PARENTHESIZED_MACROS) {
let searchFrom = 0;
while (true) {
const hit = source.indexOf(macro, searchFrom);
if (hit < 0) break;
searchFrom = hit + 1;
const after = matchIdentifierAt(source, hit, macro);
if (after < 0) continue;
const parenIdx = skipWhitespace(source, after);
if (source.charCodeAt(parenIdx) !== 0x28) continue;
const close = findMatchingParen(source, parenIdx);
if (close < 0) continue;
eraseRange(chars, hit, close + 1);
}
}
for (const macro of SIMPLE_MACROS_NO_ARGS) {
let searchFrom = 0;
while (true) {
const hit = source.indexOf(macro, searchFrom);
if (hit < 0) break;
searchFrom = hit + 1;
const after = matchIdentifierAt(source, hit, macro);
if (after < 0) continue;
const parenIdx = skipWhitespace(source, after);
if (source.charCodeAt(parenIdx) === 0x28) {
const close = findMatchingParen(source, parenIdx);
if (close < 0) continue;
eraseRange(chars, hit, close + 1);
} else {
eraseRange(chars, hit, after);
}
}
}
for (const re of [DELEGATE_MACRO_RE, API_MACRO_RE]) {
re.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = re.exec(source)) !== null) {
const start = match.index;
let end = start + match[0].length;
if (re === DELEGATE_MACRO_RE) {
const parenIdx = skipWhitespace(source, end);
if (source.charCodeAt(parenIdx) === 0x28) {
const close = findMatchingParen(source, parenIdx);
if (close >= 0) end = close + 1;
}
}
eraseRange(chars, start, end);
}
}
return chars.join('');
}
@@ -9,6 +9,11 @@ import type { SyntaxNode } from '../../utils/ast-helpers.js';
const CSHARP_VIS = new Set<FieldVisibility>(['public', 'private', 'protected', 'internal']);
const extractCsharpDeclaredType = (typeNode: SyntaxNode): string | undefined => {
if (typeNode.type === 'generic_name') return typeNode.text.trim();
return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim();
};
/**
* C# field extraction config.
*
@@ -53,17 +58,17 @@ export const csharpConfig: FieldExtractionConfig = {
const child = node.namedChild(i);
if (child?.type === 'variable_declaration') {
const typeNode = child.childForFieldName('type');
if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim();
if (typeNode) return extractCsharpDeclaredType(typeNode);
// fallback: first child that is a type
const first = child.firstNamedChild;
if (first && first.type !== 'variable_declarator') {
return extractSimpleTypeName(first) ?? first.text?.trim();
return extractCsharpDeclaredType(first);
}
}
}
// property_declaration: type is first named child
const typeNode = node.childForFieldName('type');
if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim();
if (typeNode) return extractCsharpDeclaredType(typeNode);
return undefined;
},
@@ -22,6 +22,7 @@ import { generateId } from '../../lib/utils.js';
import { getLanguageFromFilename, type NodeLabel, type SupportedLanguages } from 'gitnexus-shared';
import { isVerboseIngestionEnabled } from './utils/verbose.js';
import { yieldToEventLoop } from './utils/event-loop.js';
import { parseSourceSafe } from '../tree-sitter/safe-parse.js';
import { getProvider } from './languages/index.js';
import { getTreeSitterBufferSize } from './constants.js';
import type {
@@ -219,9 +220,13 @@ export const processHeritage = async (
let tree = astCache.get(file.path);
if (!tree) {
// Use larger bufferSize for files > 32KB
// Per-language source preprocessor (length-preserving, e.g. UE macro
// stripping for C++). MUST mirror parsing-processor on cache miss so
// re-parses see the same input as the cached AST.
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
try {
tree = parser.parse(file.content, undefined, {
bufferSize: getTreeSitterBufferSize(file.content),
tree = parseSourceSafe(parser, parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch (parseError) {
// Skip files that can't be parsed
@@ -413,9 +418,10 @@ export async function extractExtractedHeritageFromFiles(
let tree = astCache.get(file.path);
if (!tree) {
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
try {
tree = parser.parse(file.content, undefined, {
bufferSize: getTreeSitterBufferSize(file.content),
tree = parseSourceSafe(parser, parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch {
continue;
@@ -8,6 +8,7 @@ import { generateId } from '../../lib/utils.js';
import { getLanguageFromFilename } from 'gitnexus-shared';
import { isVerboseIngestionEnabled } from './utils/verbose.js';
import { yieldToEventLoop } from './utils/event-loop.js';
import { parseSourceSafe } from '../tree-sitter/safe-parse.js';
import type { ExtractedImport } from './workers/parse-worker.js';
import { getTreeSitterBufferSize } from './constants.js';
import { loadImportConfigs } from './language-config.js';
@@ -305,9 +306,10 @@ export const processImports = async (
let wasReparsed = false;
if (!tree) {
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
try {
tree = parser.parse(file.content, undefined, {
bufferSize: getTreeSitterBufferSize(file.content),
tree = parseSourceSafe(parser, parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch (parseError) {
continue;
@@ -128,7 +128,21 @@ export const resolveImportPath = (
if (importPath.startsWith('.')) {
const resolved = tryResolveWithExtensions(basePath, allFiles);
return cache(resolved);
if (resolved) return cache(resolved);
// TypeScript ESM: imports use .js/.jsx/.mjs/.cjs but source files are
// .ts/.tsx/.mts/.cts. Strip the JS-family extension and re-resolve.
// NOTE: This fallback only applies to relative imports. Path alias imports
// (e.g. @/utils.js via tsconfig paths) do not yet strip .js extensions —
// that is a known limitation tracked for follow-up.
if (language === SupportedLanguages.TypeScript || language === SupportedLanguages.JavaScript) {
const stripped = stripJsExtension(basePath);
if (stripped !== null) {
return cache(tryResolveWithExtensions(stripped, allFiles));
}
}
return cache(null);
}
// ---- Generic package/absolute import resolution (suffix matching) ----
@@ -182,3 +196,19 @@ export function resolveStandard(
export function createStandardStrategy(language: SupportedLanguages): ImportResolverStrategy {
return (raw, fp, ctx) => resolveStandard(raw, fp, ctx, language);
}
// ============================================================================
// ESM extension helpers
// ============================================================================
/** JS-family extensions that TypeScript ESM maps to TS equivalents. */
const JS_EXTENSION_PATTERN = /\.(js|jsx|mjs|cjs)$/;
/**
* Strip a JS-family extension from a path, returning the stem.
* Returns `null` if the path does not end with a JS-family extension.
*/
export function stripJsExtension(path: string): string | null {
const match = JS_EXTENSION_PATTERN.exec(path);
return match ? path.slice(0, -match[0].length) : null;
}
@@ -9,8 +9,12 @@ export const EXTENSIONS = [
// TypeScript/JavaScript
'.tsx',
'.ts',
'.mts',
'.cts',
'.jsx',
'.js',
'.mjs',
'.cjs',
'.vue',
'/index.tsx',
'/index.ts',
@@ -116,6 +116,39 @@ interface LanguageProviderConfig {
* Required for tree-sitter languages; empty string for standalone processors. */
readonly treeSitterQueries: string;
/**
* Optional source-text transform that runs **before** tree-sitter parses the file.
*
* Used to elide language constructs that confuse the grammar without affecting
* source-position fidelity — e.g., Unreal Engine reflection macros (`UCLASS`,
* `UFUNCTION`, `MODULENAME_API`) in C++ headers that prevent the parser from
* recognising class/function names correctly.
*
* **Length / position preservation:** the returned string MUST have the same
* JavaScript `.length` as the input AND preserve every newline (`\n`/`\r`)
* position byte-for-byte. Implementations replace elided characters with
* ASCII spaces while leaving newlines untouched. With this contract:
*
* - tree-sitter's reported `startPosition.row`/`startPosition.column`
* match the original file exactly (line/column come from newline counts)
* - `startIndex`/`endIndex` byte offsets match the original file exactly
* **when the elided range is pure ASCII** (UTF-16 `.length` equals UTF-8
* byte length only for ASCII).
*
* Implementations targeting languages where elided ranges may contain
* non-ASCII content must therefore preserve byte length, not just `.length`,
* if downstream code uses `startIndex` to slice the original UTF-8 bytes.
* The current C++ UE-macro preprocessor relies on the practical fact that
* UE reflection macros and module-export tokens are ASCII-only.
*
* Must be a pure function — same input always yields the same output. Called
* once per file, on every code path that re-parses (parsing-processor, import
* processor, heritage processor, call processor, parse worker).
*
* Default: undefined (no preprocessing — `file.content` is parsed verbatim).
*/
readonly preprocessSource?: (sourceText: string, filePath: string) => string;
// ── Core (required) ───────────────────────────────────────────────
/** Type extraction: declarations, initializers, for-loop bindings */
readonly typeConfig: LanguageTypeConfig;
@@ -45,6 +45,16 @@ import { cVariableConfig, cppVariableConfig } from '../variable-extractors/confi
import { createCallExtractor } from '../call-extractors/generic.js';
import { cCallConfig, cppCallConfig } from '../call-extractors/configs/c-cpp.js';
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
import { stripUeMacros } from '../cpp-ue-preprocessor.js';
import {
emitCScopeCaptures,
interpretCImport,
interpretCTypeBinding,
cArityCompatibility,
cBindingScopeFor,
cImportOwningScope,
cReceiverBinding,
} from './c/index.js';
const C_BUILT_INS: ReadonlySet<string> = new Set([
'printf',
@@ -366,6 +376,16 @@ export const cProvider = defineLanguage({
heritageExtractor: createHeritageExtractor(SupportedLanguages.C),
labelOverride: cppLabelOverride,
builtInNames: C_BUILT_INS,
// ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
emitScopeCaptures: emitCScopeCaptures,
interpretImport: interpretCImport,
interpretTypeBinding: interpretCTypeBinding,
bindingScopeFor: cBindingScopeFor,
importOwningScope: cImportOwningScope,
receiverBinding: cReceiverBinding,
arityCompatibility: cArityCompatibility,
// mergeBindings + resolveImportTarget live on ScopeResolver (see c/scope-resolver.ts).
});
export const cppProvider = defineLanguage({
@@ -410,6 +430,7 @@ export const cppProvider = defineLanguage({
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: CPP_QUERIES,
preprocessSource: stripUeMacros,
typeConfig: cCppConfig,
exportChecker: cCppExportChecker,
importResolver: createImportResolver(cppImportConfig),
@@ -0,0 +1,102 @@
import type { SyntaxNode } from '../../utils/ast-helpers.js';
export interface CArityInfo {
parameterCount?: number;
requiredParameterCount?: number;
parameterTypes?: string[];
}
/**
* Compute declaration arity from a C function definition or declaration node.
*/
export function computeCDeclarationArity(node: SyntaxNode): CArityInfo {
// Find the function_declarator child (may be wrapped in pointer_declarator)
const funcDecl = findFuncDeclarator(node);
if (funcDecl === null) return {};
const paramList = funcDecl.childForFieldName('parameters');
if (paramList === null) return {};
const params: SyntaxNode[] = [];
for (let i = 0; i < paramList.childCount; i++) {
const child = paramList.child(i);
if (child === null) continue;
if (child.type === 'parameter_declaration' || child.type === 'variadic_parameter') {
params.push(child);
}
}
// K&R old-style declaration: `int foo()` has an empty parameter_list with
// no parameter_declaration or variadic_parameter children. Per C89/C99,
// this means the function accepts an unspecified number/types of arguments —
// NOT zero arguments. Return unknown arity to avoid false 'incompatible'.
// `int foo(void)` is the explicit zero-parameter form and is handled below.
if (params.length === 0) return {};
// (void) means zero parameters
if (params.length === 1 && params[0].type === 'parameter_declaration') {
const typeNode = params[0].childForFieldName('type');
const hasDeclarator = params[0].childForFieldName('declarator') !== null;
if (typeNode !== null && typeNode.text === 'void' && !hasDeclarator) {
return { parameterCount: 0, requiredParameterCount: 0, parameterTypes: [] };
}
}
const isVariadic = params.some((p) => p.type === 'variadic_parameter');
const nonVariadicCount = params.filter((p) => p.type !== 'variadic_parameter').length;
const types: string[] = [];
for (const p of params) {
if (p.type === 'variadic_parameter') {
types.push('...');
} else {
const typeNode = p.childForFieldName('type');
types.push(typeNode?.text ?? 'unknown');
}
}
return {
parameterCount: isVariadic ? undefined : nonVariadicCount,
requiredParameterCount: nonVariadicCount,
parameterTypes: types,
};
}
/**
* Compute call-site arity from a call_expression node.
*/
export function computeCCallArity(node: SyntaxNode): number {
const argList = node.childForFieldName('arguments');
if (argList === null) return 0;
let count = 0;
for (let i = 0; i < argList.childCount; i++) {
const child = argList.child(i);
if (child === null) continue;
// Skip punctuation (commas, parens)
if (child.type !== ',' && child.type !== '(' && child.type !== ')') {
count++;
}
}
return count;
}
function findFuncDeclarator(node: SyntaxNode): SyntaxNode | null {
// Direct child
let decl = node.childForFieldName('declarator');
if (decl === null) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'function_declarator') return c;
}
return null;
}
// Unwrap pointer_declarator
while (decl.type === 'pointer_declarator') {
const next = decl.childForFieldName('declarator');
if (next === null) break;
decl = next;
}
if (decl.type === 'function_declarator') return decl;
return null;
}
@@ -0,0 +1,20 @@
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
/**
* C arity compatibility: no overloading. Variadic functions detected
* via '...' in parameterTypes. Otherwise exact match or unknown.
*/
export function cArityCompatibility(
def: SymbolDefinition,
callsite: Callsite,
): 'compatible' | 'unknown' | 'incompatible' {
const max = def.parameterCount;
const min = def.requiredParameterCount;
if (max === undefined && min === undefined) return 'unknown';
if (!Number.isFinite(callsite.arity) || callsite.arity < 0) return 'unknown';
const variadic = def.parameterTypes?.some((t) => t === '...') ?? false;
if (min !== undefined && callsite.arity < min) return 'incompatible';
if (max !== undefined && callsite.arity > max && !variadic) return 'incompatible';
return 'compatible';
}

Some files were not shown because too many files have changed in this diff Show More