Compare commits

..
Author SHA1 Message Date
github-actions[bot] 88c4857a1f release: v1.6.3-rc.9 2026-04-18 17:22:27 +00:00
Gergő Magyar 1bf9fb4ef1 feat(shared): ClassRegistry / MethodRegistry / FieldRegistry + 7-step lookup (#917, RFC #909 Ring 2 SHARED) (#963)
Capstone of Ring 2 SHARED. Implements RFC §4 — the shared, scope-aware
resolution surface the rest of the semantic model feeds into.

## Modules (`gitnexus-shared/src/scope-resolution/registries/`)

  - `context.ts`         — `RegistryContext` bundling ScopeTree / DefIndex
                           / QualifiedNameIndex / ModuleScopeIndex /
                           MethodDispatchIndex + provider hooks.
                           Narrows Ring 1's opaque `RegistryContributor`
                           to concrete `OwnerScopedContributor`.
  - `tie-breaks.ts`      — `compareByConfidenceWithTiebreaks`, the RFC
                           Appendix B cascade: confidence DESC → scope
                           depth ASC → MRO depth ASC → ORIGIN_PRIORITY
                           ASC → DefId.localeCompare.
  - `evidence.ts`        — `composeEvidence(signals)` / `confidenceFromEvidence`.
                           Translates raw walk signals into the typed
                           `ResolutionEvidence[]` using authoritative
                           `EvidenceWeights`. No magic numbers.
  - `lookup-qualified.ts`— RFC §4.5. Qualified-name fast path consumed
                           by `resolveTypeRef` dotted fallback and by
                           Step 6 of lookup-core.
  - `lookup-core.ts`     — The 7-step canonical algorithm. Pure. Param-
                           eterized by `CoreLookupParams`.
  - `{class,method,field}-registry.ts`
                         — Thin wrappers over `lookupCore` that fix
                           `acceptedKinds` + `useReceiverTypeBinding` per
                           kind. `buildClassRegistry` / `buildMethodRegistry`
                           / `buildFieldRegistry` factory functions.

## RFC §4.2 algorithm contract (honored verbatim)

  1. Lexical scope-chain walk. Hard shadow on any `scope.bindings.has(name)`
     regardless of kind survivorship.
  2. Type-binding resolution (methods/fields only, opt-in via
     `useReceiverTypeBinding`). MRO walk via `MethodDispatchIndex.mroFor`.
     MRO-depth-decayed weight via `typeBindingWeightAtDepth`.
  3. Owner-scoped contributor — when the caller knows the receiver owner,
     its direct members merge in as `origin: 'local'`.
  4. Kind filter — `acceptedKinds` per registry; `kind-match` evidence
     at weight 0 is always emitted for debuggability.
  5. Arity filter — `provider.arityCompatibility` per candidate. When at
     least one compatible candidate exists, incompatibles are dropped;
     otherwise the −0.15 penalty alone disambiguates (they stay in the
     result, just ranked lower).
  6. Global fallback — fires only when Steps 1-3 produced NO candidates
     AND the name is dotted. Delegates to `lookupQualified`.
  7. Rank + tie-break — evidence list sorted by the Appendix B cascade.

## §4.7 invariants asserted in tests

  - No tier vocabulary in the return type (`Resolution`, not `TierXResult`).
  - Confidence is per-candidate (not per-tier).
  - Shadowing is a HARD filter; globals are consulted ONLY when lexically
    empty.
  - Caller can read `[0]` for one-shot answers.
  - `Resolution.confidence` is capped at 1.0.
  - `kind-match` is always emitted (weight 0).

## Unresolved-import + dynamic-unresolved evidence shape

  - `BindingRef.via.linkStatus === 'unresolved'` applies the
    `unlinkedImportMultiplier` (0.5×) to the where-found signal only.
    Corroborators (`arity-match`, `owner-match`, `type-binding`) remain
    unaffected — the RFC §4v2 capped-signal rule applies per-signal, not
    per-candidate.
  - `BindingRef.via.kind === 'dynamic-unresolved'` adds a degraded
    `dynamic-import-unresolved` evidence signal at weight 0.02.

## Tests (28 in registries.test.ts, 259/259 combined)

Organized per RFC §4.2 step so a regression localizes to the step it broke:

  - Step 1: local + walk-to-parent + hard-shadow + origin=import
  - Step 2: explicit receiver type-binding + MRO depth decay on ancestor
  - Step 3: owner-scoped contributor + owner-match
  - Step 5: drop-incompatible-when-compatible-exists + soft-penalty-when-all-
            incompatible + unknown-when-no-provider
  - Step 6: global-qualified fires only when lexically empty + never for
            non-dotted names + not consulted when lexical hit exists
  - Step 7: tie-break cascade (inner shadows outer; defId.localeCompare
            final)
  - Corroborators: unresolved-import 0.5× cap per-signal + dynamic-
            unresolved 0.02 degraded signal
  - §4.5: lookupQualified kind filter + empty on miss + deterministic defId
          order for partial classes
  - §4.7: invariants — confidence per-candidate, capped at 1.0, kind-match
          always present, [0]-for-one-shot

## Known follow-up optimizations

`collectOwnedMembers` in `lookup-core.ts` iterates `defs.byId.values()`
for each MRO hop — O(D) per call. Acceptable for Ring 2 fixtures; a
by-owner index should land before Ring 3 migrates large-workspace
languages. Tracked alongside the existing `findDefById` follow-up from
#915 review.

## Module placement

All under `gitnexus-shared/src/scope-resolution/registries/` — consistent
with the Ring 2 SHARED folder layout (#912/#913/#914/#915/#916/#918).
Slight deviation from the issue's `gitnexus-shared/src/registries/`
suggestion for consistency with siblings.

## Part of

- Parent: #909
- Depends on (code): #910, #911, #912, #913, #914, #915, #916, #918.
- Closes the Ring 2 SHARED delivery band. Unblocks Ring 2 PKG (#919–#925
  bridges to the gitnexus/ CLI package) and Ring 3 language migrations.
2026-04-18 17:58:26 +01:00
Gergő Magyar a9a5e1c388 feat(shared): SCC-aware finalize algorithm with bounded fixpoint (#915, RFC #909 Ring 2 SHARED) (#962)
* feat(shared): SCC-aware finalize algorithm with bounded fixpoint (#915, RFC #909 Ring 2 SHARED)

Implements RFC §3.2 Phase 2 as pure logic in `gitnexus-shared`. Takes
per-file parse output and returns linked `ImportEdge[]` + materialized
module-scope bindings, fully language-agnostic (target resolution,
wildcard expansion, and binding precedence all go through caller hooks).

Three-phase algorithm:

  1. Tarjan SCC over the file-level import graph (iterative, deterministic
     node order, O(V+E)). Returns SCCs in reverse-topological order so
     leaves finalize before dependents — and so disjoint SCCs are
     explicitly surfaced for parallel-processing callers.

  2. Per-SCC bounded fixpoint. For each SCC in topo order, iterate up to
     `N = |intra-SCC edges|`; each pass tries to resolve every still-
     unlinked edge by looking up the imported name in the target file's
     local defs. Stops early when no progress. Edges still unlinked after
     the cap get `linkStatus: 'unresolved'` — keeps malformed inputs
     bounded and preserves the RFC §4v2 capped-signal contract for
     unresolved markers.

  3. Wildcard expansion + module-scope binding materialization. For each
     `wildcard` ParsedImport that linked to a module, expand via
     `expandsWildcardTo` into one `wildcard-expanded` ImportEdge per
     exported name. Bindings per module scope are the merge of local defs
     (`origin: 'local'`), named / alias / reexport imports
     (`origin: 'import' | 'reexport'`), namespace imports (`origin:
     'namespace'`), and wildcard expansions (`origin: 'wildcard'`), with
     precedence delegated to `provider.mergeBindings`.

Dynamic imports rule: `kind: 'dynamic-unresolved'` passes through as an
ImportEdge with `targetFile: null` and no BindingRef.

Re-export flattening: reexport edges land with `transitiveVia: [targetFile]`.
Multi-hop chains settle iteratively across the fixpoint.

Types:
  - Adds `'wildcard'` variant to ParsedImport (parse-time signal for
    `import * from M`). The finalize-only `'wildcard-expanded'` ImportEdge
    kind is unchanged and remains finalize output only, as documented.
  - Exports `finalize` + `FinalizeFile` / `FinalizeInput` / `FinalizeHooks`
    / `FinalizeOutput` / `FinalizedScc` / `FinalizeStats`.

Simple-name derivation: `deriveSimpleName` uses `def.qualifiedName` as the
authoritative source (tail after the last `.`). Defs without a
qualifiedName are not name-resolvable by this algorithm — an explicit
design choice that trades strictness for predictability (no heuristic
nodeId parsing).

Tests (20, all passing):
  - Trivial: empty workspace · acyclic resolution · unresolvable target
    (file + name) · dynamic-unresolved passthrough.
  - Cycles: A↔B two-file cycle linked · cycles packed into SCC with
    isCycle=true · disjoint cycles produce disjoint SCCs · mixed
    linked/unresolved edges reported correctly in stats.
  - Wildcards: one ImportEdge per exported name · unresolved wildcards
    survive as single edges · expanded bindings carry origin='wildcard'.
  - Reexports: transitiveVia carries the intermediate file path.
  - Aliased + namespace: alias preserves targetExportedName under its
    local name · namespace links to module scope even without a module-def.
  - Bindings: locals land as origin='local' · imports layer on via
    mergeBindings · mergeBindings can drop existing (last-write-wins
    precedence honored).
  - SCC-DAG: reverse-topological ordering verified (leaf first).

Combined scope-resolution / model / shadow suite: 229/229 pass.
`tsc --noEmit` clean in both `gitnexus-shared` and `gitnexus`.

Closes part of #909. Unblocks #917 (Registry.lookup's import-chain fast
path consumes finalized ImportEdges); unblocks Ring 3 language migrations
(per-language providers supply FinalizeHooks implementations).

* chore(shared): address #915 review findings — dead code, docs, tests

Review thread on PR #962.

Code changes:
  - Remove dead `resolvedTargets` map + `keyFor` + `ParsedImportKey`
    type alias. The map was populated but never read; originally intended
    to cache / dedup resolutions for later phases but that path was never
    wired (finding 1.1).
  - Drop unused params (`_edgeIndex`, `_hooks`, `_workspace`) from
    `tryFinalize`. No planned fixpoint-state consultation; no reason to
    keep them reserved (finding 2.1).

Documentation:
  - `FinalizeFile.localDefs` now documents the multi-hop re-export
    contract explicitly: `finalize` looks names up in the target's
    static `localDefs`; if B only re-exports from C and doesn't surface
    the name in its own localDefs, A's import of that name from B will
    hit the cap and be marked unresolved. Parsers that want multi-hop
    chains to settle end-to-end must include re-exported names in the
    intermediate file's localDefs (finding 1.2).
  - `FinalizeStats` now documents its counting granularity: all edge
    counters are per-`ParsedImport`, not per-materialized-`ImportEdge`.
    A wildcard expanding to N exports counts as one linked edge;
    dynamic-unresolved pass-throughs count as linked. The bindings map
    is the authoritative "has a BindingRef" source (finding 3.2).

Tests (2 added, 22 total in finalize-algorithm.test.ts, 231/231 combined):
  - Explicit cap-hit → `linkStatus: 'unresolved'` assertion for a cycle
    where the name-level lookup never succeeds (distinct from
    `targetFile: null`; cap exhaustion path) (finding 3.1).
  - Multi-hop re-export contract test: demonstrates both variants —
    intermediate B WITHOUT X in localDefs → unresolved; B WITH X in
    localDefs → resolved to the original source DefId (finding 1.2).

Not addressed (filed as follow-up issues):
  - LanguageProvider.resolveImportTarget vs FinalizeHooks signature
    divergence (finding 1.3) — pre-Ring-3 concern.
  - findDefById O(F×D) scan in Phase 5 (finding 4.1) — acceptable for
    Ring 2; optimize before large-workspace Ring 3 migrations.
2026-04-18 17:26:07 +01:00
Gergő Magyar 8cf9ae0e0d feat(shared): ScopeTree + PositionIndex + makeScopeId (#912, RFC #909 Ring 2 SHARED) (#961)
Implements the scope-tree spine and position-indexed lookup as pure logic
in `gitnexus-shared`. Generalizes the `enclosingFunctions` pattern from
closed PR #902 to arbitrary `ScopeKind`s.

Three modules under `gitnexus-shared/src/scope-resolution/`:

1. `scope-id.ts` — `makeScopeId({filePath, range, kind})` builds the
   canonical RFC §2.2 shape
     `scope:{filePath}#{startLine}:{startCol}-{endLine}:{endCol}:{kind}`
   and interns the result through a process-local pool so repeated calls
   with structurally identical inputs return the same string reference.
   `clearScopeIdInternPool()` exported for test isolation.

2. `scope-tree.ts` — `buildScopeTree(scopes)` validates invariants and
   returns an immutable `ScopeTree`:
     - `getScope(id)` / `getParent(id)` / `getChildren(id)` / `getAncestors(id)`
     - Implements the `ScopeLookup` contract from #916, so `resolveTypeRef`
       can consume a `ScopeTree` directly (test included).
   Invariants enforced (throw `ScopeTreeInvariantError` on violation):
     - Non-Module scopes must have a parent.
     - Parent must exist in the supplied set.
     - Parent range STRICTLY contains child range (equal ranges rejected).
     - Sibling ranges under the same parent do not overlap. Ranges that
       merely touch at the boundary (`a.end == b.start`) are accepted.
     - Parent and child live in the same filePath.
     - Duplicate scope ids are rejected.

3. `position-index.ts` — `buildPositionIndex(scopes)` produces a
   `PositionIndex` with `atPosition(filePath, line, col)`. Per-file sorted
   array; binary-search the upper bound of `start ≤ query`, scan backward
   through the prefix, return the first containing hit.
   Complexity: `O(log N_file + D)` typical (D = lexical depth ≤ ~10);
   degrades to `O(N_file)` only under pathological inputs (many scopes
   starting at the same position). "Innermost wins" falls out of the sort
   + backward-scan contract because `ScopeTree`'s invariants guarantee
   that scopes containing a point form an ancestor chain.

Types:
  - `ScopeTree` now exported from `scope-tree.ts`. The Ring 1 opaque
    placeholder in `types.ts` has been removed; LanguageProvider hooks
    that previously took `ScopeTree = unknown` now receive the concrete
    interface (CLI `tsc --noEmit` passes — no existing callers rely on
    the opaque shape).

Tests (39, all passing):
  - scope-id: canonical shape · all six ScopeKinds encoded · identity
    equality (same inputs → same reference) · distinguished by
    filePath / range / kind · purity under repeated calls · intern-pool
    clear preserves canonical shape.
  - scope-tree: empty tree · single module · nested Module→Class→Function
    · multiple siblings input-order preserved · ScopeLookup integration
    with resolveTypeRef · frozen children and ancestor arrays · all six
    invariant violations (non-Module orphan, parent-not-found, parent
    doesn't contain, parent == child, siblings overlap, cross-file parent,
    duplicate id) · boundary-touching siblings accepted.
  - position-index: empty · unindexed filePath · before/after-file
    queries · start/end inclusivity · innermost-wins for nested / co-
    starting / co-ending / same-line scopes · sibling dispatch · multi-
    file isolation · size · id-dedup.

Combined scope-resolution / model / shadow suite: 190/190 pass.
`tsc --noEmit` clean in both `gitnexus-shared` and `gitnexus`.

Closes part of #909. Unblocks #917 (`Registry.lookup` needs the scope
spine); makes `ScopeLookup` in #916 concrete without API churn.
2026-04-18 16:41:38 +01:00
azizur100389 ac148612ab feat(search): per-phase timing instrumentation for the query pipeline (#953)
* feat(search): per-phase timing instrumentation for the query pipeline

The eval harness already measures search-pipeline latency per phase,
but the *product* query() tool has no timing visibility. That leaves
production latency opaque:

 - Is BM25 the tail, or vector search?
 - How much Promise.all overlap do concurrent searches actually save?
 - Does symbol_lookup dominate when per-symbol Cypher round-trips pile up?

None of this is answerable from the outside, which blocks the
latency-quality Pareto work tracked in #546 / #553.

Changes:

* New PhaseTimer class at src/core/search/phase-timer.ts.
  Supports three APIs:
    - start(phase) / stop() for sequential phases (per issue spec)
    - mark(phase, durationMs) for pre-measured durations
    - time(phase, promise) to wrap a promise inside Promise.all

  The issue's original spec was sequential-only, which doesn't work
  for BM25 + vector inside Promise.all — the second start() would
  auto-stop the first and only one phase would get timed. The mark()
  and time() variants resolve that without changing the sequential
  API for the other phases.

* local-backend.ts query() instrumented across seven phase markers:
    bm25, vector   (concurrent via timer.time inside Promise.all)
    merge          (RRF reciprocal-rank-fusion)
    symbol_lookup  (per-symbol process + cohesion + content Cypher)
    ranking        (in-memory priority sort)
    formatting     (response object construction + dedup)
    wall           (end-to-end; separate mark so callers can compare
                   sum(phases) vs wall and see Promise.all savings)

* logQueryTiming() helper next to logQueryError(), same console-based
  pattern (repo has no structured logger). Emits
    GitNexus [query:timing] query="..." totalMs=N phases={...}
  to stdout — greppable prefix, JSON-parseable payload, no new deps.

* timing: Record<string, number> added as a top-level field on the
  query() response. Strict superset of the previous shape — existing
  tests only assert field presence, so no regression. Other MCP tools
  use the same top-level-metadata convention (status, row_count,
  warning) rather than a nested _meta wrapper.

Tests:

 - 6 new unit tests for PhaseTimer covering start/stop, implicit
   stop-on-start, additive mark(), Promise.all-safe time(),
   negative/NaN rejection, and totalMs auto-stop.
 - 3 new assertions on the existing query integration test verifying
   timing.wall is a non-negative number and at least one of
   bm25/vector fired.

Verification:
  npx vitest run test/unit/phase-timer.test.ts       -> 6 pass
  npx vitest run test/unit/calltool-dispatch.test.ts -> 65 pass
  npx vitest run test/integration/local-backend-calltool.test.ts -> 18 pass
  npm run test:unit                                   -> 3777 pass
    (4 pre-existing env failures unchanged: skip-git-cli needs
     built dist/, git-utils tmpdir on Windows worktree)
  npx tsc --noEmit                                    -> clean

Scope declined for v1:

 - In-process histogram aggregation — the log line is enough for
   external tooling
 - Pareto curve generation — issue asks to enable it, not generate it
 - Sub-phases of symbol_lookup (process vs cohesion vs content) —
   issue lists them under one bucket; can split later if demand surfaces

Closes #553

* fix(search): route query:timing log to stderr to preserve stdio MCP contract

CI (#953) failed the `query: JSON appears on stdout, not stderr`
e2e test in test/integration/cli-e2e.test.ts with:

  SyntaxError: Unexpected token 'G', "GitNexus [..." is not valid JSON

Root cause: my initial logQueryTiming() in 63fbdc4 used console.log,
which writes to stdout. The MCP stdio transport uses stdout
exclusively for JSON-RPC responses (#324), and the CLI e2e test
guards that contract by asserting stdout parses as JSON on every
tool invocation. The "GitNexus [query:timing] ..." line was
interleaving with the response JSON and breaking the parse.

Fix: route logQueryTiming through console.error instead. stderr is
the correct channel for human-readable diagnostics and it is what
the sibling logQueryError already uses for the same reason. The log
line format is otherwise unchanged -- still greppable, still
JSON-parseable payload.

Verification (local, with dist built):
  npx vitest run test/integration/cli-e2e.test.ts -t "query: JSON"
    -> now passes (was failing across ubuntu/windows/macos in CI)
  npx tsc --noEmit                                  -> clean
  Two unrelated pre-existing failures on non-git
  directory handling remain (same on upstream/main).

Closes the CI regression introduced in 63fbdc4.
2026-04-18 16:30:07 +01:00
Gergő Magyar 5d76dbcfa2 feat(shared): MethodDispatchIndex materialized view over HeritageMap (#914, RFC #909 Ring 2 SHARED) (#960)
Implements RFC §3.1 `MethodDispatchIndex`: a two-way materialized view
keyed by `DefId` for O(1) method-dispatch resolution:

  - `mroByOwnerDefId`       — owner class → full MRO ancestor chain
                              (excludes self, per-language strategy order)
  - `implsByInterfaceDefId` — interface/trait → classes that implement it

**Not an MRO implementation.** `buildMethodDispatchIndex` is a pure
aggregator that calls back into caller-provided `computeMro` and
`implementsOf` functions. The five existing strategies (Python C3, Ruby
kind-aware, Java/Kotlin linear, Rust qualified-syntax, COBOL none) stay
where they are today (`model/resolve.ts`, `languages/ruby.ts`); this index
does not reimplement them.

Why callbacks rather than a shared registry: the strategies depend on the
CLI's `HeritageMap` + `SemanticModel`. Migrating both to `gitnexus-shared`
is out of scope for #914; callbacks let the shared build stay pure.

Module placement: `gitnexus-shared/src/scope-resolution/method-dispatch-index.ts`
for consistency with the other RFC §3.1 indexes (#913 DefIndex /
ModuleScopeIndex / QualifiedNameIndex; #916 resolveTypeRef).

Safety surface mirrors sibling indexes:
  - First-write-wins on duplicate owners.
  - Repeated (interface, owner) pairs deduplicated.
  - Stored arrays are `Object.freeze`d; caller mutation of the source
    array does not leak into the index.
  - Miss returns a shared frozen empty array.

Tests (19, all passing): empty input, single-inheritance chain, Python
C3 diamond, Java BFS, Ruby kind-aware mixin, Rust qualified-syntax empty,
interface inversion (single, multiple, ordered), dedup within and across
callback calls, frozen miss + bucket arrays, callback-array isolation,
readonly Map iteration.

Closes part of #909.
2026-04-18 16:28:46 +01:00
Gergő Magyar 56e32b310b feat(shared): resolveTypeRef strict single-return type resolver (#916, RFC #909 Ring 2 SHARED) (#959)
Implements RFC §4.6: a strict, pure resolver for `TypeRef`s used by
`Registry.lookup` Step 2 (type-binding propagation) and by any caller that
wants the single best type-target for an annotation without paying for the
full evidence pipeline.

Algorithm (strict):

  1. Walk the scope chain from `ref.declaredAtScope`:
     - Return the first binding for `rawName` whose origin is in
       `{'local','import','namespace','reexport'}` AND whose `def.type` is a
       type-kind (class-like, interface-like, enum-like, alias-like).
     - If bindings exist but none qualify (non-type shadow, wildcard-only
       origin), return null immediately — do NOT fall through to the global
       qualified-name index.
  2. If `rawName` is dotted and the scope walk produced no match, consult
     `QualifiedNameIndex.byQualifiedName`. Only accept a UNIQUE type-kind
     hit; ambiguous or non-type results return null.

`'wildcard'` is deliberately excluded from strict origins — a
wildcard-expanded name is too loose to anchor type resolution.

Module placement: `gitnexus-shared/src/scope-resolution/resolve-type-ref.ts`
(alongside sibling indexes) rather than the issue's suggested
`gitnexus-shared/src/resolve-type-ref.ts`, for consistency with the rest of
the RFC §2/§3 surface.

A minimal `ScopeLookup` interface is declared inline so #916 ships
standalone; #912's `ScopeTree` will satisfy this contract without change.

Closes part of #909.
2026-04-18 16:09:54 +01:00
Gergő Magyar ac2012e5ed feat(shared): DefIndex / ModuleScopeIndex / QualifiedNameIndex (#913, RFC #909 Ring 2 SHARED) (#958)
Three flat O(1) indexes + pure build functions over per-file artifacts.
Contract-only; no runtime behavior change yet — consumers (#917 Registry
lookups, #915 SCC finalize, #919 ScopeExtractor) wire in later.

Each index follows the same shape:
  - build function: flat input list → frozen immutable index
  - public interface: readonly Map + get/has/size accessors
  - first-write-wins on id/filePath collisions (upstream bug signal)
  - pure, side-effect-free, safe to call repeatedly

DefIndex — the global "what is this id?" lookup
  gitnexus-shared/src/scope-resolution/def-index.ts
  buildDefIndex(defs: readonly SymbolDefinition[]): DefIndex
    byId: ReadonlyMap<DefId, SymbolDefinition>
  Consumed by Registry.lookup (#917) to materialize DefId[] hits back to
  full SymbolDefinition records.

ModuleScopeIndex — `filePath → moduleScopeId` for cross-file hops
  gitnexus-shared/src/scope-resolution/module-scope-index.ts
  buildModuleScopeIndex(entries): ModuleScopeIndex
    byFilePath: ReadonlyMap<string, ScopeId>
  Consumed by the SCC finalize link pass (#915) to resolve
  ImportEdge.targetFile to a concrete module scope in constant time.

QualifiedNameIndex — cross-kind qualified-name fast path
  gitnexus-shared/src/scope-resolution/qualified-name-index.ts
  buildQualifiedNameIndex(defs: readonly SymbolDefinition[]): QualifiedNameIndex
    byQualifiedName: ReadonlyMap<string, readonly DefId[]>
  Returns DefId[] (not a single DefId) because partial classes, method
  overloads, and cross-kind collisions can legitimately share a
  qualifiedName. Callers filter by acceptedKinds at the lookup site.
  Consumed by Registry.lookup qualified fast path + resolveTypeRef
  dotted fallback (#916, #917).

Barrel re-exports added to gitnexus-shared/src/index.ts so consumers
import from 'gitnexus-shared' rather than deep paths.

Tests (gitnexus/test/unit/scope-resolution/, 23 total):
  def-index.test.ts (6):
    empty, single def, multiple distinct, first-write-wins collision,
    missing id returns undefined, byId direct iteration
  module-scope-index.test.ts (6):
    empty, single entry, multiple files, first-write-wins on duplicate
    filePath, missing returns undefined, byFilePath direct iteration
  qualified-name-index.test.ts (11):
    empty, single qnamed def, partial classes accumulate, input-order
    preservation, qname separation, skip undefined/empty qname, pair
    dedup, cross-kind indexing, frozen-empty-array on miss, direct
    iteration

Verification:
  - gitnexus-shared + gitnexus build clean (tsc + scripts/build.js)
  - test/unit/scope-resolution: 23/23 pass
  - model + shadow + scope-resolution combined: 129/129 pass
  - No runtime consumer wiring yet — indexes are standalone library
    functions that #915, #917, #919 will import when ready

Depends on #910 (SymbolDefinition, DefId, ScopeId types — already on main).
Unblocks #915 (finalize algorithm), #917 (Registry.lookup), #919
(ScopeExtractor materialization).
2026-04-18 15:59:34 +01:00
Copilotandmagyargergo f73389eac3 fix: ENOBUFS in detect_changes by setting maxBuffer on git/rg execFileSync (#957)
* Initial plan

* Fix ENOBUFS in detect_changes by setting maxBuffer on git/rg execFileSync

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/bb241ed0-3b39-431f-a242-b0c7ced9707b

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>
2026-04-18 15:58:31 +01:00
Gergő Magyar 22f0beb057 feat(shared): shadow-mode diff + aggregate — full implementation (#918, RFC #909 Ring 2 SHARED) (#951)
Replaces the scaffold stubs with working pure-logic implementations plus
unit-test coverage for both functions. Unblocks Ring 2 PKG #923 (shadow
harness) to consume a concrete library instead of throwing scaffolds.

gitnexus-shared/src/scope-resolution/shadow/diff.ts
  `diffResolutions(callsite, legacy, newResult): ShadowDiff`
    - [0] on each side is the top match
    - both empty         → 'both-empty',   delta []
    - legacy empty only  → 'only-new',     delta = new top evidence
    - new empty only     → 'only-legacy',  delta = legacy top evidence
    - same top nodeId    → 'both-agree',   delta []
    - different nodeIds  → 'both-disagree',
                           delta = symmetric difference of evidence kinds
                           (legacy-only first in input order, then new-only)
  Evidence identity is `ResolutionEvidence.kind` — weight/note differences
  for the same kind do NOT produce delta entries. Rationale: the aggregator
  wants to know which *signals* explain a disagreement, not fluctuations
  in calibration values.

gitnexus-shared/src/scope-resolution/shadow/aggregate.ts
  `aggregateDiffs(diffs, now?): ShadowParityReport`
    - buckets by `SupportedLanguages`
    - tallies agreements, evidence-breakdown (divergences only — agree and
      empty rows do not contribute)
    - parity = bothAgree / (totalCalls - bothEmpty), yields 0 (not NaN)
      when the denominator is 0
    - perLanguage sorted alphabetically by enum value for stable output
    - evidenceBreakdown internally sorted by kind for stable output
    - overall = column-wise sum across languages
    - `now` parameter makes generatedAt deterministic in tests

gitnexus-shared/src/index.ts
  Re-exports the full shadow API: diffResolutions, aggregateDiffs, and all
  their types (ShadowAgreement, ShadowCallsite, ShadowDiff,
  LanguageParityRow, ShadowParityReport).

gitnexus/test/unit/shadow/diff.test.ts (13 tests)
  - 5 agreement outcomes
  - symmetric-by-kind evidence delta (disjoint, overlapping, fully-overlapping)
  - weight-only differences produce no delta
  - top-match only (ignores indices beyond [0])
  - callsite passthrough
  - delta ordering (legacy-only first, input order preserved)

gitnexus/test/unit/shadow/aggregate.test.ts (9 tests)
  - empty input
  - single language, all agree / mixed / all empty
  - multi-language bucketing + overall sum
  - alphabetical language sort
  - evidence breakdown scope
  - determinism via injected `now` + JSON round-trip identity

Verification:
  - gitnexus-shared + gitnexus build clean (tsc + scripts/build.js)
  - test/unit/shadow: 22/22 pass
  - test/unit/model + test/unit/shadow combined: 106/106 pass
  - No runtime behavior changes (shadow is invoked by #923, not yet wired)

Stacked on main (af1d278a). Depends on types from #910 (merged).
Unblocks: #923 (Ring 2 PKG — shadow harness wiring) — concrete library
to consume instead of scaffold stubs.

Plan: docs/plans/2026-04-18-001-refactor-911-senior-hooks-redesign-plan.md
is about #911; #918's scope is the scaffold+fill-in described in the PR
description of #951.
2026-04-18 15:32:51 +01:00
Gergő Magyar af1d278a7e feat(shared,ingestion): extend LanguageProvider with scope-resolution hooks (#911, RFC #909 Ring 1) (#950)
Adds the 14 optional scope-resolution hooks from RFC #909 §5.2 to
`LanguageProviderConfig` plus the supporting input/output types in
`gitnexus-shared`. Contract-only; no runtime behavior changes.

Review-driven refinements (addresses two non-blocking review comments on #950):

1. `ParsedImport` is now a 5-variant discriminated union, not a flat
   record. Each variant carries only its legal fields so invalid shapes
   are compile errors:
     - 'named', 'alias', 'namespace', 'reexport', 'dynamic-unresolved'
   'wildcard-expanded' is deliberately excluded — finalize materializes
   that kind; a provider must never emit it at parse time.
   'reexport' is a first-class parse-phase variant so syntactically-
   detectable re-exports (TS `export { X } from './y'`, Rust
   `pub use foo::bar`) keep their parse-time signal through to finalize
   rather than being re-derived by the SCC pass.
   `namespace` gains an `importedName` field so `import numpy as np`
   can carry both `localName: 'np'` and `importedName: 'numpy'`.
   `dynamic-unresolved.targetRaw` is `string | null` (was mandatory
   null) so providers can emit the unresolvable expression text for
   diagnostics when available.

2. `bindingScopeFor` and `importOwningScope` return type changed from
   `ScopeId` to `ScopeId | null`, aligning with the X | null convention
   used by the 12 sibling optional hooks (receiverBinding,
   resolveScopeKind, interpretTypeBinding, …). `null` = delegate to the
   central default. Enables partial overrides — a JS provider can
   return a hoisted scope for `var` and `null` for `let`/`const`
   without re-implementing the default lookup.
   Both hooks also gain a purity JSDoc contract: same inputs yield the
   same ScopeId (or null) across invocations; no closure over mutable
   state. Required to keep scope-tree construction deterministic.

   A richer callable-defaults pattern (typed BindingScopeDefaults /
   ImportOwningDefaults helper interfaces on a `defaults` parameter)
   was considered and deferred to Ring 2 PKG #919, where the concrete
   ScopeExtractor will exist to inform the helper shape. Designing that
   pattern before the first consumer would set cross-hook precedent
   based on a single motivating example.

Supporting types added to gitnexus-shared/src/scope-resolution/types.ts:
  - CaptureMatch, ParsedImport, ParsedTypeBinding
  - WorkspaceIndex, ScopeTree (opaque placeholders until Ring 2)
  - Callsite

14 hooks added to LanguageProviderConfig (all optional):
  Parse phase: emitScopeCaptures, interpretImport, receiverBinding,
    interpretTypeBinding, resolveScopeKind, shouldCreateScope,
    bindingScopeFor
  Finalize phase: resolveImportTarget, expandsWildcardTo,
    importOwningScope, mergeBindings
  Reference-extraction phase: classifyCallForm
  Resolution phase: shouldShadow, arityCompatibility

Verification:
  - gitnexus-shared builds clean (tsc)
  - gitnexus builds clean (scripts/build.js)
  - test/unit/model: 84/84 pass — no regressions
  - No provider needs updating (all hooks optional)
  - No BindingScopeDefaults/ImportOwningDefaults/defaults parameter
    introduced (deferred to #919)

Stacked on #910 (merged as afc0a8b6); rebased on main.
Tracking: #909 (meta). Unblocks Ring 2 PKG (#919 ScopeExtractor,
#922 import adapters) and all Ring 3 per-language migrations.

Plan: docs/plans/2026-04-18-001-refactor-911-senior-hooks-redesign-plan.md
2026-04-18 14:54:51 +01:00
Gergő Magyar afc0a8b6c5 feat(shared): add scope-resolution types + constants (#910, RFC #909 Ring 1) (#949)
Lands the authoritative data model and constants for the pure scope-based
resolution RFC (#909) as Ring 1, part 1. No runtime behavior changes —
types + constants only.

New in gitnexus-shared/src/scope-resolution/:
  - types.ts — Scope, ScopeKind, ScopeId, DefId, Range, Capture,
    BindingRef, ImportEdge, TypeRef, Resolution, ResolutionEvidence,
    Reference, ReferenceIndex, LookupParams, RegistryContributor
  - evidence-weights.ts — EvidenceWeights constant map + typeBindingWeightAtDepth
    (RFC Appendix A)
  - origin-priority.ts — ORIGIN_PRIORITY constant map for deterministic
    tie-breaks (RFC Appendix B)
  - language-classification.ts — LanguageClassification type +
    LanguageClassifications map (production × 14, experimental × 2
    for vue/cobol; governs Ring 4 DAG-retirement gate)
  - symbol-definition.ts — SymbolDefinition moved from
    gitnexus/src/core/ingestion/model/symbol-table.ts so scope-resolution
    types can reference it from the shared package

Consumer updates:
  - symbol-table.ts: removes local SymbolDefinition declaration; imports
    from gitnexus-shared
  - model/index.ts: drops SymbolDefinition from barrel re-export per
    "direct imports from gitnexus-shared" convention (see
    gitnexus-shared feedback in project memory)
  - 9 source files + 5 test files: import SymbolDefinition directly
    from 'gitnexus-shared'

Verification:
  - gitnexus-shared builds clean (tsc)
  - gitnexus builds clean (scripts/build.js)
  - 131/132 unit test files pass; 3767 tests green
  - Zero behavior changes; SymbolDefinition shape unchanged

Blocks: #911 (LanguageProvider hook interface extensions) and all of
Ring 2 (#912-#925). Closes part of #909.
2026-04-18 12:55:09 +01:00
Gergő Magyar d9da7d6692 fix(test): isolate cli-e2e from shared mini-repo fixture (#954)
Deterministic fix for the Windows-flaky pipeline-graph-golden test.

Root cause
  cli-e2e.test.ts wrote into the SHARED fixture directory
  (test/fixtures/mini-repo/) — git init, analyze run that creates
  AGENTS.md, CLAUDE.md, .claude/, .gitnexus/. When pipeline-graph-golden
  ran in parallel, its `cpSync` of the source directory could capture
  the mid-flight pollution before cli-e2e's afterAll cleanup fired.
  macOS/Ubuntu won the race often enough that the flake presented as
  Windows-only.

Fix
  cli-e2e now copies mini-repo into a fresh `mkdtemp`'d parent whose
  basename is `mini-repo` (preserving `--repo mini-repo` CLI lookup by
  basename), runs git-init there, and rm's the whole tmpdir in afterAll.
  The shared fixture source is never touched.

  Fallout from the cwd change: bare `--import tsx` specifiers (2
  spawnSync + 1 spawn) can't resolve `tsx` from an os.tmpdir cwd where
  there is no node_modules. Switched them to the already-existing
  `tsxImportUrl` (absolute file:// URL to the tsx loader), matching
  the `runCliOutsideProject` pattern that was already set up for this
  exact case.

  Updated the "MINI_REPO is inside the project tree" comment in the
  `status on non-indexed repo` test — MINI_REPO is now in os.tmpdir,
  so the rationale for using a separate throwaway tmp git repo is
  different (but still valid: previous tests in the suite create
  MINI_REPO/.gitnexus, which findRepo() would pick up).

  Also updated pipeline-graph-golden's comment explaining WHY it
  copies to tmp — it's now defense-in-depth rather than a necessity,
  so a future test that adds files to the source can't silently
  regress the golden.

Verification
  - 5x consecutive `cli-e2e + pipeline-graph-golden` runs: 20/20 pass
    (deterministic)
  - 3x full suite including pipeline.test: 27/27 pass
  - test/fixtures/mini-repo/ post-run contents: only `src/` —
    zero pollution from any test
  - macOS/Ubuntu behavior unchanged (they were passing; tmpdir
    isolation is purely additive)
2026-04-18 12:54:59 +01:00
azizur100389 131d411ae4 feat(mcp): rank context/impact disambiguation candidates and expose kind/file_path hints (#888)
* feat(mcp): rank context/impact disambiguation candidates and expose kind/file_path hints

The `context` MCP tool already returned `{ status: 'ambiguous', candidates }`
when a name hit multiple symbols, but the candidates were returned in
arbitrary DB order and the only hint it accepted was file_path. The
`impact` tool was worse: when its name resolver found multiple viable
matches it silently picked the first one from a priority UNION, with no
signal back to the caller that a different symbol might have been
intended.

Both failure modes were flagged in issue #470 and reconfirmed in the
comments by a second user who described impact as returning "incorrect
parsing results and meaningless tool calls" in the multi-match case.

Changes:

* Add `resolveSymbolCandidates(repo, query, hints)` private helper on
  LocalBackend. Single place that:
   - Short-circuits on direct uid (zero-ambiguity)
   - Runs the same name-or-qualified-id match as before, with LIMIT 20
     (was 10) so the ranker has headroom instead of arbitrary truncation
   - Preserves the #480 Class/Constructor preference -- when the only
     ambiguity is a Class and its own Constructor, the Class wins
     silently
   - Scores each candidate (pure TS, no extra DB round-trip): base 0.50,
     +0.40 for file_path match, +0.20 for kind match, plus a small
     kind-priority tiebreaker (Class > Interface > Function > Method >
     Constructor) when no explicit kind hint is given
   - Sorts desc by score with stable tiebreakers (shorter filePath,
     then lex uid)
   - Promotes to a single confident resolve when the top score is
     >= 0.95 AND beats the runner-up by >= 0.10 -- lets a strong hint
     cut through without forcing the caller through a disambiguation
     round-trip

* Rewire `context()` to use the shared helper. Response shape is a
  strict superset of today's: candidates gain a `score` field, the
  existing `{ uid, name, kind, filePath, line }` keys are preserved so
  every downstream consumer (rename, eval-server formatter, etc.) keeps
  working. New `kind` input hint accepted.

* Rewire `impact()` to use the shared helper. Now emits the same
  `{ status: 'ambiguous', candidates, impactedCount: 0, risk: 'UNKNOWN' }`
  shape instead of silent first-pick. New inputs accepted:
  `target_uid`, `file_path`, `kind`.

* Update tool schemas in mcp/tools.ts to advertise the new inputs and
  describe ranked disambiguation.

Backward compatibility:

The #480 Class/Constructor collapse is preserved and covered by the
existing java-class-impact integration test (still green). The
ambiguous response shape is a strict superset -- `eval-formatters`
unit test that parses the old shape is unchanged and still passes.
`impact` going from silent-first-pick to structured ambiguous is a
semantic improvement that is the entire point of the issue; callers
relying on silent first-pick now get an actionable response.

Scope declined for v1:

module/community hint -- the issue lists it as one of several hints,
but kind + file_path cover the vast majority of disambiguation needs
in practice, and a community-label filter requires an extra graph
query per candidate. Natural v2 follow-up.

Tests: calltool-dispatch.test.ts gains 5 new cases covering file_path
boost, kind hint boost, impact ambiguous shape, impact target_uid
short-circuit, and score field presence on the existing ambiguous
test. Plus the extended assertions on the existing
`context tool returns disambiguation for multiple matches`.

Verification:
  npx vitest run test/unit/calltool-dispatch.test.ts       -> 64 pass
  npx vitest run test/integration/java-class-impact.test.ts -> pass
  npm run test:unit                                         -> 3642 pass
    (4 pre-existing env failures unchanged: skip-git-cli needs built
    dist/, git-utils tmpdir on Windows worktree -- same on main)
  npx tsc --noEmit                                          -> clean

Closes #470

* fix(mcp): enrich labels from UNION when labels(n)[0] is empty; address review findings

CI on PR #888 caught 13 integration-test failures I did not cover locally:
my resolver refactor collected candidates via `labels(n)[0] AS type`, but
LadybugDB returns an empty string for that projection on certain node
types (most importantly Class). With an empty `type`, impact's downstream
`_runImpactBFS` no longer recognised `symType === 'Class' | 'Interface'`
and stopped seeding Constructor + File nodes into the frontier, so the
"impact(upstream) surfaces the file importer" assertion broke across 11
language fixtures plus 2 OVERRIDES filter tests.

The original impact resolver worked around this by running a prioritised
UNION across Class/Interface/Function/Method/Constructor and picking the
first hit. My refactor dropped that. Fix: keep the simple candidate MATCH
but enrich types afterward via a single scoped UNION query, so every
candidate carries an accurate label for both scoring and downstream
BFS seeding. The UID direct-lookup path is patched the same way.

Also addresses the findings from the senior reviewer on PR #888:

* MIGRATION.md: document the `impact` behavioural change (silent first-
  pick → structured `{ status: 'ambiguous', candidates }`) so downstream
  callers know to branch on `result.status` before reading byDepth/
  summary. `context` is unchanged shape-wise (strict superset).

* New test: `context tool promotes top candidate via scoring when
  multiple rows survive DB pre-filter`. The review flagged that the
  existing file_path test works only because the mock ignores WHERE
  parameters -- the scored-promotion path (top ≥ 0.95 AND gap > 0.09)
  wasn't directly exercised. The new test uses two candidates both in
  App.tsx-containing paths plus a kind hint so promotion is decided by
  scoring, not DB pre-filtering. Also tightened the comment on the
  earlier file_path test to describe the mock vs production divergence
  honestly.

* NIT: added a paragraph explaining why `scored.length >= 2` is kept as
  a defensive guard even though the `normalized.length === 1` early
  return already covers the single-candidate path.

* Integration: two tests in `local-backend-calltool.test.ts` targeted
  `'authenticate'`, which now correctly resolves as ambiguous (two
  Method nodes: AuthService.authenticate and BaseService.authenticate).
  Updated both to pass `file_path: 'src/auth.ts'` so they exercise the
  new disambiguation API and still assert the METHOD_OVERRIDES filtering
  they were originally about.

Edge case fix in the promotion gap check: IEEE754 makes 0.50 + 0.40 +
0.20 - 0.90 = 0.09999999999999998 instead of exactly 0.10, which would
otherwise break the "winner clearly dominates" intent for legitimate
1.00 vs 0.90 cases. Changed `>= 0.10` to `> 0.09`; same user-facing
intent, no floating-point sensitivity.

Verification (all from gitnexus/):
  npx vitest run test/integration/class-impact-all-languages.test.ts
    -> 52 pass (was 11 FAIL on CI before this fix)
  npx vitest run test/integration/local-backend-calltool.test.ts
    -> 18 pass (was 2 FAIL on CI before this fix)
  npx vitest run test/integration/java-class-impact.test.ts
    -> 10 pass (regression guard for #480 preserved)
  npx vitest run test/unit/calltool-dispatch.test.ts
    -> 65 pass (1 new test + 4 from original #470 PR)
  npm run test:unit
    -> 3626 pass, 4 pre-existing env failures unchanged
  npx tsc --noEmit
    -> clean
2026-04-18 12:52:42 +01:00
Gergő Magyar b8875b9c80 chore(release): v1.6.2 (#952)
Bumps gitnexus to v1.6.2 and adds the matching CHANGELOG entry.

Highlights since v1.6.1 (61 commits):
  - Docker support (#848)
  - Language-agnostic heritage / call / variable extractors
    (config+factory pattern, #877 #878 #890)
  - AST-aware embedding chunking (#889)
  - jQuery / axios HTTP consumer detection (#887)
  - SemanticModel wired as first-class resolution input, SM-20 (#885)
  - ImportSemantics split into per-strategy hooks (#886)
  - Python dotted-import fix (#899); worker warnings non-terminal
    (#900 / #261); global-install ENOTEMPTY fixes (#843 #846);
    embeddings staleness fix (#831)

See gitnexus/CHANGELOG.md for the full list.

After merge, tag `v1.6.2` triggers publish.yml which runs CI,
verifies tag↔package.json match, publishes to npm with provenance,
and creates the GitHub Release using the extracted CHANGELOG body.
2026-04-18 12:16:51 +01:00
RyanbaandSisyphus 969b4623ca fix: keep worker warnings non-terminal (#900)
* fix: keep worker warnings non-terminal

Treat parse-worker warning messages as informational so a warning can be surfaced without short-circuiting the worker result protocol.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* style: apply prettier formatting

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-18 11:09:15 +01:00
Copilotandmagyargergo 018e0e6b14 test(web-e2e): raise status-ready timeout to 45s for parallel-worker stability (#908)
* Initial plan

* plan: stabilize web e2e tests timing out under parallel workers

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/49d09e67-5a8e-4eee-adcd-3d5416675a6b

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

* test(web-e2e): bump status-ready timeout to 45s for parallel-worker stability

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/49d09e67-5a8e-4eee-adcd-3d5416675a6b

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>
2026-04-18 10:15:28 +01:00
Kritik Bangeraandkritik.b 040bb7a489 feat: add docker support (#848)
* feat: add docker support

* feat: move docker files to root

* feat: add docker build and push workflow

* fix: pin docker action SHAs to verified commits

Made-with: Cursor

* fix: remove redundant --platform=$TARGETPLATFORM from runtime stage

Made-with: Cursor

* fix: upgrade docker actions to Node.js 24-compatible versions

Made-with: Cursor

* docs: updated readme

* fix: update docker references

* fix(docker-server): reject null bytes in resolvePath

Defensively harden the path traversal guard by returning null
early when the URL contains a null byte, before normalization runs.

Made-with: Cursor

* fix(docker-server): handle createReadStream errors

Attach an error listener before piping so mid-flight read errors
(truncated file, permission change) cleanly destroy the response
instead of being silently swallowed.

Made-with: Cursor

* fix(docker-server): replace existsSync with async stat

Eliminates the TOCTOU race between the initial stat call and the
subsequent existsSync check. Reuses the async stat pattern already
in place and removes the now-unused existsSync import.

Made-with: Cursor

* test(docker-server): add integration tests; fix %00 null-byte bypass

Decode the URL before the null-byte check so percent-encoded null
bytes (%00) are also rejected with 400 instead of falling through
to the SPA fallback. Adds 5 node:test integration tests covering
valid assets, SPA fallback, path traversal, null bytes, and 404.

Made-with: Cursor

* style: fix prettier formatting in docker-server files

Made-with: Cursor

* fix(docker): wire tests into CI, fix resolvePath separator, correct image namespace

- Add `node --test docker-server.test.mjs` step to ci-tests.yml so the
  path-traversal guard tests run in every CI pass instead of being silently skipped.
- Fix resolvePath containment check: `startsWith(root)` would allow sibling
  directories like `/app/dist-evil/`; now guards with `root + sep` or exact match.
- Update docker-compose.yaml default image from `abhigyanpatwari` namespace to
  `brainifii` to match what docker.yml publishes to GHCR.

* fix(docker): update apt-get commands and set user permissions

- Modify Dockerfile and Dockerfile.test to include options for apt-get to bypass validity checks during updates.
- Set ownership of the /app directory to the 'node' user in the runtime stage for improved security and proper permission handling.

* fix(docker): switch to Alpine base images for smaller footprint

- Update Dockerfile to use Alpine-based Node.js images for both builder and runtime stages, reducing image size and improving performance.
- Replace apt-get commands with apk for package installation in the runtime stage.

* fix(docker): update Node.js version in Dockerfile

- Change base image from node:20-alpine to node:22-alpine

* fix(docker): update Node.js version in Dockerfile to 22-alpine for runtime

---------

Co-authored-by: kritik.b <kritik.b@media.net>
2026-04-18 08:39:18 +01:00
dependabot[bot] 725ed3fe66 chore(deps)(deps): bump glob from 11.1.0 to 13.0.6 in /gitnexus (#867) 2026-04-18 07:40:08 +01:00
dependabot[bot] 509185b8f9 chore(deps)(deps): bump commander from 12.1.0 to 14.0.3 in /gitnexus (#868) 2026-04-18 07:28:17 +01:00
dependabot[bot] 4988feec94 chore(deps)(deps-dev): bump wait-on from 8.0.5 to 9.0.5 in /gitnexus-web (#859) 2026-04-18 07:26:56 +01:00
dependabot[bot] ef953beca9 chore(deps)(deps): bump @huggingface/transformers in /gitnexus (#869) 2026-04-18 07:22:34 +01:00
dependabot[bot] 0b5381695f chore(deps)(deps-dev): bump @vitest/coverage-v8 in /gitnexus (#864) 2026-04-18 07:21:02 +01:00
dependabot[bot] 30292d7179 chore(deps)(deps): bump @modelcontextprotocol/sdk in /gitnexus (#866) 2026-04-18 07:20:19 +01:00
dependabot[bot] 7a98a01ad5 chore(deps)(deps): bump lru-cache from 11.2.7 to 11.3.5 in /gitnexus (#870) 2026-04-18 07:19:49 +01:00
dependabot[bot] 94cba48b6d chore(deps)(deps): bump mnemonist from 0.39.8 to 0.40.3 in /gitnexus (#871) 2026-04-18 07:19:24 +01:00
azizur100389 925460ab5b refactor(cli): trim duplicated ai-context CLAUDE.md block (#904) 2026-04-18 07:10:44 +01:00
dependabot[bot] 08b4505197 chore(deps)(deps): bump @ladybugdb/core in /gitnexus (#873) 2026-04-17 21:28:34 +01:00
dependabot[bot] d5225e699f chore(deps)(deps): bump mermaid from 11.12.2 to 11.14.0 in /gitnexus-web (#860) 2026-04-17 21:27:37 +01:00
dependabot[bot] 0a3b9120a0 chore(deps)(deps): bump tailwindcss in /gitnexus-web (#861) 2026-04-17 21:27:07 +01:00
dependabot[bot] 5544350e30 chore(deps)(deps-dev): bump jsdom from 29.0.0 to 29.0.2 in /gitnexus-web (#863) 2026-04-17 21:26:50 +01:00
Copilot dfa449ef41 feat(ingestion): language-agnostic heritage extractor with config+factory pattern (#890) 2026-04-17 17:51:17 +01:00
Yacine Hmito daca8360bf fix(python): avoid local matches for external dotted imports (#899) 2026-04-17 11:35:59 +01:00
Ryanba 77a13113ea fix: keep worker warnings non-terminal (#261) 2026-04-17 06:46:31 +01:00
evolution 02739085d2 feat(embeddings): AST-aware chunking with offset-based splitting (#889) 2026-04-16 22:55:04 +01:00
43098784cf refactor(ingestion): split ImportSemantics into per-strategy hooks (Strategies 1-4) (#886)
* Initial plan

* refactor(ingestion): split ImportSemantics into per-strategy hooks

- Add ImportResolverStrategy and ImportResolutionConfig types
- Create createImportResolver factory (resolver-factory.ts)
- Add createStandardStrategy to standard.ts
- Extract per-language strategies from existing resolvers:
  goPackageStrategy, javaJvmStrategy, kotlinJvmStrategy,
  rustModuleStrategy, pythonImportStrategy, csharpNamespaceStrategy,
  phpPsr4Strategy, swiftPackageStrategy, dartPackageStrategy,
  dartRelativeStrategy, rubyRequireStrategy
- Create per-language config files in import-resolvers/configs/
- Update all 15 language providers to use createImportResolver(config)
- Add 38 unit tests for factory and strategy composition
- All 3640+ existing tests pass, tsc --noEmit passes

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c3aec32d-2155-4808-88df-9cd6b2384174

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

* chore: remove unused resolver imports from language providers

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c3aec32d-2155-4808-88df-9cd6b2384174

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

* docs: add error propagation note to createImportResolver JSDoc

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c3aec32d-2155-4808-88df-9cd6b2384174

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

* refactor: consolidate strategies into configs, remove legacy resolvers

- Move all strategies from per-language files into their config files
- Remove swift.ts and vue.ts (no shared helpers needed)
- Remove legacy monolithic resolver functions from all per-language files
- Remove unused legacy wrapper functions from standard.ts
- Per-language files now only contain shared internal helpers
- Fix lint warning in languages/php.ts (no-non-null-assertion)
- Update test imports to reference configs/ instead of per-language files
- All 3262+ tests pass, tsc --noEmit passes, zero lint errors

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/f8da6bc2-957c-4d20-87ba-402fa223c6c8

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

* refactor: address review feedback — remove dart.ts shim, JSDoc language field, update ARCHITECTURE.md

- Add JSDoc to ImportResolutionConfig.language clarifying it's
  documentation-only metadata not used by the factory
- Remove dart.ts legacy shim (was only kept for backward-compat tests)
- Rewrite dart-import-resolver.test.ts to test production strategies
  (dartPackageStrategy/dartRelativeStrategy) directly, including full
  factory composition via dartImportConfig
- Fix lint warning (no-explicit-any) by using buildSuffixIndex in makeCtx
- Update ARCHITECTURE.md to mention import-resolvers/configs/ as the
  extension point for per-language import resolution

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/53f09a4f-1ff1-4a3e-a29c-fda9cdb4c4ef

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

* fix: address review comments — revert php.ts, tighten dart test assertion

- Revert php.ts: restore stack.pop()! (the while guard guarantees non-empty)
- Tighten dart relative import test to assert exact result instead of
  permissive null-or-files check

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/53f09a4f-1ff1-4a3e-a29c-fda9cdb4c4ef

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

* refactor(ingestion): strengthen import-resolver tests and document Vue config intent

Address non-blocking follow-ups from PR #886 review:
- Add inline comment to vueImportConfig explaining intentional
  language: Vue / TypeScript-strategy mismatch (Vue SFCs are
  preprocessed into TS upstream of import resolution).
- Replace 11 tautological typeof === 'function' assertions with
  behavioral tests for goPackageStrategy, kotlinJvmStrategy, and
  csharpNamespaceStrategy, including full-chain strategy-order
  guards via createImportResolver(config).
- Apply prettier formatting to sibling configs touched during
  factory introduction.

Test: 37 passed (previously 26), tsc --noEmit clean.

* test(ingestion): tighten import-resolver assertions and close coverage gaps

Apply ce-review findings on commit f4be87fb:

- Tighten dirSuffix assertions from toContain() to exact toEqual()
  shape, catching format regressions (slash normalization, prefix
  trimming) the loose matcher would miss.
- Collapse 'if (result?.kind === "package") { expect(dirSuffix)... }'
  conditional-dead-branch pattern into single toEqual() assertions.
- Add goPackageStrategy fall-through test: module prefix matches but
  package directory contains no .go files -> null (documented branch
  in configs/go.ts:27 had no coverage).
- Honestly relabel kotlinImportConfig full-chain test as a behavioral
  smoke test rather than a strategy-order guard — standard.ts:137
  returns null for '.*' imports so reordering is not observable via
  wildcard inputs. Added Kotlin member-import test for extra coverage.
- Add behavioral tests for javaJvmStrategy, rustModuleStrategy,
  phpPsr4Strategy, swiftPackageStrategy, rubyRequireStrategy (10 tests
  across 5 describe blocks) so strategy unwiring would be caught.
- Extend makeCtx() with optional overrides: Partial<ResolveCtx['configs']>
  parameter for declarative per-test config setup.

Test: 50 passed (previously 37), tsc --noEmit clean.

---------

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-04-16 19:31:44 +01:00
azizur100389 f221f93341 feat(extractors): detect jQuery $.ajax/$.get/$.post and axios object-form as HTTP consumers (#887)
* feat(extractors): detect jQuery $.ajax/$.get/$.post and axios object-form as HTTP consumers

The JS/TS HTTP consumer extractor currently recognises fetch() and
axios.<verb>() but misses three patterns extremely common in Laravel
and legacy frontends:

  - jQuery shorthand: $.get(url), $.post(url, data)
  - jQuery ajax form: $.ajax({ url, method }) / $.ajax({ url, type })
  - axios object form: axios({ method, url })

Missing them means the frontend->backend cross-link disappears from
`group sync`, breaking impact analysis for whole classes of repos.

Implementation (node.ts):
  - 3 new PatternSpecs alongside the existing FETCH_/AXIOS_ specs
  - NodePatternBundle extended with jqueryShorthand / jqueryAjax /
    axiosObject slots, compiled for JS / TS / TSX grammars
  - readStringProp() helper walks object-literal `pair` children and
    resolves `url` / `method` / `type` keys independent of order,
    sidestepping the positional S-expression constraint on the
    query form proposed in the issue
  - 3 new scan loops in scanBundle() emit HttpDetection with
    framework 'jquery' (new) or 'axios' (existing), confidence 0.7
    to match the existing source-scan consumers, defaulting method
    to GET when absent (matches both jQuery and axios runtime)

Tests (http-route-extractor.test.ts): 4 new cases -- 3 positive
(shorthand, ajax with method:/type: and default GET, object-form
with swapped key order and default GET) plus 1 negative control
that asserts unrelated \$.fn.extend / \$.each / non-axios helper
calls with {url, method} literals produce zero consumer contracts.

Closes #828

* test(extractors): cover jQuery $.ajax with template-literal URL

Extend the existing $.ajax fixture with `url: \`/api/orders/\${id}\``
and assert the consumer is emitted as http::GET::/api/orders/{param}.
This makes jQuery + template-URL explicit rather than implicit via the
axios object test (readStringProp already accepts template_string for
both; this is coverage, not new behaviour).

Addresses the single non-blocking finding on PR #887.
2026-04-16 18:36:24 +01:00
Copilot a32f5b6adb refactor(SM-20): wire SemanticModel as first-class resolution input (#885)
* Initial plan

* refactor(SM-20): fix O(n²) BFS in gatherAncestors, complete barrel exports, consolidate imports

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/1246d49e-6c67-4c79-935a-4732394b9a7a

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-04-16 14:45:44 +01:00
Copilotandmagyargergo ed5a4220dd feat(ingestion): language-agnostic variable extractor with config+factory pattern (#878)
* Initial plan

* feat(ingestion): add variable extraction types, factory, configs, and wire into language providers

- Create variable-types.ts with VariableInfo, VariableExtractionConfig, VariableExtractor interfaces
- Create variable-extractors/generic.ts with createVariableExtractor() factory
- Add variableExtractor field to LanguageProvider interface
- Create per-language variable extraction configs for all 16 languages
- Wire variableExtractor into all language providers
- Add variable metadata enrichment to parse-worker for Const/Static/Variable labels

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/3cb85c68-1792-473e-9a46-ea2588da0e5e

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

* feat(ingestion): add variable extraction tests and fix Python/TS config issues

- Create test/unit/variable-extraction.test.ts with 29 tests covering
  TypeScript, JavaScript, Python, Go, Rust, C, C++, Ruby, and factory behavior
- Fix isConst in generic factory to use config.isConst over node-type membership
  (TS let/const both use lexical_declaration)
- Fix Python type extraction for annotated assignments at module scope
- Fix Python dunder name visibility (e.g., __name__ is public, not protected)

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/3cb85c68-1792-473e-9a46-ea2588da0e5e

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

* fix: address code review feedback — move imports, clarify scope comment, use shared test context

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/3cb85c68-1792-473e-9a46-ea2588da0e5e

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

* fix: address review comments, fix prettier formatting and lint errors

- Fix prettier formatting in 5 files (c-cpp, jvm, swift configs, test file)
- Remove unused SyntaxNode imports in php.ts and ruby.ts (lint errors)
- Remove unused constNodeSet/variableNodeSet variables in generic.ts (warnings)
- Remove semantically wrong `methodProps.isReadonly = varInfo.isConst` (review)
- Remove dead `nodeLabel === 'Variable'` guard in parse-worker (review)
- Fix test guard: replace `if (declNode)` with `expect(declNode).toBeDefined()` (review)
- Add comment about Python expression_statement broadness (review)

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/040edbbf-65b5-40e1-80c8-e98f7c4bb54a

* feat(ingestion): add block-scoped variable extraction via tree-sitter queries

Add @definition.const and @definition.variable tree-sitter query patterns
for TypeScript, JavaScript, Python, Go, Java, C, C++, C#, PHP, Ruby, and
Dart. Add parse-worker dedup logic to avoid duplicate nodes when variable
captures overlap with existing function/property captures. Add 'Variable'
label support in getLabelFromCaptures and DEFINITION_CAPTURE_KEYS.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9fa828c1-87b7-4482-8f26-d2079fb4c58a

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

* test: add block-scoped variable extraction tests and query capture tests

Add 6 tests for block-scoped variable extraction (TypeScript, Go, Rust, C,
Python). Add 14 tests verifying @definition.const/@definition.variable
query patterns exist in all language query strings. Import RUBY_QUERIES
in test file.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9fa828c1-87b7-4482-8f26-d2079fb4c58a

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

* test: add Python non-assignment expression statement rejection test

Addresses code review feedback: verify that the Python variable extractor
returns null for expression_statement nodes that contain function calls
rather than assignments (e.g. `print("hello")`).

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9fa828c1-87b7-4482-8f26-d2079fb4c58a

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

* fix: Dart query node type, add Variable schema, update schema counts

- Change `top_level_variable_declaration` → `declaration` in DART_QUERIES
  (the former doesn't exist in tree-sitter-dart grammar, causing all
  Dart integration tests to fail with TSQueryErrorNodeType)
- Add VARIABLE_SCHEMA to schema.ts and register in initLbug() so that
  Variable-labeled nodes are persisted to LadybugDB (not silently dropped)
- Add 'Variable' to MULTI_LANG_TYPES in csv-generator.ts
- Update Dart variable config to remove invalid node type
- Update schema test counts (30→31 node schemas, 32→33 total)

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/f79931d1-207f-4fbb-91da-259d44f7fd88

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

* fix: address code review comment improvements

- Clarify processedDefinitionNodes tracks start indices, not nodes
- Improve Python variableNodeTypes comment wording

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/f79931d1-207f-4fbb-91da-259d44f7fd88

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

* fix: add Variable to NODE_TABLES, RELATION_SCHEMA, update golden snapshot

- Add 'Variable' to NODE_TABLES in gitnexus-shared so validTables.has('Variable')
  returns true and Variable graph edges are not silently dropped
- Add FROM File TO Variable, FROM Variable TO Community, FROM Variable TO Process
  to RELATION_SCHEMA so KuzuDB can represent edges connecting Variable nodes
- Update schema.test.ts: add Variable to multiLang list, fix count 30→31
- Regenerate pipeline-graph-golden snapshot for mini-repo fixture

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e3aad558-e7bb-40d1-b53f-0a2c0132ca96

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

* fix: isolate golden test from cli-e2e fixture pollution

The pipeline-graph-golden test was non-deterministic because cli-e2e.test.ts
creates AGENTS.md, CLAUDE.md, .claude/skills/, and .gitignore in the shared
mini-repo fixture during analyze. These leftover files caused the golden test
to find 9 files instead of 7 when tests ran in parallel.

Fixes:
- Golden test now copies the fixture to a temp dir before running, making it
  immune to concurrent test pollution
- cli-e2e afterAll cleanup now removes ALL generated files (AGENTS.md,
  CLAUDE.md, .claude/, .gitignore) not just .git/ and .gitnexus/
- Golden snapshot regenerated from clean 7-file fixture

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/bd378e73-6f37-49c6-aed6-7fabf4dc6183

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>
2026-04-16 13:57:25 +01:00
Copilotandmagyargergo 03821faf58 feat(ingestion): language-agnostic call extractor with config+factory pattern (#877)
* Initial plan

* feat(ingestion): add call-types, call-extractors factory, per-language configs, and wire into providers

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/893afa77-5b34-4e6b-a1dc-03034261fb36

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

* feat(ingestion): replace inline call extraction in parse-worker and call-processor, delete call-sites/

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/893afa77-5b34-4e6b-a1dc-03034261fb36

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

* test(ingestion): add unit tests for call extraction configs and factory

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/893afa77-5b34-4e6b-a1dc-03034261fb36

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

* style: fix prettier formatting in call-extractor files

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/d4b06f56-03b6-4fa4-801f-7ddcc6e81f13

* fix: address review comments — doc comment, idempotency note, C# behavioral test

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e53e650b-fae6-4551-ab25-cda28e4d647f

* fix: rename misleading test title, remove stale code reference in comment

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e53e650b-fae6-4551-ab25-cda28e4d647f

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
2026-04-16 11:45:30 +01:00
Copilotandmagyargergo 06f18ada15 refactor(ingestion): move class extraction configs to configs/ subdirectory (#879)
* Initial plan

* refactor(ingestion): move class extraction configs to configs/ subdirectory

Extract inline ClassExtractionConfig objects from 13 language provider files
into 11 config files under class-extractors/configs/, matching the pattern
established by method-extractors/configs/ and field-extractors/configs/.

Pure structural refactor — zero behavioral change. All class extraction
tests pass unchanged.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/8ca2bf18-46ea-41c3-9c7f-9eb3f5752ade

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

* fix: resolve prettier formatting in c-cpp.ts import

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/1b1e3c43-fc0e-444c-b109-cc0c56ffb470

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
2026-04-16 10:50:54 +01:00
Gergő MagyarandClaude Opus 4.6 d024119b33 chore(deps): tree-sitter 0.25 upgrade readiness monitor with daily Dependabot (#847)
* chore(deps): add tree-sitter aware Dependabot config and drift monitoring

Two things Dependabot cannot see on its own:

1. ABI consistency. The tree-sitter runtime supports a known range of
   grammar ABIs. When a grammar bumps past that range, require() silently
   fails and fallback paths mask the regression in test coverage.
2. Vendored upstream drift. vendor/tree-sitter-proto is a snapshot of
   coder3101/tree-sitter-proto regenerated against a pinned cli version.
   Upstream keeps moving. Nothing notices until a maintainer remembers to
   look.

Dependabot configuration
- Added npm ecosystems for gitnexus, gitnexus-web, gitnexus-shared.
- Grouped all tree-sitter-* grammar bumps into one PR (ecosystem moves in
  lockstep, one PR per grammar is noise).
- Pinned the tree-sitter runtime itself. Bumping 0.21 to 0.22+ changes
  which grammar ABIs load and requires coordinated updates to the
  vendored proto grammar. That stays a deliberate human decision.
- Pinned tree-sitter-cli for the same reason (it controls which ABI
  vendor/tree-sitter-proto/src/parser.c emits when regenerated).

Drift check (.github/scripts/check-tree-sitter-drift.py)
- Reads the tree-sitter runtime version from gitnexus/package.json.
- Walks every installed tree-sitter-* grammar plus the vendored proto
  and reports its LANGUAGE_VERSION against the runtime's supported ABI
  range (table maintained in the script; extend when bumping runtime).
- Fetches coder3101/tree-sitter-proto main parser.c and compares byte
  for byte to the vendored copy. Reports the upstream HEAD short SHA
  and the upstream ABI so a maintainer can act.
- Prints a Markdown report; exits 0 when everything is in range and
  matches upstream, 1 otherwise.
- Stdlib only, no external deps.

Drift workflow (.github/workflows/tree-sitter-drift-check.yml)
- Runs weekly (Mondays 09:00 UTC) to match Dependabot's cadence.
- Also runs on PRs that touch the script or workflow itself, where it
  fails the PR check on drift so the drift gate cannot land broken.
- On scheduled runs with drift, opens or updates a single tracking
  issue labeled tree-sitter-drift. On scheduled runs that come back
  clean, closes the open tracking issue (if any) with a comment.

* refactor(deps): rewrite drift check as tree-sitter 0.25 upgrade readiness monitor

Replace the ABI drift pass/fail gate with a daily upgrade readiness
dashboard that tracks peer-dep compatibility of all 14 grammars with
tree-sitter@0.25.0 and reports which are ready, unreleased, or blocking.

Key changes:
- Rename drift-check → upgrade-readiness (script, workflow, job id)
- Fix P0: pass report via env var, not ${{ }} template interpolation
- Fix P1: npm fetch failure now adds a blocker instead of false-green
- Fix P1: pass GITHUB_TOKEN for authenticated GitHub API calls
- Switch Dependabot to daily for tree-sitter grammars
- Use dict for blockers (no prefix collision), derive TARGET_RUNTIME
  constant, reuse GRAMMARS parser_path, normalize CRLF in comparisons
- Reduce per-call HTTP timeout from 15s to 8s for workflow budget
- PR runs warn on blockers instead of hard-failing

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

* chore(ci): remove global-upgrade smoke test workflow

The ci-global-upgrade.yml workflow tested npm global install upgrades
over a specific release candidate (1.6.2-rc.8). That RC has shipped
and the workflow is no longer needed. Remove it and all references
from ci.yml (needs, env vars, gate check).

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

* feat(ci): add changelog comments to upgrade readiness tracking issue

Each daily run now posts a comment summarizing what changed before
updating the issue body. Comments include the ready/blocker counts
and a diff of grammar status changes (e.g. tree-sitter-cpp:
Unreleased -> Ready). Gives a timeline of how the upgrade unblocks.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 09:17:21 +01:00
Gergő Magyar 0a4b31b3c5 docs: optimize context files for LLM accuracy and token efficiency (#857)
* docs: optimize context files for LLM accuracy and token efficiency

Fix factual errors across all five root context files and optimize
for LLM context window efficiency.

Corrections:
- Web UI: "runs entirely in WASM" -> thin client backed by HTTP API
- Pre-commit hook: "typecheck + tests" -> formatting + typecheck only
- MCP tools: 7 -> 16 (added api_impact, route_map, tool_map,
  shape_check, group_list/query/sync/contracts/status)
- Default serve port: 3741 -> 4747
- E2E tests: "5 tests" -> 7 spec files
- ESLint: "no config" -> eslint.config.mjs exists with TS/React rules
- npm test: "vitest run test/unit" -> "vitest run" (full suite)
- Removed nonexistent test:all script
- ci-quality.yml: added missing format + lint job descriptions
- Pipeline phase deps: added missing structure dep on mro/communities/processes
- Ingestion entry: added missing run-analyze.ts intermediate orchestrator
- Tools Quick Reference: added missing list_repos
- Group tool examples: fixed param name (group -> name)
- Removed stale vite-plugin-wasm gotcha
- Added gitnexus-shared to repository layout tables

New documentation:
- ARCHITECTURE.md: language-agnostic graph feeding (provider pattern,
  unified capture tags, import resolution tiers, chunked parse, MRO)
- ARCHITECTURE.md: full analysis flow (10 stages with progress %)
- ARCHITECTURE.md: storage layout, LadybugDB schema, embeddings, search
- ARCHITECTURE.md: DAG runner internals (Kahn's sort, dep isolation, error handling)

Token optimization:
- Removed filler prose, compressed descriptions into dense tables
- Front-loaded key facts in every section
- Eliminated redundancy between sections
- AGENTS.md: 219 -> 201 lines. ARCHITECTURE.md: 192 -> 298 lines
  (more info in fewer tokens via tables and structure)

* docs: optimize GUARDRAILS.md for LLM context efficiency

Tighten prose without losing information:
- Compressed intro, scope section, and Signs format labels
- Shortened Sign headers (removed "Sign:" prefix)
- Replaced verbose "Instruction/Reason" labels with "Do/Why"
- Removed trailing whitespace and redundant emphasis
2026-04-16 08:43:11 +01:00
Gergo MagyarandClaude Opus 4.6 54d02fcc22 fix(ci): replace removed disable-releaser with dry-run for release-drafter v7
release-drafter v7 (merged in #852) removed the `disable-releaser`
input, causing the autolabel job to attempt creating a release and
fail with "Resource not accessible by integration". Replace with
`dry-run: true` which achieves the same label-only behavior.

Also update stale version comments for release-drafter and
action-semantic-pull-request to match the actual pinned versions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 07:48:21 +01:00
Gergő Magyar d50a523837 Merge pull request #853 from abhigyanpatwari/dependabot/github_actions/amannn/action-semantic-pull-request-6.1.1
chore(deps): bump amannn/action-semantic-pull-request from 5.5.3 to 6.1.1
2026-04-16 06:48:14 +01:00
Gergő Magyar 54c7e45a2f Merge pull request #852 from abhigyanpatwari/dependabot/github_actions/release-drafter/release-drafter-7.2.0
chore(deps): bump release-drafter/release-drafter from 6.0.0 to 7.2.0
2026-04-16 06:48:11 +01:00
Gergő Magyar e947a82a18 Merge pull request #851 from abhigyanpatwari/dependabot/github_actions/marocchino/sticky-pull-request-comment-3.0.4
chore(deps): bump marocchino/sticky-pull-request-comment from 2.9.4 to 3.0.4
2026-04-16 06:48:07 +01:00
Gergő Magyar 978187b34a Merge pull request #850 from abhigyanpatwari/dependabot/github_actions/actions/github-script-9.0.0
chore(deps): bump actions/github-script from 7.0.1 to 9.0.0
2026-04-16 06:47:59 +01:00
Gergő Magyar 185cec70a1 Merge pull request #849 from abhigyanpatwari/dependabot/github_actions/softprops/action-gh-release-3.0.0
chore(deps): bump softprops/action-gh-release from 2.5.0 to 3.0.0
2026-04-16 06:47:51 +01:00
dependabot[bot] 1d0fb782a3 chore(deps): bump amannn/action-semantic-pull-request
Bumps [amannn/action-semantic-pull-request](https://github.com/amannn/action-semantic-pull-request) from 5.5.3 to 6.1.1.
- [Release notes](https://github.com/amannn/action-semantic-pull-request/releases)
- [Changelog](https://github.com/amannn/action-semantic-pull-request/blob/main/CHANGELOG.md)
- [Commits](https://github.com/amannn/action-semantic-pull-request/compare/0723387faaf9b38adef4775cd42cfd5155ed6017...48f256284bd46cdaab1048c3721360e808335d50)

---
updated-dependencies:
- dependency-name: amannn/action-semantic-pull-request
  dependency-version: 6.1.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-15 20:17:22 +00:00
dependabot[bot] 7001e8e4b4 chore(deps): bump release-drafter/release-drafter from 6.0.0 to 7.2.0
Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 6.0.0 to 7.2.0.
- [Release notes](https://github.com/release-drafter/release-drafter/releases)
- [Commits](https://github.com/release-drafter/release-drafter/compare/3f0f87098bd6b5c5b9a36d49c41d998ea58f9348...5de93583980a40bd78603b6dfdcda5b4df377b32)

---
updated-dependencies:
- dependency-name: release-drafter/release-drafter
  dependency-version: 7.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-15 20:17:15 +00:00
dependabot[bot] ed07c18b8d chore(deps): bump marocchino/sticky-pull-request-comment
Bumps [marocchino/sticky-pull-request-comment](https://github.com/marocchino/sticky-pull-request-comment) from 2.9.4 to 3.0.4.
- [Release notes](https://github.com/marocchino/sticky-pull-request-comment/releases)
- [Commits](https://github.com/marocchino/sticky-pull-request-comment/compare/773744901bac0e8cbb5a0dc842800d45e9b2b405...0ea0beb66eb9baf113663a64ec522f60e49231c0)

---
updated-dependencies:
- dependency-name: marocchino/sticky-pull-request-comment
  dependency-version: 3.0.4
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-15 20:17:10 +00:00
dependabot[bot] df429ea60c chore(deps): bump actions/github-script from 7.0.1 to 9.0.0
Bumps [actions/github-script](https://github.com/actions/github-script) from 7.0.1 to 9.0.0.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7.0.1...3a2844b7e9c422d3c10d287c895573f7108da1b3)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: 9.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-15 20:17:08 +00:00
dependabot[bot] b43cb5ee55 chore(deps): bump softprops/action-gh-release from 2.5.0 to 3.0.0
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.5.0 to 3.0.0.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/a06a81a03ee405af7f2048a818ed3f03bbf83c7b...b4309332981a82ec1c5618f44dd2e27cc8bfbfda)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-15 20:17:03 +00:00
Gergő Magyar fec06b823c fix: devendor tree-sitter-proto install lifecycle to prevent ENOTEMPTY on global upgrade (#846)
* fix: devendor tree-sitter-proto install lifecycle to fix ENOTEMPTY on global upgrade

PR #843's preinstall cleanup hook cannot address the reported bug because
it runs on the NEW package's staging tree, not the OLD install being
removed. Issue #836 still reproduces on 1.6.2-rc.8.

Root cause: vendor/tree-sitter-proto was declared as `file:` dep with its
own `dependencies` and `install` script, so npm created
`vendor/tree-sitter-proto/node_modules/node-addon-api/` at install time,
which blocked npm's rmdir on global upgrade.

Changes:
- Strip `dependencies` and `install` script from the vendored sub-package's
  package.json so npm no longer creates a nested node_modules or runs a
  lifecycle script under vendor/.
- Hoist `node-addon-api` and `node-gyp-build` into gitnexus
  optionalDependencies; npm resolves them at the consumer's top level.
- Add scripts/build-tree-sitter-proto.cjs modeled on patch-tree-sitter-swift.cjs.
  Runs at gitnexus postinstall, best-effort: skips cleanly on missing
  toolchain or --ignore-scripts so non-proto functionality keeps working.
- Remove scripts/preinstall-cleanup.cjs — dead code; cannot run against
  the old install being removed.
- Keep .npmignore entries from PR #843 (tarball hygiene, still correct).
- Add explicit .gitignore rules for gitnexus/vendor/**/build and
  gitnexus/vendor/**/node_modules (closes the repo-side hygiene gap).
- Add .github/workflows/ci-global-upgrade.yml: matrix smoke test that
  installs the previously-published rc globally, upgrades to the packed
  current branch, and verifies no vendor install-time artifacts survive.
  Runs on macOS (reporter's platform), Linux, and Windows. Also includes
  an --ignore-scripts degraded-mode lane. Wired into ci.yml gate.

Plan: docs/plans/2026-04-15-002-fix-tree-sitter-proto-vendor-deps-plan.md

Phase 1 (this commit) addresses the reported `node_modules/node-addon-api`
hazard. Phase 2 (follow-up) will migrate to prebuildify + prebuilt .node
binaries in the tarball — the 2026 canonical shape for tree-sitter
grammars, which eliminates the postinstall compile path entirely.

Refs #836

* fix(ci): ci-global-upgrade should be reusable-only and use setup-gitnexus

Three issues caught by CI on PR #846:

1. Concurrency linter rejected the `CIGU-` prefix (allowlist is
   `${{ github.workflow }}` or substring `CI-`). The literal-prefix
   guidance in ci.yml is specifically about disambiguating when
   reusable workflows run in nested contexts, and ci-global-upgrade
   doesn't need its own concurrency block at all — the caller
   (ci.yml) already governs concurrency for nested invocations.

2. `npm install` in gitnexus/ runs `prepare: node scripts/build.js`,
   which depends on gitnexus-shared/dist being built first. Other CI
   jobs handle this via the setup-gitnexus composite action. Use it
   here too (with build: 'false' — we only need the dep graph, then
   npm pack runs prepack which builds gitnexus itself).

3. Removed `pull_request` and `workflow_dispatch` triggers. The
   workflow is now pure `workflow_call` — invoked once from ci.yml
   via `uses:`. This avoids the duplicate-run problem where both the
   top-level pull_request trigger AND the nested workflow_call would
   fire on every PR.

* fix(ci): relax vendor build/ guard and use bash shell on Windows

Two fixes for ci-global-upgrade failures on PR #846:

1. The guard after the upgrade step was rejecting vendor/tree-sitter-proto/build/
   in the global install. That was too strict. The original #836 bug was
   about vendor/tree-sitter-proto/node_modules/ specifically, not build/.
   The build/ directory appears because node-gyp-build compiles through the
   symlink npm creates at node_modules/gitnexus/node_modules/tree-sitter-proto,
   and its contents are plain .node, .obj, .lib files that rmdir handles
   without trouble. We know this empirically because the test got past the
   upgrade step in the run where the old vendor/node_modules was present.
   The guard now only flags nested node_modules, which is what the fix
   actually removes.

2. The Windows --ignore-scripts lane failed with ENOENT when npm tried to
   open the tarball. The path was computed in a bash step using $(pwd),
   which on Windows returns /d/a/... form, but npm install ran in the
   default cmd shell and received a mangled Windows path. Adding
   shell: bash to the install steps keeps path handling consistent.
2026-04-15 17:38:47 +01:00
enihandSisyphus eb0d9c51a0 fix: set env.cacheDir to user-writable location (#845)
When @huggingface/transformers is installed globally (e.g. via npm install -g),
it defaults its cache directory to ./node_modules/.cache inside its own install
dir, which is unwritable by non-root users.

This causes EACCES errors on first use when the model is downloaded:
  EACCES: permission denied, mkdir '/usr/lib/node_modules/gitnexus/node_modules/@huggingface/transformers/.cache'

Set env.cacheDir before pipeline() is called in both embedders (CLI and MCP).
Respects HF_HOME env var if set, falls back to ~/.cache/huggingface.

Co-authored-by: Sisyphus <sisyphus@gitnexus.dev>
2026-04-15 16:03:43 +01:00
Copilotandmagyargergo 7a5ab57bd3 fix: add preinstall cleanup to prevent ENOTEMPTY on global upgrade (#843)
* Initial plan

* fix: add preinstall cleanup for vendor/tree-sitter-proto to prevent ENOTEMPTY on upgrade

When upgrading gitnexus globally, npm may fail with ENOTEMPTY because it
cannot cleanly remove node_modules/ and build/ directories that a previous
installation's file: dependency resolution created inside
vendor/tree-sitter-proto/.

Add a preinstall script that removes those leftover directories before npm
resolves dependencies. Also add .npmignore entries for vendor build artifacts
as a belt-and-suspenders measure.

Fixes #836

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/8b7c1fdd-0c20-4cf4-a64a-9e9d1c0b20ed

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

* fix: log warnings in preinstall cleanup catch block

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/8b7c1fdd-0c20-4cf4-a64a-9e9d1c0b20ed

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>
2026-04-15 14:10:49 +01:00
dependabot[bot] 3fd4346bcb chore(deps): bump actions/checkout from 4.3.1 to 6.0.2 (#842)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.3.1 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/v4.3.1...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>
2026-04-15 13:52:01 +01:00
dependabot[bot] c2734cd25e chore(deps): bump actions/upload-artifact from 4.6.2 to 7.0.1 (#838)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 7.0.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.1
  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>
2026-04-15 13:37:10 +01:00
dependabot[bot] 8cb2f278cc chore(deps): bump actions/setup-node from 4.4.0 to 6.3.0 (#841)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.4.0 to 6.3.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/49933ea5288caeca8642d1e84afbd3f7d6820020...53b83947a5a98c8d113130e565377fae1a50d02f)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 6.3.0
  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>
2026-04-15 13:36:48 +01:00
dependabot[bot] f3df8ab7ba chore(deps): bump dorny/paths-filter from 3.0.2 to 4.0.1 (#839)
Bumps [dorny/paths-filter](https://github.com/dorny/paths-filter) from 3.0.2 to 4.0.1.
- [Release notes](https://github.com/dorny/paths-filter/releases)
- [Changelog](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md)
- [Commits](https://github.com/dorny/paths-filter/compare/de90cc6fb38fc0963ad72b210f1f284cd68cea36...fbd0ab8f3e69293af611ebaee6363fc25e6d187d)

---
updated-dependencies:
- dependency-name: dorny/paths-filter
  dependency-version: 4.0.1
  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>
2026-04-15 13:36:45 +01:00
dependabot[bot] 93cdfb27fa chore(deps): bump actions/cache from 5.0.4 to 5.0.5 (#840)
Bumps [actions/cache](https://github.com/actions/cache) from 5.0.4 to 5.0.5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/668228422ae6a00e4ad889ee87cd7109ec5666a7...27d5ce7f107fe9357f9df03efb73ab90386fccae)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: 5.0.5
  dependency-type: direct:production
  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>
2026-04-15 13:36:38 +01:00
Gergő Magyar 109a3c6946 ci: standardize workflow concurrency and automate release-note labeling (#837)
* ci: standardize workflow concurrency and automate release-note labeling

Concurrency — prevent racing CI jobs
 - Every top-level workflow now declares an explicit concurrency block.
 - PR runs cancel-in-progress on supersede; main/push/workflow_call/publish
   runs queue instead of cancelling so every commit and every release is
   validated end-to-end.
 - ci.yml uses a literal `CI-` prefix (not `${{ github.workflow }}`) and a
   per-run nested group for workflow_call invocations, avoiding a potential
   deadlock with publish.yml and release-candidate.yml callers whose own
   concurrency groups could otherwise collide with the called workflow.
 - ci-report.yml falls back to `<head-repo>/<head-branch>` for fork PRs
   (stable across reruns) instead of the per-run-unique workflow_run.id
   which did not actually serialize anything.
 - ci-quality.yml enforces the convention: fails CI if any non-reusable
   workflow lacks a concurrency block or a reusable workflow declares one.

Release-note automation
 - New pr-labeler.yml: amannn/action-semantic-pull-request enforces
   conventional-commit PR titles on pull_request (fork-safe, read-only);
   release-drafter/release-drafter with disable-releaser: true applies the
   matching label under pull_request_target (write-scoped). sync-labels in
   .github/release-drafter.yml removes managed autolabels that no longer
   match (e.g. when `!` or `BREAKING CHANGE:` is dropped from a PR).
 - .github/release.yml (unchanged) continues to map labels to categorized
   release-notes sections.
 - dependabot.yml added for the github-actions ecosystem so pinned SHAs
   auto-refresh on a weekly cadence.

Docs
 - CONTRIBUTING.md documents the concurrency convention, the
   conventional-commit PR-title rules, and the reusable-workflow exception.

Follow-up to verify before relying on the labeler in anger
 - gh api repos/amannn/action-semantic-pull-request/git/refs/tags/v5.5.3
 - gh api repos/release-drafter/release-drafter/git/refs/tags/v6.0.0
 - Confirm release-drafter reads its config from the base ref (not fork
   head) when invoked via pull_request_target.

* ci: address PR review feedback on concurrency and labeler workflows

Two blocking fixes
 - pr-labeler.yml: separate concurrency slots for pull_request and
   pull_request_target. Previously both triggers shared a single group
   with cancel-in-progress: true, so the privileged autolabel run could
   cancel the title-validation check mid-run and leave a required status
   in a permanent cancelled state.
 - pr-labeler.yml autolabel job: add contents: read. release-drafter's
   context.config() reads .github/release-drafter.yml from the default
   branch via the repo-contents API and 403s without the scope. Job-level
   permissions nullify all unlisted scopes so an explicit grant is needed.

Two non-blocking improvements
 - Replace the hardcoded reusable-workflow allowlist in ci-quality.yml
   with dynamic on:-block parsing. New workflow_call-only workflows no
   longer produce false-positive convention failures.
 - Implement actual group-key validation. The check now also asserts that
   every concurrency.group expression references either ${{ github.workflow }}
   or the literal CI- prefix (the documented ci.yml exception).
 - Script extracted to .github/scripts/check-workflow-concurrency.py so
   it is runnable locally and independently testable.
2026-04-15 13:24:53 +01:00
Copilotandmagyargergo 1df79c2eab fix: content-hash staleness detection for embeddings and vector index creation on zero-node path (#831)
* Initial plan

* fix: stale vectors preserved on content edits and vector index missing after zero-node run

Issue 1: Add contentHash to EMBEDDING_SCHEMA and embedding pipeline.
- contentHash column persisted per CodeEmbedding row
- POST /api/embed queries nodeId+contentHash, compares per-node hash
- Stale rows (hash mismatch) are DELETE'd before re-embedding
- Legacy DBs without contentHash treated as stale (full re-embed)
- loadCachedEmbeddings and run-analyze cache restore include contentHash

Issue 2: createVectorIndex called unconditionally before zero-node early return.

Regression tests:
- contentHashForNode determinism and content-change detection
- EMBEDDING_SCHEMA includes contentHash STRING column
- Pipeline exports verified

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/1581c0c0-f359-4376-b47e-62d24a28fd2d

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

* fix: use parameterized query for stale embedding DELETE, revert package-lock.json

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/1581c0c0-f359-4376-b47e-62d24a28fd2d

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

* fix: address review feedback — config consistency, narrow catches, extract DB logic

Bug #1: Use finalConfig consistently in contentHashForNode (line 224 was
using raw `config` while line 307 used `finalConfig`). Cache precomputed
hashes in filter phase to avoid double computation (Perf #5).

Bug #2: Narrow catch in loadCachedEmbeddings to only fall back on
column/table-missing errors. Rethrow transient/connection errors.

Bug #3: Log non-trivial DELETE failures instead of silently swallowing.

Arch Violation #3: Extract fetchExistingEmbeddingHashes from api.ts into
lbug-adapter.ts. Server layer now calls a single adapter function instead
of re-implementing the DB query logic with nested try-catch.

Tests: Add config consistency test, note that fetchExistingEmbeddingHashes
tests require native module (run in CI).

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b8c4f6b0-4095-4507-a15d-d8469793efac

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

* fix: narrow Column error match to 'contentHash' in lbug-adapter fallback checks

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b8c4f6b0-4095-4507-a15d-d8469793efac

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

* fix: address production-readiness review — eliminate competing state, use schema constants, hard-fail on stale DELETE, add incremental filter tests

Gap A / Arch Violation 1: Remove duplicate vectorExtensionLoaded flag from
embedding-pipeline.ts — delegate to lbug-adapter's loadVectorExtension()
which owns the VECTOR extension lifecycle and resets on DB reconnect.

Arch Violation 2: Replace all hardcoded 'CodeEmbedding' and
'code_embedding_idx' strings in embedding-pipeline.ts and run-analyze.ts
with EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME, and CREATE_VECTOR_INDEX_QUERY
imported from schema.ts. Add EMBEDDING_INDEX_NAME export to schema.ts.

Gap B: Make DELETE failure for stale vectors a hard throw (not just a
warning). Continuing after failed DELETE risks Kuzu vector-index corruption
since the constraint requires DELETE-before-INSERT for vector-indexed
properties. "not found" / "does not exist" errors are still safe to ignore.

STALE_HASH_SENTINEL: Define a named constant in embedding types.ts for the
empty-string sentinel convention. Used consistently in lbug-adapter.ts and
run-analyze.ts so the invariant is self-documenting.

Tests: Add comprehensive unit tests for the incremental filter logic with
mocked embedder:
- New node → embedded
- Unchanged node (hash matches) → skipped
- Stale node (hash mismatch) → DELETE + re-embed
- STALE_HASH_SENTINEL → treated as stale
- Zero nodes after filter → createVectorIndex still called
- DELETE failure with non-trivial error → throws

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b21edee7-c9c5-4742-947b-d0def4fb26aa

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

* fix: tighten error classification — extract isMissingColumnOrTableError helper, remove broad pattern matching

- Extract isMissingColumnOrTableError() helper in lbug-adapter for
  consistent schema-error detection (replaces duplicate inline checks)
- Tighten 'contentHash' match: now requires 'property' AND 'contentHash'
  (Kuzu-specific pattern) instead of broad 'contentHash' substring
- Tighten DELETE error check: only ignore 'does not exist' (Kuzu's actual
  message), not broad 'not found' which could mask connection errors
- Fix test node ID/name/filePath consistency

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b21edee7-c9c5-4742-947b-d0def4fb26aa

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

* fix: CI failures and final review — move STALE_HASH_SENTINEL to schema, tighten error matching, fix test mocking, format

- Move STALE_HASH_SENTINEL from embeddings/types.ts to lbug/schema.ts
  (fixes inverted layer dependency: lbug should not import from embeddings)
- Tighten isMissingColumnOrTableError: replace broad msg.includes('not found')
  with /(table|column|property).*not found/i regex to avoid matching transient errors
- Add vi.resetModules() in test beforeEach for explicit module isolation
  (fixes vi.doMock not intercepting loadVectorExtension in CI)
- Skip precomputedHashes.set() on unchanged (return false) path
- Run prettier on all 5 files flagged by CI format check

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e20311fd-4361-47b4-a137-9adc3e533b35

* fix: address remaining review nits — rename precomputedHashes, generalize error matcher, revert package-lock

- Rename precomputedHashes → computedStaleHashes (hashes are computed
  on-demand during filter, only cached for stale nodes being re-embedded)
- Remove contentHash-specific clause from isMissingColumnOrTableError —
  the regex /(table|column|property).*not found/i already covers it
- Revert package-lock.json ssh→https protocol change

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e20311fd-4361-47b4-a137-9adc3e533b35

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
2026-04-15 11:20:48 +01:00
Copilotandmagyargergo 32c9ddaf32 fix(deps): pin tree-sitter-c-sharp to 0.23.1 (#834)
* Initial plan

* fix: pin tree-sitter-c-sharp to 0.23.1 to resolve peer dependency conflict with tree-sitter@0.21.1

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fc416867-7239-4840-9b67-c681d00fa231

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>
2026-04-15 10:41:28 +01:00
Jonas Vanderhaegenjonasvanderhaegen-xveGergo Magyar
385ee037bd [group/sync] Fix ManifestExtractor never called — config.links always produced 0 cross-links (#827)
* fix(group/sync): wire ManifestExtractor into syncGroup pipeline

ManifestExtractor was fully implemented in extractors/manifest-extractor.ts
but never imported or called in sync.ts. As a result, any links declared in
group.yaml were parsed and validated by config-parser.ts but silently dropped
— config.links was always an empty dead-end as far as syncGroup was concerned.

Changes:
- Import ManifestExtractor in sync.ts
- Call extractFromManifest(config.links, dbExecutors) inside the outer try
  block, after all repos are processed but before the finally closes the DB
  pools (symbol resolution via resolveSymbol requires open executors)
- Collect the resulting contracts into autoContracts and the cross-links into
  a separate manifestCrossLinks array
- Merge manifestCrossLinks into the final crossLinks alongside runExactMatch
  results

Without this fix, users who declare explicit service dependencies in
group.yaml links (the documented workaround for HTTP clients that use absolute
URLs and are invisible to the auto-extractors) get 0 cross-links regardless of
what they configure.

* test(group/sync): cover manifest links producing cross-links

Add a unit test that asserts config.links entries produce contract pairs
and a manifest cross-link (matchType: 'manifest') via syncGroup.

Also refactors the manifest extraction call to sit outside the else/try
block so it runs regardless of extractorOverride arity — makes the code
testable without mocked DB pools and ensures links work when callers supply
a zero-arity override (e.g. in tests or programmatic usage).

* style: prettier format sync.ts and sync.test.ts

Also removes the stray empty line in the finally block (noted in review).

* fix(group/sync): dedupe cross-links and warn on dangling manifest repos

Addresses review feedback on PR #827:

1. Dedupe cross-links. Manifest contracts participate in runExactMatch, so a
   manifest-declared link also emitted a duplicate matchType:'exact' CrossLink
   for the same endpoint pair. Dedupe by (from, to, type, contractId) and
   prefer manifest (operator-declared intent).

2. Warn on dangling repos. When a manifest link references a repo not in
   config.repos, log a warning. Synthetic UIDs keep the cross-link
   deterministic, but the operator probably meant something else.

3. Tests:
   - Assert no duplicate 'exact' CrossLink is emitted alongside the manifest one.
   - Assert synthetic UID format when no DB executors are available.
   - New test: dangling manifest repo still produces a cross-link + logs a warning.

* perf(group/manifest): parallelize and memoize symbol resolution

Previous implementation ran 2N sequential Cypher round-trips per
manifest (one for provider side, one for consumer, awaited in-order
per link). For manifests with tens of links this dominated syncGroup
latency in groups with many declared cross-repo contracts.

Changes:
- Resolve provider + consumer in parallel per link (Promise.all).
- Resolve all links in parallel (outer Promise.all over links.map).
  Each repo's executor pool is independent, so cross-repo fan-out
  scales with the number of distinct repos in the manifest.
- Memoize by (repo, type, contract). Manifests frequently declare
  the same contract from both directions or across sibling groups,
  so duplicate triples now hit the DB once instead of 2× per link.

Correctness:
- resolveSymbol is a pure LIMIT 1 read, so caching + concurrent
  invocation is safe.
- Iteration order over links is preserved in the final
  contracts / crossLinks arrays — result shape is identical.

Test:
- New test asserts that two links sharing (repo, type, contract)
  produce exactly one DB call per distinct repo-tuple.

---------

Co-authored-by: jonasvanderhaegen-xve <>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-15 09:14:09 +01:00
Gergő Magyar 28ddbe5d54 fix(lbug): wait for read stream close in splitRelCsvByLabelPair (Windows ENOTEMPTY) (#832)
* fix(lbug): wait for read stream close in splitRelCsvByLabelPair (Windows ENOTEMPTY)

The windows-latest CI job intermittently failed:

    FAIL  test/unit/rel-csv-split.test.ts > splitRelCsvByLabelPair > handles empty CSV (header only) without errors
    Error: ENOTEMPTY: directory not empty, rmdir 'C:\Users\RUNNER~1\AppData\Local\Temp\rel-csv-test-XW5KOu'

Cause: splitRelCsvByLabelPair resolved its Promise on readline's 'close'
event, but the underlying fs.ReadStream's file descriptor is released
asynchronously after that — especially on Windows. For the empty-CSV
test the function returns so quickly that afterEach fires rmSync while
the relations.csv fd is still held, so Windows reports ENOTEMPTY on
the directory.

Fixes:
- Production: after readline 'close', wait for inputStream 'close' (or
  resolve immediately if already closed/destroyed). Call inputStream
  .destroy() defensively so we never hang if the fd never emits 'close'.
- Test: afterEach now retries rmSync up to 5 times on ENOTEMPTY/EBUSY/
  EPERM with a brief back-off — defense-in-depth so the test doesn't
  flake on slow CI runners independent of the production change.

The production fix benefits every caller, not just the test: any code
that deletes the CSV's parent directory right after the Promise
resolves previously hit the same race on Windows.

* refactor(lbug): replace custom stream state machines with stdlib primitives

Full audit of splitRelCsvByLabelPair's stream usage after the original
ENOTEMPTY fix. Replaced three hand-rolled mechanisms with their
standard-library equivalents — 147 -> 71 lines in the function, and
the caller's WriteStream closure dropped from 13 lines to 5.

- readline: 'on(line)' + pause/resume/waitingForDrain state machine
  -> 'for await (const line of rl)'. Async-iterator delivery naturally
  serializes line processing with our awaits, so at most one ws is in
  backpressure at a time. We just 'await once(ws, "drain")' when
  'write()' returns false — the custom Set, the settled flag and the
  'only resume when all streams have drained' logic all go away.

- Multi-stream error coordination: hand-rolled cleanup() that had to
  be entered exactly once and had to destroy the inputStream and every
  pair ws -> single AbortController shared across every 'once(ws,
  'drain', { signal })'. Any stream error aborts every pending wait.

- 'stream/promises.finished(inputStream)' in the 'finally' block
  replaces the manual 'rl.on('close', () => inputStream.once('close',
  ...))' dance, and covers both the success and error paths with the
  same primitive. This closes the Windows ENOTEMPTY race root cause —
  we never return while the fd might still be in flight.

- Caller closure: 'new Promise((res, rej) => ws.end(cb) + remove
  listener on error)' -> 'ws.end(); await finished(ws)'.

- Test 'afterEach': custom retry loop -> 'fs.rmSync(..., { maxRetries:
  5, retryDelay: 50 })' (Node added these options specifically for
  cross-platform tmpdir cleanup).

- Test 'destroys all streams when one errors': old code leaked
  backpressure and created multiple pair streams before the first
  blocked; new strict serial backpressure doesn't, so the test now
  unblocks the first stream once to advance the loop and create the
  second stream before triggering the error.
2026-04-15 08:59:09 +01:00
Jonas Vanderhaegenjonasvanderhaegen-xveGergo Magyar
c100577e5e fix(embeddings): prevent batch errors from CodeEmbedding PK violations and vector-index SET restriction (#823)
* fix(csv-generator): deduplicate all node types, not just File nodes

The pipeline can produce duplicate node IDs across all symbol types
(Class, Method, Function, etc.). Only File nodes were guarded by a
seenFileIds Set, leaving every other type unprotected. When the CSV
was COPY'd into LadybugDB, duplicate PKs caused mass "Batch execution
error: Found duplicated primary key value" warnings on gitnexus serve.

Replace the per-type seenFileIds with a single seenNodeIds Set checked
at the top of the iteration loop, before the switch, so every label is
covered by the same O(1) deduplication guard.

Fixes: #822

* fix(embeddings): use MERGE instead of CREATE for CodeEmbedding inserts

CREATE fails with duplicate PK when a CodeEmbedding node already exists,
which happens when:
- A PostToolUse hook triggers a concurrent gitnexus analyze during an
  active analyze run (git commits fire the hook)
- A partial prior run left some embeddings in the DB before a crash

Switching to MERGE makes the insert idempotent: existing embeddings are
updated in place, new ones are created, no PK violations.

Fixes: #822

* fix(server): skip already-embedded nodes in POST /api/embed to avoid vector-index SET error

Kuzu/LadybugDB forbids SET on a property that is part of a vector index.
The /api/embed endpoint was calling runEmbeddingPipeline without skipNodeIds,
causing it to attempt MERGE+SET on every node including those already embedded.

Fix: query existing CodeEmbedding nodeIds before running the pipeline and pass
them as skipNodeIds so only new (unembedded) nodes are processed.

* fix(server): narrow catch to table-not-exist errors only in POST /api/embed

Bare catch{} would silently swallow connection errors and proceed to
re-embed all nodes, hiding infrastructure issues. Now only swallows
errors where the CodeEmbedding table does not yet exist.

* style: prettier format gitnexus/src/server/api.ts

* fix(server): log skip-embedding count and table-not-found swallow path

Addresses review feedback on PR #823:
- Log count of already-embedded nodes when skipNodeIds is populated
  (aids debugging if Kuzu driver row shape changes).
- Log when the 'table does not exist' swallow path fires so ops can
  catch it if Kuzu ever changes error wording.
- Document the {} config positional argument with an inline comment
  referencing the runEmbeddingPipeline signature.

---------

Co-authored-by: jonasvanderhaegen-xve <>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-15 08:05:11 +01:00
Gergő Magyar baf3f9e37d feat(ci): add release-candidate publish pipeline (#825)
* feat(ci): add release-candidate publish pipeline

Auto-publishes gitnexus@rc on every merge to main. Version scheme is
canonical semver X.Y.Z-rc.N where the base is the current npm 'latest'
bumped by the 'bump' input (default patch) and N auto-increments by
querying existing rc versions on the registry. First rc for a new base
is rc.1; the counter resets naturally when the base advances after a
stable release.

- Reuses ci.yml via workflow_call so tests must pass before publish
- SHA-pinned actions, per-job permission scoping, provenance enabled
- Guard job dedupes duplicate dispatches against HEAD via v*-rc.* tags
- Docs-only pushes skipped via paths-ignore
- workflow_dispatch inputs: bump (patch/minor/major), force (override guard)
- Publishes under the 'rc' dist-tag so 'latest' is never moved
- Tags commits as v<rc-version> and creates GitHub prereleases

* fix(ci): address release-candidate review feedback

- Sort rc tags by creatordate (handles out-of-order pushes correctly)
- Fail fast on npm registry errors; only fall back to package.json on E404
- Drop unused pull-requests: write permission on the reused CI job
- Add secrets: inherit so any future CI secrets are available to sub-jobs
- Remove unused reltag step output

* fix(ci): address Copilot review comments

- Correct concurrency comment (runs serialize on same ref, not overlap)
- Apply E404-only fallback to 'npm view versions' query, matching the
  pattern used for the 'npm view version' query
- README: clarify that docs-only merges don't trigger rc publish
- CONTRIBUTING: drop 'from main' claim for publish.yml; the tag-push
  trigger does not enforce branch reachability

* fix(ci): address adversarial review — idempotency, cycle continuity, tag integrity

Codex adversarial review flagged three release-safety issues in the rc
pipeline. Fixes:

1. Cycle continuity (H). Non-patch rc trains no longer collapse back to
   patch on the next push. 'bump' input accepts a new 'auto' value
   (default) that infers the active rc base from the registry: if any
   X.Y.Z-rc.* exists with X.Y.Z > latest, continue that base; otherwise
   patch-bump. Explicit patch/minor/major still forces a cycle reset and
   now also bypasses the dedup guard so an explicit dispatch on a
   tagged HEAD is honored.

2. Idempotency across post-publish failures (H). The guard marker
   ('rc/<HEAD_SHA>' lightweight tag) and the release tag ('v<RC>'
   annotated) are now pushed atomically *before* 'npm publish'. A
   publish failure leaves the marker in place and the guard refuses to
   re-publish. Added a defensive 'npm view <pkg>@<rc> version' check
   before publish to catch registry-level races. Recovery path
   documented in CONTRIBUTING.md.

3. Tag ↔ package integrity (M). 'v<RC>' now points at a detached
   release commit whose tree contains the rewritten package.json, so
   the tag's source archive matches the npm tarball exactly. 'main'
   stays pristine; the release commit is reachable only via the tag.

* fix(ci): surface registry errors on defensive version check; drop actions: read

- npm view <pkg>@<rc> version now distinguishes E404 (safe) from network
  failures (abort) via the same mktemp+grep pattern used for the other
  two npm view calls
- Dropped actions: read on the ci workflow_call — no sub-workflow uses
  the Actions API
2026-04-14 17:32:47 +01:00
Md. Mekayel Anik b340c5d87a fix: prevent drain listener leak in relationship CSV streaming (#818)
* fix: add setMaxListeners(50) to relationship pair WriteStreams

Dynamically-created per-pair WriteStreams for relationship CSV splitting
default to Node.js's maxListeners limit of 10. On large repositories with
many relationship types, readline backpressure causes repeated
ws.once('drain', ...) calls that exceed this limit, flooding stderr with
MaxListenersExceededWarning messages.

This matches the existing pattern in csv-generator.ts where
BufferedCSVWriter already calls this.ws.setMaxListeners(50).

* fix: address all 3 stream bugs in relationship CSV splitting

Addresses review feedback from @magyargergo and Claude CI analysis:

Bug 1 (High): Add error handlers to per-pair WriteStreams.
Previously, if a WriteStream errored (disk full, EMFILE) while rl was
paused waiting for drain, the drain callback never fired, rl.resume()
was never called, and the outer Promise hung forever — leaking all
open file descriptors until process kill.

Now each WriteStream gets an error handler that destroys all streams,
closes the readline interface + its input ReadStream, and rejects the
Promise.

Bug 2 (Medium): Add waitingForDrain Set to prevent drain listener
accumulation. rl.pause() is not synchronous — buffered line events
continue firing after pause(), and multiple lines targeting the same
pairKey each added another ws.once('drain', ...) listener. This was the
root cause of MaxListenersExceededWarning.

Now a Set<string> tracks which streams are already waiting for drain.
Only the first backpressure event registers the listener; subsequent
lines for the same stream are silently skipped (they're already written
to the stream buffer). This eliminates listener accumulation entirely
and makes setMaxListeners(50) a safety net rather than a band-aid.

Bug 3 (Low): Close readline and destroy input ReadStream in error
handler. Previously only the WriteStreams were destroyed on error,
leaving the ReadStream FD to linger until GC.

* fix: address review feedback — remove setMaxListeners, harden cleanup

- Remove setMaxListeners(50) entirely. The waitingForDrain guard
  guarantees at most 1 drain listener per stream at any time. Tested
  with 200 pairs x 500 lines (100k total) — max listeners was always 1,
  zero warnings. No hard-coded limit needed.

- Wrap destroy() calls in cleanup() with try/catch so already-destroyed
  streams don't throw synchronously (addresses @xkonjin review point 1).

- Add ws.once('error', reject) to the ws.end() phase so flush errors
  during stream close properly reject instead of hanging Promise.all
  (addresses Claude CI Bug 3b finding).

* test: add 8 regression tests for relationship CSV stream fixes

Covers all bugs fixed in this PR:
- Bug 1: WriteStream error rejects Promise and destroys all streams
- Bug 2: waitingForDrain guard keeps drain listeners at max 1 per stream
- Bug 3: cleanup() handles already-destroyed streams safely

Tests use a MockWriteStream with controllable backpressure and error
injection to verify the exact patterns in loadGraphToLbug() without
needing a real LadybugDB instance.

* style: run prettier on changed files

* fix(test): use backpressure to keep promise pending during error tests

The error tests were racing — readline finished reading the tiny CSV
and resolved the Promise before setTimeout fired the error. Now the
mock streams use blocked=true to trigger backpressure, keeping the
Promise pending so the error fires while the split is still in progress.

* fix: use named error handler in ws.end() to prevent listener leak

ws.once() wraps the callback, so removeListener with the original
function reference won't match. Switch to ws.on() with a named
onError function so removeListener correctly detaches it after
successful close.

* refactor: extract splitRelCsvByLabelPair, fix multi-stream drain

1. Extract splitRelCsvByLabelPair as an exported function with optional
   wsFactory parameter for dependency injection. loadGraphToLbug now
   delegates to it. Tests import and call the real function instead of
   a local reimplementation.

2. Fix multi-stream drain coordination: rl.resume() is now guarded by
   waitingForDrain.size === 0, so readline only resumes when ALL
   backpressured streams have drained. Previously, any single stream
   draining would resume readline while other streams were still full,
   allowing unbounded buffer growth.

3. Export WriteStreamFactory type and RelCsvSplitResult interface for
   test consumption.
2026-04-14 12:26:38 +01:00
283 changed files with 23091 additions and 3496 deletions
+1
View File
@@ -0,0 +1 @@
plans/
+19
View File
@@ -0,0 +1,19 @@
.git
.gitignore
.DS_Store
node_modules
**/node_modules
dist
**/dist
coverage
**/coverage
.env
.env.local
.env.*.local
.gitnexus
gitnexus-web/playwright-report
gitnexus-web/test-results
+3
View File
@@ -0,0 +1,3 @@
IMAGE_NAME=ghcr.io/abhigyanpatwari/gitnexus:latest
CONTAINER_NAME=gitnexus
HOST_PORT=4173
+75
View File
@@ -0,0 +1,75 @@
version: 2
updates:
# Keep third-party Actions SHA pins current. See CONTRIBUTING.md — when
# reviewing these bumps, verify the SHA corresponds to the claimed tag by
# running `gh api repos/<owner>/<action>/git/refs/tags/<tag>` before merge.
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
commit-message:
prefix: chore
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
# RUNTIME is pinned — upgrade deliberately via the drift check workflow.
# See .github/scripts/check-tree-sitter-upgrade-readiness.py for
# the upgrade readiness tracker.
- package-ecosystem: npm
directory: /gitnexus
schedule:
interval: daily
open-pull-requests-limit: 10
commit-message:
prefix: chore(deps)
include: scope
labels:
- dependencies
groups:
tree-sitter-grammars:
patterns:
- tree-sitter-*
exclude-patterns:
- tree-sitter
- tree-sitter-cli
ignore:
# Pin the tree-sitter runtime at 0.21.x until the drift check
# reports all grammars are peer-dep compatible with 0.25.
- dependency-name: tree-sitter
update-types:
- version-update:semver-major
- version-update:semver-minor
# tree-sitter-cli follows the runtime's version cadence. Bump when
# regenerating vendor/tree-sitter-proto/src/parser.c, not on a schedule.
- dependency-name: tree-sitter-cli
# gitnexus-web (thin frontend client).
- package-ecosystem: npm
directory: /gitnexus-web
schedule:
interval: weekly
open-pull-requests-limit: 5
commit-message:
prefix: chore(deps)
include: scope
labels:
- dependencies
- frontend
# Shared types package.
- package-ecosystem: npm
directory: /gitnexus-shared
schedule:
interval: weekly
open-pull-requests-limit: 5
commit-message:
prefix: chore(deps)
include: scope
labels:
- dependencies
+53
View File
@@ -0,0 +1,53 @@
# release-drafter config — used only for PR autolabeling by
# `.github/workflows/pr-labeler.yml` (the workflow passes `disable-releaser: true`,
# so the draft-release side of release-drafter never runs).
#
# The labels applied here are the same ones `.github/release.yml` maps to
# categorized release-notes sections.
#
# `sync-labels: true` removes managed autolabels that no longer match the PR —
# critical for the breaking-change case: if a PR title drops the `!` or the body
# drops `BREAKING CHANGE:`, the `breaking` label is pulled off automatically.
# Required by release-drafter; not used because releaser is disabled.
name-template: 'unused'
tag-template: 'unused'
template: |
$CHANGES
sync-labels: true
autolabeler:
- label: enhancement
title:
- '/^feat(\([^)]+\))?!?:/i'
- label: bug
title:
- '/^fix(\([^)]+\))?!?:/i'
- label: performance
title:
- '/^perf(\([^)]+\))?!?:/i'
- label: refactor
title:
- '/^refactor(\([^)]+\))?!?:/i'
- label: documentation
title:
- '/^docs(\([^)]+\))?!?:/i'
- label: test
title:
- '/^test(\([^)]+\))?!?:/i'
- label: ci
title:
- '/^ci(\([^)]+\))?!?:/i'
- label: dependencies
title:
- '/^(build|deps)(\([^)]+\))?!?:/i'
- label: chore
title:
- '/^(chore|revert)(\([^)]+\))?!?:/i'
# Breaking-change marker: either `!` in the type prefix or `BREAKING CHANGE:` in body.
- label: breaking
title:
- '/^[a-z]+(\([^)]+\))?!:/i'
body:
- '/BREAKING[ -]CHANGE:/i'
@@ -0,0 +1,358 @@
#!/usr/bin/env python3
"""Monitor tree-sitter 0.25 upgrade readiness.
Tracks two things Dependabot cannot see:
1. Peer-dep compatibility. Each tree-sitter-* grammar declares a peer
dependency on the tree-sitter runtime. We want to know when every
grammar's *latest npm release* satisfies tree-sitter@0.25.0 so we
can upgrade without --legacy-peer-deps.
2. Vendored upstream drift. vendor/tree-sitter-proto/ is a snapshot of
coder3101/tree-sitter-proto's parser.c. When upstream moves, we want
to know whether we can pick it up.
Invoked from .github/workflows/tree-sitter-upgrade-readiness.yml daily.
Runs locally too:
python3 .github/scripts/check-tree-sitter-upgrade-readiness.py
Outputs Markdown to stdout. Exit 0 when every grammar is upgrade-ready
and the vendored proto is in sync. Exit 1 when blockers remain (the
workflow uses this to open or update a tracking issue).
No external deps -- stdlib only, so it runs on any vanilla runner.
"""
from __future__ import annotations
import json
import os
import pathlib
import re
import sys
import urllib.error
import urllib.request
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
GITNEXUS_DIR = REPO_ROOT / "gitnexus"
VENDOR_PROTO_DIR = GITNEXUS_DIR / "vendor" / "tree-sitter-proto"
# ── Upgrade target ──────────────────────────────────────────────────────
# The runtime version we want to upgrade TO. Update this when the goal
# changes (e.g. once 0.25 lands and we target 0.26).
TARGET_RUNTIME = "0.25.0"
TARGET_RUNTIME_MAJOR_MINOR = ".".join(TARGET_RUNTIME.split(".")[:2])
# Tree-sitter runtime -> (min_abi, max_abi) it can load. Only the current
# and target entries matter; extend when changing TARGET_RUNTIME.
RUNTIME_ABI_RANGES: dict[str, tuple[int, int]] = {
"0.21": (13, 14),
"0.25": (13, 15),
}
assert TARGET_RUNTIME_MAJOR_MINOR in RUNTIME_ABI_RANGES, (
f"RUNTIME_ABI_RANGES has no entry for {TARGET_RUNTIME_MAJOR_MINOR!r}. "
f"Add the ABI range after auditing the upstream release notes."
)
# Grammars we use. Values are the upstream GitHub repos to check for
# unreleased ABI bumps (owner/repo, branch, parser.c path).
GRAMMARS: dict[str, tuple[str, str, str]] = {
"tree-sitter-c": ("tree-sitter/tree-sitter-c", "master", "src/parser.c"),
"tree-sitter-c-sharp": ("tree-sitter/tree-sitter-c-sharp", "master", "src/parser.c"),
"tree-sitter-cpp": ("tree-sitter/tree-sitter-cpp", "master", "src/parser.c"),
"tree-sitter-dart": ("UserNobody14/tree-sitter-dart", "master", "src/parser.c"),
"tree-sitter-go": ("tree-sitter/tree-sitter-go", "master", "src/parser.c"),
"tree-sitter-java": ("tree-sitter/tree-sitter-java", "master", "src/parser.c"),
"tree-sitter-javascript": ("tree-sitter/tree-sitter-javascript", "master", "src/parser.c"),
"tree-sitter-kotlin": ("fwcd/tree-sitter-kotlin", "main", "src/parser.c"),
"tree-sitter-php": ("tree-sitter/tree-sitter-php", "master", "php/src/parser.c"),
"tree-sitter-python": ("tree-sitter/tree-sitter-python", "master", "src/parser.c"),
"tree-sitter-ruby": ("tree-sitter/tree-sitter-ruby", "master", "src/parser.c"),
"tree-sitter-rust": ("tree-sitter/tree-sitter-rust", "master", "src/parser.c"),
"tree-sitter-swift": ("alex-pinkus/tree-sitter-swift", "main", "src/parser.c"),
"tree-sitter-typescript": ("tree-sitter/tree-sitter-typescript", "master", "typescript/src/parser.c"),
}
UPSTREAM_PROTO_OWNER = "coder3101"
UPSTREAM_PROTO_REPO = "tree-sitter-proto"
UPSTREAM_PROTO_BRANCH = "main"
# ── Helpers ─────────────────────────────────────────────────────────────
def read_current_runtime() -> str:
"""Return the tree-sitter runtime version pinned in package.json (e.g. '0.21')."""
pkg = json.loads((GITNEXUS_DIR / "package.json").read_text())
raw = pkg["dependencies"]["tree-sitter"]
match = re.search(r"(\d+)\.(\d+)", raw)
if not match:
raise SystemExit(f"could not parse tree-sitter version: {raw!r}")
return f"{match.group(1)}.{match.group(2)}"
def npm_view_json(pkg: str) -> dict | None:
"""Fetch package metadata from the npm registry via HTTPS.
Uses the registry API directly so we don't depend on the npm CLI
being available (it's a batch file on Windows which complicates
subprocess calls).
"""
url = f"https://registry.npmjs.org/{pkg}/latest"
try:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=8) as resp:
return json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError):
return None
def satisfies_target(peer_range: str | None, target: str) -> bool:
"""Check if a semver range like '^0.22.4' or '^0.25.0' satisfies the target.
Simple heuristic: extract the minimum version from the range and check
if target >= min. For caret ranges (^X.Y.Z), the upper bound is the
next major (for X>0) or next minor (for X==0). We check both bounds.
"""
if peer_range is None:
# No peer dep declared = no constraint = compatible.
return True
match = re.search(r"(\d+)\.(\d+)\.(\d+)", peer_range)
if not match:
return False
min_major, min_minor, min_patch = int(match.group(1)), int(match.group(2)), int(match.group(3))
t_match = re.search(r"(\d+)\.(\d+)\.(\d+)", target)
if not t_match:
return False
t_major, t_minor, t_patch = int(t_match.group(1)), int(t_match.group(2)), int(t_match.group(3))
# Target must be >= minimum.
target_tuple = (t_major, t_minor, t_patch)
min_tuple = (min_major, min_minor, min_patch)
if target_tuple < min_tuple:
return False
# For caret ranges with major 0: ^0.X.Y allows [0.X.Y, 0.(X+1).0).
if peer_range.startswith("^") and min_major == 0:
if t_major != 0 or t_minor >= min_minor + 1:
return False
# For caret ranges with major >0: ^X.Y.Z allows [X.Y.Z, (X+1).0.0).
elif peer_range.startswith("^") and min_major > 0:
if t_major >= min_major + 1:
return False
return True
_GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN")
def fetch_text(url: str, timeout: int = 8) -> str | None:
"""Fetch a URL and return its text, or None on failure.
Adds an Authorization header for github.com URLs when GITHUB_TOKEN is
set (raises the rate limit from 60 to 5 000 requests/hour).
"""
headers: dict[str, str] = {}
if _GITHUB_TOKEN and ("github.com" in url or "githubusercontent.com" in url):
headers["Authorization"] = f"Bearer {_GITHUB_TOKEN}"
try:
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read().decode("utf-8", errors="ignore")
except (urllib.error.URLError, urllib.error.HTTPError):
return None
def extract_abi_from_text(text: str) -> int | None:
"""Extract LANGUAGE_VERSION from parser.c text."""
match = re.search(r"#define\s+LANGUAGE_VERSION\s+(\d+)", text[:4096])
return int(match.group(1)) if match else None
def extract_language_version(parser_c: pathlib.Path) -> int | None:
"""Return the LANGUAGE_VERSION defined in a parser.c, or None if absent."""
if not parser_c.is_file():
return None
with parser_c.open("r", encoding="utf-8", errors="ignore") as fh:
head = fh.read(4096)
return extract_abi_from_text(head)
def md_h(text: str, level: int = 2) -> str:
return f"{'#' * level} {text}\n"
# ── Main ────────────────────────────────────────────────────────────────
def main() -> int:
blockers: dict[str, str] = {}
lines: list[str] = []
lines.append(md_h("Tree-sitter 0.25 upgrade readiness", 1))
lines.append("")
current_runtime = read_current_runtime()
current_abi_range = RUNTIME_ABI_RANGES.get(current_runtime, (0, 0))
target_abi_range = RUNTIME_ABI_RANGES.get(TARGET_RUNTIME_MAJOR_MINOR, (0, 0))
lines.append(f"- Current runtime: `tree-sitter@{current_runtime}.x` (ABI {current_abi_range[0]}..{current_abi_range[1]})")
lines.append(f"- Target runtime: `tree-sitter@{TARGET_RUNTIME}` (ABI {target_abi_range[0]}..{target_abi_range[1]})")
lines.append("")
# ── Grammar peer-dep compatibility ───────────────────────────────
lines.append(md_h("Grammar compatibility", 2))
lines.append("| Grammar | npm latest | Peer dep | Satisfies 0.25? | ABI | Upstream ABI | Status |")
lines.append("|---|---|---|---|---|---|---|")
ready_count = 0
total_count = len(GRAMMARS)
for name, (upstream_repo, upstream_branch, parser_path) in sorted(GRAMMARS.items()):
# Fetch latest npm metadata.
info = npm_view_json(name)
fetch_failed = info is None
npm_version = "?"
peer_range = None
peer_optional = True
if info:
npm_version = info.get("version", "?")
peers = info.get("peerDependencies") or {}
peer_range = peers.get("tree-sitter")
meta = info.get("peerDependenciesMeta") or {}
ts_meta = meta.get("tree-sitter") or {}
peer_optional = ts_meta.get("optional", False) if peer_range else True
if fetch_failed:
peer_display = "? (fetch failed)"
compatible = False
else:
peer_display = peer_range or "none"
if peer_range and not peer_optional:
peer_display += " (required)"
compatible = satisfies_target(peer_range, TARGET_RUNTIME)
# Check installed ABI using the same parser_path from GRAMMARS.
installed_parser = GITNEXUS_DIR / "node_modules" / name / parser_path
if not installed_parser.is_file():
# Fallback to default location.
installed_parser = GITNEXUS_DIR / "node_modules" / name / "src" / "parser.c"
installed_abi = extract_language_version(installed_parser)
abi_display = str(installed_abi) if installed_abi else "?"
# Check upstream (main/master branch) ABI for unreleased work.
upstream_url = (
f"https://raw.githubusercontent.com/{upstream_repo}/"
f"{upstream_branch}/{parser_path}"
)
upstream_text = fetch_text(upstream_url)
upstream_abi = extract_abi_from_text(upstream_text) if upstream_text else None
upstream_abi_display = str(upstream_abi) if upstream_abi else "?"
# Determine status.
if fetch_failed:
status = "Unknown (fetch failed)"
blockers[name] = f"`{name}`: npm registry fetch failed — could not verify peer dep"
elif compatible:
status = "Ready"
ready_count += 1
elif upstream_abi and upstream_abi >= 15:
status = "Unreleased (ABI 15 on main)"
blockers[name] = f"`{name}`: ABI 15 on `{upstream_repo}` main but not published to npm"
else:
status = "Blocking"
blockers[name] = f"`{name}@{npm_version}`: peer `{peer_display}` incompatible with 0.25"
# Also check upstream package.json for relaxed peer dep.
if not compatible and not fetch_failed:
upstream_pkg_url = (
f"https://raw.githubusercontent.com/{upstream_repo}/"
f"{upstream_branch}/package.json"
)
upstream_pkg_text = fetch_text(upstream_pkg_url)
if upstream_pkg_text:
try:
upstream_pkg = json.loads(upstream_pkg_text)
upstream_peer = (upstream_pkg.get("peerDependencies") or {}).get("tree-sitter")
if upstream_peer and satisfies_target(upstream_peer, TARGET_RUNTIME):
status = "Unreleased (peer relaxed on main)"
blockers[name] = f"`{name}`: peer dep relaxed on `{upstream_repo}` main but not published to npm"
except json.JSONDecodeError:
pass
compat_icon = "Yes" if compatible else "**No**"
lines.append(
f"| `{name}` | {npm_version} | {peer_display} | {compat_icon} | {abi_display} | {upstream_abi_display} | {status} |"
)
lines.append("")
lines.append(f"**{ready_count}/{total_count}** grammars ready for `tree-sitter@{TARGET_RUNTIME}`.")
lines.append("")
# ── Vendored proto drift ─────────────────────────────────────────
lines.append(md_h("Vendored tree-sitter-proto", 2))
vendored_abi = extract_language_version(VENDOR_PROTO_DIR / "src" / "parser.c")
upstream_proto_url = (
f"https://raw.githubusercontent.com/{UPSTREAM_PROTO_OWNER}/"
f"{UPSTREAM_PROTO_REPO}/{UPSTREAM_PROTO_BRANCH}/src/parser.c"
)
upstream_proto_text = fetch_text(upstream_proto_url)
upstream_proto_abi = extract_abi_from_text(upstream_proto_text) if upstream_proto_text else None
sha_url = (
f"https://api.github.com/repos/{UPSTREAM_PROTO_OWNER}/"
f"{UPSTREAM_PROTO_REPO}/commits/{UPSTREAM_PROTO_BRANCH}"
)
sha_text = fetch_text(sha_url)
upstream_sha = "?"
if sha_text:
try:
upstream_sha = json.loads(sha_text).get("sha", "?")[:12]
except json.JSONDecodeError:
pass
local_proto_path = VENDOR_PROTO_DIR / "src" / "parser.c"
local_proto_text = local_proto_path.read_text(encoding="utf-8", errors="ignore") if local_proto_path.is_file() else ""
in_sync = bool(
upstream_proto_text
and local_proto_text.replace("\r\n", "\n")
== upstream_proto_text.replace("\r\n", "\n")
)
lines.append(f"- Upstream: `{UPSTREAM_PROTO_OWNER}/{UPSTREAM_PROTO_REPO}@{UPSTREAM_PROTO_BRANCH}` (HEAD `{upstream_sha}`)")
lines.append(f"- Upstream ABI: **{upstream_proto_abi}**")
lines.append(f"- Vendored ABI: **{vendored_abi}**")
lines.append(f"- In sync: {'yes' if in_sync else 'no — upstream has diverged'}")
if upstream_proto_abi and vendored_abi and upstream_proto_abi > vendored_abi:
can_upgrade = upstream_proto_abi <= target_abi_range[1]
lines.append(f"- Upstream ABI {upstream_proto_abi} {'is' if can_upgrade else 'is NOT'} within target runtime range ({target_abi_range[0]}..{target_abi_range[1]})")
if can_upgrade:
lines.append(f"- **Action:** after upgrading to tree-sitter@{TARGET_RUNTIME}, regenerate vendored parser.c from upstream `{upstream_sha}`")
else:
lines.append(f"- **Action:** wait for runtime upgrade beyond {TARGET_RUNTIME} that supports ABI {upstream_proto_abi}")
blockers["vendored-proto-abi"] = f"vendored tree-sitter-proto: upstream ABI {upstream_proto_abi} outside target range"
elif not in_sync:
lines.append("- **Action:** review upstream changes; vendored copy may need updating")
blockers["vendored-proto-sync"] = "vendored tree-sitter-proto: out of sync with upstream"
# ── Summary ──────────────────────────────────────────────────────
lines.append("")
lines.append(md_h("Summary", 2))
if blockers:
lines.append(f"**{len(blockers)} blocker(s) remaining:**\n")
for b in blockers.values():
lines.append(f"- {b}")
lines.append("")
lines.append("Upgrade to `tree-sitter@0.25` is **blocked**.")
else:
lines.append("All grammars are compatible. Upgrade to `tree-sitter@0.25` is **ready**.")
print("\n".join(lines))
return 1 if blockers else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""Enforce the GitHub Actions concurrency convention.
See CONTRIBUTING.md -> "GitHub Actions — Concurrency Convention" for the rules.
Invoked from .github/workflows/ci-quality.yml. Runs locally too:
python3 .github/scripts/check-workflow-concurrency.py .github/workflows
Rules:
1. Every entry-point (non-reusable) workflow declares a top-level
`concurrency:` block.
2. Reusable workflows (on: workflow_call ONLY) do NOT declare one.
3. The `concurrency.group` expression MUST reference either
`${{ github.workflow }}` or a literal `CI-` prefix (the documented
ci.yml reusable-workflow-safe exception). This is checked by substring
containment rather than prefix match because ci.yml's group is a
conditional expression that resolves to a `CI-…` literal at runtime.
We deliberately do not use a YAML library — keeps the script dependency-free
on any vanilla runner. `on:` block parsing is line-based and handles both the
flat (`on: workflow_call`) and mapping (`on:\n workflow_call:`) forms.
"""
from __future__ import annotations
import pathlib
import re
import sys
REQUIRED_TOKENS = ("${{ github.workflow }}", "CI-")
def is_reusable(lines: list[str]) -> bool:
"""Return True iff the workflow's `on:` block names only `workflow_call`."""
in_on = False
on_indent: int | None = None
keys: list[str] = []
for raw in lines:
# Skip blank lines and comments
stripped = raw.strip()
if not stripped or stripped.startswith("#"):
continue
indent = len(raw) - len(raw.lstrip(" "))
if not in_on:
if raw.startswith("on:"):
remainder = raw[len("on:"):].strip()
if not remainder:
# `on:` followed by indented mapping on next lines
in_on = True
on_indent = indent
continue
if remainder.startswith("[") and remainder.endswith("]"):
# Flow-style list: on: [workflow_call]
items = [
item.strip() for item in remainder.strip("[]").split(",")
]
return items == ["workflow_call"]
# Scalar form: on: workflow_call (or a single other event)
return remainder == "workflow_call"
continue
# Inside the `on:` block; stop when indentation returns to <= on_indent
if on_indent is not None and indent <= on_indent:
break
# Only consider keys at on_indent + indentation step (anything deeper
# is nested config like `types:`)
if ":" not in stripped:
continue
# Heuristic: first-level event keys are those with indent == on_indent + 2
# (the canonical step for a 2-space YAML doc). We collect all first-level
# keys by tracking the smallest indent seen inside the block.
keys.append((indent, stripped.split(":", 1)[0].strip()))
if not keys:
return False
# Take only the outermost-indented keys as the event list
min_indent = min(i for i, _ in keys)
events = [name for i, name in keys if i == min_indent]
return events == ["workflow_call"]
CONCURRENCY_RE = re.compile(r"^concurrency:\s*$")
GROUP_RE = re.compile(r"^\s+group:\s*(.+?)\s*$")
def extract_group_key(lines: list[str]) -> str | None:
"""Return the `group:` value of the top-level `concurrency:` block, or None."""
for idx, raw in enumerate(lines):
if CONCURRENCY_RE.match(raw):
# Scan forward until we leave the concurrency block (next top-level key
# is at column 0 and ends with `:`).
for follow in lines[idx + 1:]:
if follow and not follow.startswith(" ") and follow.rstrip().endswith(":"):
break
m = GROUP_RE.match(follow)
if m:
return m.group(1).strip().strip("'").strip('"')
break
return None
def has_top_level_concurrency(lines: list[str]) -> bool:
return any(CONCURRENCY_RE.match(raw) for raw in lines)
def check(workflows_dir: pathlib.Path) -> int:
fail = 0
files = sorted(
list(workflows_dir.glob("*.yml")) + list(workflows_dir.glob("*.yaml"))
)
for path in files:
lines = path.read_text(encoding="utf-8").splitlines()
reusable = is_reusable(lines)
has_conc = has_top_level_concurrency(lines)
if reusable:
if has_conc:
print(
f"::error file={path}::Reusable workflow (on: workflow_call) "
"must NOT declare its own concurrency block — it inherits "
"from the caller. See CONTRIBUTING.md -> GitHub Actions — "
"Concurrency Convention."
)
fail = 1
continue
if not has_conc:
print(
f"::error file={path}::Missing top-level concurrency block. "
"See CONTRIBUTING.md -> GitHub Actions — Concurrency Convention."
)
fail = 1
continue
group = extract_group_key(lines)
if group is None:
print(
f"::error file={path}::concurrency block is missing a "
"`group:` key."
)
fail = 1
continue
if not any(token in group for token in REQUIRED_TOKENS):
print(
f"::error file={path}::concurrency.group `{group}` must "
f"reference one of {REQUIRED_TOKENS}. See CONTRIBUTING.md -> "
"GitHub Actions — Concurrency Convention."
)
fail = 1
return fail
def main(argv: list[str]) -> int:
if len(argv) != 2:
print(f"usage: {argv[0]} <workflows-dir>", file=sys.stderr)
return 2
workflows_dir = pathlib.Path(argv[1])
if not workflows_dir.is_dir():
print(f"not a directory: {workflows_dir}", file=sys.stderr)
return 2
return check(workflows_dir)
if __name__ == "__main__":
sys.exit(main(sys.argv))
+4 -4
View File
@@ -11,8 +11,8 @@ jobs:
outputs:
web_changed: ${{ steps.filter.outputs.web }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v3
id: filter
with:
filters: |
@@ -26,7 +26,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/setup-gitnexus-web
@@ -74,7 +74,7 @@ jobs:
- name: Upload test results
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-results
path: |
+29 -6
View File
@@ -8,8 +8,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 20
cache: npm
@@ -21,8 +21,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 20
cache: npm
@@ -34,7 +34,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/setup-gitnexus
- run: npx tsc --noEmit
working-directory: gitnexus
@@ -43,7 +43,30 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/setup-gitnexus-web
- run: npx tsc -b --noEmit
working-directory: gitnexus-web
# Enforces the convention documented in CONTRIBUTING.md → "GitHub Actions —
# Concurrency Convention":
# 1. Every entry-point (non-reusable) workflow declares a top-level
# `concurrency:` block.
# 2. Reusable workflows (`on: workflow_call` only) do NOT declare one —
# they inherit concurrency from the caller.
# 3. The concurrency group key starts with `${{ github.workflow }}` or
# the literal `CI-` prefix (the documented ci.yml exception for
# reusable-workflow-safe grouping).
# Reusability is detected by parsing each workflow's `on:` block, not an
# allowlist, so new reusable workflows never produce false positives.
workflow-convention:
name: Workflow concurrency convention
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Validate workflow concurrency convention
shell: bash
run: |
set -euo pipefail
python3 .github/scripts/check-workflow-concurrency.py .github/workflows
+14 -4
View File
@@ -14,6 +14,16 @@ permissions:
contents: read # needed for sparse checkout of vitest.config.ts
pull-requests: write # needed to post sticky PR comment
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
# Serialize sticky-comment writes per PR so two rapid CI completions don't race.
# Internal PRs surface in `pull_requests[0].number`. Fork PRs leave that array empty,
# so we fall back to `<head-repo-full-name>/<head-branch>`, which is stable across
# reruns and subsequent pushes for the same fork PR (unlike `workflow_run.id` which
# is unique per run and therefore does not serialize anything).
concurrency:
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
jobs:
pr-report:
name: PR Report
@@ -26,7 +36,7 @@ jobs:
steps:
# ── Download artifacts from the CI run ────────────────────────
- name: Download artifacts
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7
with:
script: |
const fs = require('fs');
@@ -113,7 +123,7 @@ jobs:
- name: Checkout (for vitest config)
if: steps.meta.outputs.skip != 'true'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: gitnexus/vitest.config.ts
sparse-checkout-cone-mode: false
@@ -122,7 +132,7 @@ jobs:
- name: Fetch base branch coverage
if: steps.meta.outputs.skip != 'true'
id: base-coverage
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7
with:
script: |
const fs = require('fs');
@@ -406,7 +416,7 @@ jobs:
- name: Comment on PR
if: steps.meta.outputs.skip != 'true'
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v2
with:
header: ci-report
number: ${{ steps.meta.outputs.pr_number }}
+6 -3
View File
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
@@ -41,9 +41,12 @@ jobs:
--outputFile=web-test-results.json
working-directory: gitnexus-web
- name: Run docker-server integration tests
run: node --test docker-server.test.mjs
- name: Upload test reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-reports
path: |
@@ -63,7 +66,7 @@ jobs:
runs-on: ${{ matrix.os }}
timeout-minutes: 25
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
+17 -6
View File
@@ -9,9 +9,20 @@ on:
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
workflow_call:
# 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
# called-workflow context `github.workflow` evaluation is ambiguous across GitHub
# Actions versions, and a prefix that could resolve to the caller's name would
# share a concurrency group with the caller → deadlock. A literal prefix is
# immune. Direct `push`/`pull_request` invocations use `CI-<ref>`; invocations
# from a reusable-workflow caller fall into a per-run-unique group that never
# serializes with the caller.
# cancel-in-progress is event-aware: cancel superseded PR runs, queue every other
# event (push to main, workflow_call from publish.yml, etc.).
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
group: ${{ (github.event_name == 'pull_request' || github.event_name == 'push') && format('CI-{0}', github.ref) || format('CI-nested-{0}', github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
# ── Reusable workflow orchestration ─────────────────────────────────
# Each concern lives in its own workflow file for maintainability:
@@ -74,7 +85,7 @@ jobs:
cp pr-meta/e2e_result pr-meta/e2e-result
- name: Upload PR metadata
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pr-meta
path: pr-meta/
@@ -96,9 +107,9 @@ jobs:
TESTS: ${{ needs.tests.result }}
E2E: ${{ needs.e2e.result }}
run: |
echo "Quality: $QUALITY"
echo "Tests: $TESTS"
echo "E2E: $E2E"
echo "Quality: $QUALITY"
echo "Tests: $TESTS"
echo "E2E: $E2E"
if [[ "$QUALITY" != "success" ]] ||
[[ "$TESTS" != "success" ]]; then
echo "::error::Quality or test jobs failed"
+4 -3
View File
@@ -16,9 +16,10 @@ on:
issue_comment:
types: [created]
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
# Serialize per-PR to avoid racing review comments.
concurrency:
group: claude-review-${{ github.event.issue.number || github.event.pull_request.number }}
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }}
cancel-in-progress: false
jobs:
@@ -56,7 +57,7 @@ jobs:
# For issue_comment triggers, resolve the PR number, head SHA, and fork repo
- name: Resolve PR context
id: pr
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7
with:
script: |
let pr;
@@ -76,7 +77,7 @@ jobs:
core.setOutput('branch', pr.head.ref);
- name: Checkout PR head
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: ${{ steps.pr.outputs.repo }}
ref: ${{ steps.pr.outputs.sha }}
+4 -3
View File
@@ -10,9 +10,10 @@ on:
pull_request_review:
types: [submitted]
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
# Serialize per-PR/issue to avoid racing comments.
concurrency:
group: claude-code-${{ github.event.issue.number || github.event.pull_request.number || github.event.issue.id }}
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.event.issue.id }}
cancel-in-progress: false
jobs:
@@ -58,7 +59,7 @@ jobs:
# For PR-related triggers, resolve the fork repo so we can checkout correctly.
- name: Resolve PR context
id: pr
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7
with:
script: |
// Determine if this event is PR-related
@@ -90,7 +91,7 @@ jobs:
core.setOutput('branch', pr.head.ref);
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.repo || github.repository }}
ref: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.sha || '' }}
+71
View File
@@ -0,0 +1,71 @@
name: Docker Build & Push
on:
push:
tags:
- 'v*'
branches:
- main
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
workflow_dispatch:
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
# Tag refs are unique per release — distinct tags run in parallel.
# Pushes to main serialize; cancel superseded runs.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref == 'refs/heads/main' }}
jobs:
build-push:
name: Build & Push image
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Required for multi-platform (linux/arm64) emulation.
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Computes image tags and labels from Git metadata:
# v* tag → ghcr.io/<owner>/<repo>:<semver> (e.g. 1.2.3, 1.2, 1)
# main push → ghcr.io/<owner>/<repo>:latest
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix=sha-,format=short
- name: Build and push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
BUILDPLATFORM=${{ runner.os == 'Linux' && 'linux/amd64' || 'linux/amd64' }}
+3 -2
View File
@@ -8,8 +8,9 @@ on:
permissions:
pull-requests: write
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
concurrency:
group: pr-desc-${{ github.event.pull_request.number }}
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
@@ -18,7 +19,7 @@ jobs:
timeout-minutes: 5
steps:
- name: Check PR description quality
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const MIN_BODY_LENGTH = 50;
+113
View File
@@ -0,0 +1,113 @@
name: PR Conventional Labeler
# Two workflows in one file with different triggers, matched to the minimum
# privilege each needs:
#
# validate-title (on: pull_request)
# Fork-safe. Runs with the PR-head's read-only GITHUB_TOKEN. Uses
# `amannn/action-semantic-pull-request` to fail the check when the PR
# title doesn't follow the conventional-commit format. Because the
# action only reads the event payload, no fork-controlled code runs.
#
# autolabel (on: pull_request_target)
# Needs `pull-requests: write` to apply labels, so must be
# pull_request_target. Uses `release-drafter/release-drafter` with
# `dry-run: true` to only run the autolabeler against the
# `.github/release-drafter.yml` config from the BASE ref (release-
# drafter reads the config from the repository's default branch, NOT
# the PR head — verify with `gh api repos/release-drafter/release-drafter/contents/...`
# or a fork-test PR before merging if the repo is high-value).
# `sync-labels: true` in the config removes managed autolabels that no
# longer match (e.g. when `!` or `BREAKING CHANGE:` is dropped).
#
# Title format: <type>[(scope)][!]: <subject>
# Allowed types: feat, fix, perf, refactor, docs, test, ci, build, chore, revert, deps
# Trailing `!` on the type marks a breaking change.
# See CONTRIBUTING.md → "Pull request titles".
on:
pull_request:
# Title-only changes fire `edited`. `opened` and `reopened` cover creation.
# `synchronize` (push to the PR branch) is intentionally excluded — titles
# don't change on push, so it only wastes CI minutes and broadens the
# privileged-token exposure window on the autolabel job.
types: [opened, edited, reopened]
pull_request_target:
types: [opened, edited, reopened]
# 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
# and therefore cannot cancel each other — a cancelled required-check would
# permanently block merge until the next title edit.
# Within each trigger the latest title edit still supersedes the prior run.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
validate-title:
# Fork-safe job — only runs on `pull_request` (not `pull_request_target`).
# Token is read-only; writes a commit status that branch protection can
# require before merge.
name: Validate PR title
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
pull-requests: read
steps:
# Pinned to v6.1.1. Verify SHA via:
# gh api repos/amannn/action-semantic-pull-request/git/refs/tags/v6.1.1
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
fix
perf
refactor
docs
test
ci
build
chore
revert
deps
requireScope: false
# Subject must be non-empty. We DO allow capitalized proper nouns
# (MCP, GitHub, API, etc.) — the old `^(?![A-Z]).+$` pattern
# rejected legitimate titles like `fix: MCP tool schema`.
subjectPattern: ^\S.{2,}$
subjectPatternError: |
The subject "{subject}" in PR title "{title}" is invalid.
Subjects must be at least 3 characters and must not start with whitespace.
wip: false
autolabel:
# Privileged job — runs only on `pull_request_target` so it can write labels.
# Never checks out fork code, never executes fork-controlled input; only
# reads the PR metadata (title, body, labels) and calls the GitHub API.
name: Apply conventional label
if: github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
# `contents: read` is required — release-drafter's context.config() reads
# `.github/release-drafter.yml` from the repo's default branch via the
# repo-contents API. Without it the job silently 403s and no labels are
# applied. Job-level permissions nullify all unlisted scopes, so an
# explicit grant is necessary here.
contents: read
pull-requests: write
steps:
# Pinned to v7.2.0. Verify SHA via:
# gh api repos/release-drafter/release-drafter/git/refs/tags/v7.2.0
# v7 removed `disable-releaser`; use `dry-run: true` to only autolabel.
- uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
with:
config-name: release-drafter.yml
dry-run: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+13 -4
View File
@@ -7,13 +7,22 @@ on:
# No workflow-level permissions — scoped per job below.
# 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.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
ci:
uses: ./.github/workflows/ci.yml
permissions:
contents: read
actions: read
pull-requests: write
# No pull-requests:write — `ci.yml`'s save-pr-meta job is gated on
# `github.event_name == 'pull_request'`, so it never runs during a
# tag-triggered publish. Least-privilege for release-critical paths.
publish:
needs: ci
@@ -23,8 +32,8 @@ jobs:
contents: write
id-token: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 20
registry-url: https://registry.npmjs.org
@@ -82,7 +91,7 @@ jobs:
fi
- name: Create GitHub Release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v2
with:
body_path: ${{ steps.changelog.outputs.fallback == 'false' && '/tmp/release-notes.md' || '' }}
generate_release_notes: ${{ steps.changelog.outputs.fallback == 'true' }}
+366
View File
@@ -0,0 +1,366 @@
name: Release Candidate
on:
# Publish a release-candidate build whenever a merge/commit lands on main.
# Docs/README-only changes are filtered out so prose updates don't
# cut a release.
push:
branches: [main]
paths-ignore:
- '**.md'
- 'docs/**'
- 'LICENSE'
workflow_dispatch:
inputs:
bump:
description: >-
Cycle policy. 'auto' (default) continues the active rc cycle on
this branch if there is one, otherwise bumps patch from latest.
Choose 'patch' / 'minor' / 'major' to explicitly start or reset
an rc cycle.
required: false
default: 'auto'
type: choice
options:
- auto
- patch
- minor
- major
force:
description: 'Publish even when HEAD already has an rc marker'
required: false
default: 'false'
type: choice
options:
- 'false'
- 'true'
# No workflow-level permissions — scoped per job below.
permissions: {}
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
# Serialize all runs on the same ref (push + workflow_dispatch) to prevent two publishes
# racing on the rc counter. cancel-in-progress: false — the earlier merge publishes first.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
# ── Skip when HEAD already has an rc marker (retry / duplicate dispatch) ──
# The marker is a lightweight tag `rc/<HEAD_SHA>` pushed *before* `npm
# publish`, so a failed publish leaves the marker in place and the guard
# refuses to re-publish. Recovery path after a partial failure:
# git push --delete origin rc/<HEAD_SHA> v<RC_VERSION>
# then redispatch with force=true.
guard:
name: Check if release candidate should run
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
should_run: ${{ steps.decide.outputs.should_run }}
head_sha: ${{ steps.decide.outputs.head_sha }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
fetch-tags: true
- name: Decide
id: decide
shell: bash
env:
FORCE: ${{ inputs.force }}
BUMP_INPUT: ${{ inputs.bump }}
EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
HEAD_SHA=$(git rev-parse HEAD)
echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT"
if [ "$FORCE" = "true" ]; then
echo "Force flag set — running regardless of marker tag."
echo "should_run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# An explicit cycle reset on dispatch (bump != auto) also bypasses
# the dedup guard — the maintainer is deliberately asking for a
# new rc from the same commit.
if [ "$EVENT_NAME" = "workflow_dispatch" ] \
&& [ -n "${BUMP_INPUT:-}" ] \
&& [ "${BUMP_INPUT:-auto}" != "auto" ]; then
echo "Explicit bump=$BUMP_INPUT — bypassing marker dedup."
echo "should_run=true" >> "$GITHUB_OUTPUT"
exit 0
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
echo "HEAD already has marker $MARKER — skipping."
echo "should_run=false" >> "$GITHUB_OUTPUT"
else
echo "No marker on HEAD — proceeding."
echo "should_run=true" >> "$GITHUB_OUTPUT"
fi
# ── Reuse the stable CI workflow ─────────────────────────────────────
ci:
needs: guard
if: needs.guard.outputs.should_run == 'true'
uses: ./.github/workflows/ci.yml
permissions:
contents: read
secrets: inherit
# ── Publish the rc build to npm + create GitHub prerelease ───────────
publish:
name: Publish release candidate to npm
needs: [guard, ci]
if: needs.guard.outputs.should_run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: write # push rc tag + marker
id-token: write # npm provenance
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 20
registry-url: https://registry.npmjs.org
cache: npm
cache-dependency-path: gitnexus/package-lock.json
- name: Build gitnexus-shared
run: npm install && npm run build
working-directory: gitnexus-shared
- name: Install gitnexus dependencies
run: npm ci
working-directory: gitnexus
- name: Resolve rc version
id: version
shell: bash
working-directory: gitnexus
env:
BUMP_INPUT: ${{ inputs.bump }}
EVENT_NAME: ${{ github.event_name }}
PKG_NAME: gitnexus
run: |
set -euo pipefail
# 1. Current published `latest` — the floor for any new rc base.
# Only E404 ("never published") falls back to package.json; any
# other error (network, auth, malformed response) fails fast.
NPM_STDERR_LATEST="$(mktemp)"
if CURRENT_LATEST="$(npm view "$PKG_NAME" version 2>"$NPM_STDERR_LATEST")"; then
:
else
if grep -q 'E404' "$NPM_STDERR_LATEST"; then
CURRENT_LATEST="$(node -p "require('./package.json').version")"
echo "Package not on registry (E404) — seeding from package.json: $CURRENT_LATEST"
else
echo "::error::npm registry unreachable for 'view version':" >&2
cat "$NPM_STDERR_LATEST" >&2
rm -f "$NPM_STDERR_LATEST"
exit 1
fi
fi
rm -f "$NPM_STDERR_LATEST"
CURRENT_LATEST_CLEAN="${CURRENT_LATEST%%-*}"
# 2. Full version list — needed for the counter and for active-cycle
# inference. Same E404-only fallback.
NPM_STDERR_VERSIONS="$(mktemp)"
if VERSIONS_JSON="$(npm view "$PKG_NAME" versions --json 2>"$NPM_STDERR_VERSIONS")"; then
:
else
if grep -q 'E404' "$NPM_STDERR_VERSIONS"; then
VERSIONS_JSON='[]'
echo "No published versions for $PKG_NAME yet (E404)."
else
echo "::error::npm registry unreachable for 'view versions':" >&2
cat "$NPM_STDERR_VERSIONS" >&2
rm -f "$NPM_STDERR_VERSIONS"
exit 1
fi
fi
rm -f "$NPM_STDERR_VERSIONS"
# 3. Base selection.
# - workflow_dispatch + bump ∈ {patch,minor,major} → explicit cycle
# reset from latest.
# - Everything else (push, or dispatch with bump=auto) → continue
# the highest active rc base > latest if one exists; else
# default to patch from latest.
if [ "$EVENT_NAME" = "workflow_dispatch" ] \
&& [ -n "${BUMP_INPUT:-}" ] \
&& [ "${BUMP_INPUT:-auto}" != "auto" ]; then
BASE="$(npx --yes -p semver@7 semver -i "$BUMP_INPUT" "$CURRENT_LATEST_CLEAN")"
echo "Explicit bump=$BUMP_INPUT → BASE=$BASE"
else
cat > /tmp/active_base.mjs <<'NODESCRIPT'
const latest = process.env.LATEST;
let v;
try { v = JSON.parse(process.env.VERSIONS_JSON); } catch { v = []; }
if (!Array.isArray(v)) v = [v];
const parse = s => s.split(".").map(n => parseInt(n, 10));
const gt = (a, b) => {
const [A, B] = [parse(a), parse(b)];
for (let i = 0; i < 3; i++) if (A[i] !== B[i]) return A[i] > B[i];
return false;
};
const bases = new Set();
for (const s of v) {
const m = /^(\d+\.\d+\.\d+)-rc\.\d+$/.exec(s);
if (m && gt(m[1], latest)) bases.add(m[1]);
}
if (!bases.size) { process.stdout.write(""); process.exit(0); }
const sorted = [...bases].sort((a, b) => gt(a, b) ? 1 : -1);
process.stdout.write(sorted[sorted.length - 1]);
NODESCRIPT
ACTIVE_BASE="$(LATEST="$CURRENT_LATEST_CLEAN" VERSIONS_JSON="$VERSIONS_JSON" node /tmp/active_base.mjs)"
if [ -n "$ACTIVE_BASE" ]; then
BASE="$ACTIVE_BASE"
echo "Continuing active rc cycle → BASE=$BASE"
else
BASE="$(npx --yes -p semver@7 semver -i patch "$CURRENT_LATEST_CLEAN")"
echo "No active rc cycle → patch bump from latest → BASE=$BASE"
fi
fi
# 4. Counter: 1 + max existing N for `${BASE}-rc.*`, else 1.
cat > /tmp/next_rc.mjs <<'NODESCRIPT'
const base = process.env.BASE;
const prefix = base + "-rc.";
let v;
try { v = JSON.parse(process.env.VERSIONS_JSON); } catch { v = []; }
if (!Array.isArray(v)) v = [v];
const ns = v
.filter(s => typeof s === "string" && s.startsWith(prefix))
.map(s => parseInt(s.slice(prefix.length), 10))
.filter(n => Number.isInteger(n) && n >= 0);
process.stdout.write(String(ns.length ? Math.max(...ns) + 1 : 1));
NODESCRIPT
NEXT_N="$(BASE="$BASE" VERSIONS_JSON="$VERSIONS_JSON" node /tmp/next_rc.mjs)"
RC_VERSION="${BASE}-rc.${NEXT_N}"
echo "Computed rc: $RC_VERSION"
# 5. Defensive: if the exact version already exists on the registry
# (e.g., race with another run), abort before re-publishing.
# Same E404-only pattern used above — a transient network
# failure must fail loudly, not pretend the version is missing.
NPM_STDERR_EXISTS="$(mktemp)"
if npm view "$PKG_NAME@$RC_VERSION" version 2>"$NPM_STDERR_EXISTS" >/dev/null; then
rm -f "$NPM_STDERR_EXISTS"
echo "::error::Version $RC_VERSION already exists on npm — aborting."
exit 1
else
if grep -qiE 'E404|not found' "$NPM_STDERR_EXISTS"; then
rm -f "$NPM_STDERR_EXISTS"
# Version doesn't exist — safe to proceed.
else
echo "::error::npm registry unreachable for existence check:" >&2
cat "$NPM_STDERR_EXISTS" >&2
rm -f "$NPM_STDERR_EXISTS"
exit 1
fi
fi
echo "base=$BASE" >> "$GITHUB_OUTPUT"
echo "rc_n=$NEXT_N" >> "$GITHUB_OUTPUT"
echo "rc_version=$RC_VERSION" >> "$GITHUB_OUTPUT"
- name: Apply rc version in-CI
shell: bash
working-directory: gitnexus
run: |
set -euo pipefail
npm version "${{ steps.version.outputs.rc_version }}" \
--no-git-tag-version --allow-same-version
- name: Build gitnexus
run: npm run build
working-directory: gitnexus
- name: Dry-run publish
run: npm publish --dry-run --tag rc
working-directory: gitnexus
# ── Acquire the "rc lock" BEFORE publishing (fixes idempotency) ─────
# We create two tags and push them atomically:
# v<RC_VERSION> → annotated tag on a detached release commit
# whose tree contains the rewritten package.json
# (so the tag's source matches the npm tarball)
# rc/<HEAD_SHA> → lightweight tag on HEAD; the guard's dedup key
# If this push fails, nothing is published — safe.
# If this push succeeds but npm publish fails, the marker stays on
# the remote and blocks retries until an operator manually cleans up.
- name: Create and push rc tags
id: reltag
shell: bash
working-directory: gitnexus
env:
RC_VERSION: ${{ steps.version.outputs.rc_version }}
HEAD_SHA: ${{ needs.guard.outputs.head_sha }}
run: |
set -euo pipefail
VTAG="v${RC_VERSION}"
MARKER="rc/${HEAD_SHA}"
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
# Detached release commit with the version bump — keeps `main`
# pristine but gives the v-tag a tree that matches the published
# package contents exactly (fixes release-integrity gap).
git add package.json package-lock.json 2>/dev/null || git add package.json
git commit -m "release: ${VTAG}" --allow-empty
RELEASE_SHA="$(git rev-parse HEAD)"
echo "Detached release commit: $RELEASE_SHA"
# Annotated release tag on the release commit.
git tag -a "$VTAG" "$RELEASE_SHA" -m "$VTAG"
# Lightweight marker on the user-visible HEAD for the guard.
git tag "$MARKER" "$HEAD_SHA"
# Atomic push of both refs. If either would clobber an existing
# 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"
- name: Publish to npm (rc dist-tag)
run: npm publish --provenance --access public --tag rc
working-directory: gitnexus
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Create GitHub prerelease
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v2
with:
tag_name: ${{ steps.reltag.outputs.vtag }}
name: Release Candidate ${{ steps.reltag.outputs.vtag }}
prerelease: true
make_latest: 'false'
generate_release_notes: true
body: |
Automated release candidate build from `main`.
**npm:** `npm install gitnexus@rc`
**Version:** `${{ steps.version.outputs.rc_version }}`
**Target base:** `${{ steps.version.outputs.base }}` (rc #${{ steps.version.outputs.rc_n }})
**Source commit (main):** ${{ needs.guard.outputs.head_sha }}
**Release commit (versioned tree):** ${{ steps.reltag.outputs.release_sha }}
Release candidates are pre-stable builds intended for early testing.
Stable releases remain on the `latest` dist-tag.
@@ -0,0 +1,185 @@
name: Tree-sitter Upgrade Readiness
# Monitors readiness for upgrading tree-sitter to 0.25.x. Tracks:
# 1. Peer-dep compatibility — can each grammar install cleanly with
# tree-sitter@0.25.0 without --legacy-peer-deps?
# 2. Vendored proto drift — has coder3101/tree-sitter-proto moved
# ahead of our vendored snapshot?
# See .github/scripts/check-tree-sitter-upgrade-readiness.py for the logic.
#
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
on:
schedule:
# Daily at 09:00 UTC. Matches Dependabot's daily cadence so drift
# and dep PRs surface together.
- cron: '0 9 * * *'
workflow_dispatch:
pull_request:
paths:
- '.github/scripts/check-tree-sitter-upgrade-readiness.py'
- '.github/workflows/tree-sitter-upgrade-readiness.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
readiness:
name: Check upgrade readiness
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
# Needed to open/update the tracking issue on scheduled runs.
issues: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/setup-gitnexus
with:
build: 'false'
- name: Run upgrade readiness check
id: readiness
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set +e
python3 .github/scripts/check-tree-sitter-upgrade-readiness.py > drift-report.md
code=$?
set -e
echo "exit_code=$code" >> "$GITHUB_OUTPUT"
{
echo 'report<<DRIFT_EOF'
cat drift-report.md
echo 'DRIFT_EOF'
} >> "$GITHUB_OUTPUT"
echo "=== Report ==="
cat drift-report.md
# On PR runs, the script validates that it runs correctly. Blockers
# are informational — the scheduled run opens a tracking issue.
- name: Annotate PR with readiness status
if: github.event_name == 'pull_request' && steps.readiness.outputs.exit_code != '0'
run: |
echo "::warning::Tree-sitter 0.25 upgrade has blockers. See job output for the full readiness report."
- name: Upsert tracking issue on scheduled runs
if: >
github.event_name == 'schedule' &&
steps.readiness.outputs.exit_code != '0'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
REPORT: ${{ steps.readiness.outputs.report }}
with:
script: |
const title = 'Tree-sitter 0.25 upgrade readiness';
const report = process.env.REPORT;
const body = report + '\n\n' +
'<sub>Generated daily by `.github/workflows/tree-sitter-upgrade-readiness.yml`. ' +
'Closes automatically when all blockers are resolved.</sub>';
const { data: open } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: 'tree-sitter-drift',
per_page: 10,
});
const existing = open.find(i => i.title === title);
if (existing) {
// Extract ready/total count for the changelog comment.
const readyMatch = report.match(/\*\*(\d+)\/(\d+)\*\* grammars ready/);
const blockerMatch = report.match(/\*\*(\d+) blocker/);
const ready = readyMatch ? readyMatch[1] : '?';
const total = readyMatch ? readyMatch[2] : '?';
const blockers = blockerMatch ? blockerMatch[1] : '?';
// Find grammars whose status changed by diffing the old and
// new table rows. Each row looks like:
// | `tree-sitter-foo` | ... | Ready |
// | `tree-sitter-foo` | ... | Blocking |
const parseRows = (md) => {
const map = {};
for (const m of md.matchAll(/\| `(tree-sitter-[^`]+)` \|.*?\| (\S+(?:\s\S+)*?) \|$/gm)) {
map[m[1]] = m[2].trim();
}
return map;
};
const oldRows = parseRows(existing.body || '');
const newRows = parseRows(report);
const changes = [];
for (const [name, newStatus] of Object.entries(newRows)) {
const oldStatus = oldRows[name];
if (oldStatus && oldStatus !== newStatus) {
changes.push(`\`${name}\`: ${oldStatus} → ${newStatus}`);
}
}
const today = new Date().toISOString().slice(0, 10);
let comment = `**${today}:** ${ready}/${total} ready. ${blockers} blocker(s) remaining.`;
if (changes.length > 0) {
comment += '\n\nChanges:\n' + changes.map(c => `- ${c}`).join('\n');
} else {
comment += ' No changes from previous run.';
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existing.number,
body: comment,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existing.number,
body,
});
core.info(`Updated existing issue #${existing.number}`);
} else {
const { data: created } = await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title,
body,
labels: ['tree-sitter-drift', 'dependencies'],
});
core.info(`Opened issue #${created.number}`);
}
- name: Close tracking issue on clean scheduled runs
if: >
github.event_name == 'schedule' &&
steps.readiness.outputs.exit_code == '0'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const title = 'Tree-sitter 0.25 upgrade readiness';
const { data: open } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: 'tree-sitter-drift',
per_page: 10,
});
const existing = open.find(i => i.title === title);
if (existing) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existing.number,
body: 'All grammars are now compatible with tree-sitter@0.25. Upgrade is ready! Closing automatically.',
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existing.number,
state: 'closed',
});
core.info(`Closed issue #${existing.number}`);
}
+4 -2
View File
@@ -47,8 +47,10 @@ permissions:
issues: write
pull-requests: write
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
# Single global slot — newest manual dispatch supersedes any in-flight run.
concurrency:
group: triage-sweep
group: ${{ github.workflow }}
cancel-in-progress: true
jobs:
@@ -74,7 +76,7 @@ jobs:
run: pip install -r .github/scripts/triage/requirements.txt
- name: Cache FastEmbed model weights
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ${{ github.workspace }}/.fastembed_cache
key: fastembed-bge-small-en-v1.5
+8 -1
View File
@@ -23,6 +23,7 @@ Thumbs.db
.env
.env.local
.env.*.local
docker/.env
# Logs
*.log
@@ -81,6 +82,11 @@ GitNexus.sln
# Git worktrees
.worktrees/
# Vendored tree-sitter grammar build artifacts (created at install time,
# never committed). See docs/plans/2026-04-15-002-fix-tree-sitter-proto-vendor-deps-plan.md
gitnexus/vendor/**/build/
gitnexus/vendor/**/node_modules/
/github/scripts/triage/__pycache__/
.claude-flow/
@@ -95,4 +101,5 @@ GitNexus.sln
.swarm/
local_docs/
local_docs/
+101 -103
View File
@@ -1,117 +1,122 @@
<!-- version: 1.3.0 -->
<!--
Metadata: version, last reviewed, scope, model policy, reference docs, changelog.
Last updated: 2026-03-22
-->
<!-- version: 1.4.0 -->
<!-- Last updated: 2026-04-16 -->
Last reviewed: 2026-04-13
Last reviewed: 2026-04-16
**Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub)
This file uses a standard agent header (version, scope, model policy, reference docs, changelog), adapted for this **TypeScript/JavaScript monorepo**.
## Scope
| | |
|--|--|
| **Reads** | Repository tree as needed for the task: `gitnexus/`, `gitnexus-web/`, `eval/`, plugin packages, `.github/`, `.gitnexus/` when present, and docs. |
| **Writes** | Only paths required for the requested change; keep diffs minimal. Update lockfiles when dependencies change. |
| **Executes** | `npm`, `npx`, `node` under `gitnexus/` and `gitnexus-web/`; `uv run` for Python under `eval/` when applicable; shell utilities for documented CI/dev workflows. |
| **Off-limits** | User secrets (e.g. real `.env`), production deployment credentials, unrelated repositories, destructive git history operations without explicit human confirmation. |
| Boundary | Rule |
|----------|------|
| **Reads** | `gitnexus/`, `gitnexus-web/`, `eval/`, plugin packages, `.github/`, `.gitnexus/`, docs. |
| **Writes** | Only paths required for the change; keep diffs minimal. Update lockfiles when deps change. |
| **Executes** | `npm`, `npx`, `node` under `gitnexus/` and `gitnexus-web/`; `uv run` for Python under `eval/`; documented CI/dev workflows. |
| **Off-limits** | Real `.env` / secrets, production credentials, unrelated repos, destructive git ops without confirmation. |
## Model Configuration
- **Primary:** Pin in **Cursor** (Settings → model). Use a **named** model (e.g. GPT-5.2, Claude Sonnet 4.x). Avoid relying on **Auto** when reproducibility or audit trail matters.
- **Fallback:** As configured in Cursor or your organization (do not encode `latest` or wildcards in automation configs).
- **Notes:** The open-source GitNexus CLI indexer does not call an LLM. Optional Nexus AI in the web UI uses end-user provider keys and models.
- **Primary:** Use a named model (e.g. Claude Sonnet 4.x). Avoid `Auto` or unversioned `latest` when reproducibility matters.
- **Notes:** The GitNexus CLI indexer does not call an LLM.
## Execution Sequence (complex tasks)
Long sessions dilute instructions. For **multi-step** work, state up front:
For multi-step work, state up front:
1. Which rules in this file and **[GUARDRAILS.md](GUARDRAILS.md)** apply (and any relevant Signs).
2. Current **Scope** boundaries (Reads / Writes / Off-limits).
3. Which **validation commands** you will run (e.g. `cd gitnexus && npm test`, `npx tsc --noEmit`).
2. Current **Scope** boundaries.
3. Which **validation commands** you will run (`cd gitnexus && npm test`, `npx tsc --noEmit`).
On very long threads, the human may add *“Remember: apply all AGENTS.md rules”* to re-weight rule tokens against context dilution.
On long threads, *"Remember: apply all AGENTS.md rules"* re-weights these instructions against context dilution.
## Claude Code hooks
Hooks enforce gates that prompts cannot. In **Claude Code**, **PreToolUse** hooks can block tools such as `git_commit` until checks pass. Adapt to this repo: e.g. `cd gitnexus && npm test` before commit.
**PreToolUse** hooks can block tools (e.g. `git_commit`) until checks pass. Adapt to this repo: `cd gitnexus && npm test` before commit.
## Context budget (Cursor / standards)
## Context budget
Generic “core standards” playbooks are often long and stack-specific. For this monorepo, commands and gotchas live under **Cursor Cloud specific instructions** below and in **[CONTRIBUTING.md](CONTRIBUTING.md)**. If always-on rules grow, split domain rules into **`.cursor/rules/*.mdc`** (globs). **Cursor:** project-wide rules live in **`.cursor/index.mdc`** (YAML frontmatter with `alwaysApply: true`). **Claude Code:** optionally load a **`STANDARDS.md`** only when needed (e.g. *“When writing new code, read STANDARDS.md”*) to save context.
Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.md](CONTRIBUTING.md)**. If always-on rules grow, split into **`.cursor/rules/*.mdc`** (globs). **Cursor:** project-wide rules in `.cursor/index.mdc`. **Claude Code:** load `STANDARDS.md` only when needed.
## Reference Documentation
## Reference docs
- **This repository:** **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)**.
- **Cursor:** `.cursor/index.mdc` (always-on rules); optional `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` is deprecated — see `.cursor/index.mdc`.
- **Optional local files:** `NOTES.md` (short vendor-neutral project snapshot). For handoffs, keep notes local (e.g., a scratch file outside the repo) rather than committing `HANDOFF.md`.
- **GitNexus:** skills under `.claude/skills/gitnexus/`; machine-oriented rules in the `gitnexus:start` … `gitnexus:end` block below.
- **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)**
- **Call-resolution DAG:** See ARCHITECTURE.md § Call-Resolution DAG. Typed 6-stage DAG inside the `parse` phase; language-specific behavior behind `inferImplicitReceiver` / `selectDispatch` hooks on `LanguageProvider`. Shared code in `gitnexus/src/core/ingestion/` must not name languages. Types: `gitnexus/src/core/ingestion/call-types.ts`.
- **Cursor:** `.cursor/index.mdc` (always-on); `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` deprecated.
- **GitNexus:** skills in `.claude/skills/gitnexus/`; MCP rules in `gitnexus:start` block below.
## Changelog
| Date | Version | Change |
|------|---------|--------|
| 2026-04-16 | 1.4.0 | Fixed: web UI description, pre-commit behavior, MCP tools (7->16), added gitnexus-shared, removed stale vite-plugin-wasm gotcha. |
| 2026-04-13 | 1.3.0 | Updated GitNexus index stats after DAG refactor. |
| 2026-03-24 | 1.2.0 | Fixed gitnexus:start block duplication (was inlined in Reference Docs bullet). |
| 2026-03-23 | 1.1.0 | Updated agent instructions (sections, references, Cursor layout). |
| 2026-03-22 | 1.0.0 | Added structured agent header and changelog. |
| 2026-03-24 | 1.2.0 | Fixed gitnexus:start block duplication. |
| 2026-03-23 | 1.1.0 | Updated agent instructions, references, Cursor layout. |
| 2026-03-22 | 1.0.0 | Initial structured header and changelog. |
---
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
Indexed as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows). Use MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
> If any tool warns the index is stale, run `npx gitnexus analyze` first.
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
- **MUST run impact analysis before editing any symbol.** `gitnexus_impact({target: "symbolName", direction: "upstream"})` — report blast radius to the user.
- **MUST run `gitnexus_detect_changes()` before committing** — verify only expected symbols and flows are affected.
- **MUST warn the user** if impact returns HIGH or CRITICAL risk.
- Explore unfamiliar code with `gitnexus_query({query: "concept"})` (process-grouped, ranked) instead of grepping.
- Full context on a symbol: `gitnexus_context({name: "symbolName"})`.
## When Debugging
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
1. `gitnexus_query({query: "<error or symptom>"})` — find related execution flows
2. `gitnexus_context({name: "<suspect function>"})` — callers, callees, process participation
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace flow step by step
4. Regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})`
## When Refactoring
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
- **Rename:** `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Graph edits are safe; text_search edits need manual review.
- **Extract/Split:** `gitnexus_context` (incoming/outgoing refs) then `gitnexus_impact` (upstream callers) before moving code.
- **After any refactor:** `gitnexus_detect_changes({scope: "all"})` to verify scope.
## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
- Edit a symbol without running `gitnexus_impact` first.
- Ignore HIGH/CRITICAL risk warnings.
- Rename with find-and-replace — use `gitnexus_rename`.
- Commit without `gitnexus_detect_changes()`.
- Add language-specific behavior to shared ingestion code (`gitnexus/src/core/ingestion/`) — use a `LanguageProvider` hook. Seeing `provider.mroStrategy === 'xxx'` or an import from `languages/xxx.ts` in shared code means stop and add a hook.
## Tools Quick Reference
| Tool | When to use | Command |
| Tool | When to use | Example |
|------|-------------|---------|
| `list_repos` | Discover indexed repos | `gitnexus_list_repos({})` |
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
| `api_impact` | Pre-change API route impact | `gitnexus_api_impact({route: "/api/users", method: "GET"})` |
| `route_map` | Route → handler → consumer map | `gitnexus_route_map({})` |
| `tool_map` | MCP/RPC tool definitions | `gitnexus_tool_map({})` |
| `shape_check` | Response shape vs consumer access | `gitnexus_shape_check({route: "/api/users"})` |
| `group_list` | List repo groups | `gitnexus_group_list({})` |
| `group_query` | Cross-repo search in a group | `gitnexus_group_query({name: "myGroup", query: "auth"})` |
| `group_sync` | Rebuild group Contract Registry | `gitnexus_group_sync({name: "myGroup"})` |
| `group_contracts` | Inspect group contracts | `gitnexus_group_contracts({name: "myGroup"})` |
| `group_status` | Group staleness report | `gitnexus_group_status({name: "myGroup"})` |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=1 | WILL BREAK — direct callers/importers | MUST update |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
@@ -119,87 +124,80 @@ This project is indexed by GitNexus as **GitNexus** (4325 symbols, 10556 relatio
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
| `gitnexus://repo/GitNexus/context` | Codebase overview, index freshness |
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
| `gitnexus://repo/GitNexus/processes` | All execution flows |
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
Before completing any code modification task, verify:
1. `gitnexus_impact` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
2. No HIGH/CRITICAL warnings were ignored
3. `gitnexus_detect_changes()` confirms expected scope
4. All d=1 dependents were updated
## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
```bash
npx gitnexus analyze
npx gitnexus analyze # basic refresh
npx gitnexus analyze --embeddings # preserve embeddings
```
If the index previously included embeddings, preserve them by adding `--embeddings`:
Check `.gitnexus/meta.json` `stats.embeddings` (0 = none). Running without `--embeddings` deletes existing vectors.
```bash
npx gitnexus analyze --embeddings
```
> Claude Code: PostToolUse hook handles this after `git commit` and `git merge`.
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
## CLI Skills
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
## CLI
| Task | Read this skill file |
|------|---------------------|
| 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` |
| Task | Skill file |
|------|-----------|
| Architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Debugging / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Refactoring | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools/resources/schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| CLI commands (index, status, clean, wiki) | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->
## Cursor Cloud specific instructions
## Repo reference
### Repository structure
### Packages
This is a monorepo with two main products and supporting config packages:
| Component | Path | Purpose |
|-----------|------|---------|
| **GitNexus CLI/Core** | `gitnexus/` | Main product — TypeScript CLI, indexing pipeline, MCP server. Published to npm. |
| **GitNexus Web UI** | `gitnexus-web/` | React/Vite browser app — graph explorer + AI chat. Runs entirely in WASM. |
| Claude Plugin | `gitnexus-claude-plugin/` | Static config for Claude marketplace (no build). |
| Cursor Integration | `gitnexus-cursor-integration/` | Static config for Cursor editor (no build). |
| SWE-bench Eval | `eval/` | Python evaluation harness (optional; needs Docker + LLM API keys). |
| Package | Path | Purpose |
|---------|------|---------|
| **CLI/Core** | `gitnexus/` | TypeScript CLI, indexing pipeline, MCP server. Published to npm. |
| **Web UI** | `gitnexus-web/` | React/Vite thin client. All queries via `gitnexus serve` HTTP API. |
| **Shared** | `gitnexus-shared/` | Shared TypeScript types and constants. |
| Claude Plugin | `gitnexus-claude-plugin/` | Static config for Claude marketplace. |
| Cursor Integration | `gitnexus-cursor-integration/` | Static config for Cursor editor. |
| Eval | `eval/` | Python evaluation harness (Docker + LLM API keys). |
### Running services
- **CLI/Core**: `cd gitnexus && npm run dev` (tsx watch mode) or `npm run build && node dist/cli/index.js <command>`
- **Web UI**: `cd gitnexus-web && npm run dev` (Vite on port 5173)
- **Backend mode**: `cd <indexed-repo> && node /workspace/gitnexus/dist/cli/index.js serve` (HTTP API on port 3741 by default)
```bash
cd gitnexus && npm run dev # CLI: tsx watch mode
cd gitnexus-web && npm run dev # Web UI: Vite on port 5173
npx gitnexus serve # HTTP API on port 4747 (from any indexed repo)
```
### Testing
**CLI / Core (`gitnexus/`)**
- **Unit tests**: `cd gitnexus && npm test` (vitest, ~2000 tests)
- **Integration tests**: `cd gitnexus && npm run test:integration` (vitest, ~1850 tests). Two LadybugDB file-locking tests (`lbug-core-adapter`, `search-core`) may fail in containerized environments due to `/tmp` locking limitations — this is a known environment issue, not a code bug.
- **TypeScript check**: `cd gitnexus && npx tsc --noEmit`
- `npm test` — full vitest suite (~2000 tests)
- `npm run test:unit` — unit tests only
- `npm run test:integration` — integration (~1850 tests). LadybugDB file-locking tests may fail in containers (known env issue).
- `npx tsc --noEmit` — typecheck
**Web UI (`gitnexus-web/`)**
- **Unit tests**: `cd gitnexus-web && npm test` (vitest, ~200 tests)
- **E2E tests**: `cd gitnexus-web && E2E=1 npx playwright test` (Playwright, 5 tests — requires `gitnexus serve` + `npm run dev` running)
- **TypeScript check**: `cd gitnexus-web && npx tsc -b --noEmit`
- `npm test` — vitest (~200 tests)
- `npm run test:e2e` — Playwright (7 spec files; requires `gitnexus serve` + `npm run dev`)
- `npx tsc -b --noEmit` — typecheck
No separate lint command is configured; TypeScript strict checking serves as the primary static analysis.
**Pre-commit hook** (`.husky/pre-commit`): formatting (prettier via lint-staged) + typecheck for staged packages. Tests do **not** run in pre-commit — CI only.
### Gotchas
- `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (patches tree-sitter-swift). Native tree-sitter bindings require `python3`, `make`, and `g++` to be present.
- `tree-sitter-kotlin` and `tree-sitter-swift` are optional dependencies — install warnings for these are expected and non-blocking.
- The Web UI uses `vite-plugin-wasm` and requires `Cross-Origin-Opener-Policy`/`Cross-Origin-Embedder-Policy` headers for `SharedArrayBuffer` (handled automatically by Vite dev server).
- There is no ESLint/Prettier configuration in this repo.
- `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (patches tree-sitter-swift, builds tree-sitter-proto). Native bindings need `python3`, `make`, `g++`.
- `tree-sitter-kotlin` and `tree-sitter-swift` are optional — install warnings expected.
- ESLint configured via `eslint.config.mjs` (TS, React Hooks, unused-imports). No `npm run lint` script; use `npx eslint .`. Prettier runs via lint-staged. CI checks both in `ci-quality.yml`.
+300 -116
View File
@@ -1,99 +1,129 @@
# Architecture — GitNexus
This repository is a **monorepo** with two main products: the **CLI / MCP package** (`gitnexus/`) and the **browser UI** (`gitnexus-web/`). Supporting folders ship editor integrations and plugins without changing the core graph engine.
Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
## Repository layout
| Path | Role |
|------|------|
| `gitnexus/` | Published npm package `gitnexus`: CLI, MCP server (stdio), local HTTP API for bridge mode, ingestion pipeline, LadybugDB graph, embeddings (optional). |
| `gitnexus-web/` | Vite + React UI: in-browser indexing (WASM), graph visualization, optional connection to `gitnexus serve`. |
| `.claude/`, `gitnexus-claude-plugin/`, `gitnexus-cursor-integration/` | Packaged **skills** and plugin metadata so agents discover the same workflows as documented in `AGENTS.md`. |
| `eval/` | Evaluation harnesses and docs for benchmarking tool usage. |
| `.github/` | CI workflows (quality, unit, integration, E2E) and composite actions. |
| `gitnexus/` | npm package `gitnexus`: CLI, MCP server (stdio), HTTP API, ingestion pipeline, LadybugDB graph, embeddings. |
| `gitnexus-web/` | Vite + React thin client: graph explorer + AI chat. All queries via `gitnexus serve` HTTP API. |
| `gitnexus-shared/` | Shared TypeScript types and constants (consumed by CLI and Web). |
| `.claude/`, `gitnexus-claude-plugin/`, `gitnexus-cursor-integration/` | Agent skills and plugin metadata. |
| `eval/` | Evaluation harnesses for benchmarking tool usage. |
| `.github/` | CI workflows + composite actions (`setup-gitnexus/`, `setup-gitnexus-web/`). |
## End-to-end flow: index → graph → tools
1. **Ingestion** (`gitnexus analyze`)
- Entry: `gitnexus/src/cli/analyze.ts` → `runPipelineFromRepo` in `gitnexus/src/core/ingestion/pipeline.ts`.
- The pipeline is structured as a **DAG (Directed Acyclic Graph)** of named phases (see [Pipeline Phase DAG](#pipeline-phase-dag) below).
- Output is loaded into **LadybugDB** under **`.gitnexus/`** at the repo root (`lbug/`, `meta.json`, etc.). Optional **FTS** indexes and **embeddings** attach to the same store.
- The repo is registered in **`~/.gitnexus/registry.json`** so MCP can find it from any working directory.
1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). DAG of 12 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery.
2. **Persistence & metadata**
- `gitnexus/src/storage/repo-manager.ts` — paths, registry, cleanup of legacy Kuzu artifacts.
- `gitnexus/src/core/lbug/lbug-adapter.ts` — graph load, queries, embedding restore batches.
2. **Persistence** — `repo-manager.ts` (paths, registry, KuzuDB cleanup). `lbug-adapter.ts` (graph load, queries, embedding batches).
3. **Query & agents**
- **MCP (stdio):** `gitnexus/src/cli/mcp.ts` → `startMCPServer` → `LocalBackend` (`gitnexus/src/mcp/local/local-backend.ts`) opens registered repos and serves **tools** from `gitnexus/src/mcp/tools.ts` and **resources** from `gitnexus/src/mcp/resources.ts`.
- **Bridge HTTP:** `gitnexus/src/cli/serve.ts` → Express app in `gitnexus/src/server/api.ts` (CORS-limited) exposes REST + MCP-over-HTTP for the web UI.
- **CLI tools (no MCP):** `gitnexus query`, `context`, `impact`, `cypher` in `gitnexus/src/cli/tool.ts` call the same backend for scripts and CI.
3. **Query layer** — three interfaces to the same backend:
- **MCP (stdio):** `mcp.ts` → `LocalBackend` → tools (`tools.ts`) + resources (`resources.ts`)
- **HTTP bridge:** `serve.ts` → Express (`api.ts`, `mcp-http.ts`) for web UI
- **CLI direct:** `gitnexus query|context|impact|cypher` in `tool.ts`
4. **Staleness**
- `gitnexus/src/mcp/staleness.ts` compares indexed `lastCommit` to `HEAD` and surfaces hints when the graph is behind git.
4. **Staleness** — `staleness.ts` compares indexed `lastCommit` to `HEAD`, surfaces hints.
## MCP tools (summary)
## MCP tools
| Tool | Purpose |
|------|---------|
| `list_repos` | Discover indexed repositories when more than one is registered. |
| `query` | Natural-language / keyword search over the graph (hybrid BM25 + optional vectors). |
| `cypher` | Ad hoc **Cypher** against the schema (see resource `gitnexus://repo/{name}/schema`). |
| `context` | Callers, callees, processes for one symbol (with disambiguation). |
| `impact` | Blast radius (upstream/downstream) with depth and risk summary. |
| `detect_changes` | Map git diffs to affected symbols and processes. |
| `rename` | Graph-assisted rename with `dry_run` preview (`graph` vs `text_search` confidence). |
| `list_repos` | Discover indexed repos |
| `query` | Hybrid BM25 + vector search over the graph |
| `cypher` | Ad hoc Cypher against the schema |
| `context` | Callers, callees, processes for one symbol |
| `impact` | Blast radius (upstream/downstream) with risk summary |
| `detect_changes` | Map git diffs to affected symbols and processes |
| `rename` | Graph-assisted multi-file rename with `dry_run` preview |
| `api_impact` | Pre-change impact report for an API route handler |
| `route_map` | API route → handler → consumer mappings |
| `tool_map` | MCP/RPC tool definitions and handlers |
| `shape_check` | Response shape vs consumer property access mismatches |
| `group_list` | List repo groups or details for one group |
| `group_query` | Cross-repo search in a group (reciprocal rank fusion) |
| `group_sync` | Rebuild group Contract Registry (`contracts.json`) |
| `group_contracts` | Inspect group contracts and cross-links |
| `group_status` | Index and Contract Registry staleness per repo in a group |
## Where to change what
| If you are changing… | Start in… |
|----------------------|-----------|
| CLI commands / flags | `gitnexus/src/cli/` (`index.ts`, per-command modules). |
| Parsing or graph construction | `gitnexus/src/core/ingestion/pipeline-phases/` (individual phase files), `pipeline.ts` (orchestrator). |
| Graph schema / DB access | `gitnexus/src/core/lbug/` (`schema.ts`, `lbug-adapter.ts`), `gitnexus/src/mcp/core/lbug-adapter.ts` if MCP-specific. |
| MCP protocol, tools, resources | `gitnexus/src/mcp/server.ts`, `tools.ts`, `resources.ts`. |
| Search ranking | `gitnexus/src/core/search/` (BM25, hybrid fusion). |
| Embeddings | `gitnexus/src/core/embeddings/`, phases in `analyze.ts`. |
| Wiki generation | `gitnexus/src/core/wiki/`. |
| Web UI behavior | `gitnexus-web/src/` (components, workers, graph client). |
| CI | `.github/workflows/*.yml`, `.github/actions/setup-gitnexus/`. |
| Concern | Start in |
|---------|----------|
| CLI commands/flags | `src/cli/` (`index.ts`, per-command modules) |
| Parsing/graph construction | `src/core/ingestion/pipeline-phases/` + `pipeline.ts` |
| Graph schema/DB | `src/core/lbug/` (`schema.ts`, `lbug-adapter.ts`) |
| MCP tools/resources | `src/mcp/server.ts`, `tools.ts`, `resources.ts` |
| Search ranking | `src/core/search/` (BM25, hybrid fusion) |
| Embeddings | `src/core/embeddings/` + `src/core/run-analyze.ts` |
| Wiki generation | `src/core/wiki/` |
| Language support | `src/core/ingestion/languages/` + `tree-sitter-queries.ts` + `gitnexus-shared/src/languages.ts` |
| Import resolution | `src/core/ingestion/import-processor.ts` + `import-resolvers/configs/` + `model/resolution-context.ts` |
| Call resolution/MRO | `src/core/ingestion/call-processor.ts` + `model/resolve.ts` |
| Type extraction | `src/core/ingestion/type-extractors/` |
| Worker pool | `src/core/ingestion/workers/` |
| Web UI | `gitnexus-web/src/` |
| CI | `.github/workflows/*.yml`, `.github/actions/` |
> Paths above are relative to `gitnexus/` unless they start with `gitnexus-web/` or `.github/`.
---
## Pipeline Phase DAG
The ingestion pipeline is a DAG of named phases. Each phase is defined in its own file under `gitnexus/src/core/ingestion/pipeline-phases/` with explicit dependencies, typed inputs, and typed outputs.
12 phases defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output.
```
scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
→ crossFile → mro → communities → processes
```
### Phase files
| Phase | File | Deps | Output |
|-------|------|------|--------|
| `scan` | `scan.ts` | (root) | File paths + sizes |
| `structure` | `structure.ts` | `scan` | File/Folder nodes, CONTAINS edges, `allPathSet` |
| `markdown` | `markdown.ts` | `structure` | Section nodes, cross-link edges from .md/.mdx |
| `cobol` | `cobol.ts` | `structure` | COBOL program/paragraph/section nodes (regex, no tree-sitter) |
| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Symbol nodes, IMPORTS/CALLS/EXTENDS edges, extracted routes/tools/ORM queries |
| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators) |
| `tools` | `tools.ts` | `parse` | Tool nodes + HANDLES_TOOL edges |
| `orm` | `orm.ts` | `parse` | QUERIES edges (Prisma, Supabase) |
| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological import order |
| `mro` | `mro.ts` | `crossFile`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges |
| `communities` | `communities.ts` | `mro`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) |
| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `structure` | Process nodes + STEP_IN_PROCESS edges |
| Phase | File | Dependencies | What it does |
|-------|------|-------------|--------------|
| `scan` | `scan.ts` | (root) | Walk repo filesystem, collect paths + sizes |
| `structure` | `structure.ts` | `scan` | Build File/Folder nodes + CONTAINS edges |
| `markdown` | `markdown.ts` | `structure` | Extract headings and cross-links from .md/.mdx |
| `cobol` | `cobol.ts` | `structure` | Regex-based COBOL/JCL extraction |
| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Chunked tree-sitter parse, import/call/heritage resolution |
| `routes` | `routes.ts` | `parse` | Route registry (Next.js, Expo, PHP, decorator-based) |
| `tools` | `tools.ts` | `parse` | MCP/RPC tool detection |
| `orm` | `orm.ts` | `parse` | Prisma/Supabase ORM query edges |
| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological order |
| `mro` | `mro.ts` | `crossFile` | Method Resolution Order, METHOD_OVERRIDES edges |
| `communities` | `communities.ts` | `mro` | Leiden community detection |
| `processes` | `processes.ts` | `communities`, `routes`, `tools` | Execution flow detection, Route/Tool → Process links |
**Non-phase files in the same directory:** `parse-impl.ts`, `cross-file-impl.ts` (implementation), `wildcard-synthesis.ts` (whole-module import expansion), `orm-extraction.ts` (sequential ORM fallback), `types.ts`, `runner.ts`, `index.ts`.
### DAG runner
`runner.ts` — static phase graph, no plugins, compile-time type safety.
1. **Validation** — Kahn's topological sort. Rejects on: duplicate names, missing deps, cycles (DFS traces the concrete cycle path, e.g., `A -> B -> C -> A`, plus count of transitively blocked dependents).
2. **Execution** — sequential in topological order. Each phase receives:
- `ctx: PipelineContext` — shared mutable `KnowledgeGraph`, `repoPath`, progress callback, options
- `deps: ReadonlyMap<string, PhaseResult>` — **declared deps only** (runner filters the results map to prevent hidden coupling)
3. **Error handling** — wraps phase errors with the phase name, emits terminal `error` progress event, swallows progress handler errors to preserve the original cause.
4. **Timing** — per-phase `durationMs` in `PhaseResult`, dev-mode console logging.
**Design patterns:**
- **Single graph accumulator** — all phases mutate the same `KnowledgeGraph` in `ctx`; the graph is the primary output.
- **Typed phase access** — `getPhaseOutput<T>(deps, 'name')` for type-safe upstream results.
- **Binding accumulator lifecycle** — created in `parse`, disposed by `crossFile` (in `finally`). No other phase should take ownership.
- **Skippable phases** — `skipGraphPhases` omits MRO/communities/processes (faster tests). `skipWorkers` forces sequential parsing.
### How to add a new phase
1. Create a new file in `pipeline-phases/` (e.g. `my-phase.ts`)
2. Define a `PipelinePhase<MyOutput>` object with `name`, `deps`, and `execute(ctx, deps)`
3. Export it from `pipeline-phases/index.ts`
4. Add it to the `buildPhaseList()` function in `pipeline.ts`
1. Create `pipeline-phases/my-phase.ts` with a `PipelinePhase<MyOutput>` (name, deps, execute)
2. Export from `pipeline-phases/index.ts`
3. Add to `buildPhaseList()` in `pipeline.ts`
```typescript
// pipeline-phases/my-phase.ts
import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js';
import type { PipelinePhase, PhaseResult } from './types.js';
import { getPhaseOutput } from './types.js';
import type { ParseOutput } from './parse.js';
@@ -101,81 +131,235 @@ export interface MyPhaseOutput { /* ... */ }
export const myPhase: PipelinePhase<MyPhaseOutput> = {
name: 'myPhase',
deps: ['parse'], // runs after parse completes
deps: ['parse'],
async execute(ctx, deps) {
const { allPaths } = getPhaseOutput<ParseOutput>(deps, 'parse');
// ... do work, write to ctx.graph ...
// ... write to ctx.graph ...
return { /* typed output */ };
},
};
```
### DAG runner
---
The runner (`pipeline-phases/runner.ts`) validates the DAG at startup (detects cycles and missing deps via topological sort), then executes phases in dependency order. Each phase receives:
- `ctx: PipelineContext` — shared graph, repoPath, progress callback
- `deps: Map<string, PhaseResult>` — outputs from all upstream phases
## Call-Resolution DAG
Typed 6-stage pipeline in `call-processor.ts` (inside the `parse` phase) that resolves method/function calls and emits CALLS edges. Language behavior plugs in at two `LanguageProvider` hook points (stages 3–4); shared code names no languages. Scope: call resolution only — import resolution, type extraction, heritage, and symbol-table population live in other phases.
### Stages
```
extract-call ──▶ classify-form ──▶ infer-receiver ──▶ select-dispatch ──▶ resolve-target ──▶ emit-edge
(1) (2) (3) [hook] (4) [hook] (5) (6)
```
| Stage | Produces | Location |
|-------|----------|----------|
| **extract-call** | `ExtractedCallSite` (name, form, receiver, argCount) | `call-extractors/` (per-language); runs in worker |
| **classify-form** | callForm (`free`/`member`/`constructor`) + arity | `call-analysis.ts` → `inferCallForm`; shared, runs in worker |
| **infer-receiver** | `ReceiverEnriched` (receiver type finalized) | `call-processor.ts`; shared default chain, then `inferImplicitReceiver` hook |
| **select-dispatch** | `DispatchDecision` (primary, fallback, ancestryView) | `selectDispatch` hook, falls back to shared default |
| **resolve-target** | `TieredCandidates` | `model/resolve.ts` → `lookupMethodByOwnerWithMRO` (MRO walk) |
| **emit-edge** | CALLS edge in graph | `call-processor.ts`; writes edge with confidence tier |
### Provider hooks
Both hooks are optional on `LanguageProvider`. Ruby is the only current implementer.
**`inferImplicitReceiver`** — called after shared infer-receiver defaults. Returns `ImplicitReceiverOverride | null`.
| | |
|---|---|
| Inputs | `calledName`, `callForm`, `receiverName`, `receiverTypeName`, `callNode` (AST), `filePath` |
| Non-null fields | `callForm`, `receiverName`, `receiverTypeName` (required); `receiverSource: 'implicit-self'` (fixed); `hint?` (opaque, passed to `selectDispatch`) |
| Null | Keep existing `ReceiverEnriched` state |
**`selectDispatch`** — called after infer-receiver (including hook). Returns `DispatchDecision | null`; null uses shared default (constructor → `primary:'constructor'`; typed receiver → `primary:'owner-scoped'`; else → `primary:'free'`).
| | |
|---|---|
| Inputs | `calledName`, `callForm`, `receiverName`, `receiverTypeName`, `receiverSource`, `hint` |
| Non-null fields | `primary: 'owner-scoped' \| 'free' \| 'constructor'`; `fallback?: 'free-arity-narrowed'`; `ancestryView?: 'instance' \| 'singleton'`; `hint?` |
**`DispatchDecision` field semantics:**
- `primary: 'owner-scoped'` — MRO walk from receiver's type; used when receiver type is known.
- `fallback: 'free-arity-narrowed'` — after owner-scoped miss, search free-call candidates by arity only (Ruby uses this for implicit-self calls that miss their owner's MRO).
- `ancestryView: 'singleton'` — walk singleton/class ancestry instead of instance ancestry (Ruby `def self.foo` bodies, so `extend`-ed methods are found).
### Adding language behavior
1. **Implicit receivers** — implement `inferImplicitReceiver`: return null if call already has a receiver; otherwise use `findEnclosingClassInfo` (`ast-helpers.ts`) to find the enclosing context, return `ImplicitReceiverOverride` with `receiverSource: 'implicit-self'`, and optionally set `hint` for `selectDispatch`.
2. **Custom dispatch** — implement `selectDispatch`: inspect `receiverSource` and `hint`, return `DispatchDecision` with `primary`, optional `fallback`, optional `ancestryView`; return null to keep shared defaults.
3. **MRO strategy** — confirm `mroStrategy` is `'first-wins'`, `'c3'`, `'ruby-mixin'`, or `'none'`; consumed by `lookupMethodByOwnerWithMRO`.
**Ruby example** (`languages/ruby.ts` + `utils/ruby-self-call.ts`): `inferImplicitReceiver` rewrites bare-identifier calls to `self.method` and sets `hint` to `'instance'`/`'singleton'`; `selectDispatch` uses hint for `ancestryView` and adds `fallback: 'free-arity-narrowed'` for implicit-self calls.
### Code references
| Module | Purpose |
|--------|---------|
| `core/ingestion/call-types.ts` | DAG types: `ReceiverEnriched`, `DispatchDecision`, `ImplicitReceiverOverride` |
| `core/ingestion/language-provider.ts` | Hook signatures: `inferImplicitReceiver`, `selectDispatch` |
| `core/ingestion/call-processor.ts` | `processCalls`: stages 3–6 |
| `core/ingestion/model/resolve.ts` | `lookupMethodByOwnerWithMRO`: stage 5 MRO walk |
| `core/ingestion/languages/ruby.ts` | Both hooks + `mroStrategy: 'ruby-mixin'` |
| `core/ingestion/utils/ruby-self-call.ts` | Bare-call rewrite for `inferImplicitReceiver` |
---
## Language-agnostic graph feeding
16 languages → single unified graph. Four abstraction layers:
```
Unified Graph Schema (44 node types, 21 relationship types)
↑
Unified Resolution (3-tier name lookup + MRO walk)
↑
Language Providers (import semantics, type config, export checker, MRO strategy)
↑
Tree-Sitter Queries (per-language S-expressions, unified capture tags)
```
### Language providers
Each language implements `LanguageProvider` (`language-provider.ts`). Key fields:
| Field | Purpose |
|-------|---------|
| `id`, `extensions` | Language identity and file matching |
| `treeSitterQueries` | S-expression queries for AST extraction |
| `importSemantics` | `named` / `wildcard-leaf` / `wildcard-transitive` / `namespace` |
| `importResolver` | Language-specific path → file resolution |
| `exportChecker` | Public/exported symbol detection |
| `typeConfig` | Type annotation extraction rules |
| `mroStrategy` | `first-wins` / `c3` / `none` |
16 providers in `languages/index.ts` via `satisfies Record<SupportedLanguages, LanguageProvider>` — missing a language is a compile error.
### Unified capture tags
Per-language tree-sitter queries use different AST node names but produce the **same semantic capture tags**: `@definition.class`, `@definition.function`, `@call.name`, `@import.source`, `@heritage.extends`. Downstream extraction needs no language branching. Defined in `tree-sitter-queries.ts`.
### Import resolution
Per-language import resolution uses the **configs + factory** pattern (like call/method/class extractors). Each language declares an `ImportResolutionConfig` in `import-resolvers/configs/`, listing an ordered chain of `ImportResolverStrategy` functions. `createImportResolver()` (in `resolver-factory.ts`) composes them: first non-null result wins. Low-level helpers shared across strategies live alongside the configs in `import-resolvers/` (e.g. `go.ts`, `rust.ts`, `python.ts`).
Unified 3-tier algorithm (`model/resolution-context.ts`), per-language `importSemantics` controls which tier activates:
| Tier | Confidence | Mechanism |
|------|-----------|-----------|
| 1 — same-file | 0.95 | Symbol table for caller's file |
| 2 — import-scoped | 0.9 | `NamedImportMap` chains (named) or all files in `importMap` (wildcard) |
| 3 — global | 0.5 | O(1) index lookups: class, impl, callable. Fallback only |
| Import strategy | Languages | Behavior |
|----------------|-----------|----------|
| `named` | TS, JS, Java, C#, Rust, PHP, Kotlin | Only explicitly imported names visible |
| `wildcard-leaf` | Go, Ruby, Swift, Dart | Whole-package import, no transitive re-exports |
| `wildcard-transitive` | C, C++ | `#include` closure chains through re-exports |
| `namespace` | Python | Module aliases resolved at call site |
### Chunked parse-and-resolve
`parse` processes files in ~20 MB byte-budget chunks to bound memory. Per chunk:
1. Worker pool dispatches files (or sequential fallback via `skipWorkers`)
2. Each worker: detect language → load grammar → run queries → return unified `ParseWorkerResult`
3. Synthesize wildcard bindings (`wildcard-synthesis.ts`)
4. Resolve imports and heritage
5. Collect `BindingAccumulator` entries for cross-file propagation
Workers: `workers/worker-pool.ts`, `workers/parse-worker.ts`.
### Heritage and MRO
All languages emit unified `ExtractedHeritage` (child, parent, `EXTENDS`/`IMPLEMENTS`). MRO phase walks the heritage graph using per-language strategy:
- **`first-wins`** — Java, C#, C++, TS, Ruby, Go
- **`c3`** — Python (C3 linearization)
- **`none`** — single-inheritance languages
Unified walk: `lookupMethodByOwnerWithMRO()` in `model/resolve.ts`.
---
## Full analysis flow
`runFullAnalysis` in `run-analyze.ts` orchestrates everything around the pipeline:
```
CLI (analyze.ts) → runFullAnalysis(repoPath, options, callbacks)
1. Early exit if lastCommit == HEAD (unless --force) [0%]
2. Cache existing embeddings from prior index [0%]
3. runPipelineFromRepo() → KnowledgeGraph [0-60%]
4. Clean up legacy KuzuDB files [60%]
5. initLbug() → loadGraphToLbug() via CSV streaming [60-85%]
6. Create FTS indexes (File, Function, Class, Method...) [85-90%]
7. Restore cached embeddings (batch insert) [88%]
8. Generate new embeddings if --embeddings [90-98%]
9. Save metadata + register repo + update .gitignore [98-100%]
10. Generate AI context files (AGENTS.md, CLAUDE.md) [100%]
```
**Options:** `--force` (rebuild regardless), `--embeddings` (opt-in, skipped if >50k nodes), `--skipGit`, `--noStats`.
## Storage
```
<repo>/.gitnexus/
├── lbug # LadybugDB database
├── lbug.wal # Write-ahead log
├── lbug.lock # Single-writer lock
└── meta.json # lastCommit, indexedAt, stats
~/.gitnexus/
└── registry.json # Global repo registry (MCP discovery)
```
Managed by `repo-manager.ts`.
## LadybugDB schema
Defined in `lbug/schema.ts`. Separate node tables per type, single `CodeRelation` table.
**Node tables:** File, Folder, Function, Class, Interface, Method, Constructor, CodeElement, Struct, Enum, Macro, Typedef, Union, Namespace, Trait, Impl, TypeAlias, Const, Static, Property, Record, Delegate, Annotation, Template, Module, Community, Process, Route, Tool, Section, Embedding.
**Relation types** (`CodeRelation.type`): CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, ACCESSES, METHOD_OVERRIDES, METHOD_IMPLEMENTS, MEMBER_OF, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF.
## Embeddings and search
**Embeddings** (`src/core/embeddings/`): Snowflake arctic-embed-xs (384D). Embeddable: File, Function, Class, Method, Interface. Incremental via SHA1 content hash. Separate `Embedding` table.
**Search** (`src/core/search/`): Hybrid BM25 + semantic vector, merged via Reciprocal Rank Fusion (K=60).
## Known limitations
### Overloaded method resolution
Method and Constructor node IDs include an arity suffix (`#<paramCount>`) to
disambiguate overloaded methods. Two overloads with different parameter counts
produce distinct graph nodes: `Method:file:Class.method#1` vs
`Method:file:Class.method#2`.
Node IDs use arity suffix (`#<paramCount>`): `Method:file:Class.method#1` vs `#2`.
**Same-arity overload disambiguation:** When two overloads share the same
parameter count but differ in types (e.g. `save(int)` vs `save(String)`), a
type-hash suffix `~type1,type2` is appended to produce distinct node IDs:
`Method:file:Class.save#1~int` vs `Method:file:Class.save#1~String`. The suffix
is only added when a same-arity collision is detected within a class and all
parameters have non-null type annotations. Languages without type info (Python,
Ruby, JS) fall back to arity-only IDs. TypeScript/JavaScript overload signatures
are intentionally excluded from type-hashing because they are declaration-only
contracts that should collapse to the implementation body's node ID. See issue
\#651.
**Same-arity disambiguation:** type-hash suffix `~type1,type2` when collision detected and type annotations present. Languages without types (Python, Ruby, JS) use arity-only. TS/JS overload signatures excluded (collapse to implementation body). See #651.
**C++ const-qualified overload disambiguation:** Methods overloaded by const
qualification (e.g. `begin()` vs `begin() const`) are disambiguated via an
`isConst` property and a `$const` ID suffix appended to the const-qualified
variant when a non-const collision exists. The `$const` suffix appears after the
type-hash suffix: e.g. `Method:file:Container.begin#0$const`.
**C++ const-qualified:** `$const` suffix after type-hash when non-const collision exists: `Method:file:Container.begin#0$const`.
**Generic/template type preservation in type-hash:** The type-hash suffix uses
`rawType` (full AST text including generic/template args) rather than the
simplified `type` from `extractSimpleTypeName`. This means C++ template overloads
like `process(vector<int>)` vs `process(vector<string>)` produce distinct IDs:
`~vector<int>` vs `~vector<std::string>`. Java generic overloads like
`process(List<String>)` vs `process(List<Integer>)` are a compile error due to
type erasure, so this gap is theoretical for Java.
**Generic/template types:** type-hash uses `rawType` (full AST text including generics): `~vector<int>` vs `~vector<std::string>`.
**ID stability on first overload:** Type and const tags are collision-only. When
a class has `save(int)` as its only `save` method, the ID is `save#1` (no tag).
Adding `save(String)` changes the original to `save#1~int`. This is correct for
fresh analysis but means IDs are not stable across overload additions. Future
incremental re-analysis should account for this.
**ID stability:** collision-only tags mean IDs change when overloads are added. `save#1` becomes `save#1~int` when `save(String)` is added.
**Variadic method matching:** When one side is variadic (`parameterCount`
undefined) and the other has a fixed count, `METHOD_IMPLEMENTS` edges are
emitted with confidence 0.7 instead of 1.0. Variadic methods like
`foo(String... args)` may superficially match `foo(String s)` by type but
are not guaranteed to be interchangeable across all languages (Java/Kotlin
accept this via varargs sugar; TypeScript, C#, Rust do not).
**Variadic matching:** confidence 0.7 when one side is variadic and the other has fixed count.
**Confidence tiering** for `METHOD_IMPLEMENTS` edges:
**METHOD_IMPLEMENTS confidence tiering:**
| Match quality | Confidence | When |
|---|---|---|
| Exact parameter types match | 1.0 | Both sides have `parameterTypes` arrays and they match |
| Arity (count) matches | 1.0 | Both sides have `parameterCount`, types unavailable |
| Variadic vs fixed | 0.7 | One side is variadic, other has fixed count |
| Lenient (insufficient info) | 0.7 | One or both sides lack type and count data |
| Match quality | Confidence |
|---|---|
| Exact parameter types match | 1.0 |
| Arity match, types unavailable | 1.0 |
| Variadic vs fixed | 0.7 |
| Insufficient info | 0.7 |
## Related docs
- [MIGRATION.md](MIGRATION.md) — breaking changes and migration guidance.
- [RUNBOOK.md](RUNBOOK.md) — operational commands and recovery.
- [GUARDRAILS.md](GUARDRAILS.md) — safety boundaries for humans and agents.
- [TESTING.md](TESTING.md) — how to run tests.
- `AGENTS.md` / `CLAUDE.md` — agent workflows and tool usage expectations for **this** repo when indexed by GitNexus.
- [MIGRATION.md](MIGRATION.md) — breaking changes and migration guidance
- [RUNBOOK.md](RUNBOOK.md) — operational commands and recovery
- [GUARDRAILS.md](GUARDRAILS.md) — safety boundaries for humans and agents
- [TESTING.md](TESTING.md) — how to run tests
- `AGENTS.md` / `CLAUDE.md` — agent workflows and tool usage
+2 -203
View File
@@ -35,6 +35,7 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g
## Reference Documentation
- **This repository:** [AGENTS.md](AGENTS.md) (Cursor + monorepo notes), [ARCHITECTURE.md](ARCHITECTURE.md), [CONTRIBUTING.md](CONTRIBUTING.md), [GUARDRAILS.md](GUARDRAILS.md).
- **Call-resolution DAG:** See ARCHITECTURE.md § Call-Resolution DAG. Shared pipeline code in `gitnexus/src/core/ingestion/` must not name languages — use `LanguageProvider` hooks instead (see AGENTS.md).
- **GitNexus:** `.claude/skills/gitnexus/`; MCP and indexed-repo rules live only in [AGENTS.md](AGENTS.md) (`gitnexus:start` … `gitnexus:end`). See **GitNexus rules** below.
## Changelog
@@ -50,206 +51,4 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g
## GitNexus rules
GitNexus MCP rules are in the `<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
## When Debugging
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
## Tools Quick Reference
| Tool | When to use | Command |
|------|-------------|---------|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
| `gitnexus://repo/GitNexus/processes` | All execution flows |
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
Before completing any code modification task, verify:
1. `gitnexus_impact` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
```bash
npx gitnexus analyze
```
If the index previously included embeddings, preserve them by adding `--embeddings`:
```bash
npx gitnexus analyze --embeddings
```
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
## CLI
| Task | Read this skill file |
|------|---------------------|
| 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` |
<!-- gitnexus:end -->` block in **[AGENTS.md](AGENTS.md)** — load that section when working with MCP tools or the graph index.
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus** (3298 symbols, 7954 relationships, 185 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
## When Debugging
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
## Tools Quick Reference
| Tool | When to use | Command |
|------|-------------|---------|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
| `gitnexus://repo/GitNexus/processes` | All execution flows |
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
Before completing any code modification task, verify:
1. `gitnexus_impact` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
```bash
npx gitnexus analyze
```
If the index previously included embeddings, preserve them by adding `--embeddings`:
```bash
npx gitnexus analyze --embeddings
```
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
## CLI
| Task | Read this skill file |
|------|---------------------|
| 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` |
<!-- gitnexus:end -->
See the `<!-- gitnexus:start --> … <!-- gitnexus:end -->` block in **[AGENTS.md](AGENTS.md)** for the canonical MCP tools, impact analysis rules, and index instructions.
+108 -11
View File
@@ -21,30 +21,127 @@ This project uses the [PolyForm Noncommercial License 1.0.0](https://polyformpro
## Branch and pull requests
- Use short-lived branches off the default branch of the repo you are targeting.
- Prefer **conventional commits** (short prefix + description), for example:
```text
feat: add graph export option
fix: correct MCP tool schema for query
test: cover cluster merge edge case
docs: clarify analyze flags
```
- **PR title:** `[area] Short description` (e.g. `[cli] Fix index refresh race`).
- **PR titles MUST follow the conventional-commit format** — `pr-labeler.yml` enforces this on every PR and auto-applies the matching label so release notes group the change correctly.
- **PR description:** what changed, why, how to verify (commands), and any risk or rollback notes.
### Pull request titles
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) |
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.
Examples:
```text
feat(web): add smart chat scroll
fix(extractors): resolve silent contract mis-resolution
perf: avoid O(n²) traversal in heritage walker
chore(deps): bump vitest to 3.0.0
ci: standardize workflow concurrency
```
Commits within a PR may use any style — only the **merged PR title** shows up in release notes, so that's the one the convention applies to.
## Before you open a PR
- [ ] Tests pass for the packages you touched (`gitnexus` and/or `gitnexus-web`).
- [ ] Typecheck passes: `npx tsc --noEmit` in `gitnexus/` and `npx tsc -b --noEmit` in `gitnexus-web/`.
- [ ] No secrets, tokens, or machine-specific paths committed.
- [ ] Documentation updated if behavior or public CLI/MCP contract changes.
- [ ] Pre-commit hook runs clean (`.husky/pre-commit` — typecheck + unit tests for staged packages).
- [ ] Pre-commit hook runs clean (`.husky/pre-commit` — formatting via lint-staged + typecheck for staged packages; tests run in CI only).
## Code review
Maintainers may request changes for correctness, tests, performance, or consistency with existing patterns. Keeping diffs focused makes review faster.
## GitHub Actions — Concurrency Convention
Every workflow under `.github/workflows/` MUST declare a top-level `concurrency:` block using this convention:
- **Group key** starts with `${{ github.workflow }}` so no two workflows can collide on the same group name. The discriminator that follows is chosen per event shape:
- Branch/tag scope: `${{ github.workflow }}-${{ github.ref }}`
- Per-PR scope (for `issue_comment`, `pull_request_review*`, `pull_request` meta events): `${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}`
- `workflow_run` scope (e.g. `ci-report.yml`): `${{ 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) }}` — the fork fallback must be stable across reruns (never `workflow_run.id`, which is per-run-unique and defeats serialization).
- Global single-slot (manual dispatch utilities): `${{ github.workflow }}`
- **Reusable workflows invoked via `workflow_call`:** do NOT use `${{ github.workflow }}` in the group key — in called-workflow context its evaluation is ambiguous and can resolve to the caller's name, which would deadlock against the caller's own group. Use a hardcoded literal prefix and a `github.event_name`-aware expression that falls through to `github.run_id` for reusable invocations (see `ci.yml` for the canonical form).
- **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 |
- 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:
```yaml
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
```
- When adding a new workflow, copy the concurrency block from an existing workflow of the same event shape.
## 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.
## Releases
Two publish workflows ship `gitnexus` to npm:
- **Stable** (`.github/workflows/publish.yml`) — triggered by pushing any `v*`
tag. Publishes to the `latest` dist-tag with a changelog-backed GitHub
release. Maintainers are expected to tag from `main` as a convention; the
workflow itself does not enforce branch reachability.
- **Release Candidate** (`.github/workflows/release-candidate.yml`) — runs on
every push to `main` (typically a merged PR) plus manual dispatch. Docs-only
changes are skipped via `paths-ignore`. Publishes to the `rc` dist-tag with
version `X.Y.Z-rc.N` and a GitHub prerelease, where:
- `X.Y.Z` is selected automatically. On push (and on dispatch with
`bump: auto`, the default) the workflow **continues the active rc cycle**:
if the registry already has `X.Y.Z-rc.*` versions with `X.Y.Z` > current
`latest`, it reuses the highest such base; otherwise it patch-bumps
from `latest`. Dispatching with `bump: patch|minor|major` **resets**
the cycle from `latest`.
- `N` is auto-incremented against existing `X.Y.Z-rc.*` entries on the
registry. First rc for a given base is `rc.1`.
Idempotency: the workflow pushes an `rc/<HEAD_SHA>` marker tag and a
`v<RC>` release tag **atomically, before** calling `npm publish`. The guard
refuses to re-run once the marker exists, so a post-publish failure will
not mint a duplicate rc for the same commit. The `v<RC>` tag points at a
detached release commit whose `package.json` matches the npm tarball
exactly (traceable releases). Recovery after a partial failure:
```bash
git push --delete origin rc/<HEAD_SHA> v<RC>
# then redispatch the workflow with force: true
```
The rc workflow never moves `latest`. To verify after a change, inspect dist-tags:
```bash
npm view gitnexus dist-tags
```
+36
View File
@@ -0,0 +1,36 @@
ARG BUILDPLATFORM
ARG TARGETPLATFORM
FROM --platform=$BUILDPLATFORM node:22-alpine AS builder
WORKDIR /app
COPY gitnexus-shared/package.json gitnexus-shared/package-lock.json ./gitnexus-shared/
RUN npm ci --prefix gitnexus-shared
COPY gitnexus-shared ./gitnexus-shared
RUN npm run build --prefix gitnexus-shared
COPY gitnexus/package.json ./gitnexus/package.json
COPY gitnexus-web/package.json gitnexus-web/package-lock.json ./gitnexus-web/
RUN npm ci --prefix gitnexus-web
COPY gitnexus-web ./gitnexus-web
RUN npm run build --prefix gitnexus-web
FROM node:22-alpine AS runtime
RUN apk add --no-cache curl
WORKDIR /app
COPY --from=builder /app/gitnexus-web/dist ./dist
COPY docker-server.mjs ./docker-server.mjs
RUN chown -R node:node /app
USER node
EXPOSE 4173
CMD ["node", "docker-server.mjs"]
+43 -46
View File
@@ -1,72 +1,69 @@
# Guardrails — GitNexus (repo + agents)
# Guardrails — GitNexus
Rules for **human contributors** and **AI agents** working on this codebase or publishing artifacts. These complement `AGENTS.md` / `CLAUDE.md` (which focus on GitNexus-in-GitNexus workflows).
Rules for **human contributors** and **AI agents**. Complements `AGENTS.md` (workflows) and `CONTRIBUTING.md` (PR process).
## Scope (typical agent session)
## Scope (least privilege)
When automating changes in this repository, treat scope as **least privilege**:
- **Read:** Source, tests, docs, public config as needed.
- **Write:** Only files required for the fix or feature; no unrelated formatting or refactors.
- **Execute:** Tests, typecheck, documented CLI commands. No destructive commands on user data without approval.
- **Off-limits:** Other people's machines, production deployments you don't own, credentials you lack permission to use.
- **Read:** Source, tests, docs, public config as needed for the task.
- **Write:** Only files required for the requested fix or feature; avoid unrelated formatting or refactors.
- **Execute:** Tests, typecheck, and documented CLI commands; do not run destructive commands on user data outside the repo without explicit approval.
- **Off-limits:** Other people’s machines, production deployments you don’t own, and credentials you didn’t receive permission to use.
Adjust explicitly if the maintainer defines a different scope for a task.
Maintainer may widen scope per task.
---
## Non-negotiables
1. **Never commit secrets** — API keys, tokens, `.env` with real values, private URLs, or session cookies. Use `.env.example` with placeholders only.
2. **Never rename symbols with blind find-and-replace** when working in a GitNexus-indexed project — use the **`rename` MCP tool** with **`dry_run: true` first**, then review `graph` vs `text_search` edits. (There is no separate `gitnexus rename` CLI; renaming goes through MCP or editor integration.)
3. **Run impact analysis before editing shared symbols** — use **`impact`** (upstream) for functions/classes/methods others call; do not ignore **HIGH** / **CRITICAL** risk without maintainer sign-off.
4. **Prefer `detect_changes` before commit** — confirm diffs map to expected symbols/processes when the graph is available.
5. **Preserve embeddings** — if `.gitnexus/meta.json` shows embeddings, run `npx gitnexus analyze --embeddings` when refreshing the index; plain `analyze` can drop them.
1. **Never commit secrets** — API keys, tokens, real `.env` values, private URLs, session cookies. Use `.env.example` with placeholders.
2. **Never rename with find-and-replace** in GitNexus-indexed projects — use `rename` MCP tool with `dry_run: true` first, review `graph` vs `text_search` edits. No separate `gitnexus rename` CLI exists.
3. **Run impact analysis before editing shared symbols** — `impact` (upstream) for functions/classes/methods others call. Do not ignore HIGH/CRITICAL without maintainer sign-off.
4. **Run `detect_changes` before commit** — confirm diffs map to expected symbols/processes when the graph is available.
5. **Preserve embeddings** — if `.gitnexus/meta.json` shows embeddings, use `npx gitnexus analyze --embeddings`; plain `analyze` drops them.
---
## Signs (recurring failure patterns)
Use this format: **Trigger → Instruction → Reason**.
Append new Signs here when the same mistake repeats (e.g. CI broken twice the same way).
Format: **Trigger → Instruction → Reason**. Append new Signs when the same mistake repeats.
### Sign: Stale graph after edits
### Stale graph after edits
- **Trigger:** MCP or resources warn the index is behind `HEAD`, or code search doesn’t match latest commit.
- **Instruction:** Run `npx gitnexus analyze` from the repo root (plus `--embeddings` if the project used them).
- **Reason:** Tools query LadybugDB built at last analyze; git changes are invisible until re-indexed.
- **Trigger:** MCP warns index is behind `HEAD`, or search doesn't match latest commit.
- **Do:** `npx gitnexus analyze` (plus `--embeddings` if used).
- **Why:** Tools query LadybugDB from last analyze; git changes are invisible until re-indexed.
### Sign: Embeddings vanished after analyze
### Embeddings vanished after analyze
- **Trigger:** Semantic search quality drops; `stats.embeddings` in `.gitnexus/meta.json` is 0 after a refresh.
- **Instruction:** Re-run `npx gitnexus analyze --embeddings` and confirm `meta.json` reflects stored embeddings.
- **Reason:** Embedding generation is opt-in; analyze without the flag does not preserve prior vectors.
- **Trigger:** Semantic search quality drops; `stats.embeddings` in `meta.json` is 0 after refresh.
- **Do:** `npx gitnexus analyze --embeddings`, confirm `meta.json` reflects stored embeddings.
- **Why:** Embedding generation is opt-in; analyze without the flag does not preserve prior vectors.
### Sign: MCP lists no repos
### MCP lists no repos
- **Trigger:** MCP stderr says no indexed repos.
- **Instruction:** Run `npx gitnexus analyze` in the target repository; verify `npx gitnexus list` shows it.
- **Reason:** The MCP server discovers repos via `~/.gitnexus/registry.json`, populated by analyze.
- **Trigger:** MCP stderr says no indexed repos.
- **Do:** `npx gitnexus analyze` in the target repo; verify `npx gitnexus list` shows it.
- **Why:** MCP discovers repos via `~/.gitnexus/registry.json`, populated by analyze.
### Sign: Wrong repo in multi-repo setups
### Wrong repo in multi-repo setups
- **Trigger:** Query/impact results clearly belong to another project.
- **Instruction:** Call `list_repos`, then pass **`repo`** on subsequent tools (or use per-workspace MCP config).
- **Reason:** Default target may be ambiguous when multiple repos are registered.
- **Trigger:** Query/impact results belong to another project.
- **Do:** Call `list_repos`, then pass `repo` on subsequent tools.
- **Why:** Default target is ambiguous when multiple repos are registered.
### Sign: LadybugDB lock / “database busy”
### LadybugDB lock / "database busy"
- **Trigger:** Errors opening `.gitnexus/lbug` while MCP and analyze both run.
- **Instruction:** Stop overlapping processes; one writer at a time. Retry analyze or restart MCP.
- **Reason:** Embedded DB expects single-process ownership of the store.
- **Trigger:** Errors opening `.gitnexus/lbug` while MCP and analyze both run.
- **Do:** Stop overlapping processes (one writer at a time). Retry analyze or restart MCP.
- **Why:** Embedded DB expects single-process ownership.
---
## Publishing & supply chain
- **npm:** Do not publish from unreviewed automation; follow maintainer release process. Bump version intentionally; tag releases to match `package.json`.
- **Dependencies:** Prefer minimal, auditable changes to `package.json`; run tests and CI after lockfile updates.
- **License:** This project ships under **PolyForm Noncommercial 1.0.0** — do not relicense or imply a different license in docs or metadata without maintainer approval.
- **npm:** Do not publish from unreviewed automation. Bump version intentionally; tag releases to match `package.json`.
- **Dependencies:** Minimal, auditable `package.json` changes; run tests and CI after lockfile updates.
- **License:** PolyForm Noncommercial 1.0.0 — do not relicense without maintainer approval.
---
@@ -74,15 +71,15 @@ Append new Signs here when the same mistake repeats (e.g. CI broken twice the sa
Stop and ask a **human maintainer** when:
- Impact analysis shows **HIGH** / **CRITICAL** risk and the task still requires the change.
- You need to alter **CI**, **release**, or **security-sensitive** config.
- Requirements conflict (e.g. “speed up analyze” vs “must keep all embeddings on huge repo”).
- Impact analysis shows HIGH/CRITICAL risk and the task still requires the change.
- You need to alter CI, release, or security-sensitive config.
- Requirements conflict (e.g. "speed up analyze" vs "must keep all embeddings on huge repo").
- You are unsure whether data loss is acceptable (`clean`, forced migrations, schema changes).
---
## Related docs
- [ARCHITECTURE.md](ARCHITECTURE.md) — components and data flow.
- [RUNBOOK.md](RUNBOOK.md) — commands for recovery.
- [CONTRIBUTING.md](CONTRIBUTING.md) — PR and commit expectations.
- [ARCHITECTURE.md](ARCHITECTURE.md) — components and data flow
- [RUNBOOK.md](RUNBOOK.md) — commands for recovery
- [CONTRIBUTING.md](CONTRIBUTING.md) — PR and commit expectations
+44
View File
@@ -1,5 +1,49 @@
# Migration Guide
## `impact` tool may now return `{ status: 'ambiguous' }` (PR #888, issue #470)
Before this change the `impact` MCP tool silently picked the first match
when the `target` name hit multiple symbols (Class → Interface → Function
→ Method → Constructor priority UNION). This often produced analysis for
the wrong symbol with no signal back to the caller.
After this change, when the resolver finds more than one viable match
and the caller supplied none of `target_uid` / `file_path` / `kind`,
`impact` returns a disambiguation response shaped like:
```json
{
"status": "ambiguous",
"message": "Found N symbols matching '<target>'. Use target_uid, file_path, or kind to disambiguate.",
"target": { "name": "<target>" },
"direction": "upstream",
"impactedCount": 0,
"risk": "UNKNOWN",
"candidates": [
{ "uid": "...", "name": "...", "kind": "Function", "filePath": "...", "line": 42, "score": 0.76 }
]
}
```
### Do I need to migrate?
**Probably not, but check for assumptions.** Callers that unconditionally
read `result.byDepth` / `result.summary` / `result.affected_processes`
without first checking `result.status` will now see `undefined` in the
ambiguous case. The fix is to branch on `result.status === 'ambiguous'`
first and follow up with `target_uid` (preferred) or `file_path` / `kind`.
The `context` tool's ambiguous response is a strict superset of the
existing shape — every candidate gains a `score` field, no existing field
has changed. No migration required for `context` callers.
### What happens on re-index?
Nothing — this is an MCP-surface change only. The graph schema, indexer,
and stored data are untouched.
---
## OVERRIDES → METHOD_OVERRIDES (PR #642)
The `OVERRIDES` relationship type has been renamed to `METHOD_OVERRIDES` for
+36
View File
@@ -335,6 +335,42 @@ cd ../gitnexus-web && npm install
npm run dev
```
## Docker
```bash
docker run --rm \
--name gitnexus \
-p 4173:4173 \
ghcr.io/abhigyanpatwari/gitnexus:latest
```
Or with Docker Compose:
```bash
docker compose up -d
```
Optional env file:
```bash
cp .env.example .env
set -a
source .env
set +a
```
Docker files:
- [Dockerfile](Dockerfile) is the source for the published `gitnexus` image. It builds `gitnexus-shared` and `gitnexus-web`, then serves the production frontend.
- [docker-compose.yaml](docker-compose.yaml) starts the published image with Docker Compose.
- [.env.example](.env.example) sets the image name, container name, and exposed port for the example commands.
Notes:
- The published image serves the production frontend only. It does not start `gitnexus serve`.
- In backend mode, the app still defaults to `http://localhost:4747` unless you change the server URL in the UI.
- If you do not want an env file, the defaults are `ghcr.io/abhigyanpatwari/gitnexus:latest`, container name `gitnexus`, and port `4173`.
The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAssembly (Tree-sitter WASM, LadybugDB WASM, in-browser embeddings). It's great for quick exploration but limited by browser memory for larger repos.
**Local Backend Mode:** Run `gitnexus serve` and open the web UI locally — it auto-detects the server and shows all your indexed repos, with full AI chat support. No need to re-upload or re-index. The agent's tools (Cypher queries, search, code navigation) route through the backend HTTP API automatically.
+8 -5
View File
@@ -20,9 +20,9 @@ From repository root, unless noted:
cd gitnexus
npm install
npm run build
npm test # unit: vitest run test/unit
npm test # full suite: vitest run
npm run test:unit # unit only: vitest run test/unit
npm run test:integration # integration suite
npm run test:all
npm run test:coverage
npx tsc --noEmit # typecheck (matches CI)
```
@@ -42,8 +42,11 @@ npm run test:e2e # Playwright (requires gitnexus serve + npm run dev)
A husky pre-commit hook (`.husky/pre-commit`) runs automatically on every `git commit`:
- **`gitnexus-web/` files staged** → `tsc -b --noEmit` + `vitest run`
- **`gitnexus/` files staged** → `tsc --noEmit` + `vitest run --project default`
1. **Formatting** — `lint-staged` runs prettier on staged files
2. **`gitnexus-web/` files staged** → `tsc -b --noEmit`
3. **`gitnexus/` files staged** → `tsc --noEmit`
Tests do **not** run in the pre-commit hook — they run in CI (`ci-tests.yml`) only.
Skip with `git commit --no-verify` (use sparingly).
@@ -77,7 +80,7 @@ Re-run the full relevant suite when:
GitHub Actions (`.github/workflows/ci.yml`) orchestrate:
- **`ci-quality.yml`** — `tsc --noEmit` for `gitnexus/` + `tsc -b --noEmit` for `gitnexus-web/`
- **`ci-quality.yml`** — prettier format check, eslint lint, `tsc --noEmit` for `gitnexus/`, `tsc -b --noEmit` for `gitnexus-web/`
- **`ci-tests.yml`** — `vitest run` with coverage (ubuntu) + cross-platform (macOS, Windows)
- **`ci-e2e.yml`** — Playwright E2E tests, gated on `gitnexus-web/**` changes
+13
View File
@@ -0,0 +1,13 @@
services:
gitnexus:
image: ${IMAGE_NAME:-ghcr.io/brainifii/gitnexus:latest}
container_name: ${CONTAINER_NAME:-gitnexus}
ports:
- '${HOST_PORT:-4173}:4173'
restart: unless-stopped
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:4173/']
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
+81
View File
@@ -0,0 +1,81 @@
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { createServer } from 'node:http';
import { extname, join, normalize, sep } from 'node:path';
const host = '0.0.0.0';
const port = Number(process.env.PORT || '4173');
const root = join(process.cwd(), 'dist');
const contentTypes = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.txt': 'text/plain; charset=utf-8',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
};
function resolvePath(urlPath) {
let decoded;
try {
decoded = decodeURIComponent(urlPath);
} catch {
return null;
}
if (decoded.includes('\0')) return null;
const cleanPath = normalize(decoded.replace(/^\/+/, ''));
const candidate = join(root, cleanPath);
if (candidate !== root && !candidate.startsWith(root + sep)) return null;
return candidate;
}
const server = createServer(async (req, res) => {
const requestPath = req.url?.split('?')[0] || '/';
let filePath = resolvePath(requestPath);
if (!filePath) {
res.writeHead(400);
res.end('Bad request');
return;
}
try {
const fileStat = await stat(filePath).catch(() => null);
if (fileStat?.isDirectory()) {
filePath = join(filePath, 'index.html');
} else if (!fileStat?.isFile()) {
filePath = join(root, 'index.html');
}
const finalStat = await stat(filePath).catch(() => null);
if (!finalStat?.isFile()) {
res.writeHead(404);
res.end('Not found');
return;
}
res.writeHead(200, {
'Cache-Control': filePath.includes('/assets/')
? 'public, max-age=31536000, immutable'
: 'no-cache',
'Content-Type': contentTypes[extname(filePath)] || 'application/octet-stream',
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
});
const stream = createReadStream(filePath);
stream.on('error', () => res.destroy());
stream.pipe(res);
} catch (error) {
res.writeHead(500);
res.end(error instanceof Error ? error.message : 'Internal server error');
}
});
server.listen(port, host, () => {
console.log(`gitnexus-web listening on http://${host}:${port}`);
});
+107
View File
@@ -0,0 +1,107 @@
import { mkdir, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises';
import http, { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { after, before, it } from 'node:test';
import assert from 'node:assert/strict';
const __dirname = dirname(fileURLToPath(import.meta.url));
const serverScript = join(__dirname, 'docker-server.mjs');
function getFreePort() {
return new Promise((resolve) => {
const s = createServer();
s.listen(0, '127.0.0.1', () => {
const { port } = s.address();
s.close(() => resolve(port));
});
});
}
function rawGet(port, path) {
return new Promise((resolve, reject) => {
const req = http.request({ host: '127.0.0.1', port, path }, (res) => {
let body = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
});
req.on('error', reject);
req.end();
});
}
async function waitForServer(port, retries = 30) {
for (let i = 0; i < retries; i++) {
try {
await rawGet(port, '/');
return;
} catch {
await new Promise((r) => setTimeout(r, 100));
}
}
throw new Error('Server did not start in time');
}
let tmpDir, serverPort, child;
before(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gitnexus-docker-test-'));
const distDir = join(tmpDir, 'dist');
const assetsDir = join(distDir, 'assets');
await mkdir(assetsDir, { recursive: true });
await writeFile(join(distDir, 'index.html'), '<html><body>spa</body></html>');
await writeFile(join(assetsDir, 'app.abc123.js'), 'console.log("app")');
serverPort = await getFreePort();
child = spawn(process.execPath, [serverScript], {
cwd: tmpDir,
env: { ...process.env, PORT: String(serverPort) },
stdio: 'pipe',
});
child.on('error', (err) => {
throw err;
});
await waitForServer(serverPort);
});
after(async () => {
child?.kill();
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
});
it('serves a valid asset with immutable cache header', async () => {
const res = await rawGet(serverPort, '/assets/app.abc123.js');
assert.equal(res.status, 200);
assert.match(res.headers['cache-control'], /immutable/);
assert.equal(res.headers['cross-origin-opener-policy'], 'same-origin');
assert.equal(res.headers['cross-origin-embedder-policy'], 'require-corp');
});
it('serves SPA fallback for unknown routes', async () => {
const res = await rawGet(serverPort, '/some/unknown/route');
assert.equal(res.status, 200);
assert.match(res.body, /spa/);
assert.match(res.headers['cache-control'], /no-cache/);
});
it('rejects path traversal with 400', async () => {
const res = await rawGet(serverPort, '/../../../etc/passwd');
assert.equal(res.status, 400);
});
it('rejects percent-encoded null bytes with 400', async () => {
const res = await rawGet(serverPort, '/foo%00bar');
assert.equal(res.status, 400);
});
it('returns 404 when dist/index.html is missing', async () => {
await unlink(join(tmpDir, 'dist', 'index.html'));
const res = await rawGet(serverPort, '/nonexistent-page');
assert.equal(res.status, 404);
});
+118
View File
@@ -23,3 +23,121 @@ export type { MroStrategy } from './mro-strategy.js';
// Pipeline progress
export type { PipelinePhase, PipelineProgress } from './pipeline.js';
// ─── Scope-based resolution — RFC #909 (Ring 1 #910) ────────────────────────
// Data model (RFC §2)
export type { SymbolDefinition } from './scope-resolution/symbol-definition.js';
export type {
ScopeId,
DefId,
ScopeKind,
Range,
Capture,
CaptureMatch,
BindingRef,
ImportEdge,
TypeRef,
Scope,
ResolutionEvidence,
Resolution,
Reference,
ReferenceIndex,
LookupParams,
RegistryContributor,
ParsedImport,
ParsedTypeBinding,
WorkspaceIndex,
Callsite,
} from './scope-resolution/types.js';
// Evidence + tie-break constants (RFC Appendix A, Appendix B)
export { EvidenceWeights, typeBindingWeightAtDepth } from './scope-resolution/evidence-weights.js';
export { ORIGIN_PRIORITY } from './scope-resolution/origin-priority.js';
export type { OriginForTieBreak } from './scope-resolution/origin-priority.js';
// Language classification (RFC §6.1 Ring 3/4 governance)
export {
LanguageClassifications,
isProductionLanguage,
} from './scope-resolution/language-classification.js';
export type { LanguageClassification } from './scope-resolution/language-classification.js';
// Core indexes over per-file artifacts (RFC §3.1; Ring 2 SHARED #913)
export { buildDefIndex } from './scope-resolution/def-index.js';
export type { DefIndex } from './scope-resolution/def-index.js';
export { buildModuleScopeIndex } from './scope-resolution/module-scope-index.js';
export type { ModuleScopeIndex, ModuleScopeEntry } from './scope-resolution/module-scope-index.js';
export { buildQualifiedNameIndex } from './scope-resolution/qualified-name-index.js';
export type { QualifiedNameIndex } from './scope-resolution/qualified-name-index.js';
// Strict type-reference resolver (RFC §4.6; Ring 2 SHARED #916)
export { resolveTypeRef } from './scope-resolution/resolve-type-ref.js';
export type { ResolveTypeRefContext, ScopeLookup } from './scope-resolution/resolve-type-ref.js';
// Method-dispatch materialized view over HeritageMap (RFC §3.1; Ring 2 SHARED #914)
export { buildMethodDispatchIndex } from './scope-resolution/method-dispatch-index.js';
export type {
MethodDispatchIndex,
MethodDispatchInput,
} from './scope-resolution/method-dispatch-index.js';
// SCC-aware cross-file finalize (RFC §3.2 Phase 2; Ring 2 SHARED #915)
export { finalize } from './scope-resolution/finalize-algorithm.js';
export type {
FinalizeInput,
FinalizeFile,
FinalizeHooks,
FinalizeOutput,
FinalizedScc,
FinalizeStats,
} from './scope-resolution/finalize-algorithm.js';
// Scope-aware registries + 7-step lookup (RFC §4; Ring 2 SHARED #917)
export { buildClassRegistry } from './scope-resolution/registries/class-registry.js';
export type { ClassRegistry } from './scope-resolution/registries/class-registry.js';
export { buildMethodRegistry } from './scope-resolution/registries/method-registry.js';
export type {
MethodRegistry,
MethodLookupOptions,
} from './scope-resolution/registries/method-registry.js';
export { buildFieldRegistry } from './scope-resolution/registries/field-registry.js';
export type {
FieldRegistry,
FieldLookupOptions,
} from './scope-resolution/registries/field-registry.js';
export { lookupCore } from './scope-resolution/registries/lookup-core.js';
export type { CoreLookupParams } from './scope-resolution/registries/lookup-core.js';
export { lookupQualified } from './scope-resolution/registries/lookup-qualified.js';
export type { LookupQualifiedParams } from './scope-resolution/registries/lookup-qualified.js';
export { composeEvidence, confidenceFromEvidence } from './scope-resolution/registries/evidence.js';
export type { RawSignals } from './scope-resolution/registries/evidence.js';
export {
compareByConfidenceWithTiebreaks,
CONFIDENCE_EPSILON,
} from './scope-resolution/registries/tie-breaks.js';
export type { TieBreakKey } from './scope-resolution/registries/tie-breaks.js';
export { CLASS_KINDS, METHOD_KINDS, FIELD_KINDS } from './scope-resolution/registries/context.js';
export type {
RegistryContext,
RegistryProviders,
OwnerScopedContributor,
ArityVerdict,
} from './scope-resolution/registries/context.js';
// Scope tree spine + position lookup (RFC §2.2 + §3.1; Ring 2 SHARED #912)
export { makeScopeId, clearScopeIdInternPool } from './scope-resolution/scope-id.js';
export type { ScopeIdInput } from './scope-resolution/scope-id.js';
export { buildScopeTree, ScopeTreeInvariantError } from './scope-resolution/scope-tree.js';
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';
// Shadow-mode diff + aggregation (RFC §6.3; Ring 2 SHARED #918)
export { diffResolutions } from './scope-resolution/shadow/diff.js';
export type {
ShadowAgreement,
ShadowCallsite,
ShadowDiff,
} from './scope-resolution/shadow/diff.js';
export { aggregateDiffs } from './scope-resolution/shadow/aggregate.js';
export type { LanguageParityRow, ShadowParityReport } from './scope-resolution/shadow/aggregate.js';
@@ -30,6 +30,7 @@ export const NODE_TABLES = [
'TypeAlias',
'Const',
'Static',
'Variable',
'Property',
'Record',
'Delegate',
+37 -14
View File
@@ -1,23 +1,46 @@
/**
* MRO (Method Resolution Order) strategy — shared between CLI and any
* future consumer that reasons about multiple-inheritance semantics.
* MRO (Method Resolution Order) strategy — shared canonical definition.
*
* Lives in `gitnexus-shared` so the low-level resolution module
* (`core/ingestion/model/resolve.ts`) does not need to import from
* `languages/` — keeping the `model/` layer free of language-registry
* coupling.
* Lives in `gitnexus-shared` so `model/resolve.ts` and `mro-processor.ts` share
* the type without importing the language registry (avoids circular coupling).
*
* Strategy semantics:
* - `first-wins`: BFS ancestor walk, first match wins (default).
* - `leftmost-base`: BFS ancestor walk, leftmost base wins (C++).
* - `c3`: C3-linearized ancestor order, first match wins (Python).
* - `implements-split`: BFS walk, first match wins (Java/C#/Kotlin) — full
* interface-default ambiguity is handled at graph level.
* - `qualified-syntax`: No auto-resolution (Rust — requires `<T as Trait>::m`).
* `first-wins` (default, Java/C#/Kotlin/Go/Swift/Dart):
* BFS ancestor walk in declaration order; first match wins.
*
* `leftmost-base` (C++):
* BFS walk; HeritageMap preserves source insertion order, so BFS naturally
* picks the leftmost base in diamond inheritance.
*
* `c3` (Python):
* C3-linearization; falls back to BFS on cyclic/inconsistent hierarchy.
* See model/resolve.ts § c3Linearize.
*
* `implements-split` (Java/C#/Kotlin):
* Low-level lookup is BFS; graph-level mro-processor detects and warns on
* interface-default method ambiguity.
*
* `qualified-syntax` (Rust):
* No auto-resolution — `lookupMethodByOwnerWithMRO` returns undefined immediately.
* Rust requires explicit `<Type as Trait>::method` syntax.
*
* `ruby-mixin` (Ruby):
* Kind-aware walk that does NOT short-circuit on direct owner first (`prepend`
* must beat the class's own method). Walk order:
* 1. Prepend providers (reverse declaration — last-prepended wins)
* 2. Direct owner's own methods
* 3. Include providers (reverse declaration)
* 4. Transitive ancestors (BFS fallback)
* Singleton dispatch: caller passes `ancestryOverride` (extend providers only);
* becomes a simple left-to-right scan. Miss NEVER falls through to file-scoped
* lookup — null-routes or honors `fallback`.
*
* @see model/resolve.ts § lookupMethodByOwnerWithMRO
* @see languages/ruby.ts § selectDispatch
*/
export type MroStrategy =
| 'first-wins'
| 'c3'
| 'leftmost-base'
| 'implements-split'
| 'qualified-syntax';
| 'qualified-syntax'
| 'ruby-mixin';
@@ -0,0 +1,62 @@
/**
* `DefIndex` — O(1) `DefId → SymbolDefinition` materialization.
*
* The global "what is this id?" lookup. Every per-kind registry (ClassRegistry,
* MethodRegistry, FieldRegistry) returns `DefId[]` and resolves them back to
* full `SymbolDefinition` records through this index — one central hash map,
* one allocation per def.
*
* Part of RFC #909 Ring 2 SHARED — #913.
*
* Consumed by: #917 (`Registry.lookup` implementations), #915 (SCC finalize).
*/
import type { SymbolDefinition } from './symbol-definition.js';
import type { DefId } from './types.js';
export interface DefIndex {
readonly byId: ReadonlyMap<DefId, SymbolDefinition>;
readonly size: number;
get(id: DefId): SymbolDefinition | undefined;
has(id: DefId): boolean;
}
/**
* Build a `DefIndex` from a flat list of `SymbolDefinition` records.
*
* **Collision policy: first-write-wins.** `DefId` is meant to be unique
* (`nodeId` is the stable graph identifier), so a collision indicates an
* upstream bug — most likely the same symbol parsed twice or a duplicate
* commit into the pipeline. Rather than silently overwriting with a later
* definition that may be partial or wrong, the first record wins and
* subsequent records for the same id are dropped. Pipeline bugs surface
* later as `has(id) === true` but the def looking older than expected,
* which is easier to debug than a silent overwrite.
*
* Pure function — safe to call repeatedly; no side effects.
*/
export function buildDefIndex(defs: readonly SymbolDefinition[]): DefIndex {
const byId = new Map<DefId, SymbolDefinition>();
for (const def of defs) {
if (byId.has(def.nodeId)) continue; // first-write-wins
byId.set(def.nodeId, def);
}
return freezeIndex(byId);
}
// ─── Internal ───────────────────────────────────────────────────────────────
function freezeIndex(byId: Map<DefId, SymbolDefinition>): DefIndex {
return {
byId,
get size() {
return byId.size;
},
get(id: DefId): SymbolDefinition | undefined {
return byId.get(id);
},
has(id: DefId): boolean {
return byId.has(id);
},
};
}
@@ -0,0 +1,90 @@
/**
* `EvidenceWeights` — RFC Appendix A (authoritative values).
*
* Starting calibration for scope-based resolution. Shadow-first rollout
* tunes these against legacy DAG parity. Every `ResolutionEvidence.weight`
* value in the codebase MUST reference this map; inline magic numbers are a
* lint violation. Extends issue #429 (centralize hardcoded confidence values).
*
* Evidence composes additively inside `composeEvidence`; the sum is capped
* at 1.0 in `Resolution.confidence`.
*/
/**
* Authoritative weight map. Keys are a mix of `ResolutionEvidence.kind`
* values and special modifiers (scope-chain depth, MRO depth decay,
* unlinked-import multiplicative cap).
*/
export const EvidenceWeights = {
// ─── Where-found signals (visibility) ─────────────────────────────────────
/** `BindingRef.origin === 'local'` */
local: 0.55,
/** `BindingRef.origin === 'import'` */
import: 0.45,
/** `BindingRef.origin === 'reexport'` */
reexport: 0.4,
/** `BindingRef.origin === 'namespace'` */
namespace: 0.4,
/** `BindingRef.origin === 'wildcard'` */
wildcard: 0.3,
// ─── Scope-chain deduction (per-hop) ──────────────────────────────────────
/** Deducted per parent-hop taken (depth-0 = 0, depth-1 = −0.02, …). */
scopeChainPerDepth: -0.02,
// ─── Receiver-type-binding signal (decays by MRO depth) ───────────────────
/**
* Weight applied when the receiver's type binding resolves to a class that
* declares the candidate as a method/field. Decays by MRO depth: direct
* class = index 0; 1 parent hop = index 1; etc. Falls back to the last
* value for depths beyond the table.
*/
typeBindingByMroDepth: [0.5, 0.42, 0.36, 0.32, 0.3] as const,
// ─── Corroborating signals ────────────────────────────────────────────────
/** `def.ownerId === resolvedReceiver.def.id` (exact owner match). */
ownerMatch: 0.2,
/** Explanatory only — retained for debuggability. Never discriminates
* because surviving candidates already passed `acceptedKinds`. */
kindMatch: 0.0,
// ─── Arity compatibility (from `provider.arityCompatibility`) ─────────────
/** `provider.arityCompatibility(...) === 'compatible'` */
arityMatchCompatible: 0.1,
/** `provider.arityCompatibility(...) === 'unknown'` */
arityMatchUnknown: 0.0,
/** `provider.arityCompatibility(...) === 'incompatible'` — penalizes;
* candidates filtered only when a compatible candidate exists. */
arityMatchIncompatible: -0.15,
// ─── Global fallback (only when nothing lexically visible) ────────────────
/** Hit via `QualifiedNameIndex.byQualifiedName`. */
globalQualified: 0.35,
/** Fallback hit in a `byName` index (and nothing was lexically visible). */
globalName: 0.1,
// ─── Degraded signals ─────────────────────────────────────────────────────
/** Call/reference flowing through a `dynamic-unresolved` edge. */
dynamicImportUnresolved: 0.02,
// ─── Unresolved-import cap (multiplicative, applied per-signal) ───────────
/**
* Multiplicative cap on the edge-derived evidence signal
* (`import`/`wildcard`/`reexport`/`namespace`) when
* `ImportEdge.linkStatus === 'unresolved'`. Independent corroborating
* signals on the same candidate (`owner-match`, `arity-match`,
* `type-binding`) are NOT penalized.
*/
unlinkedImportMultiplier: 0.5,
} as const;
/**
* Look up the `type-binding` signal weight for a given MRO depth, falling
* back to the last tabulated value for depths beyond the table.
*/
export function typeBindingWeightAtDepth(mroDepth: number): number {
const table = EvidenceWeights.typeBindingByMroDepth;
if (mroDepth < 0) return table[0];
if (mroDepth >= table.length) return table[table.length - 1];
return table[mroDepth];
}
@@ -0,0 +1,663 @@
/**
* `finalize` — cross-file finalize algorithm for the SemanticModel
* (RFC §3.2 Phase 2; Ring 2 SHARED #915).
*
* Pure logic that takes per-file parse output (`ParsedImport[]` +
* `SymbolDefinition[]`) and returns:
*
* - Linked `ImportEdge[]` per module scope, with `targetModuleScope` and
* `targetDefId` filled where resolvable; edges that could not be
* resolved within the hard fixpoint cap are marked
* `linkStatus: 'unresolved'`.
* - Materialized `bindings` per module scope — local defs merged with
* imported / wildcard-expanded / re-exported names via the provider's
* `mergeBindings` precedence.
* - The SCC condensation of the import graph, exposed so disjoint SCCs
* can be processed in parallel by callers that want that.
*
* The algorithm is **SCC-aware**: it runs Tarjan SCC over the file-level
* import graph, processes SCCs in reverse-topological order (leaves
* first), and within each SCC runs a bounded fixpoint link pass capped at
* `N = |edges in SCC|`. Cyclic imports finalize without hanging; malformed
* inputs are bounded by the cap.
*
* **No language-specific logic.** Target resolution, wildcard expansion,
* and binding precedence all go through caller-supplied hooks
* (`resolveImportTarget`, `expandsWildcardTo`, `mergeBindings`) that
* match the LanguageProvider surface from #911.
*
* **Dynamic imports rule.** `kind === 'dynamic-unresolved'` passes through
* as an `ImportEdge { kind: 'dynamic-unresolved', targetFile: null }`
* with no `BindingRef`. They are parse-time signals, not linkable targets.
*/
import type { SymbolDefinition } from './symbol-definition.js';
import type { BindingRef, ImportEdge, ParsedImport, ScopeId, WorkspaceIndex } from './types.js';
// ─── Public contracts ───────────────────────────────────────────────────────
/** Per-file input for the finalize pass. */
export interface FinalizeFile {
readonly filePath: string;
/** The module scope id for this file; owns the finalized imports + bindings. */
readonly moduleScope: ScopeId;
readonly parsedImports: readonly ParsedImport[];
/**
* Defs exported from this file — the "what other files can import by name"
* surface. Typically those with `isExported: true` (the module's own
* declarations) plus, for multi-hop re-export chains, the re-exported
* names the parser chose to surface here.
*
* **Multi-hop re-export contract.** `finalize` resolves an edge
* `A → B (importedName: 'X')` by looking up `X` in `B.localDefs`. If B
* only has `export { X } from './C'` and the parser *does not* include
* `X` in `B.localDefs`, A's edge hits the fixpoint cap and is marked
* `linkStatus: 'unresolved'`. The fixpoint does NOT mutate `localDefs`
* across iterations — it is static input.
*
* Parsers that want multi-hop re-export chains to settle end-to-end must
* include re-exported names in the intermediate file's `localDefs` (with
* the original `DefId` of the source symbol). This keeps the algorithm
* O(1) per lookup and avoids graph-crawl during finalize.
*/
readonly localDefs: readonly SymbolDefinition[];
}
/** Input to `finalize`. */
export interface FinalizeInput {
readonly files: readonly FinalizeFile[];
/** Opaque workspace context forwarded to provider hooks. */
readonly workspaceIndex: WorkspaceIndex;
}
/**
* Provider-supplied hooks. Mirror the optional LanguageProvider scope-
* resolution hooks declared in #911; `finalize` calls them pure-ly and
* expects pure answers.
*/
export interface FinalizeHooks {
/**
* Resolve a raw import target to the concrete file path that owns it.
* Return `null` when no target file is resolvable (e.g., `np.foo` when
* `numpy` is external to the workspace).
*/
resolveImportTarget(
targetRaw: string,
fromFile: string,
workspaceIndex: WorkspaceIndex,
): string | null;
/**
* For a wildcard `import * from M`, return the names visible in the
* exporting module scope `M`. The finalize pass looks each name up in
* `M`'s local defs to produce a concrete `BindingRef`; names with no
* matching export are dropped.
*/
expandsWildcardTo(targetModuleScope: ScopeId, workspaceIndex: WorkspaceIndex): readonly string[];
/**
* Merge `incoming` bindings into `existing` for a given name. Called
* once per name at each scope. Typical rules:
* - Python: local > imported > wildcard (last-write-wins within tier).
* - Rust: explicit `use` > glob; `pub use` overrides.
* Return value replaces the bucket entirely — no implicit append.
*/
mergeBindings(
existing: readonly BindingRef[],
incoming: readonly BindingRef[],
scope: ScopeId,
): readonly BindingRef[];
}
/** One SCC in the file-level import graph. */
export interface FinalizedScc {
readonly files: readonly string[];
/** True iff this SCC has ≥ 2 files OR a single file that self-imports. */
readonly isCycle: boolean;
}
/**
* Counters reported by `finalize`.
*
* **Counting granularity** — all edge counters are **per-`ParsedImport`**,
* not per-materialized-`ImportEdge`. A single `wildcard` ParsedImport that
* expands to N exports counts as one linked edge in these stats; the
* materialized output (`FinalizeOutput.imports`) will have N edges for
* that input. `dynamic-unresolved` ParsedImports count as linked (they
* pass through with no `linkStatus`), so `linkedEdges` ≠ "has a
* BindingRef" — use the `bindings` map for that.
*
* In other words: `totalEdges === input.parsedImports.length` summed
* across files, and `linkedEdges + unresolvedEdges === totalEdges`.
*/
export interface FinalizeStats {
readonly totalFiles: number;
/** Total `ParsedImport` records seen across all files. */
readonly totalEdges: number;
/**
* `ParsedImport`s whose finalized edge does NOT carry
* `linkStatus: 'unresolved'`. Includes `dynamic-unresolved` pass-throughs.
*/
readonly linkedEdges: number;
/** `ParsedImport`s whose finalized edge carries `linkStatus: 'unresolved'`. */
readonly unresolvedEdges: number;
readonly sccCount: number;
readonly largestSccSize: number;
}
export interface FinalizeOutput {
/** Linked `ImportEdge[]` per module scope, in original input order. */
readonly imports: ReadonlyMap<ScopeId, readonly ImportEdge[]>;
/** Materialized bindings per module scope. */
readonly bindings: ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>;
/** SCCs in reverse-topological order (leaves first). */
readonly sccs: readonly FinalizedScc[];
readonly stats: FinalizeStats;
}
// ─── Entry point ───────────────────────────────────────────────────────────
export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOutput {
const byFilePath = new Map<string, FinalizeFile>();
for (const f of input.files) byFilePath.set(f.filePath, f);
// ── Phase 0: pre-resolve raw import targets (one syscall-equivalent per
// (file, parsedImport)). Edges with no resolvable target become
// `linkStatus: 'unresolved'` or, for dynamic-unresolved, pass through
// with `targetFile: null`.
const edgeIndex = new Map<string, ImportEdgeDraft[]>(); // filePath → drafts
let totalEdges = 0;
for (const file of input.files) {
const drafts: ImportEdgeDraft[] = [];
for (const parsed of file.parsedImports) {
const draft = makeEdgeDraft(parsed, file, hooks, input.workspaceIndex);
drafts.push(draft);
totalEdges++;
}
edgeIndex.set(file.filePath, drafts);
}
// ── Phase 1: build file-level import graph (only resolvable edges form
// graph edges; unresolvable ones are terminal and contribute no
// fixpoint obligation).
const graph = new Map<string, Set<string>>();
for (const file of input.files) {
graph.set(file.filePath, new Set());
}
for (const [fromFile, drafts] of edgeIndex) {
const edges = graph.get(fromFile)!;
for (const d of drafts) {
if (d.targetFile !== null && byFilePath.has(d.targetFile)) {
edges.add(d.targetFile);
}
}
}
// ── Phase 2: Tarjan SCC → reverse-topological list of SCCs.
const sccs = tarjanSccs(graph);
// ── Phase 3: process SCCs in reverse-topological order (leaves first).
// Within each SCC, run a bounded fixpoint that resolves intra-SCC edges.
// Edges leaving the SCC are already resolved (their target SCC is
// already finalized); edges inside the SCC may need multiple passes.
const linkedByScope = new Map<ScopeId, readonly ImportEdge[]>();
let linkedEdges = 0;
for (const scc of sccs) {
const sccFiles = new Set(scc.files);
const capacity = countEdgesWithin(edgeIndex, sccFiles);
// Run the fixpoint up to `capacity` iterations. Each iteration tries to
// resolve every still-unlinked edge in the SCC; stops early if a pass
// makes no progress.
let progressed = true;
let iterations = 0;
while (progressed && iterations < capacity) {
progressed = false;
iterations++;
for (const filePath of scc.files) {
const drafts = edgeIndex.get(filePath)!;
for (const draft of drafts) {
if (draft.finalized !== null) continue;
const finalized = tryFinalize(draft, byFilePath);
if (finalized !== null) {
draft.finalized = finalized;
progressed = true;
}
}
}
}
// Any drafts still not finalized within this SCC hit the cap → unresolved.
for (const filePath of scc.files) {
const drafts = edgeIndex.get(filePath)!;
for (const draft of drafts) {
if (draft.finalized !== null) continue;
draft.finalized = {
...draft.base,
linkStatus: 'unresolved' as const,
};
}
}
}
// ── Phase 4: collect finalized `ImportEdge[]` per module scope, preserving
// input order within each file, and wildcard-expand where applicable.
for (const file of input.files) {
const drafts = edgeIndex.get(file.filePath)!;
const finalized: ImportEdge[] = [];
for (const d of drafts) {
const edge = d.finalized!;
if (d.source.kind === 'wildcard' && edge.linkStatus !== 'unresolved') {
// Produce one `wildcard-expanded` ImportEdge per exported name.
const expanded = expandWildcard(edge, byFilePath, hooks, input.workspaceIndex);
for (const e of expanded) finalized.push(e);
} else {
finalized.push(edge);
}
if (edge.linkStatus !== 'unresolved') linkedEdges++;
}
linkedByScope.set(file.moduleScope, Object.freeze(finalized));
}
// ── Phase 5: materialize module-scope bindings (local + imports + wildcards),
// delegating precedence to `provider.mergeBindings`.
const bindingsByScope = materializeBindings(input.files, linkedByScope, hooks);
// ── Stats.
const sccCount = sccs.length;
let largestSccSize = 0;
for (const scc of sccs) {
if (scc.files.length > largestSccSize) largestSccSize = scc.files.length;
}
const stats: FinalizeStats = {
totalFiles: input.files.length,
totalEdges,
linkedEdges,
unresolvedEdges: totalEdges - linkedEdges,
sccCount,
largestSccSize,
};
return Object.freeze({
imports: linkedByScope,
bindings: bindingsByScope,
sccs,
stats,
});
}
// ─── Internal: edge drafting (phase 0) ──────────────────────────────────────
interface ImportEdgeDraft {
readonly source: ParsedImport;
readonly fromFile: string;
readonly fromScope: ScopeId;
readonly targetFile: string | null;
readonly base: ImportEdge;
finalized: ImportEdge | null;
}
function makeEdgeDraft(
parsed: ParsedImport,
file: FinalizeFile,
hooks: FinalizeHooks,
workspace: WorkspaceIndex,
): ImportEdgeDraft {
// Dynamic-unresolved passes through — no `BindingRef`, no target file.
if (parsed.kind === 'dynamic-unresolved') {
const base: ImportEdge = {
localName: parsed.localName,
targetFile: null,
targetExportedName: '',
kind: 'dynamic-unresolved',
};
return {
source: parsed,
fromFile: file.filePath,
fromScope: file.moduleScope,
targetFile: null,
base,
finalized: base, // already fully finalized
};
}
const targetFile = hooks.resolveImportTarget(parsed.targetRaw ?? '', file.filePath, workspace);
// Edge is unresolvable at the file level — mark unresolved now.
if (targetFile === null) {
const edgeKind = parsed.kind === 'wildcard' ? 'wildcard-expanded' : parsed.kind;
const localName = parsed.kind === 'wildcard' ? '' : parsed.localName;
const targetExportedName = extractExportedName(parsed);
const base: ImportEdge = {
localName,
targetFile: null,
targetExportedName,
kind: edgeKind,
linkStatus: 'unresolved',
};
return {
source: parsed,
fromFile: file.filePath,
fromScope: file.moduleScope,
targetFile: null,
base,
finalized: base,
};
}
// Resolvable at the file level; intra-SCC fixpoint may still fail to fill
// in `targetDefId` (e.g., symbol not exported from target).
const edgeKind = parsed.kind === 'wildcard' ? 'wildcard-expanded' : parsed.kind;
const localName = parsed.kind === 'wildcard' ? '' : parsed.localName;
const targetExportedName = extractExportedName(parsed);
const base: ImportEdge = {
localName,
targetFile,
targetExportedName,
kind: edgeKind,
};
return {
source: parsed,
fromFile: file.filePath,
fromScope: file.moduleScope,
targetFile,
base,
finalized: null,
};
}
function extractExportedName(parsed: ParsedImport): string {
switch (parsed.kind) {
case 'named':
case 'alias':
case 'namespace':
case 'reexport':
return parsed.importedName;
case 'wildcard':
case 'dynamic-unresolved':
return '';
}
}
// ─── Internal: per-edge finalization (phase 3) ─────────────────────────────
function tryFinalize(
draft: ImportEdgeDraft,
byFilePath: Map<string, FinalizeFile>,
): ImportEdge | null {
const targetFile = draft.targetFile;
if (targetFile === null) return draft.base; // already terminal
const targetModule = byFilePath.get(targetFile);
if (targetModule === undefined) return draft.base; // external target — leave as-is
// Wildcards finalize at the file level; their per-name expansion happens
// in phase 4. At this stage we just record the target module scope.
if (draft.source.kind === 'wildcard') {
return {
...draft.base,
targetModuleScope: targetModule.moduleScope,
};
}
// Namespace imports alias the target *module*; they don't name a
// specific export. Link the module scope unconditionally. If the target
// also exposes a def whose simple name matches `importedName` (some
// languages emit a synthetic module-def), pick it up as the `targetDefId`
// so consumers can reach the module as a symbol — but its absence is not
// a failure.
if (draft.source.kind === 'namespace') {
const moduleDef = findExportByName(targetModule.localDefs, extractExportedName(draft.source));
return {
...draft.base,
targetModuleScope: targetModule.moduleScope,
...(moduleDef !== undefined ? { targetDefId: moduleDef.nodeId } : {}),
};
}
// named / alias / reexport: look up the imported name in the target's
// local defs. Multi-hop re-export chains settle iteratively — each hop
// resolves once its prior hop is finalized.
const importedName = extractExportedName(draft.source);
const exported = findExportByName(targetModule.localDefs, importedName);
if (exported === undefined) {
// Target resolvable but the name isn't exported — keep trying in case a
// re-export inside the target's SCC surfaces it in a later iteration.
return null;
}
const transitiveVia = draft.source.kind === 'reexport' ? Object.freeze([targetFile]) : undefined;
return {
...draft.base,
targetModuleScope: targetModule.moduleScope,
targetDefId: exported.nodeId,
...(transitiveVia !== undefined ? { transitiveVia } : {}),
};
}
/**
* The "simple" (unqualified) name of a def, for import-name matching.
*
* Canonical source: `def.qualifiedName` — the tail after the last `.` (or
* the whole string if no dot). Defs without a qualifiedName can't be
* resolved by name here and return `null`; callers treat that as "name
* not exported" and either retry in a later fixpoint iteration or mark
* the edge unresolved.
*/
function deriveSimpleName(def: SymbolDefinition): string | null {
const q = def.qualifiedName;
if (q === undefined || q.length === 0) return null;
const dot = q.lastIndexOf('.');
return dot === -1 ? q : q.slice(dot + 1);
}
function findExportByName(
defs: readonly SymbolDefinition[],
name: string,
): SymbolDefinition | undefined {
for (const d of defs) {
if (deriveSimpleName(d) === name) return d;
}
return undefined;
}
function countEdgesWithin(edgeIndex: Map<string, ImportEdgeDraft[]>, files: Set<string>): number {
let n = 0;
for (const filePath of files) {
const drafts = edgeIndex.get(filePath);
if (drafts === undefined) continue;
for (const d of drafts) {
if (d.targetFile !== null && files.has(d.targetFile)) n++;
}
}
// Guarantee at least one pass even for a trivial SCC (ensures deterministic
// fixpoint termination even when a single-file SCC has zero intra-SCC edges
// but still needs one settle pass).
return Math.max(n, 1);
}
// ─── Internal: wildcard expansion (phase 4) ────────────────────────────────
function expandWildcard(
edge: ImportEdge,
byFilePath: Map<string, FinalizeFile>,
hooks: FinalizeHooks,
workspace: WorkspaceIndex,
): readonly ImportEdge[] {
if (edge.targetModuleScope === undefined || edge.targetFile === null) {
return [edge]; // unresolvable wildcard survives as a single unlinked edge
}
const target = byFilePath.get(edge.targetFile);
if (target === undefined) return [edge];
const names = hooks.expandsWildcardTo(edge.targetModuleScope, workspace);
if (names.length === 0) return [];
const expanded: ImportEdge[] = [];
for (const name of names) {
const def = findExportByName(target.localDefs, name);
if (def === undefined) continue;
expanded.push({
localName: name,
targetFile: edge.targetFile,
targetExportedName: name,
kind: 'wildcard-expanded',
targetModuleScope: edge.targetModuleScope,
targetDefId: def.nodeId,
});
}
return expanded;
}
// ─── Internal: bindings materialization (phase 5) ───────────────────────────
function materializeBindings(
files: readonly FinalizeFile[],
linkedByScope: ReadonlyMap<ScopeId, readonly ImportEdge[]>,
hooks: FinalizeHooks,
): ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>> {
const out = new Map<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>();
for (const file of files) {
const scopeBindings = new Map<string, readonly BindingRef[]>();
// Start with local defs as `origin: 'local'` bindings.
for (const def of file.localDefs) {
const name = deriveSimpleName(def);
if (name === null) continue;
const incoming: BindingRef[] = [{ def, origin: 'local' }];
const existing = scopeBindings.get(name) ?? [];
scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope));
}
// Layer in finalized imports.
const imports = linkedByScope.get(file.moduleScope) ?? [];
for (const edge of imports) {
if (edge.targetDefId === undefined || edge.linkStatus === 'unresolved') continue;
// Every def the importing file needs to reach is in some other file's
// `localDefs`; walk all files to find it. In practice we could index
// this, but at finalize-time N(files) is small per workspace pass.
const def = findDefById(files, edge.targetDefId);
if (def === undefined) continue;
const origin: BindingRef['origin'] =
edge.kind === 'namespace'
? 'namespace'
: edge.kind === 'wildcard-expanded'
? 'wildcard'
: edge.kind === 'reexport'
? 'reexport'
: 'import';
const fallback = deriveSimpleName(def);
const name = edge.localName.length > 0 ? edge.localName : fallback;
if (name === null) continue;
const incoming: BindingRef[] = [{ def, origin, via: edge }];
const existing = scopeBindings.get(name) ?? [];
scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope));
}
// Freeze nested buckets for immutability.
const frozen = new Map<string, readonly BindingRef[]>();
for (const [name, refs] of scopeBindings) {
frozen.set(name, Object.freeze(refs.slice()));
}
out.set(file.moduleScope, frozen);
}
return out;
}
function findDefById(files: readonly FinalizeFile[], defId: string): SymbolDefinition | undefined {
for (const f of files) {
for (const d of f.localDefs) {
if (d.nodeId === defId) return d;
}
}
return undefined;
}
// ─── Internal: Tarjan SCC ──────────────────────────────────────────────────
/**
* Iterative Tarjan SCC. Returns SCCs in **reverse-topological** order
* (leaves first — a property Tarjan gives for free, and the order
* `finalize` wants so leaves are fully resolved before their dependents).
*/
function tarjanSccs(graph: ReadonlyMap<string, ReadonlySet<string>>): FinalizedScc[] {
const index = new Map<string, number>();
const lowlink = new Map<string, number>();
const onStack = new Set<string>();
const stack: string[] = [];
const sccs: FinalizedScc[] = [];
let idx = 0;
// Iterative DFS to avoid stack overflow on deep import chains.
const allNodes = Array.from(graph.keys()).sort(); // deterministic order
const iterStack: Array<{ node: string; children: Iterator<string>; entered: boolean }> = [];
for (const root of allNodes) {
if (index.has(root)) continue;
iterStack.push({
node: root,
children: (graph.get(root) ?? new Set<string>()).values(),
entered: false,
});
while (iterStack.length > 0) {
const frame = iterStack[iterStack.length - 1]!;
if (!frame.entered) {
frame.entered = true;
index.set(frame.node, idx);
lowlink.set(frame.node, idx);
idx++;
stack.push(frame.node);
onStack.add(frame.node);
}
const nextChild = frame.children.next();
if (nextChild.done) {
// Post-visit: compute SCC membership if frame.node is a root.
if (lowlink.get(frame.node) === index.get(frame.node)) {
const scc: string[] = [];
let selfInCycle = false;
while (true) {
const w = stack.pop()!;
onStack.delete(w);
scc.push(w);
// A single-file self-loop counts as a cycle.
if (w === frame.node) {
selfInCycle = (graph.get(w) ?? new Set()).has(w);
break;
}
}
const isCycle = scc.length > 1 || selfInCycle;
sccs.push({ files: Object.freeze(scc), isCycle });
}
iterStack.pop();
// Propagate lowlink to parent.
if (iterStack.length > 0) {
const parent = iterStack[iterStack.length - 1]!;
lowlink.set(parent.node, Math.min(lowlink.get(parent.node)!, lowlink.get(frame.node)!));
}
continue;
}
const child = nextChild.value;
if (!index.has(child)) {
iterStack.push({
node: child,
children: (graph.get(child) ?? new Set<string>()).values(),
entered: false,
});
} else if (onStack.has(child)) {
lowlink.set(frame.node, Math.min(lowlink.get(frame.node)!, index.get(child)!));
}
}
}
return sccs;
}
@@ -0,0 +1,49 @@
/**
* `LanguageClassification` — RFC §6.1 Ring 3 / Ring 4 governance.
*
* Classifies each `SupportedLanguages` member for the rollout. Ring 4 (DAG
* retirement) is gated on *all production languages* being registry-primary
* and stable for one release cycle; `experimental` and `quarantined`
* languages do not block.
*
* Initial classification (locked in Ring 1 #910):
* - production: javascript, typescript, python, java, c, cpp, csharp, go,
* ruby, rust, php, kotlin, swift, dart
* - experimental: vue (embedded-language / SFC complexity),
* cobol (regex-provider path)
* - quarantined: (none)
*/
import { SupportedLanguages } from '../languages.js';
export type LanguageClassification = 'production' | 'experimental' | 'quarantined';
/**
* The canonical classification for each supported language. Governance
* changes (promote `experimental` → `production`, quarantine a language, …)
* update this map in a dedicated PR.
*/
export const LanguageClassifications: Readonly<Record<SupportedLanguages, LanguageClassification>> =
{
[SupportedLanguages.JavaScript]: 'production',
[SupportedLanguages.TypeScript]: 'production',
[SupportedLanguages.Python]: 'production',
[SupportedLanguages.Java]: 'production',
[SupportedLanguages.C]: 'production',
[SupportedLanguages.CPlusPlus]: 'production',
[SupportedLanguages.CSharp]: 'production',
[SupportedLanguages.Go]: 'production',
[SupportedLanguages.Ruby]: 'production',
[SupportedLanguages.Rust]: 'production',
[SupportedLanguages.PHP]: 'production',
[SupportedLanguages.Kotlin]: 'production',
[SupportedLanguages.Swift]: 'production',
[SupportedLanguages.Dart]: 'production',
[SupportedLanguages.Vue]: 'experimental',
[SupportedLanguages.Cobol]: 'experimental',
};
/** Convenience predicate: is this language gating Ring 4 retirement? */
export function isProductionLanguage(lang: SupportedLanguages): boolean {
return LanguageClassifications[lang] === 'production';
}
@@ -0,0 +1,137 @@
/**
* `MethodDispatchIndex` — materialized view of class hierarchies keyed by
* `DefId` (RFC §3.1; Ring 2 SHARED #914).
*
* Two O(1)-access maps used by `Registry.lookupMethod` and interface-
* dispatch callers:
*
* - `mroByOwnerDefId` : owner class → full MRO ancestor chain
* (excludes the owner itself, in per-language
* strategy order).
* - `implsByInterfaceDefId` : interface/trait → classes that implement it.
*
* **Not an MRO implementation.** The build function is a pure aggregator: it
* asks the caller (via `computeMro` and `implementsOf` callbacks) for the
* per-language answers and materializes the two-way index. MRO strategies
* live where they already do today (`model/resolve.ts § c3Linearize`,
* `languages/ruby.ts § selectDispatch`, etc.) — this index does not
* reimplement them.
*
* Why callbacks and not a shared strategy registry: the five strategies
* (Python C3, Ruby kind-aware, Java/Kotlin linear, Rust qualified-syntax,
* COBOL none) already exist in the CLI package and depend on the CLI's
* `HeritageMap` + `SemanticModel`. Pulling them into `gitnexus-shared` would
* require migrating both — out of scope for #914. Callbacks let the shared
* build stay pure while honoring existing strategies verbatim.
*
* Consumed by: #917 (`Registry.lookupMethod` MRO fast path, interface
* dispatch resolver).
*/
import type { DefId } from './types.js';
// ─── Public contracts ───────────────────────────────────────────────────────
export interface MethodDispatchIndex {
/**
* Full MRO ancestor chain per owner class (excludes the owner itself).
* Order reflects the per-language strategy used by `computeMro`.
*/
readonly mroByOwnerDefId: ReadonlyMap<DefId, readonly DefId[]>;
/** Interfaces / traits → classes that implement them. */
readonly implsByInterfaceDefId: 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[];
}
export interface MethodDispatchInput {
/**
* Owner defs to index (classes, structs, traits, interfaces — any kind
* that can appear on the owner side of a method-dispatch graph).
*/
readonly owners: readonly DefId[];
/**
* Return the full MRO ancestor chain for `ownerDefId`, **excluding the
* owner itself**, in the order dictated by the owner's language-specific
* MRO strategy.
*
* Contract:
* - Pure (no side effects).
* - Deterministic per input.
* - `undefined` not allowed — return `[]` when the owner has no parents.
*/
readonly computeMro: (ownerDefId: DefId) => readonly DefId[];
/**
* Return the set of interface/trait defs that `ownerDefId` implements.
* Transitive inclusion (e.g., `implements` on a parent class) is the
* caller's choice — the build function simply inverts whatever is
* returned.
*
* Repeated IDs in the output are deduplicated automatically.
*/
readonly implementsOf: (ownerDefId: DefId) => readonly DefId[];
}
// ─── Builder ────────────────────────────────────────────────────────────────
export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDispatchIndex {
const mroByOwnerDefId = new Map<DefId, readonly DefId[]>();
const implsBuilding = new Map<DefId, DefId[]>();
const implsSeen = new Map<DefId, Set<DefId>>();
for (const ownerId of input.owners) {
// First-write-wins on duplicate owner ids: a stable policy consistent
// with sibling indexes (#913 DefIndex / ModuleScopeIndex).
if (!mroByOwnerDefId.has(ownerId)) {
const chain = input.computeMro(ownerId);
mroByOwnerDefId.set(ownerId, Object.freeze(chain.slice()));
}
for (const ifaceId of input.implementsOf(ownerId)) {
let seen = implsSeen.get(ifaceId);
if (seen === undefined) {
seen = new Set<DefId>();
implsSeen.set(ifaceId, seen);
}
if (seen.has(ownerId)) continue;
seen.add(ownerId);
let bucket = implsBuilding.get(ifaceId);
if (bucket === undefined) {
bucket = [];
implsBuilding.set(ifaceId, bucket);
}
bucket.push(ownerId);
}
}
const implsByInterfaceDefId = new Map<DefId, readonly DefId[]>();
for (const [ifaceId, owners] of implsBuilding) {
implsByInterfaceDefId.set(ifaceId, Object.freeze(owners.slice()));
}
return freezeIndex(mroByOwnerDefId, implsByInterfaceDefId);
}
// ─── Internal ───────────────────────────────────────────────────────────────
const EMPTY: readonly DefId[] = Object.freeze([]);
function freezeIndex(
mroByOwnerDefId: Map<DefId, readonly DefId[]>,
implsByInterfaceDefId: Map<DefId, readonly DefId[]>,
): MethodDispatchIndex {
return {
mroByOwnerDefId,
implsByInterfaceDefId,
mroFor(ownerDefId: DefId): readonly DefId[] {
return mroByOwnerDefId.get(ownerDefId) ?? EMPTY;
},
implementorsOf(interfaceDefId: DefId): readonly DefId[] {
return implsByInterfaceDefId.get(interfaceDefId) ?? EMPTY;
},
};
}
@@ -0,0 +1,65 @@
/**
* `ModuleScopeIndex` — O(1) `filePath → moduleScopeId` lookup.
*
* Every file parsed produces exactly one `Module` scope at its root. The
* finalize algorithm needs to resolve `ImportEdge.targetFile` to a concrete
* module scope id in constant time during the link pass; this index is that
* mapping.
*
* Part of RFC #909 Ring 2 SHARED — #913.
*
* Consumed by: #915 (SCC finalize link pass), #923 (shadow harness when
* resolving callsite file → enclosing module).
*/
import type { ScopeId } from './types.js';
export interface ModuleScopeIndex {
readonly byFilePath: ReadonlyMap<string, ScopeId>;
readonly size: number;
get(filePath: string): ScopeId | undefined;
has(filePath: string): boolean;
}
export interface ModuleScopeEntry {
readonly filePath: string;
readonly moduleScopeId: ScopeId;
}
/**
* Build a `ModuleScopeIndex` from a flat list of `{ filePath, moduleScopeId }`
* pairs.
*
* **Collision policy: first-write-wins.** A file should appear exactly once
* in a single ingestion run; collisions indicate the same file was parsed
* twice or a `filePath` normalization bug upstream. Dropping the later
* entry preserves the first-stable id the rest of the pipeline may already
* have registered against.
*
* Pure function — safe to call repeatedly; no side effects.
*/
export function buildModuleScopeIndex(entries: readonly ModuleScopeEntry[]): ModuleScopeIndex {
const byFilePath = new Map<string, ScopeId>();
for (const { filePath, moduleScopeId } of entries) {
if (byFilePath.has(filePath)) continue; // first-write-wins
byFilePath.set(filePath, moduleScopeId);
}
return freezeIndex(byFilePath);
}
// ─── Internal ───────────────────────────────────────────────────────────────
function freezeIndex(byFilePath: Map<string, ScopeId>): ModuleScopeIndex {
return {
byFilePath,
get size() {
return byFilePath.size;
},
get(filePath: string): ScopeId | undefined {
return byFilePath.get(filePath);
},
has(filePath: string): boolean {
return byFilePath.has(filePath);
},
};
}
@@ -0,0 +1,30 @@
/**
* `ORIGIN_PRIORITY` — RFC Appendix B (authoritative values).
*
* Tie-break ordering applied inside `Registry.lookup` Step 7 when
* `|Δconfidence| < 0.001` between two `Resolution` candidates. Lower number
* = stronger (wins the tie).
*
* Full tie-break order (§4.2 Step 7):
* confidence DESC → scope depth ASC → MRO depth ASC → ORIGIN_PRIORITY ASC
* → DefId.localeCompare
*/
export type OriginForTieBreak =
| 'local'
| 'import'
| 'reexport'
| 'namespace'
| 'wildcard'
| 'global-qualified'
| 'global-name';
export const ORIGIN_PRIORITY: Readonly<Record<OriginForTieBreak, number>> = {
local: 0,
import: 1,
reexport: 2,
namespace: 3,
wildcard: 4,
'global-qualified': 5,
'global-name': 6,
};
@@ -0,0 +1,154 @@
/**
* `PositionIndex` — O(log N_file) scope-at-position lookup
* (RFC §3.1; Ring 2 SHARED #912).
*
* Per-file sorted array of `(range, scopeId)` entries, sorted by start
* position ASC (`startLine`, then `startCol`). `atPosition(filePath, line,
* col)` binary-searches for the last entry whose start ≤ (line, col), then
* scans backward through the sorted prefix and returns the first entry
* whose range contains the query position.
*
* **Why this works.** `ScopeTree`'s invariants (parent strictly contains
* child; siblings don't overlap) guarantee that the scopes containing a
* given point form an **ancestor chain**. When scanning backward through
* entries sorted by start position ASC, the first scope we find that
* contains the query is the innermost one — any deeper-starting scope
* that also contained the query would appear *later* in the sorted array,
* but we're only scanning entries with start ≤ query, so anything later
* necessarily starts after the query and can't contain it.
*
* Expected complexity: `O(log N_file + D)` where `D` is the lexical depth
* at the query position (typically ≤ 10). Worst-case degrades to `O(N_file)`
* only under pathological inputs (many scopes starting at the same line).
*
* **Line/column conventions.** Matches `Range` in `types.ts`: lines are
* 1-based, columns are 0-based. Ranges are **inclusive on both ends** —
* a scope whose `endLine:endCol` equals the query position still contains
* it. That matches how tree-sitter captures bodies (closing brace
* included) and how closed PR #902's `enclosingFunctions` behaved.
*/
import type { Range, Scope, ScopeId } from './types.js';
export interface PositionIndex {
/** Total scope entries indexed across all files. */
readonly size: number;
/**
* Innermost scope containing `(line, col)` in `filePath`, or `undefined`
* when nothing contains it (position before file start, after file end,
* or filePath not indexed).
*/
atPosition(filePath: string, line: number, col: number): ScopeId | undefined;
}
/**
* Build a `PositionIndex` from a flat list of `Scope` records.
*
* Duplicate `id`s are tolerated and deduplicated — the caller's
* `ScopeTree.buildScopeTree` is the authoritative validator of scope
* identity, and the position index does not need to re-check that
* invariant.
*/
export function buildPositionIndex(scopes: readonly Scope[]): PositionIndex {
const entriesByFile = new Map<string, Entry[]>();
const seen = new Set<ScopeId>();
for (const scope of scopes) {
if (seen.has(scope.id)) continue;
seen.add(scope.id);
let bucket = entriesByFile.get(scope.filePath);
if (bucket === undefined) {
bucket = [];
entriesByFile.set(scope.filePath, bucket);
}
bucket.push({ id: scope.id, range: scope.range });
}
for (const bucket of entriesByFile.values()) {
bucket.sort(compareEntry);
}
return freezeIndex(entriesByFile, seen.size);
}
// ─── Internals ──────────────────────────────────────────────────────────────
interface Entry {
readonly id: ScopeId;
readonly range: Range;
}
/**
* Sort by start position ASC, breaking ties by end position DESC so that
* larger (outer) scopes appear before their smaller (inner) co-starting
* siblings in the array. Makes the backward-scan contract crisp: the
* first containing hit from the end of the scanned prefix is the
* innermost scope.
*/
function compareEntry(a: Entry, b: Entry): number {
if (a.range.startLine !== b.range.startLine) return a.range.startLine - b.range.startLine;
if (a.range.startCol !== b.range.startCol) return a.range.startCol - b.range.startCol;
if (a.range.endLine !== b.range.endLine) return b.range.endLine - a.range.endLine;
return b.range.endCol - a.range.endCol;
}
/** Whether `(line, col)` is at or after `range`'s start. */
function startIsAtOrBefore(range: Range, line: number, col: number): boolean {
if (range.startLine < line) return true;
if (range.startLine > line) return false;
return range.startCol <= col;
}
/** Whether `(line, col)` is at or before `range`'s end (inclusive). */
function endIsAtOrAfter(range: Range, line: number, col: number): boolean {
if (range.endLine > line) return true;
if (range.endLine < line) return false;
return range.endCol >= col;
}
/**
* Return the largest index `i` in `arr` where `arr[i].range` starts at or
* before `(line, col)`. Returns `-1` if no entry starts ≤ the query.
*
* Classic "upper bound - 1" binary search: find the first entry that
* starts *after* the query, then step back one.
*/
function findLastStartLteIndex(arr: readonly Entry[], line: number, col: number): number {
let lo = 0;
let hi = arr.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (startIsAtOrBefore(arr[mid]!.range, line, col)) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo - 1;
}
function freezeIndex(entriesByFile: Map<string, Entry[]>, size: number): PositionIndex {
return {
get size() {
return size;
},
atPosition(filePath: string, line: number, col: number): ScopeId | undefined {
const bucket = entriesByFile.get(filePath);
if (bucket === undefined || bucket.length === 0) return undefined;
const endIdx = findLastStartLteIndex(bucket, line, col);
if (endIdx < 0) return undefined;
// Scan backward; first containing hit is innermost (see file header).
for (let i = endIdx; i >= 0; i--) {
const entry = bucket[i]!;
if (endIsAtOrAfter(entry.range, line, col)) {
// `startIsAtOrBefore` is guaranteed true by the binary search.
return entry.id;
}
}
return undefined;
},
};
}
@@ -0,0 +1,92 @@
/**
* `QualifiedNameIndex` — O(1) `qualifiedName → DefId[]` lookup across all kinds.
*
* Cross-kind fast path for qualified-name resolution
* (`lookupQualified(qname, scope, params)` in RFC §4.5). Class, method,
* field, and namespace defs all contribute to a single index here; consumers
* filter the returned `DefId[]` by `p.acceptedKinds` at the call site.
*
* Returns `DefId[]` (not a single `DefId`) because multiple defs can legally
* share a qualified name — partial classes in C#, method overloads, or
* accidental cross-kind collisions. The lookup caller filters to the expected
* kind(s) and ranks the survivors.
*
* Part of RFC #909 Ring 2 SHARED — #913.
*
* Consumed by: #917 (`Registry.lookup` qualified fast path, `resolveTypeRef`
* dotted fallback via #916).
*/
import type { SymbolDefinition } from './symbol-definition.js';
import type { DefId } from './types.js';
export interface QualifiedNameIndex {
readonly byQualifiedName: ReadonlyMap<string, readonly DefId[]>;
readonly size: number;
/** Returns all `DefId`s registered under this qualified name; empty frozen
* array on miss so callers can iterate without null checks. */
get(qualifiedName: string): readonly DefId[];
has(qualifiedName: string): boolean;
}
/**
* Build a `QualifiedNameIndex` from a flat list of `SymbolDefinition` records.
*
* Only defs with a non-empty `qualifiedName` contribute; defs without one are
* silently skipped (not every kind carries a qualified name — anonymous or
* top-level symbols, dynamic-unresolved imports, etc.).
*
* **Duplicate policy: appended in input order.** Each unique `(qname, DefId)`
* pair contributes at most once — repeated entries for the same pair are
* deduplicated. Distinct `DefId`s sharing a `qname` accumulate in insertion
* order (stable output for deterministic lookup ranking at the call site).
*
* Pure function — safe to call repeatedly; no side effects.
*/
export function buildQualifiedNameIndex(defs: readonly SymbolDefinition[]): QualifiedNameIndex {
const byQualifiedName = new Map<string, DefId[]>();
const seenPairs = new Set<string>();
for (const def of defs) {
const qname = def.qualifiedName;
if (qname === undefined || qname.length === 0) continue;
const pairKey = `${qname}\0${def.nodeId}`;
if (seenPairs.has(pairKey)) continue;
seenPairs.add(pairKey);
const bucket = byQualifiedName.get(qname);
if (bucket === undefined) {
byQualifiedName.set(qname, [def.nodeId]);
} else {
bucket.push(def.nodeId);
}
}
// Freeze bucket arrays so consumers can't mutate the index.
const frozen = new Map<string, readonly DefId[]>();
for (const [k, v] of byQualifiedName) {
frozen.set(k, Object.freeze(v.slice()));
}
return freezeIndex(frozen);
}
// ─── Internal ───────────────────────────────────────────────────────────────
const EMPTY: readonly DefId[] = Object.freeze([]);
function freezeIndex(byQualifiedName: Map<string, readonly DefId[]>): QualifiedNameIndex {
return {
byQualifiedName,
get size() {
return byQualifiedName.size;
},
get(qualifiedName: string): readonly DefId[] {
return byQualifiedName.get(qualifiedName) ?? EMPTY;
},
has(qualifiedName: string): boolean {
return byQualifiedName.has(qualifiedName);
},
};
}
@@ -0,0 +1,41 @@
/**
* `ClassRegistry` — scope-aware lookup for class-like symbols
* (RFC §4.4; Ring 2 SHARED #917).
*
* Thin wrapper over `lookupCore`, specialized for class kinds:
*
* - `acceptedKinds` = Class / Interface / Enum / Struct / Union /
* Trait / TypeAlias / Typedef / Record / Delegate / Annotation /
* Template / Namespace.
* - `useReceiverTypeBinding` is **false** — classes are resolved by
* name through the lexical chain + global qualified fallback, not
* via a receiver type.
* - Arity filter is not applicable (classes are not called with
* argument counts at lookup time).
*/
import type { Resolution, ScopeId } from '../types.js';
import { lookupCore, type CoreLookupParams } from './lookup-core.js';
import { CLASS_KINDS, type RegistryContext } from './context.js';
export interface ClassRegistry {
/**
* Look up a class-like symbol by simple or dotted name anchored at
* `scope`. Returns a confidence-ranked `Resolution[]`; consume `[0]`
* for the best answer.
*/
lookup(name: string, scope: ScopeId): readonly Resolution[];
}
export function buildClassRegistry(ctx: RegistryContext): ClassRegistry {
const params: CoreLookupParams = {
acceptedKinds: CLASS_KINDS,
useReceiverTypeBinding: false,
ownerScopedContributor: null,
};
return {
lookup(name: string, scope: ScopeId) {
return lookupCore(name, scope, params, ctx);
},
};
}
@@ -0,0 +1,110 @@
/**
* `RegistryContext` — the injected state required by the scope-aware
* registry lookups (RFC §4; Ring 2 SHARED #917).
*
* Bundles every Ring 2 index + every provider hook the 7-step algorithm
* might consult. Threaded through `lookupCore` and the three public
* registries unchanged; construction is the caller's responsibility
* (typically once per workspace-indexing pass in Ring 2 PKG).
*
* The design intent is **pure-logic in `gitnexus-shared`, data + hooks
* supplied by the caller**. Nothing here loads files, parses AST, or
* reaches into the CLI package.
*/
import type { NodeLabel } from '../../graph/types.js';
import type { SymbolDefinition } from '../symbol-definition.js';
import type { Callsite, DefId } from '../types.js';
import type { DefIndex } from '../def-index.js';
import type { QualifiedNameIndex } from '../qualified-name-index.js';
import type { ModuleScopeIndex } from '../module-scope-index.js';
import type { ScopeTree } from '../scope-tree.js';
import type { MethodDispatchIndex } from '../method-dispatch-index.js';
// ─── Provider hooks consumed by the registries ─────────────────────────────
export interface RegistryProviders {
/**
* Language-specific arity compatibility between a callsite and a candidate
* `def`. Mirrors `LanguageProvider.arityCompatibility` from #911. Optional:
* when absent, every candidate receives `'unknown'` (neutral signal).
*/
arityCompatibility?(callsite: Callsite, def: SymbolDefinition): ArityVerdict;
}
export type ArityVerdict = 'compatible' | 'unknown' | 'incompatible';
// ─── Owner-scoped contributor (concrete shape for `RegistryContributor`) ────
/**
* Per-owner membership view plugged into `LookupParams.ownerScopedContributor`.
*
* When the caller knows a receiver is of type `Owner` (e.g., after
* resolving an explicit receiver or via `self`), it can supply the
* `Owner`'s own member bucket here. `lookupCore` treats hits from this
* contributor as `origin: 'local'` inside the owner's body scope —
* strongest-visibility evidence, unaffected by the scope-chain hop
* deduction that punishes outer-scope hits.
*
* Ring 1's `RegistryContributor = unknown` opaque placeholder is narrowed
* to this concrete shape here in Ring 2 SHARED (#917).
*/
export interface OwnerScopedContributor {
/** The owner (class/struct/trait/interface) that bounds this view. */
readonly ownerDefId: DefId;
/**
* Methods / fields directly declared on the owner, keyed by simple name.
* Return empty array on miss; implementations should NOT walk the MRO —
* that's `MethodDispatchIndex`'s job, handled in the type-binding step.
*/
byName(name: string): readonly SymbolDefinition[];
}
// ─── Top-level context threaded through every lookup ───────────────────────
export interface RegistryContext {
readonly scopes: ScopeTree;
readonly defs: DefIndex;
readonly qualifiedNames: QualifiedNameIndex;
readonly moduleScopes: ModuleScopeIndex;
/**
* Method-dispatch index; required for method/field registries that
* honor `useReceiverTypeBinding`. Omit for class-only lookups.
*/
readonly methodDispatch?: MethodDispatchIndex;
readonly providers: RegistryProviders;
}
// ─── Per-kind default `acceptedKinds` sets ─────────────────────────────────
//
// Exported so the three public registries stay declarative (each one just
// points at the right constant + passes it to `lookupCore`).
export const CLASS_KINDS: readonly NodeLabel[] = Object.freeze([
'Class',
'Interface',
'Enum',
'Struct',
'Union',
'Trait',
'TypeAlias',
'Typedef',
'Record',
'Delegate',
'Annotation',
'Template',
'Namespace',
]);
export const METHOD_KINDS: readonly NodeLabel[] = Object.freeze([
'Method',
'Function',
'Constructor',
]);
export const FIELD_KINDS: readonly NodeLabel[] = Object.freeze([
'Variable',
'Property',
'Const',
'Static',
]);
@@ -0,0 +1,191 @@
/**
* `composeEvidence` — translate accumulated raw signals per candidate
* into a `ResolutionEvidence[]` using the authoritative `EvidenceWeights`
* map (RFC §4.3 + Appendix A; Ring 2 SHARED #917).
*
* Each `RawSignals` record describes what was observed about a candidate
* during the 7-step walk: where it was found, at what depth, whether
* anything corroborates it. This module turns those raw facts into the
* typed evidence list attached to the outgoing `Resolution`.
*
* **Every weight comes from `EvidenceWeights`.** No inline magic numbers.
* Extends issue #429 (centralize hardcoded confidence values).
*
* **Confidence compose rule.** Signals add; the sum is capped at 1.0 at
* the call site (inside `lookupCore`). This module only emits the list;
* it does NOT compute the capped sum so callers can inspect per-signal
* contributions for debugging.
*/
import type { BindingRef, ResolutionEvidence } from '../types.js';
import { EvidenceWeights, typeBindingWeightAtDepth } from '../evidence-weights.js';
/**
* Raw signals observed for a single candidate during the 7-step walk.
* Optional fields encode "this signal did not fire"; presence encodes
* "emit an evidence record".
*/
export interface RawSignals {
// ── Where-found ────────────────────────────────────────────────────────
/** Visibility origin of the binding that produced this candidate. */
readonly origin?: BindingRef['origin'] | 'global-qualified' | 'global-name';
/** Depth at which the binding was found (hops up from start scope). */
readonly scopeChainDepth?: number;
/** `ImportEdge` that brought the name in; present when origin is a non-local. */
readonly viaUnlinkedImport?: boolean;
// ── Type-binding path ──────────────────────────────────────────────────
/** Set when the candidate came via the receiver's type-binding MRO walk. */
readonly typeBindingMroDepth?: number;
// ── Corroborators ──────────────────────────────────────────────────────
/** `def.ownerId === resolvedReceiver.def.nodeId`. */
readonly ownerMatch?: boolean;
/** Always fires for candidates that pass `acceptedKinds`; weight 0. */
readonly kindMatch: true;
// ── Arity ──────────────────────────────────────────────────────────────
readonly arityVerdict?: 'compatible' | 'unknown' | 'incompatible';
// ── Dynamic-unresolved passthrough ─────────────────────────────────────
/** Candidate flows through a `kind: 'dynamic-unresolved'` ImportEdge. */
readonly dynamicUnresolved?: boolean;
}
/**
* Compose the raw signals into a stable `ResolutionEvidence[]` list.
*
* Emission order mirrors the `EvidenceWeights` layout: where-found →
* type-binding → corroborators → arity → degraded. Stable order makes
* the per-signal contributions easy to reason about in tests and in the
* shadow-mode parity dashboard.
*/
export function composeEvidence(signals: RawSignals): readonly ResolutionEvidence[] {
const out: ResolutionEvidence[] = [];
// ── Where-found visibility ─────────────────────────────────────────────
if (signals.origin !== undefined) {
const baseWeight = getOriginWeight(signals.origin);
const capped = signals.viaUnlinkedImport
? baseWeight * EvidenceWeights.unlinkedImportMultiplier
: baseWeight;
const evidenceKind = whereFoundEvidenceKind(signals.origin);
out.push({
kind: evidenceKind,
weight: capped,
...(signals.viaUnlinkedImport
? { note: `via unresolved import (${EvidenceWeights.unlinkedImportMultiplier}× cap)` }
: {}),
});
}
// ── Scope-chain depth deduction (per-hop, only meaningful for lexical
// hits where scopeChainDepth ≥ 1). Depth 0 = no deduction; depth N ≥ 1
// emits a single `scope-chain` evidence with the accumulated penalty.
if (signals.scopeChainDepth !== undefined && signals.scopeChainDepth > 0) {
out.push({
kind: 'scope-chain',
weight: EvidenceWeights.scopeChainPerDepth * signals.scopeChainDepth,
note: `depth=${signals.scopeChainDepth}`,
});
}
// ── Type-binding / MRO path ────────────────────────────────────────────
if (signals.typeBindingMroDepth !== undefined) {
out.push({
kind: 'type-binding',
weight: typeBindingWeightAtDepth(signals.typeBindingMroDepth),
note: `mroDepth=${signals.typeBindingMroDepth}`,
});
}
// ── Owner match (explanatory for debug) ────────────────────────────────
if (signals.ownerMatch === true) {
out.push({
kind: 'owner-match',
weight: EvidenceWeights.ownerMatch,
});
}
// ── Kind match (always present; weight 0; retained for debuggability) ──
out.push({
kind: 'kind-match',
weight: EvidenceWeights.kindMatch,
});
// ── Arity ──────────────────────────────────────────────────────────────
if (signals.arityVerdict !== undefined) {
const weight =
signals.arityVerdict === 'compatible'
? EvidenceWeights.arityMatchCompatible
: signals.arityVerdict === 'incompatible'
? EvidenceWeights.arityMatchIncompatible
: EvidenceWeights.arityMatchUnknown;
out.push({
kind: 'arity-match',
weight,
note: signals.arityVerdict,
});
}
// ── Dynamic-unresolved (degraded signal) ───────────────────────────────
if (signals.dynamicUnresolved === true) {
out.push({
kind: 'dynamic-import-unresolved',
weight: EvidenceWeights.dynamicImportUnresolved,
});
}
return out;
}
/**
* Sum evidence weights and clamp to `[0, 1]`. Separate from `composeEvidence`
* so tests and the parity dashboard can inspect the raw evidence list.
*/
export function confidenceFromEvidence(evidence: readonly ResolutionEvidence[]): number {
let sum = 0;
for (const e of evidence) sum += e.weight;
if (sum < 0) return 0;
if (sum > 1) return 1;
return sum;
}
// ─── Internal ───────────────────────────────────────────────────────────────
function getOriginWeight(origin: NonNullable<RawSignals['origin']>): number {
switch (origin) {
case 'local':
return EvidenceWeights.local;
case 'import':
return EvidenceWeights.import;
case 'reexport':
return EvidenceWeights.reexport;
case 'namespace':
return EvidenceWeights.namespace;
case 'wildcard':
return EvidenceWeights.wildcard;
case 'global-qualified':
return EvidenceWeights.globalQualified;
case 'global-name':
return EvidenceWeights.globalName;
}
}
function whereFoundEvidenceKind(
origin: NonNullable<RawSignals['origin']>,
): ResolutionEvidence['kind'] {
switch (origin) {
case 'local':
return 'local';
case 'import':
case 'reexport':
case 'namespace':
case 'wildcard':
return 'import';
case 'global-qualified':
return 'global-qualified';
case 'global-name':
return 'global-name';
}
}
@@ -0,0 +1,43 @@
/**
* `FieldRegistry` — scope-aware lookup for field / property / variable
* access (RFC §4.4; Ring 2 SHARED #917).
*
* Thin wrapper over `lookupCore`, specialized for data-member kinds:
*
* - `acceptedKinds` = Variable / Property / Const / Static.
* - `useReceiverTypeBinding` is **true** — fields are resolved against
* the receiver type's MRO first, then via the lexical chain for
* free variables.
* - `callsite` is not meaningful for field access (no arity), but the
* `explicitReceiver` and `ownerScopedContributor` knobs are.
*/
import type { Resolution, ScopeId } from '../types.js';
import { lookupCore, type CoreLookupParams } from './lookup-core.js';
import type { OwnerScopedContributor, RegistryContext } from './context.js';
import { FIELD_KINDS } from './context.js';
export interface FieldLookupOptions {
readonly explicitReceiver?: { readonly name: string };
readonly ownerScopedContributor?: OwnerScopedContributor;
}
export interface FieldRegistry {
lookup(name: string, scope: ScopeId, options?: FieldLookupOptions): readonly Resolution[];
}
export function buildFieldRegistry(ctx: RegistryContext): FieldRegistry {
return {
lookup(name: string, scope: ScopeId, options: FieldLookupOptions = {}) {
const params: CoreLookupParams = {
acceptedKinds: FIELD_KINDS,
useReceiverTypeBinding: true,
ownerScopedContributor: options.ownerScopedContributor ?? null,
...(options.explicitReceiver !== undefined
? { explicitReceiver: options.explicitReceiver }
: {}),
};
return lookupCore(name, scope, params, ctx);
},
};
}
@@ -0,0 +1,447 @@
/**
* `lookupCore` — the shared 7-step canonical resolution algorithm
* (RFC §4.2; Ring 2 SHARED #917).
*
* Pure function. Given a name, a starting scope, and per-kind parameters,
* walks lexical scopes + optional type-binding MRO + optional owner
* contributor + global qualified-name fallback, and returns a ranked
* `Resolution[]` with per-candidate evidence.
*
* All three public registries (`ClassRegistry` / `MethodRegistry` /
* `FieldRegistry`) dispatch into this function, differing only in the
* parameters they pass. The CHOICE of which steps fire is expressed
* through `LookupParams`, not through different algorithms per kind.
*
* ## Algorithm (RFC §4.2, verbatim names)
*
* **Step 1 — Lexical scope-chain walk.** From `startScope`, walk
* parent-ward. At each scope, consult `scope.bindings.get(name)`:
* - Filter candidates whose `def.type ∈ acceptedKinds`.
* - For each surviving candidate, record a raw signal with the
* binding's origin + the current scope-chain depth.
* - **Hard shadow.** If `bindings.get(name)` is non-empty (including
* non-kind-matching candidates), stop walking. The name is
* lexically bound here; outer scopes are not consulted.
*
* **Step 2 — Type-binding resolution.** When `useReceiverTypeBinding`
* is true, resolve the receiver's type at `startScope` (from
* `scope.typeBindings`), then walk the MRO via
* `MethodDispatchIndex.mroFor(ownerDefId)`. Membership per owner comes
* through `RegistryContext.methodDispatch` + owner lookups into
* `scope.ownedDefs`; each hit records a raw signal with the owner's
* MRO depth.
*
* **Step 3 — Owner-scoped contributor.** When
* `params.ownerScopedContributor` is present, merge its `byName(name)`
* hits with `origin: 'local'` (they are declared directly on the
* receiver). Distinct from Step 2 — Step 2 walks the MRO; Step 3 only
* looks at the directly-declared owner members.
*
* **Step 4 — Kind filter (emit `kind-match` evidence).** Already
* applied during Steps 1-3; this step just adds a `kind-match` signal
* at weight 0 to every candidate for debuggability (so the evidence
* array is self-describing).
*
* **Step 5 — Arity filter.** Call `providers.arityCompatibility(callsite,
* def)` per surviving candidate. Verdicts: `compatible` / `unknown` /
* `incompatible`. If at least one candidate is `compatible`, drop
* `incompatible` ones. Otherwise keep all (the penalty weight alone
* will rank them lower but they remain in the result).
*
* **Step 6 — Global fallback.** When Steps 1-3 produced **no**
* candidates and the name contains a `.`, consult the
* `QualifiedNameIndex` via `lookupQualified` — see §4.5. The `scope`
* argument is NOT passed here because global lookup is scope-agnostic.
*
* **Step 7 — Rank + tie-break.** Compose evidence, compute confidence
* (sum capped at 1.0), sort by the RFC Appendix B cascade.
*
* ## What this module does NOT do
*
* - No AST reads (pure data in, pure data out).
* - No `gitnexus/` imports.
* - No language switches. Language-specific behavior flows exclusively
* through `providers.*` and the `params` object.
* - No caching. Callers that want memoization can wrap this function.
*/
import type { NodeLabel } from '../../graph/types.js';
import type { SymbolDefinition } from '../symbol-definition.js';
import type {
BindingRef,
Callsite,
DefId,
LookupParams,
Resolution,
Scope,
ScopeId,
} from '../types.js';
import type { OriginForTieBreak } from '../origin-priority.js';
import { composeEvidence, confidenceFromEvidence, type RawSignals } from './evidence.js';
import { compareByConfidenceWithTiebreaks, type TieBreakKey } from './tie-breaks.js';
import { lookupQualified } from './lookup-qualified.js';
import type { ArityVerdict, OwnerScopedContributor, RegistryContext } from './context.js';
// ─── Public entry point ─────────────────────────────────────────────────────
/** Extended `LookupParams` narrowing `ownerScopedContributor` to the concrete shape. */
export interface CoreLookupParams extends Omit<LookupParams, 'ownerScopedContributor'> {
readonly ownerScopedContributor: OwnerScopedContributor | null;
/** Call-site description forwarded to `arityCompatibility`. Optional — for non-call lookups. */
readonly callsite?: Callsite;
}
/**
* Run the 7-step lookup. Returns a non-empty `Resolution[]` when any
* candidate was found; an empty array otherwise. Callers consume `[0]`
* for the best answer and optionally inspect the rest for alternates.
*/
export function lookupCore(
name: string,
startScope: ScopeId,
params: CoreLookupParams,
ctx: RegistryContext,
): readonly Resolution[] {
const acceptedKinds = new Set<NodeLabel>(params.acceptedKinds);
const perCandidate = new Map<DefId, CandidateState>();
// ── Step 1: lexical scope-chain walk ──────────────────────────────────
const lexicalShadowed = walkLexicalChain(name, startScope, acceptedKinds, ctx, perCandidate);
// ── Step 2: type-binding / MRO walk (methods/fields) ──────────────────
if (params.useReceiverTypeBinding && ctx.methodDispatch !== undefined) {
walkReceiverTypeBinding(name, startScope, acceptedKinds, params, ctx, perCandidate);
}
// ── Step 3: owner-scoped contributor ──────────────────────────────────
if (params.ownerScopedContributor !== null) {
seedFromOwnerScopedContributor(
name,
params.ownerScopedContributor,
acceptedKinds,
perCandidate,
);
}
// ── Step 4: kind-match evidence (emitted by composeEvidence directly) ──
// Handled inside `composeEvidence`.
// ── Step 5: arity filter ──────────────────────────────────────────────
if (params.callsite !== undefined) {
applyArityFilter(params.callsite, perCandidate, ctx);
}
// ── Step 6: global fallback (only when Steps 1-3 produced nothing) ──
if (perCandidate.size === 0 && !lexicalShadowed && name.includes('.')) {
const globals = lookupQualified(name, { acceptedKinds: params.acceptedKinds }, ctx);
if (globals.length > 0) return globals;
}
if (perCandidate.size === 0) return EMPTY;
// ── Step 7: compose evidence + rank ──────────────────────────────────
return rankCandidates(perCandidate);
}
// ─── Internal state ────────────────────────────────────────────────────────
interface CandidateState {
readonly def: SymbolDefinition;
readonly signals: MutableRawSignals;
readonly tieBreakKey: MutableTieBreakKey;
}
interface MutableRawSignals {
origin?: BindingRef['origin'] | 'global-qualified' | 'global-name';
scopeChainDepth?: number;
viaUnlinkedImport?: boolean;
typeBindingMroDepth?: number;
ownerMatch?: boolean;
kindMatch: true;
arityVerdict?: ArityVerdict;
dynamicUnresolved?: boolean;
}
interface MutableTieBreakKey {
scopeDepth: number;
mroDepth: number;
origin: OriginForTieBreak;
}
function ensureCandidate(
perCandidate: Map<DefId, CandidateState>,
def: SymbolDefinition,
): CandidateState {
const existing = perCandidate.get(def.nodeId);
if (existing !== undefined) return existing;
const fresh: CandidateState = {
def,
signals: { kindMatch: true },
tieBreakKey: { scopeDepth: 0, mroDepth: 0, origin: 'local' },
};
perCandidate.set(def.nodeId, fresh);
return fresh;
}
// ─── Step 1 implementation ─────────────────────────────────────────────────
/**
* Walk the lexical scope chain from `startScope` upward. Returns `true`
* iff a scope with any `bindings.get(name)` entries was found — the
* caller uses this to decide whether to run the global fallback.
*/
function walkLexicalChain(
name: string,
startScope: ScopeId,
acceptedKinds: ReadonlySet<NodeLabel>,
ctx: RegistryContext,
perCandidate: Map<DefId, CandidateState>,
): boolean {
let currentId: ScopeId | null = startScope;
let depth = 0;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId)) return false;
visited.add(currentId);
const scope: Scope | undefined = ctx.scopes.getScope(currentId);
if (scope === undefined) return false;
const bindings = scope.bindings.get(name);
if (bindings !== undefined && bindings.length > 0) {
for (const binding of bindings) {
if (!acceptedKinds.has(binding.def.type)) continue;
recordLexicalHit(perCandidate, binding, depth);
}
return true; // hard shadow regardless of kind-filter survivorship
}
currentId = scope.parent;
depth++;
}
return false;
}
function recordLexicalHit(
perCandidate: Map<DefId, CandidateState>,
binding: BindingRef,
scopeChainDepth: number,
): void {
const state = ensureCandidate(perCandidate, binding.def);
state.signals.origin = binding.origin;
state.signals.scopeChainDepth = scopeChainDepth;
if (binding.via?.linkStatus === 'unresolved') {
state.signals.viaUnlinkedImport = true;
}
if (binding.via?.kind === 'dynamic-unresolved') {
state.signals.dynamicUnresolved = true;
}
state.tieBreakKey.scopeDepth = scopeChainDepth;
state.tieBreakKey.origin = binding.origin as OriginForTieBreak;
}
// ─── Step 2 implementation ─────────────────────────────────────────────────
function walkReceiverTypeBinding(
name: string,
startScope: ScopeId,
acceptedKinds: ReadonlySet<NodeLabel>,
params: CoreLookupParams,
ctx: RegistryContext,
perCandidate: Map<DefId, CandidateState>,
): void {
const ownerDefId = resolveReceiverOwner(startScope, params, ctx);
if (ownerDefId === undefined) return;
if (ctx.methodDispatch === undefined) return;
const ownerDef = ctx.defs.get(ownerDefId);
if (ownerDef === undefined) return;
// Walk the owner itself at depth 0, then its MRO chain.
const walk: DefId[] = [ownerDefId, ...ctx.methodDispatch.mroFor(ownerDefId)];
for (let mroDepth = 0; mroDepth < walk.length; mroDepth++) {
const currentOwnerId = walk[mroDepth]!;
const members = collectOwnedMembers(currentOwnerId, name, ctx);
for (const def of members) {
if (!acceptedKinds.has(def.type)) continue;
recordTypeBindingHit(perCandidate, def, mroDepth, ownerDefId);
}
}
}
function resolveReceiverOwner(
startScope: ScopeId,
params: CoreLookupParams,
ctx: RegistryContext,
): DefId | undefined {
// Explicit receiver: consult the callsite scope's typeBindings for the
// named receiver; the attached TypeRef identifies the owner. Without a
// ready resolveTypeRef call (that module is separate), we do a direct
// lookup and trust the caller to have populated the binding.
if (params.explicitReceiver !== undefined) {
return lookupReceiverType(startScope, params.explicitReceiver.name, ctx);
}
// Implicit `self` / `this` — the scope's typeBindings should carry it.
for (const implicitName of IMPLICIT_RECEIVERS) {
const owner = lookupReceiverType(startScope, implicitName, ctx);
if (owner !== undefined) return owner;
}
return undefined;
}
const IMPLICIT_RECEIVERS: readonly string[] = Object.freeze(['self', 'this']);
function lookupReceiverType(
startScope: ScopeId,
receiverName: string,
ctx: RegistryContext,
): DefId | undefined {
let currentId: ScopeId | null = startScope;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId)) return undefined;
visited.add(currentId);
const scope = ctx.scopes.getScope(currentId);
if (scope === undefined) return undefined;
const typeRef = scope.typeBindings.get(receiverName);
if (typeRef !== undefined) {
// rawName must resolve to a def via qualifiedNames; if it doesn't, we
// can't claim the receiver type. No fallback — that's what
// `resolveTypeRef` would do, but we keep this path lean and let
// callers pre-resolve if they want the richer semantics.
const candidateIds = ctx.qualifiedNames.get(typeRef.rawName);
if (candidateIds.length === 1) return candidateIds[0];
// If ambiguous or missing, try a name-match among class-like defs —
// but only when the rawName has no dots (simple name).
return undefined;
}
currentId = scope.parent;
}
return undefined;
}
function collectOwnedMembers(
ownerDefId: DefId,
memberName: string,
ctx: RegistryContext,
): readonly SymbolDefinition[] {
// An owner's members are defs whose `ownerId === ownerDefId` and whose
// simple name matches `memberName`. We iterate `defs.byId` — O(D) per
// call today. A future by-owner index would make this O(K); tracked as
// a follow-up optimization before Ring 3 flips go production.
const out: SymbolDefinition[] = [];
for (const def of ctx.defs.byId.values()) {
if (def.ownerId !== ownerDefId) continue;
if (simpleNameOf(def) !== memberName) continue;
out.push(def);
}
return out;
}
function simpleNameOf(def: SymbolDefinition): string | undefined {
if (def.qualifiedName === undefined || def.qualifiedName.length === 0) return undefined;
const dot = def.qualifiedName.lastIndexOf('.');
return dot === -1 ? def.qualifiedName : def.qualifiedName.slice(dot + 1);
}
function recordTypeBindingHit(
perCandidate: Map<DefId, CandidateState>,
def: SymbolDefinition,
mroDepth: number,
receiverOwner: DefId,
): void {
const state = ensureCandidate(perCandidate, def);
// Only replace if this hit is shallower (smaller MRO depth).
if (
state.signals.typeBindingMroDepth === undefined ||
mroDepth < state.signals.typeBindingMroDepth
) {
state.signals.typeBindingMroDepth = mroDepth;
state.tieBreakKey.mroDepth = mroDepth;
}
if (def.ownerId === receiverOwner) {
state.signals.ownerMatch = true;
}
}
// ─── Step 3 implementation ─────────────────────────────────────────────────
function seedFromOwnerScopedContributor(
name: string,
contributor: OwnerScopedContributor,
acceptedKinds: ReadonlySet<NodeLabel>,
perCandidate: Map<DefId, CandidateState>,
): void {
for (const def of contributor.byName(name)) {
if (!acceptedKinds.has(def.type)) continue;
const state = ensureCandidate(perCandidate, def);
// Treat the contributor's direct membership as `origin: 'local'` —
// strongest visibility, no scope-chain penalty.
state.signals.origin = 'local';
state.signals.scopeChainDepth = 0;
state.signals.ownerMatch = def.ownerId === contributor.ownerDefId;
state.tieBreakKey.origin = 'local';
}
}
// ─── Step 5 implementation ─────────────────────────────────────────────────
function applyArityFilter(
callsite: Callsite,
perCandidate: Map<DefId, CandidateState>,
ctx: RegistryContext,
): void {
const arityFn = ctx.providers.arityCompatibility;
if (arityFn === undefined) {
// No provider → record 'unknown' for every candidate; keeps signal
// shape uniform for composeEvidence.
for (const state of perCandidate.values()) {
state.signals.arityVerdict = 'unknown';
}
return;
}
let anyCompatible = false;
for (const state of perCandidate.values()) {
const verdict = arityFn(callsite, state.def);
state.signals.arityVerdict = verdict;
if (verdict === 'compatible') anyCompatible = true;
}
if (!anyCompatible) return;
// Filter: when at least one compatible candidate exists, drop incompatibles.
for (const [defId, state] of perCandidate) {
if (state.signals.arityVerdict === 'incompatible') {
perCandidate.delete(defId);
}
}
}
// ─── Step 7 implementation ─────────────────────────────────────────────────
function rankCandidates(perCandidate: Map<DefId, CandidateState>): readonly Resolution[] {
const resolutions: Resolution[] = [];
const tieKeys = new Map<string, TieBreakKey>();
for (const state of perCandidate.values()) {
const evidence = composeEvidence(state.signals as RawSignals);
const confidence = confidenceFromEvidence(evidence);
resolutions.push({ def: state.def, confidence, evidence });
tieKeys.set(state.def.nodeId, { ...state.tieBreakKey });
}
resolutions.sort((a, b) => compareByConfidenceWithTiebreaks(a, b, tieKeys));
return Object.freeze(resolutions);
}
// ─── Constants ──────────────────────────────────────────────────────────────
const EMPTY: readonly Resolution[] = Object.freeze([]);
@@ -0,0 +1,71 @@
/**
* `lookupQualified` — qualified-name fast path (RFC §4.5; Ring 2 SHARED #917).
*
* Consults `QualifiedNameIndex` directly, filters by `acceptedKinds`, and
* returns `Resolution[]` with `origin: 'global-qualified'` evidence. Used by:
*
* - `resolveTypeRef` dotted fallback (#916)
* - `Registry.lookup` Step 6 when no lexical candidate survived
* - Explicit dotted identifiers in Cypher / MCP tools where the caller
* knows the target's canonical qualified name
*
* **Strict + deterministic.** No receiver-type resolution, no scope walk.
* Every surviving candidate gets the same base confidence (from
* `EvidenceWeights.globalQualified`), then the tie-break cascade
* disambiguates.
*/
import type { NodeLabel } from '../../graph/types.js';
import type { Resolution } from '../types.js';
import { composeEvidence, confidenceFromEvidence } from './evidence.js';
import { compareByConfidenceWithTiebreaks, type TieBreakKey } from './tie-breaks.js';
import type { RegistryContext } from './context.js';
export interface LookupQualifiedParams {
readonly acceptedKinds: readonly NodeLabel[];
}
/**
* Look up a canonical qualified name (e.g., `app.models.User`) across all
* defs, filtered by `acceptedKinds`. Returns an empty array when the name
* is not indexed or no candidate matches the kind filter.
*
* Callers consume `[0]` for the strict single-return answer; the remainder
* carries alternate candidates (partial classes, overloads, accidental
* cross-kind hits) ordered by the tie-break cascade.
*/
export function lookupQualified(
qualifiedName: string,
params: LookupQualifiedParams,
ctx: RegistryContext,
): readonly Resolution[] {
const defIds = ctx.qualifiedNames.get(qualifiedName);
if (defIds.length === 0) return EMPTY;
const acceptedKinds = new Set<NodeLabel>(params.acceptedKinds);
const resolutions: Resolution[] = [];
const tieKeys = new Map<string, TieBreakKey>();
for (const defId of defIds) {
const def = ctx.defs.get(defId);
if (def === undefined) continue;
if (!acceptedKinds.has(def.type)) continue;
const evidence = composeEvidence({ origin: 'global-qualified', kindMatch: true });
const confidence = confidenceFromEvidence(evidence);
resolutions.push({ def, confidence, evidence });
tieKeys.set(def.nodeId, {
scopeDepth: 0,
mroDepth: 0,
origin: 'global-qualified',
});
}
if (resolutions.length === 0) return EMPTY;
resolutions.sort((a, b) => compareByConfidenceWithTiebreaks(a, b, tieKeys));
return Object.freeze(resolutions);
}
const EMPTY: readonly Resolution[] = Object.freeze([]);
@@ -0,0 +1,54 @@
/**
* `MethodRegistry` — scope-aware lookup for method / function / constructor
* dispatch (RFC §4.4; Ring 2 SHARED #917).
*
* Thin wrapper over `lookupCore`, specialized for callable kinds:
*
* - `acceptedKinds` = Method / Function / Constructor.
* - `useReceiverTypeBinding` is **true** — the type-binding + MRO walk
* (Step 2) is the primary evidence path for receiver-dispatched calls.
* - `callsite.arity` flows through to `provider.arityCompatibility`
* when provided. When the provider is absent, arity evidence is
* `unknown` (neutral signal).
*/
import type { Callsite, Resolution, ScopeId } from '../types.js';
import { lookupCore, type CoreLookupParams } from './lookup-core.js';
import type { OwnerScopedContributor, RegistryContext } from './context.js';
import { METHOD_KINDS } from './context.js';
/**
* Extra per-call parameters that vary across call sites but NOT across
* registries. Kept as a separate shape so `MethodRegistry.lookup` stays
* concise while still exposing the explicit-receiver + owner-contributor +
* arity knobs the RFC algorithm needs.
*/
export interface MethodLookupOptions {
/** Call-site arity for `provider.arityCompatibility`. */
readonly callsite?: Callsite;
/** Explicit receiver (e.g., `user` in `user.save()`). See §4.1. */
readonly explicitReceiver?: { readonly name: string };
/** Optional per-owner contributor (Step 3). */
readonly ownerScopedContributor?: OwnerScopedContributor;
}
export interface MethodRegistry {
lookup(name: string, scope: ScopeId, options?: MethodLookupOptions): readonly Resolution[];
}
export function buildMethodRegistry(ctx: RegistryContext): MethodRegistry {
return {
lookup(name: string, scope: ScopeId, options: MethodLookupOptions = {}) {
const params: CoreLookupParams = {
acceptedKinds: METHOD_KINDS,
useReceiverTypeBinding: true,
ownerScopedContributor: options.ownerScopedContributor ?? null,
...(options.callsite !== undefined ? { callsite: options.callsite } : {}),
...(options.explicitReceiver !== undefined
? { explicitReceiver: options.explicitReceiver }
: {}),
};
return lookupCore(name, scope, params, ctx);
},
};
}
@@ -0,0 +1,76 @@
/**
* `compareByConfidenceWithTiebreaks` — the RFC §4.2 Step 7 total order
* over `Resolution` candidates (Ring 2 SHARED #917).
*
* Primary key is confidence (DESC). Remaining ties within `CONFIDENCE_EPSILON`
* fall through a deterministic cascade so the same inputs always produce
* the same winner, independent of insertion order.
*
* Tie-break cascade (per RFC Appendix B):
*
* 1. confidence DESC (primary)
* 2. scope depth ASC (nearer lexical scope wins)
* 3. MRO depth ASC (nearer class in hierarchy wins)
* 4. `ORIGIN_PRIORITY` ASC (local > import > … > global-name)
* 5. DefId.localeCompare (final deterministic tiebreaker)
*
* The per-candidate inputs needed beyond `Resolution.confidence` —
* `scopeDepth`, `mroDepth`, `origin` — are supplied via a sidecar
* `TieBreakKey` so the comparator stays pure and `Resolution` itself
* doesn't need to carry book-keeping fields.
*/
import { ORIGIN_PRIORITY, type OriginForTieBreak } from '../origin-priority.js';
import type { Resolution } from '../types.js';
export const CONFIDENCE_EPSILON = 0.001;
/** Side-information per candidate used for secondary tie-breaks. */
export interface TieBreakKey {
readonly scopeDepth: number;
readonly mroDepth: number;
readonly origin: OriginForTieBreak;
}
/**
* Pure comparator suitable for `Array.prototype.sort`. Return value follows
* the JavaScript convention: negative → `a` wins, positive → `b` wins.
*
* **Important:** `keys` is keyed by `Resolution.def.nodeId`, not by array
* index — stable across reorderings. Missing keys fall back to neutral
* values (`scopeDepth: 0`, `mroDepth: 0`, `origin: 'local'`), which means
* the tie-break degrades gracefully to defId-lexicographic ordering when
* side-info is unavailable. That keeps the total order deterministic
* even on malformed inputs.
*/
export function compareByConfidenceWithTiebreaks(
a: Resolution,
b: Resolution,
keys: ReadonlyMap<string, TieBreakKey>,
): number {
// Primary: confidence DESC, treating values within epsilon as equal.
const delta = b.confidence - a.confidence;
if (Math.abs(delta) >= CONFIDENCE_EPSILON) return delta < 0 ? -1 : 1;
const ka = keys.get(a.def.nodeId) ?? DEFAULT_KEY;
const kb = keys.get(b.def.nodeId) ?? DEFAULT_KEY;
// Secondary: scope depth ASC.
if (ka.scopeDepth !== kb.scopeDepth) return ka.scopeDepth - kb.scopeDepth;
// Tertiary: MRO depth ASC.
if (ka.mroDepth !== kb.mroDepth) return ka.mroDepth - kb.mroDepth;
// Quaternary: ORIGIN_PRIORITY ASC.
const po = ORIGIN_PRIORITY[ka.origin] - ORIGIN_PRIORITY[kb.origin];
if (po !== 0) return po;
// Final: DefId lexicographic, locale-aware for deterministic cross-platform output.
return a.def.nodeId.localeCompare(b.def.nodeId);
}
const DEFAULT_KEY: TieBreakKey = Object.freeze({
scopeDepth: 0,
mroDepth: 0,
origin: 'local',
});
@@ -0,0 +1,152 @@
/**
* `resolveTypeRef` — strict single-return resolver for `TypeRef`s
* (RFC §4.6; Ring 2 SHARED #916).
*
* Narrower contract than `Registry.lookup`: no name-only global fallback, no
* confidence ranking, no arity check. Used by `Registry.lookup` Step 2 (type-
* binding propagation) and by any caller that wants the single best type-
* target for an annotation without paying for the full evidence pipeline.
*
* **Algorithm (strict).** Walk the scope chain from `ref.declaredAtScope`:
*
* 1. At each scope, inspect `bindings.get(ref.rawName)`:
* - If one of the bindings is a **type-kind** def with a **strict origin**
* (`'local' | 'import' | 'namespace' | 'reexport'`), return it.
* - If any binding for this name exists at this scope but none qualifies
* (e.g., a local variable named `User` shadows an outer import of class
* `User`), return `null`. The nearer binding shadows; we do NOT fall
* through to the global qualified-name index.
* - Otherwise continue to the parent scope.
* 2. If the raw name is a dotted path (e.g., `'models.User'`) and the scope
* walk produced no match, consult `QualifiedNameIndex.byQualifiedName`.
* Only accept **exactly one** type-kind hit — anything ambiguous returns
* `null` rather than a guess.
* 3. Return `null`.
*
* **What `'strict' origins' means.** `'wildcard'` is intentionally excluded.
* A wildcard-expanded name (`from x import *`) is too loose to use as an
* anchor for type resolution — it gives no signal about whether the name was
* actually imported. `Registry.lookup` may accept wildcard bindings at its
* own discretion (with lower evidence weight); `resolveTypeRef` does not.
*
* **What 'type-kind' means.** The subset of `NodeLabel` that a type annotation
* may legitimately reference: class-like, interface-like, enum-like, and
* alias-like kinds. See `TYPE_KINDS` below.
*
* Pure function — safe to call repeatedly; no side effects.
*/
import type { NodeLabel } from '../graph/types.js';
import type { SymbolDefinition } from './symbol-definition.js';
import type { BindingRef, Scope, ScopeId, TypeRef } from './types.js';
import type { DefIndex } from './def-index.js';
import type { QualifiedNameIndex } from './qualified-name-index.js';
// ─── Public contracts ───────────────────────────────────────────────────────
/**
* Minimal scope-lookup contract required by `resolveTypeRef`. Implemented by
* the `ScopeTree` from #912; declared here so #916 can ship as a standalone
* piece without a hard dependency on the full scope-tree implementation. Any
* structure that hands back a `Scope` by `ScopeId` satisfies this contract.
*/
export interface ScopeLookup {
getScope(id: ScopeId): Scope | undefined;
}
/**
* All inputs `resolveTypeRef` needs from the semantic model. Bundled into a
* context object so the call site stays short and the interface is stable as
* additional indexes get threaded through in later rings.
*/
export interface ResolveTypeRefContext {
readonly scopes: ScopeLookup;
readonly defIndex: DefIndex;
readonly qualifiedNameIndex: QualifiedNameIndex;
}
// ─── Strict policy constants ────────────────────────────────────────────────
/** `'wildcard'` is deliberately absent. See file header. */
const STRICT_ORIGINS: ReadonlySet<BindingRef['origin']> = new Set<BindingRef['origin']>([
'local',
'import',
'namespace',
'reexport',
]);
/**
* `NodeLabel` values that may appear on the RHS of a type annotation.
*
* Includes the usual class-like and interface-like kinds plus the alias-like
* ones (`TypeAlias`, `Typedef`). `Namespace` is excluded — it is a scope
* container, not a value type. `Function` / `Method` / `Variable` are
* excluded by design: a `rawName` bound to them at a strict origin is a
* *shadowing* binding, which the algorithm short-circuits to `null`.
*/
const TYPE_KINDS: ReadonlySet<NodeLabel> = new Set<NodeLabel>([
'Class',
'Interface',
'Enum',
'Struct',
'Union',
'Trait',
'TypeAlias',
'Typedef',
'Record',
'Delegate',
'Annotation',
'Template',
]);
// ─── Main entry point ──────────────────────────────────────────────────────
export function resolveTypeRef(ref: TypeRef, ctx: ResolveTypeRefContext): SymbolDefinition | null {
// Phase 1: scope-chain walk anchored at the declaration site.
let currentId: ScopeId | null = ref.declaredAtScope;
const visited = new Set<ScopeId>();
while (currentId !== null) {
// Cycle guard — a well-formed scope tree never loops, but a bug in the
// construction path should fail fast here rather than hanging.
if (visited.has(currentId)) return null;
visited.add(currentId);
const scope = ctx.scopes.getScope(currentId);
if (scope === undefined) return null; // broken chain = unresolvable
const bindings = scope.bindings.get(ref.rawName);
if (bindings !== undefined && bindings.length > 0) {
// At least one binding exists at this scope → it is the shadowing site.
// Either one of them qualifies, or the name is shadowed by a non-type.
for (const binding of bindings) {
if (!STRICT_ORIGINS.has(binding.origin)) continue;
if (TYPE_KINDS.has(binding.def.type)) {
return binding.def;
}
}
// Shadowed by a non-type / non-strict-origin binding. Fail fast — no
// global fallback, no walk to the parent.
return null;
}
currentId = scope.parent;
}
// Phase 2: dotted fallback via `QualifiedNameIndex`. Only accept a unique
// type-kind hit; anything ambiguous returns null (strict: no guesses).
if (ref.rawName.includes('.')) {
const candidates = ctx.qualifiedNameIndex.get(ref.rawName);
let onlyTypeDef: SymbolDefinition | null = null;
for (const defId of candidates) {
const def = ctx.defIndex.get(defId);
if (def === undefined) continue;
if (!TYPE_KINDS.has(def.type)) continue;
if (onlyTypeDef !== null) return null; // ambiguous
onlyTypeDef = def;
}
if (onlyTypeDef !== null) return onlyTypeDef;
}
return null;
}
@@ -0,0 +1,57 @@
/**
* `ScopeId` canonical constructor + string intern pool
* (RFC §2.2; Ring 2 SHARED #912).
*
* `ScopeId` is a deterministic string derived from the scope's file path,
* byte range, and kind:
*
* scope:{filePath}#{startLine}:{startCol}-{endLine}:{endCol}:{kind}
*
* Two scopes produced by reparsing the same file at the same positions are
* `===`-equal as strings. Beyond the canonical shape, `makeScopeId` also
* **interns** the string through a process-local pool, so repeated calls
* with structurally identical inputs return the same string reference —
* making `Map<ScopeId, ...>` lookups and cache keys identity-fast.
*
* The intern pool is unbounded. The number of distinct `ScopeId`s across a
* single indexing run is O(total scopes in workspace), which is bounded by
* source-text size and already in memory; interning adds no asymptotic
* pressure. `clearScopeIdInternPool` is exported for test isolation.
*/
import type { Range } from './types.js';
import type { ScopeId, ScopeKind } from './types.js';
/** Inputs required to construct a canonical `ScopeId`. */
export interface ScopeIdInput {
readonly filePath: string;
readonly range: Range;
readonly kind: ScopeKind;
}
/**
* Build a canonical `ScopeId` from its structural parts and intern it.
*
* Pure + referentially transparent: given the same input shape, always
* returns the same string reference for the lifetime of the pool.
*/
export function makeScopeId(input: ScopeIdInput): ScopeId {
const raw = `scope:${input.filePath}#${input.range.startLine}:${input.range.startCol}-${input.range.endLine}:${input.range.endCol}:${input.kind}`;
const existing = INTERN_POOL.get(raw);
if (existing !== undefined) return existing;
INTERN_POOL.set(raw, raw);
return raw;
}
/**
* Drop the intern pool. Intended for test setup/teardown — production code
* should not need this, since the pool's memory usage is bounded by the
* number of live scopes and cleaning it mid-run would break identity
* equality for existing scope ids.
*/
export function clearScopeIdInternPool(): void {
INTERN_POOL.clear();
}
/** Internal: shared intern pool (process-local). */
const INTERN_POOL = new Map<string, string>();
@@ -0,0 +1,254 @@
/**
* `ScopeTree` — the lexical-scope spine of the `SemanticModel`
* (RFC §2.2 + §3.1; Ring 2 SHARED #912).
*
* Generalizes the `enclosingFunctions` pattern from closed PR #902 to
* arbitrary `ScopeKind`s. Owns the (parent ↔ children) relationship
* derived from each `Scope.parent` pointer, and validates the structural
* invariants a well-formed scope tree must satisfy.
*
* Invariants enforced at build time (throw on violation):
*
* - Every non-`Module` scope has a non-null parent.
* - Every parent pointer references a scope that was also supplied to
* `buildScopeTree`.
* - Parent range **strictly contains** child range.
* - Sibling ranges under the same parent do not overlap.
* - Parent and child live in the same `filePath`. (Cross-file parent
* pointers would be a category error — a `File` scope is not the
* parent of another file's scopes; imports do that job.)
*
* Satisfies the `ScopeLookup` contract from #916 (`resolve-type-ref`), so
* `resolveTypeRef` can take a `ScopeTree` directly without adapters.
*
* Immutable surface: `byId` is a `ReadonlyMap`; children arrays are
* `Object.freeze`d; miss lookups return a shared frozen empty array.
*/
import type { Scope, ScopeId, Range } from './types.js';
import type { ScopeLookup } from './resolve-type-ref.js';
// ─── Public contract ────────────────────────────────────────────────────────
export interface ScopeTree extends ScopeLookup {
readonly size: number;
readonly byId: ReadonlyMap<ScopeId, Scope>;
getScope(id: ScopeId): Scope | undefined;
getParent(id: ScopeId): Scope | undefined;
/** Child `ScopeId`s of `id`, in input order. Frozen empty array on miss. */
getChildren(id: ScopeId): readonly ScopeId[];
/**
* Ancestor chain from the immediate parent up to (and including) the
* root module scope. Excludes the starting scope itself. Frozen empty
* array on miss / for a root scope.
*/
getAncestors(id: ScopeId): readonly ScopeId[];
has(id: ScopeId): boolean;
}
// ─── Build errors ───────────────────────────────────────────────────────────
/**
* Thrown by `buildScopeTree` when the input violates a structural
* invariant. Carries the offending ids + the invariant name so failed
* extraction pipelines can report actionable diagnostics.
*/
export class ScopeTreeInvariantError extends Error {
constructor(
readonly invariant:
| 'non-module-requires-parent'
| 'parent-not-found'
| 'parent-must-contain-child'
| 'sibling-ranges-overlap'
| 'parent-must-share-filepath'
| 'duplicate-scope-id',
message: string,
) {
super(message);
this.name = 'ScopeTreeInvariantError';
}
}
// ─── Builder ───────────────────────────────────────────────────────────────
/**
* Build an immutable `ScopeTree` from a flat list of `Scope` records.
*
* Throws `ScopeTreeInvariantError` on the first invariant violation; a
* malformed tree is a bug in the extraction pipeline, not a data case for
* consumers to handle, so fail-fast is the correct posture.
*/
export function buildScopeTree(scopes: readonly Scope[]): ScopeTree {
const byId = new Map<ScopeId, Scope>();
const childrenById = new Map<ScopeId, ScopeId[]>();
// ── Pass 1: collect by id + duplicate check ───────────────────────────
for (const scope of scopes) {
if (byId.has(scope.id)) {
throw new ScopeTreeInvariantError(
'duplicate-scope-id',
`Two scopes share id '${scope.id}'. Scope ids must be unique per tree.`,
);
}
byId.set(scope.id, scope);
}
// ── Pass 2: validate parent pointers + build children buckets ─────────
for (const scope of scopes) {
if (scope.parent === null) {
if (scope.kind !== 'Module') {
throw new ScopeTreeInvariantError(
'non-module-requires-parent',
`Scope '${scope.id}' has kind '${scope.kind}' but no parent. Only 'Module' scopes may be root-level.`,
);
}
continue;
}
const parent = byId.get(scope.parent);
if (parent === undefined) {
throw new ScopeTreeInvariantError(
'parent-not-found',
`Scope '${scope.id}' references parent '${scope.parent}' which is not part of this tree.`,
);
}
if (parent.filePath !== scope.filePath) {
throw new ScopeTreeInvariantError(
'parent-must-share-filepath',
`Scope '${scope.id}' (${scope.filePath}) has parent '${parent.id}' in a different file (${parent.filePath}). Parent/child scopes must share filePath.`,
);
}
if (!rangeStrictlyContains(parent.range, scope.range)) {
throw new ScopeTreeInvariantError(
'parent-must-contain-child',
`Parent scope '${parent.id}' at ${formatRange(parent.range)} does not strictly contain child '${scope.id}' at ${formatRange(scope.range)}.`,
);
}
let bucket = childrenById.get(parent.id);
if (bucket === undefined) {
bucket = [];
childrenById.set(parent.id, bucket);
}
bucket.push(scope.id);
}
// ── Pass 3: sibling-overlap check ─────────────────────────────────────
for (const [parentId, childIds] of childrenById) {
if (childIds.length < 2) continue;
// Sort siblings by (startLine, startCol) for an O(n log n) pairwise
// scan instead of O(n²) all-pairs.
const children = childIds.map((id) => byId.get(id)!).slice();
children.sort((a, b) => comparePosition(a.range, b.range));
for (let i = 1; i < children.length; i++) {
const prev = children[i - 1]!;
const curr = children[i]!;
if (rangesOverlap(prev.range, curr.range)) {
throw new ScopeTreeInvariantError(
'sibling-ranges-overlap',
`Sibling scopes under parent '${parentId}' overlap: '${prev.id}' ${formatRange(prev.range)} and '${curr.id}' ${formatRange(curr.range)}.`,
);
}
}
}
// Freeze children arrays so the surface is truly read-only.
const frozenChildren = new Map<ScopeId, readonly ScopeId[]>();
for (const [parentId, childIds] of childrenById) {
frozenChildren.set(parentId, Object.freeze(childIds.slice()));
}
return freezeTree(byId, frozenChildren);
}
// ─── Internals ──────────────────────────────────────────────────────────────
const EMPTY_CHILDREN: readonly ScopeId[] = Object.freeze([]);
function freezeTree(
byId: Map<ScopeId, Scope>,
childrenById: Map<ScopeId, readonly ScopeId[]>,
): ScopeTree {
return {
byId,
get size() {
return byId.size;
},
getScope(id: ScopeId): Scope | undefined {
return byId.get(id);
},
getParent(id: ScopeId): Scope | undefined {
const scope = byId.get(id);
if (scope === undefined || scope.parent === null) return undefined;
return byId.get(scope.parent);
},
getChildren(id: ScopeId): readonly ScopeId[] {
return childrenById.get(id) ?? EMPTY_CHILDREN;
},
getAncestors(id: ScopeId): readonly ScopeId[] {
const start = byId.get(id);
if (start === undefined || start.parent === null) return EMPTY_CHILDREN;
const out: ScopeId[] = [];
const visited = new Set<ScopeId>([id]);
let cursor: ScopeId | null = start.parent;
while (cursor !== null && !visited.has(cursor)) {
visited.add(cursor);
out.push(cursor);
const next = byId.get(cursor);
cursor = next === undefined ? null : next.parent;
}
return Object.freeze(out);
},
has(id: ScopeId): boolean {
return byId.has(id);
},
};
}
/**
* `outer` strictly contains `inner` when `outer`'s start is at or before
* `inner`'s start, `outer`'s end is at or after `inner`'s end, and they are
* not the exact same range. Equal ranges are rejected — a child cannot
* occupy the exact same span as its parent.
*/
function rangeStrictlyContains(outer: Range, inner: Range): boolean {
if (
outer.startLine === inner.startLine &&
outer.startCol === inner.startCol &&
outer.endLine === inner.endLine &&
outer.endCol === inner.endCol
) {
return false;
}
const outerStartsAtOrBefore =
outer.startLine < inner.startLine ||
(outer.startLine === inner.startLine && outer.startCol <= inner.startCol);
const outerEndsAtOrAfter =
outer.endLine > inner.endLine ||
(outer.endLine === inner.endLine && outer.endCol >= inner.endCol);
return outerStartsAtOrBefore && outerEndsAtOrAfter;
}
/**
* Two ranges overlap when neither finishes before the other begins. Ranges
* that merely touch at a single boundary point (`a.end === b.start`) do
* NOT overlap — this matches tree-sitter's half-open-like range semantics
* and the typical "sibling blocks meet but don't overlap" pattern.
*/
function rangesOverlap(a: Range, b: Range): boolean {
const aEndsBeforeB =
a.endLine < b.startLine || (a.endLine === b.startLine && a.endCol <= b.startCol);
const bEndsBeforeA =
b.endLine < a.startLine || (b.endLine === a.startLine && b.endCol <= a.startCol);
return !(aEndsBeforeB || bEndsBeforeA);
}
function comparePosition(a: Range, b: Range): number {
if (a.startLine !== b.startLine) return a.startLine - b.startLine;
return a.startCol - b.startCol;
}
function formatRange(r: Range): string {
return `${r.startLine}:${r.startCol}-${r.endLine}:${r.endCol}`;
}
@@ -0,0 +1,185 @@
/**
* Shadow-mode aggregation — per-language parity %, per-evidence-kind
* breakdown of divergences. Consumed by the parity dashboard (RING2-PKG-5).
*
* Pure functions; no I/O. The harness persists per-run JSON; the dashboard
* reads `.gitnexus/shadow-parity/latest.json` and renders.
*
* Part of RFC #909 Ring 2 SHARED — #918.
*/
import type { SupportedLanguages } from '../../languages.js';
import type { ResolutionEvidence } from '../types.js';
import type { ShadowAgreement, ShadowDiff } from './diff.js';
// ─── Aggregated report shape ────────────────────────────────────────────────
export interface LanguageParityRow {
readonly language: SupportedLanguages;
readonly totalCalls: number;
readonly bothAgree: number;
readonly onlyLegacy: number;
readonly onlyNew: number;
readonly bothDisagree: number;
readonly bothEmpty: number;
/**
* Fraction in [0, 1]. Numerator = `bothAgree`; denominator = "calls where
* at least one side resolved" = `totalCalls - bothEmpty`.
*
* When the denominator is 0 (all calls for this language were
* `both-empty`), returns 0. Callers rendering the dashboard should treat
* a 0 parity alongside `totalCalls === bothEmpty` as "no signal" rather
* than "total disagreement".
*/
readonly parity: number;
/**
* Divergence signals broken down by `ResolutionEvidence.kind`. Sourced
* from `ShadowDiff.evidenceDelta` on non-agreeing rows only — `both-agree`
* and `both-empty` do not contribute.
*/
readonly evidenceBreakdown: ReadonlyMap<ResolutionEvidence['kind'], number>;
}
export interface ShadowParityReport {
readonly generatedAt: string; // ISO 8601
readonly perLanguage: readonly LanguageParityRow[];
readonly overall: Omit<LanguageParityRow, 'language' | 'evidenceBreakdown'>;
}
// ─── Public API ─────────────────────────────────────────────────────────────
/**
* Aggregate a stream of `ShadowDiff` records into a `ShadowParityReport`,
* bucketed by language. Pure function.
*
* - `perLanguage` rows are sorted alphabetically by `SupportedLanguages`
* value for stable JSON output (the dashboard reads
* `.gitnexus/shadow-parity/latest.json` and diffing snapshots is useful).
* - `overall` is the column-wise sum across languages.
* - `generatedAt` is injected via the `now` parameter so tests stay
* deterministic; production callers let it default to `new Date()`.
*/
export function aggregateDiffs(
diffs: readonly { readonly language: SupportedLanguages; readonly diff: ShadowDiff }[],
now: Date = new Date(),
): ShadowParityReport {
const perLanguageMap = new Map<SupportedLanguages, MutableCounts>();
for (const { language, diff } of diffs) {
let counts = perLanguageMap.get(language);
if (!counts) {
counts = makeEmptyCounts();
perLanguageMap.set(language, counts);
}
tallyDiff(counts, diff);
}
const perLanguage: LanguageParityRow[] = Array.from(perLanguageMap.entries())
.map(([language, counts]) => buildRow(language, counts))
.sort((a, b) => a.language.localeCompare(b.language));
const overall = buildOverallRow(perLanguage);
return {
generatedAt: now.toISOString(),
perLanguage,
overall,
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
interface MutableCounts {
totalCalls: number;
bothAgree: number;
onlyLegacy: number;
onlyNew: number;
bothDisagree: number;
bothEmpty: number;
evidenceBreakdown: Map<ResolutionEvidence['kind'], number>;
}
function makeEmptyCounts(): MutableCounts {
return {
totalCalls: 0,
bothAgree: 0,
onlyLegacy: 0,
onlyNew: 0,
bothDisagree: 0,
bothEmpty: 0,
evidenceBreakdown: new Map(),
};
}
function tallyDiff(counts: MutableCounts, diff: ShadowDiff): void {
counts.totalCalls += 1;
incrementAgreement(counts, diff.agreement);
if (diff.agreement === 'both-agree' || diff.agreement === 'both-empty') return;
for (const ev of diff.evidenceDelta) {
counts.evidenceBreakdown.set(ev.kind, (counts.evidenceBreakdown.get(ev.kind) ?? 0) + 1);
}
}
function incrementAgreement(counts: MutableCounts, agreement: ShadowAgreement): void {
switch (agreement) {
case 'both-agree':
counts.bothAgree += 1;
return;
case 'only-legacy':
counts.onlyLegacy += 1;
return;
case 'only-new':
counts.onlyNew += 1;
return;
case 'both-disagree':
counts.bothDisagree += 1;
return;
case 'both-empty':
counts.bothEmpty += 1;
return;
}
}
function buildRow(language: SupportedLanguages, counts: MutableCounts): LanguageParityRow {
const resolved = counts.totalCalls - counts.bothEmpty;
const parity = resolved > 0 ? counts.bothAgree / resolved : 0;
return {
language,
totalCalls: counts.totalCalls,
bothAgree: counts.bothAgree,
onlyLegacy: counts.onlyLegacy,
onlyNew: counts.onlyNew,
bothDisagree: counts.bothDisagree,
bothEmpty: counts.bothEmpty,
parity,
// Freeze via `new Map` on a sorted-kind copy so downstream consumers
// can't mutate the aggregator's internal state.
evidenceBreakdown: new Map(
Array.from(counts.evidenceBreakdown.entries()).sort(([a], [b]) => a.localeCompare(b)),
),
};
}
function buildOverallRow(
perLanguage: readonly LanguageParityRow[],
): Omit<LanguageParityRow, 'language' | 'evidenceBreakdown'> {
let totalCalls = 0;
let bothAgree = 0;
let onlyLegacy = 0;
let onlyNew = 0;
let bothDisagree = 0;
let bothEmpty = 0;
for (const row of perLanguage) {
totalCalls += row.totalCalls;
bothAgree += row.bothAgree;
onlyLegacy += row.onlyLegacy;
onlyNew += row.onlyNew;
bothDisagree += row.bothDisagree;
bothEmpty += row.bothEmpty;
}
const resolved = totalCalls - bothEmpty;
const parity = resolved > 0 ? bothAgree / resolved : 0;
return { totalCalls, bothAgree, onlyLegacy, onlyNew, bothDisagree, bothEmpty, parity };
}
export type { ShadowAgreement, ShadowDiff };
@@ -0,0 +1,126 @@
/**
* Shadow-mode diff logic — RFC §6.3.
*
* Pure comparison logic for shadow mode. Takes two `Resolution[]` (legacy
* DAG result + new scope-based registry result) and produces a structured
* diff record for the parity dashboard.
*
* Consumed by the Ring 2 PKG shadow harness (#923), which dual-runs each
* call through legacy + new paths, diffs results, and persists per-run JSON
* for the parity dashboard.
*
* Part of RFC #909 Ring 2 SHARED — #918.
*/
import type { Resolution, ResolutionEvidence } from '../types.js';
// ─── Diff record shape ──────────────────────────────────────────────────────
export type ShadowAgreement =
| 'both-agree' // top match identical (same DefId)
| 'only-legacy' // legacy resolved; new did not
| 'only-new' // new resolved; legacy did not
| 'both-disagree' // both resolved, but to different targets
| 'both-empty'; // both returned empty
export interface ShadowDiff {
readonly callsite: ShadowCallsite;
readonly legacy: Resolution | null;
readonly newResult: Resolution | null;
readonly agreement: ShadowAgreement;
/**
* Symmetric difference of the two top resolutions' `evidence` arrays,
* keyed on `ResolutionEvidence.kind`.
*
* - For `'both-agree'` and `'both-empty'` agreements, always empty.
* - For `'both-disagree'`, contains evidence kinds present on exactly one
* side (not in both).
* - For `'only-legacy'`, contains all of legacy's top evidence.
* - For `'only-new'`, contains all of new's top evidence.
*/
readonly evidenceDelta: readonly ResolutionEvidence[];
}
export interface ShadowCallsite {
readonly filePath: string;
readonly line: number;
readonly col: number;
readonly calledName: string;
}
// ─── Public API ─────────────────────────────────────────────────────────────
/**
* Compare two `Resolution[]` arrays (top matches at `[0]`) and produce a
* `ShadowDiff`. Pure function.
*
* Agreement rules:
* - both arrays empty → `'both-empty'`, `evidenceDelta: []`
* - legacy empty, new non-empty → `'only-new'`, `evidenceDelta` = new's top evidence
* - legacy non-empty, new empty → `'only-legacy'`, `evidenceDelta` = legacy's top evidence
* - both non-empty, same top `def.nodeId` → `'both-agree'`, `evidenceDelta: []`
* - both non-empty, different top `def.nodeId` → `'both-disagree'`,
* `evidenceDelta` = symmetric difference by `ResolutionEvidence.kind`
* (first occurrence of a kind-only-on-legacy then kind-only-on-new; order
* preserved from input arrays)
*
* Evidence-delta rationale: callers aggregating divergences want to know
* which signal kinds explain a disagreement. Keying on `kind` (not full
* equality over `weight`/`note`) avoids spurious deltas when the same
* signal fires with slightly different calibration weights on each side.
*/
export function diffResolutions(
callsite: ShadowCallsite,
legacy: readonly Resolution[],
newResult: readonly Resolution[],
): ShadowDiff {
const legacyTop: Resolution | null = legacy.length > 0 ? legacy[0] : null;
const newTop: Resolution | null = newResult.length > 0 ? newResult[0] : null;
const agreement: ShadowAgreement = (() => {
if (legacyTop === null && newTop === null) return 'both-empty';
if (legacyTop === null) return 'only-new';
if (newTop === null) return 'only-legacy';
return legacyTop.def.nodeId === newTop.def.nodeId ? 'both-agree' : 'both-disagree';
})();
const evidenceDelta = computeEvidenceDelta(legacyTop, newTop, agreement);
return {
callsite,
legacy: legacyTop,
newResult: newTop,
agreement,
evidenceDelta,
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
/**
* Symmetric difference of two evidence arrays, keyed on
* `ResolutionEvidence.kind`. Preserves input order: legacy-only signals
* first (in legacy's original order), then new-only signals (in new's order).
*
* For `'both-agree'` / `'both-empty'` the delta is empty by contract. For
* `'only-legacy'` / `'only-new'` one side's evidence is the delta (nothing to
* subtract against).
*/
function computeEvidenceDelta(
legacy: Resolution | null,
newResult: Resolution | null,
agreement: ShadowAgreement,
): readonly ResolutionEvidence[] {
if (agreement === 'both-agree' || agreement === 'both-empty') return [];
if (agreement === 'only-legacy') return legacy!.evidence;
if (agreement === 'only-new') return newResult!.evidence;
// both-disagree: symmetric difference keyed on `kind`
const legacyKinds = new Set(legacy!.evidence.map((e) => e.kind));
const newKinds = new Set(newResult!.evidence.map((e) => e.kind));
const onlyInLegacy = legacy!.evidence.filter((e) => !newKinds.has(e.kind));
const onlyInNew = newResult!.evidence.filter((e) => !legacyKinds.has(e.kind));
return [...onlyInLegacy, ...onlyInNew];
}
@@ -0,0 +1,35 @@
/**
* `SymbolDefinition` — the canonical shape of an indexed symbol record.
*
* Historically defined in `gitnexus/src/core/ingestion/model/symbol-table.ts`;
* moved into `gitnexus-shared` as part of RFC #909 Ring 1 (#910) so the
* scope-resolution types that reference it can live in the shared package
* alongside their consumers (`gitnexus/` and `gitnexus-web/`).
*
* Shape is unchanged from the prior local definition.
*/
import type { NodeLabel } from '../graph/types.js';
export interface SymbolDefinition {
nodeId: string;
filePath: string;
type: NodeLabel;
/** Canonical dot-separated qualified type name for class-like symbols
* (e.g. `App.Models.User`). Falls back to the simple symbol name when no
* package/namespace/module scope exists or no explicit qualified metadata is provided. */
qualifiedName?: string;
parameterCount?: number;
/** Number of required (non-optional, non-default) parameters.
* Enables range-based arity filtering: argCount >= requiredParameterCount && argCount <= parameterCount. */
requiredParameterCount?: number;
/** Per-parameter type names for overload disambiguation (e.g. ['int', 'String']).
* Populated when parameter types are resolvable from AST (any typed language). */
parameterTypes?: string[];
/** Raw return type text extracted from AST (e.g. 'User', 'Promise<User>') */
returnType?: string;
/** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List<User>') */
declaredType?: string;
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
ownerId?: string;
}
@@ -0,0 +1,420 @@
/**
* Scope-resolution type definitions — RFC §2 data model (authoritative source).
*
* See: https://www.notion.so/346dc50b6ed281cfaacbe480bf231d50
*
* Anti-drift rule: every type, interface, and enum defined here is the single
* source of truth. Later code that references these names must import them
* from `gitnexus-shared`; it must not re-define them locally.
*
* Lifecycle contract (RFC §2.8): scopes are **constructed during extraction,
* linked during finalize, immutable after finalize**. All fields are
* `readonly` at the type level; `Object.freeze` is applied at runtime in dev
* builds. `ReferenceIndex` is the sole structure populated after freeze — by
* resolution, before emission.
*/
import type { NodeLabel } from '../graph/types.js';
import type { SymbolDefinition } from './symbol-definition.js';
// ─── §2.1 Type aliases ──────────────────────────────────────────────────────
/** Stable per-(file, range, kind) scope identifier; interned for identity-fast equality. */
export type ScopeId = string;
/** Stable symbol-definition identifier (graph nodeId). */
export type DefId = string;
/** Kinds of lexical scope a `Scope` node can represent. */
export type ScopeKind =
| 'Module' // file root
| 'Namespace' // C++ namespace, C# namespace, Kotlin package-object, Rust mod
| 'Class' // class/struct/trait/interface body
| 'Function' // function/method/closure/lambda body
| 'Block' // { ... }, if-body, for-body, with-body, match arms
| 'Expression'; // comprehensions, for-init, pattern bindings, lambda param lists
// ─── Range + Capture (parser-agnostic) ──────────────────────────────────────
/** Source-text range. 1-based `startLine`/`endLine`; 0-based `startCol`/`endCol`. */
export interface Range {
readonly startLine: number;
readonly startCol: number;
readonly endLine: number;
readonly endCol: number;
}
/**
* Tagged capture emitted by a LanguageProvider's `emitScopeCaptures` hook.
*
* Parser-agnostic: tree-sitter queries and COBOL's regex tagger both produce
* `Capture[]`. The central `ScopeExtractor` consumes captures without
* knowing which parser produced them.
*/
export interface Capture {
/** Capture name, including leading `@` (e.g., `'@scope.module'`, `'@declaration.class'`). */
readonly name: string;
readonly range: Range;
/** The captured source text. */
readonly text: string;
}
/**
* A grouping of `Capture`s that came from a single query match (e.g., one
* `@import.statement` match carries `@import.source`, `@import.name`,
* `@import.alias?` as child captures). Keyed by capture name for O(1)
* child access.
*/
export type CaptureMatch = Readonly<Record<string, Capture>>;
// ─── Hook input/output types (RFC §5.2) ─────────────────────────────────────
/**
* Provider-interpreted raw import, consumed by finalize (Phase 2) to produce
* linked `ImportEdge[]`. The provider's `interpretImport` hook turns a
* `CaptureMatch` for an `@import.statement` into one of these; the central
* finalize algorithm resolves `targetRaw` to a concrete file via
* `resolveImportTarget` and materializes the final `ImportEdge`.
*
* Discriminated union — each variant carries only the fields that make sense
* for its kind. Invalid shapes (e.g., a `namespace` import with an alias-like
* `importedName` mismatch) are compile errors, not latent bugs. `'wildcard-
* expanded'` is deliberately NOT a variant: that kind is finalize output only,
* produced when `expandsWildcardTo` materializes a wildcard against target
* exports — a provider must never emit it at parse time.
*/
export type ParsedImport =
/**
* Per-name import without rename.
*
* Examples:
* - Python `from foo import X` → `{ kind: 'named', localName: 'X', importedName: 'X', targetRaw: 'foo' }`
* - TS `import { X } from './foo'` → `{ kind: 'named', localName: 'X', importedName: 'X', targetRaw: './foo' }`
* - Java `import foo.bar.X` → `{ kind: 'named', localName: 'X', importedName: 'X', targetRaw: 'foo.bar' }`
*/
| {
readonly kind: 'named';
readonly localName: string;
readonly importedName: string;
readonly targetRaw: string;
}
/**
* Per-name import with rename.
*
* Examples:
* - Python `from foo import X as Y` → `{ kind: 'alias', localName: 'Y', importedName: 'X', alias: 'Y', targetRaw: 'foo' }`
* - TS `import { X as Y } from './foo'` → `{ kind: 'alias', localName: 'Y', importedName: 'X', alias: 'Y', targetRaw: './foo' }`
*/
| {
readonly kind: 'alias';
readonly localName: string;
readonly importedName: string;
readonly alias: string;
readonly targetRaw: string;
}
/**
* Qualified module handle, with or without rename. `importedName` is the
* module being aliased; `localName` is the scope-visible handle (often the
* same unless renamed).
*
* Examples:
* - Python `import numpy` → `{ kind: 'namespace', localName: 'numpy', importedName: 'numpy', targetRaw: 'numpy' }`
* - Python `import numpy as np` → `{ kind: 'namespace', localName: 'np', importedName: 'numpy', targetRaw: 'numpy' }`
* - TS `import * as np from 'numpy'` → `{ kind: 'namespace', localName: 'np', importedName: 'numpy', targetRaw: 'numpy' }`
* - Go `import foo "pkg/bar"` → `{ kind: 'namespace', localName: 'foo', importedName: 'bar', targetRaw: 'pkg/bar' }`
*/
| {
readonly kind: 'namespace';
/** Scope-visible handle (e.g. `np` in `import numpy as np`; `numpy` when unaliased). */
readonly localName: string;
/** Module being aliased (e.g. `numpy` in `import numpy as np`). */
readonly importedName: string;
readonly targetRaw: string;
}
/**
* Syntactically-detectable parse-time re-export. Finalize may still produce
* `ImportEdge { kind: 'reexport', transitiveVia }` when flattening chains;
* this variant preserves the *parse-time* signal so finalize doesn't have
* to re-derive it from scratch.
*
* Examples:
* - TS `export { X } from './y'` → `{ kind: 'reexport', localName: 'X', importedName: 'X', targetRaw: './y' }`
* - TS `export { X as Y } from './y'` → `{ kind: 'reexport', localName: 'Y', importedName: 'X', alias: 'Y', targetRaw: './y' }`
* - Rust `pub use foo::bar` → `{ kind: 'reexport', localName: 'bar', importedName: 'bar', targetRaw: 'foo' }`
*/
| {
readonly kind: 'reexport';
/** Name as re-exported in the current module. */
readonly localName: string;
/** Name in the source module. */
readonly importedName: string;
readonly targetRaw: string;
/** Set when the re-export renames the symbol (e.g. `export { X as Y } from './y'`). */
readonly alias?: string;
}
/**
* Wildcard import — brings every exported name from the target module into
* the importing scope. The finalize algorithm expands this into one
* `BindingRef` per exported name via the provider's `expandsWildcardTo`
* hook, producing the finalize-only `ImportEdge` kind `'wildcard-expanded'`.
*
* Examples:
* - Python `from foo import *` → `{ kind: 'wildcard', targetRaw: 'foo' }`
* - JS `export * from './foo'` → `{ kind: 'wildcard', targetRaw: './foo' }`
* - Rust `pub use foo::*` → `{ kind: 'wildcard', targetRaw: 'foo' }`
*/
| {
readonly kind: 'wildcard';
readonly targetRaw: string;
}
/**
* Runtime-computed target — the import path is not a static literal at
* parse time. Providers SHOULD emit the unresolvable expression's source
* text as `targetRaw` to aid diagnostics; `null` only when no string form
* exists.
*
* Examples:
* - JS `await import(expr)` → `{ kind: 'dynamic-unresolved', localName: '', targetRaw: 'expr' }`
* - Python `importlib.import_module(f'pkg.{name}')` → `{ kind: 'dynamic-unresolved', localName: '', targetRaw: "f'pkg.{name}'" }`
*/
| {
readonly kind: 'dynamic-unresolved';
readonly localName: string;
/** Source text of the unresolved expression when available; `null` otherwise. */
readonly targetRaw: string | null;
};
/**
* Provider-interpreted type binding. The provider's `interpretTypeBinding`
* hook turns a `CaptureMatch` (e.g., `@type-binding.parameter`) into one of
* these; the central extractor attaches the resulting `TypeRef` to the
* appropriate scope's `typeBindings` map.
*/
export interface ParsedTypeBinding {
/** The name being bound (parameter name, `self`, assignment LHS, …). */
readonly boundName: string;
/** The raw type name as written in source (`'User'`, `'models.User'`, …). */
readonly rawTypeName: string;
readonly source: TypeRef['source'];
}
/**
* Cross-file workspace index consumed by finalize-phase hooks
* (`resolveImportTarget`, `expandsWildcardTo`). Opaque placeholder in Ring 1;
* concretely typed in Ring 2 SHARED (#915).
*/
export type WorkspaceIndex = unknown;
// `ScopeTree` is exported from `./scope-tree.js` as of Ring 2 SHARED (#912).
// The former opaque placeholder lived here during Ring 1; removed now that
// the concrete type exists. Consumers import from `gitnexus-shared` directly.
/** Call-site description passed to `arityCompatibility`. */
export interface Callsite {
/** Number of arguments at the call site. */
readonly arity: number;
}
// ─── §2.4 ImportEdge ────────────────────────────────────────────────────────
/**
* A cross-file import edge attached to a module/namespace scope.
*
* Raw (unlinked) edges are emitted during parse (Phase 1); `targetModuleScope`
* and `targetDefId` are filled in during finalize (Phase 2) via SCC-aware
* bounded-fixpoint linking (RFC §3.2).
*/
export interface ImportEdge {
/** How this scope sees the imported name (after alias). */
readonly localName: string;
/** Exporting file; `null` only when `kind === 'dynamic-unresolved'`. */
readonly targetFile: string | null;
/** The name under which the target exports this symbol. */
readonly targetExportedName: string;
/** Pre-resolved at finalize: the module scope of the exporting file. */
readonly targetModuleScope?: ScopeId;
/** Pre-resolved at finalize: the exported symbol's `DefId`. */
readonly targetDefId?: DefId;
readonly kind:
| 'named'
| 'alias'
| 'namespace'
| 'wildcard-expanded'
| 'reexport'
| 'dynamic-unresolved';
/** Re-export chain, for provenance (e.g., `['./y']` when re-exported via `./y`). */
readonly transitiveVia?: readonly string[];
/** Set to `'unresolved'` when the SCC fixpoint could not link this edge. */
readonly linkStatus?: 'unresolved';
}
// ─── §2.3 BindingRef ────────────────────────────────────────────────────────
/**
* A name binding visible at a scope, with provenance.
*
* Provenance stays at the visibility layer — a name being visible because it
* is local vs imported vs wildcard-expanded vs re-exported is a property of
* the binding itself. This keeps evidence emission and `import-use` reference
* stamping first-class instead of reconstructing provenance from a side table.
*/
export interface BindingRef {
readonly def: SymbolDefinition;
readonly origin: 'local' | 'import' | 'namespace' | 'wildcard' | 'reexport';
/** Non-null for non-local origins; carries the `ImportEdge` that brought the name into this scope. */
readonly via?: ImportEdge;
}
// ─── §2.5 TypeRef ───────────────────────────────────────────────────────────
/**
* A reference to a named type, anchored at its declaration site.
*
* Design choice: raw name + declaration-site scope, resolved at lookup time.
* Pre-resolution would invert the extraction/resolution wall. Deferred thunks
* add no capability. Structured type systems are months of work per language.
* This shape keeps V1 tractable while preserving correctness for aliases,
* re-exports, and nested modules. Generics deferred to V2 via `typeArgs`.
*/
export interface TypeRef {
/** The name as written in source (e.g., `'User'`, `'models.User'`, `'List'`). */
readonly rawName: string;
/** Anchor for resolving `rawName` — the scope where the annotation/inference was written. */
readonly declaredAtScope: ScopeId;
readonly source:
| 'annotation'
| 'parameter-annotation'
| 'return-annotation'
| 'self'
| 'assignment-inferred'
| 'constructor-inferred'
| 'receiver-propagated';
/** Reserved for V2+: generic type arguments (`List<User>` → `[TypeRef('User')]`). V1 ignores. */
readonly typeArgs?: readonly TypeRef[];
}
// ─── §2.2 Scope ─────────────────────────────────────────────────────────────
/**
* The canonical lexical-scope node. Forms the spine of the SemanticModel.
*
* ScopeId shape (RFC §2.2): `scope:{filePath}#{startLine}:{startCol}-{endLine}:{endCol}:{kind}`
* — deterministic, stable across reparses of the same source, interned.
*/
export interface Scope {
readonly id: ScopeId;
readonly parent: ScopeId | null;
readonly kind: ScopeKind;
readonly range: Range;
readonly filePath: string;
/** Names visible from this scope. Provenance preserved via `BindingRef.origin`. */
readonly bindings: ReadonlyMap<string, readonly BindingRef[]>;
/** Defs structurally owned by this scope (e.g., methods owned by a class body scope). */
readonly ownedDefs: readonly SymbolDefinition[];
/** Import edges attached to this scope. Mostly module/namespace scopes, but some
* languages allow local imports (Python `def f(): from x import Y`, Rust
* fn-local `use`, TS dynamic `import()`). */
readonly imports: readonly ImportEdge[];
/** Local type facts visible from this scope (parameter annotations, `self` binding, etc.). */
readonly typeBindings: ReadonlyMap<string, TypeRef>;
}
// ─── §2.6 Resolution + ResolutionEvidence ───────────────────────────────────
/**
* One piece of evidence for a `Resolution`. Multiple signals corroborate a
* single match; their weights compose additively to produce `confidence`.
*
* Weights come from `EvidenceWeights` (see `./evidence-weights.ts`).
*/
export interface ResolutionEvidence {
readonly kind:
| 'local'
| 'scope-chain'
| 'import'
| 'type-binding'
| 'owner-match'
| 'kind-match'
| 'arity-match'
| 'global-name'
| 'global-qualified'
| 'dynamic-import-unresolved';
/** Signal weight, sourced from `EvidenceWeights`. Additive; sum capped at 1.0. */
readonly weight: number;
/** Optional debug annotation (e.g., `'matched via self: User'`). */
readonly note?: string;
}
/**
* A ranked resolution candidate returned by `ClassRegistry.lookup` /
* `MethodRegistry.lookup` / `FieldRegistry.lookup`. Evidence composes
* additively; callers read `[0]` for the one-shot answer or inspect the
* evidence trace for debugging.
*/
export interface Resolution {
readonly def: SymbolDefinition;
/** Σ of `evidence[].weight`, capped at 1.0. */
readonly confidence: number;
readonly evidence: readonly ResolutionEvidence[];
/** Optional debug trace: scopes walked to reach `def`. */
readonly path?: readonly ScopeId[];
}
// ─── §2.7 Reference + ReferenceIndex ────────────────────────────────────────
/**
* A post-resolution usage fact: some code at `atRange` inside `fromScope`
* references `toDef` with the given confidence/evidence. Materialized by the
* resolution phase; emitted as graph edges (`CALLS`/`READS`/`WRITES`/etc.)
* during the emit phase.
*/
export interface Reference {
/** Innermost lexical scope containing `atRange`. */
readonly fromScope: ScopeId;
readonly toDef: DefId;
/** Location of the reference in source. */
readonly atRange: Range;
readonly kind: 'call' | 'read' | 'write' | 'type-reference' | 'inherits' | 'import-use';
readonly confidence: number;
readonly evidence: readonly ResolutionEvidence[];
}
/**
* Two-way index over `Reference` records, populated during the resolution
* phase. Scopes stay immutable after finalize; references accumulate here.
*/
export interface ReferenceIndex {
readonly bySourceScope: ReadonlyMap<ScopeId, readonly Reference[]>;
readonly byTargetDef: ReadonlyMap<DefId, readonly Reference[]>;
}
// ─── §4.1 LookupParams ──────────────────────────────────────────────────────
/**
* Opaque placeholder for the per-kind registry passed as the owner-scoped
* contributor. Typed concretely in Ring 2 SHARED (#917); kept as `unknown`
* here so Ring 1 can ship without pulling in the registry implementation.
*/
export type RegistryContributor = unknown;
/**
* Parameters accepted by `Registry.lookup`. Three registries (Class/Method/
* Field) run the same 7-step algorithm with different parameter tuples; see
* RFC §4.4 for per-registry specializations.
*/
export interface LookupParams {
readonly acceptedKinds: readonly NodeLabel[];
/** Class lookups: false. Method/Field lookups: true. */
readonly useReceiverTypeBinding: boolean;
readonly ownerScopedContributor: RegistryContributor | null;
/** Optional arity hint fed to `provider.arityCompatibility`. */
readonly arityHint?: number;
/** Explicit receiver name (e.g., `'user'` in `user.save()`). When present,
* the receiver's type binding at the callsite scope is used; otherwise
* the enclosing method's implicit `self`/`this` is consulted. See §4.1. */
readonly explicitReceiver?: { readonly name: string };
}
+23 -4
View File
@@ -61,13 +61,22 @@ test.beforeAll(async () => {
}
});
// Auto-connect downloads the full graph from the backend; under parallel
// workers in CI the same backend serves multiple downloads concurrently, so
// reaching the "Ready" state can take noticeably longer than a single-worker
// run. Match the 45s budget used by waitForGraphLoaded() in
// server-connect.spec.ts which has been stable on the same backend.
const READY_TIMEOUT_MS = 45_000;
test.describe('Multi-Repo Scoping', () => {
test('auto-connect via ?server= sets ?project= in URL', async ({ page }) => {
// Navigate with ?server= param (the bookmarkable shortcut)
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
// Wait for graph to load
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({
timeout: READY_TIMEOUT_MS,
});
// URL should now contain ?project= with the repo name
const url = new URL(page.url());
@@ -77,8 +86,14 @@ test.describe('Multi-Repo Scoping', () => {
});
test('?server= is preserved in URL for F5 recovery', async ({ page }) => {
// Two sequential auto-connects (initial + reload), each up to READY_TIMEOUT_MS,
// can exceed the default 60s test timeout under parallel workers.
test.slow();
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({
timeout: READY_TIMEOUT_MS,
});
// URL should still have ?server=
const url = new URL(page.url());
@@ -86,12 +101,16 @@ test.describe('Multi-Repo Scoping', () => {
// F5 should reconnect (not show onboarding)
await page.reload();
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({
timeout: READY_TIMEOUT_MS,
});
});
test('node count in status bar matches backend data', async ({ page }) => {
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({
timeout: READY_TIMEOUT_MS,
});
// Fetch expected node count from backend
const res = await fetch(`${BACKEND_URL}/api/repo?repo=${encodeURIComponent(firstRepoName)}`);
+8 -1
View File
@@ -26,7 +26,10 @@ async function enterExploringView(page: import('@playwright/test').Page) {
// Landing screen may not appear (e.g. ?server auto-connect)
}
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// Match the 45s budget used by waitForGraphLoaded() in
// server-connect.spec.ts; under parallel CI workers, downloading the full
// graph can occasionally exceed 30s.
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 45_000 });
}
// ── Flow 1: Onboarding (no server running) ─────────────────────────────────
@@ -244,6 +247,10 @@ test.describe('Flow 3: Analyze form', () => {
test.describe('Flow 4: Repo dropdown in exploring view', () => {
const SKIP_MSG = 'Requires running gitnexus server with indexed repos';
// enterExploringView() can take up to ~45s under parallel CI workers; combined
// with the dropdown interactions this can exceed the default 60s test budget.
test.slow();
test.beforeAll(async () => {
if (process.env.E2E) return;
try {
+26 -5
View File
@@ -84,11 +84,20 @@ test.describe('Hold-queue timeout error', () => {
// ── 2. ?project= URL persistence ─────────────────────────────────────────────
// Auto-connect downloads the full graph from the backend; under parallel
// workers in CI the same backend serves multiple downloads concurrently, so
// reaching the "Ready" state can take noticeably longer than a single-worker
// run. Match the 45s budget used by waitForGraphLoaded() in
// server-connect.spec.ts which has been stable on the same backend.
const READY_TIMEOUT_MS = 45_000;
test.describe('?project= URL persistence', () => {
test('?project= is set in URL after connecting via ?server=', async ({ page }) => {
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({
timeout: READY_TIMEOUT_MS,
});
const url = new URL(page.url());
const project = url.searchParams.get('project');
@@ -98,12 +107,20 @@ test.describe('?project= URL persistence', () => {
});
test('?project= is still present after F5 reload', async ({ page }) => {
// Two sequential auto-connects (initial + reload), each up to READY_TIMEOUT_MS,
// can exceed the default 60s test timeout under parallel workers.
test.slow();
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({
timeout: READY_TIMEOUT_MS,
});
// After connect, URL has ?server=&project= — F5 re-uses both params
await page.reload();
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({
timeout: READY_TIMEOUT_MS,
});
const url = new URL(page.url());
expect(url.searchParams.get('project')).toBeTruthy();
@@ -122,7 +139,9 @@ test.describe('?project= auto-connect', () => {
`/?server=${encodeURIComponent(BACKEND_URL)}&project=${encodeURIComponent(firstRepoName)}`,
);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({
timeout: READY_TIMEOUT_MS,
});
// ?project= in URL should match what we passed in
const url = new URL(page.url());
@@ -155,7 +174,9 @@ test.describe('Windows path normalization', () => {
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({
timeout: READY_TIMEOUT_MS,
});
// URL ?project= must be the short basename, NOT the full Windows path
const url = new URL(page.url());
+162 -393
View File
@@ -29,7 +29,7 @@
"langchain": "^1.2.10",
"lru-cache": "^11.2.4",
"lucide-react": "^0.562.0",
"mermaid": "^11.12.2",
"mermaid": "^11.14.0",
"mnemonist": "^0.39.0",
"pandemonium": "^2.4.0",
"react": "^18.3.1",
@@ -39,7 +39,7 @@
"react-zoom-pan-pinch": "^3.7.0",
"remark-gfm": "^4.0.1",
"sigma": "^3.0.2",
"tailwindcss": "^4.1.18",
"tailwindcss": "^4.2.2",
"uuid": "^13.0.0",
"zod": "^3.25.76"
},
@@ -57,12 +57,12 @@
"@vercel/node": "^5.5.16",
"@vitejs/plugin-react": "^5.1.0",
"@vitest/coverage-v8": "^3.2.4",
"jsdom": "^29.0.0",
"jsdom": "^29.0.2",
"tree-sitter-wasms": "^0.1.13",
"typescript": "^5.4.5",
"vite": "^5.2.0",
"vitest": "^3.2.4",
"wait-on": "^8.0.5"
"wait-on": "^9.0.5"
},
"engines": {
"node": ">=20.0.0"
@@ -129,39 +129,49 @@
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz",
"integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==",
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.1.1",
"@csstools/css-color-parser": "^4.0.2",
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0",
"lru-cache": "^11.2.6"
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.3.tgz",
"integrity": "sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA==",
"version": "7.0.10",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.10.tgz",
"integrity": "sha512-KyOb19eytNSELkmdqzZZUXWCU25byIlOld5qVFg0RYdS0T3tt7jeDByxk9hIAC73frclD8GKrHttr0SUjKCCdQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.2.7"
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
@@ -533,54 +543,40 @@
"license": "MIT"
},
"node_modules/@chevrotain/cst-dts-gen": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz",
"integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==",
"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": "11.0.3",
"@chevrotain/types": "11.0.3",
"lodash-es": "4.17.21"
"@chevrotain/gast": "12.0.0",
"@chevrotain/types": "12.0.0"
}
},
"node_modules/@chevrotain/cst-dts-gen/node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
},
"node_modules/@chevrotain/gast": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz",
"integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==",
"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": "11.0.3",
"lodash-es": "4.17.21"
"@chevrotain/types": "12.0.0"
}
},
"node_modules/@chevrotain/gast/node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
},
"node_modules/@chevrotain/regexp-to-ast": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz",
"integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==",
"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": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz",
"integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==",
"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": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz",
"integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==",
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz",
"integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==",
"license": "Apache-2.0"
},
"node_modules/@cspotcode/source-map-support": {
@@ -628,9 +624,9 @@
}
},
"node_modules/@csstools/css-calc": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
"integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz",
"integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==",
"dev": true,
"funding": [
{
@@ -652,9 +648,9 @@
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz",
"integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz",
"integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==",
"dev": true,
"funding": [
{
@@ -669,7 +665,7 @@
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.0.2",
"@csstools/css-calc": "^3.1.1"
"@csstools/css-calc": "^3.2.0"
},
"engines": {
"node": ">=20.19.0"
@@ -1764,12 +1760,12 @@
}
},
"node_modules/@mermaid-js/parser": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz",
"integrity": "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz",
"integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==",
"license": "MIT",
"dependencies": {
"langium": "3.3.1"
"langium": "^4.0.0"
}
},
"node_modules/@napi-rs/wasm-runtime": {
@@ -2224,257 +2220,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@swc/core": {
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.8.tgz",
"integrity": "sha512-T8keoJjXaSUoVBCIjgL6wAnhADIb09GOELzKg10CjNg+vLX48P93SME6jTfte9MZIm5m+Il57H3rTSk/0kzDUw==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.25"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
"@swc/core-darwin-arm64": "1.15.8",
"@swc/core-darwin-x64": "1.15.8",
"@swc/core-linux-arm-gnueabihf": "1.15.8",
"@swc/core-linux-arm64-gnu": "1.15.8",
"@swc/core-linux-arm64-musl": "1.15.8",
"@swc/core-linux-x64-gnu": "1.15.8",
"@swc/core-linux-x64-musl": "1.15.8",
"@swc/core-win32-arm64-msvc": "1.15.8",
"@swc/core-win32-ia32-msvc": "1.15.8",
"@swc/core-win32-x64-msvc": "1.15.8"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
},
"peerDependenciesMeta": {
"@swc/helpers": {
"optional": true
}
}
},
"node_modules/@swc/core-darwin-arm64": {
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.8.tgz",
"integrity": "sha512-M9cK5GwyWWRkRGwwCbREuj6r8jKdES/haCZ3Xckgkl8MUQJZA3XB7IXXK1IXRNeLjg6m7cnoMICpXv1v1hlJOg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-darwin-x64": {
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.8.tgz",
"integrity": "sha512-j47DasuOvXl80sKJHSi2X25l44CMc3VDhlJwA7oewC1nV1VsSzwX+KOwE5tLnfORvVJJyeiXgJORNYg4jeIjYQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.8.tgz",
"integrity": "sha512-siAzDENu2rUbwr9+fayWa26r5A9fol1iORG53HWxQL1J8ym4k7xt9eME0dMPXlYZDytK5r9sW8zEA10F2U3Xwg==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.8.tgz",
"integrity": "sha512-o+1y5u6k2FfPYbTRUPvurwzNt5qd0NTumCTFscCNuBksycloXY16J8L+SMW5QRX59n4Hp9EmFa3vpvNHRVv1+Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-musl": {
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.8.tgz",
"integrity": "sha512-koiCqL09EwOP1S2RShCI7NbsQuG6r2brTqUYE7pV7kZm9O17wZ0LSz22m6gVibpwEnw8jI3IE1yYsQTVpluALw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-gnu": {
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.8.tgz",
"integrity": "sha512-4p6lOMU3bC+Vd5ARtKJ/FxpIC5G8v3XLoPEZ5s7mLR8h7411HWC/LmTXDHcrSXRC55zvAVia1eldy6zDLz8iFQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-musl": {
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.8.tgz",
"integrity": "sha512-z3XBnbrZAL+6xDGAhJoN4lOueIxC/8rGrJ9tg+fEaeqLEuAtHSW2QHDHxDwkxZMjuF/pZ6MUTjHjbp8wLbuRLA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.8.tgz",
"integrity": "sha512-djQPJ9Rh9vP8GTS/Df3hcc6XP6xnG5c8qsngWId/BLA9oX6C7UzCPAn74BG/wGb9a6j4w3RINuoaieJB3t+7iQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.8.tgz",
"integrity": "sha512-/wfAgxORg2VBaUoFdytcVBVCgf1isWZIEXB9MZEUty4wwK93M/PxAkjifOho9RN3WrM3inPLabICRCEgdHpKKQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-x64-msvc": {
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.8.tgz",
"integrity": "sha512-GpMePrh9Sl4d61o4KAHOOv5is5+zt6BEXCOCgs/H0FLGeii7j9bWDE8ExvKFy2GRRZVNR1ugsnzaGWHKM6kuzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"peer": true
},
"node_modules/@swc/types": {
"version": "0.1.25",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz",
"integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"@swc/counter": "^0.1.3"
}
},
"node_modules/@swc/wasm": {
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.15.8.tgz",
"integrity": "sha512-RG2BxGbbsjtddFCo1ghKH6A/BMXbY1eMBfpysV0lJMCpI4DZOjW1BNBnxvBt7YsYmlJtmy5UXIg9/4ekBTFFaQ==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"peer": true
},
"node_modules/@tailwindcss/node": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
@@ -2490,6 +2235,12 @@
"tailwindcss": "4.1.18"
}
},
"node_modules/@tailwindcss/node/node_modules/tailwindcss": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"license": "MIT"
},
"node_modules/@tailwindcss/oxide": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
@@ -2732,6 +2483,12 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
"node_modules/@tailwindcss/vite/node_modules/tailwindcss": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"license": "MIT"
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -3358,6 +3115,16 @@
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"license": "ISC"
},
"node_modules/@upsetjs/venn.js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz",
"integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==",
"license": "MIT",
"optionalDependencies": {
"d3-selection": "^3.0.0",
"d3-transition": "^3.0.1"
}
},
"node_modules/@vercel/build-utils": {
"version": "13.2.11",
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.2.11.tgz",
@@ -3829,14 +3596,14 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/bail": {
@@ -4129,37 +3896,33 @@
}
},
"node_modules/chevrotain": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
"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": "11.0.3",
"@chevrotain/gast": "11.0.3",
"@chevrotain/regexp-to-ast": "11.0.3",
"@chevrotain/types": "11.0.3",
"@chevrotain/utils": "11.0.3",
"lodash-es": "4.17.21"
"@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.3.1",
"resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz",
"integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==",
"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": "^11.0.0"
"chevrotain": "^12.0.0"
}
},
"node_modules/chevrotain/node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@@ -4830,9 +4593,9 @@
}
},
"node_modules/dagre-d3-es": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz",
"integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==",
"version": "7.0.14",
"resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz",
"integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==",
"license": "MIT",
"dependencies": {
"d3": "^7.9.0",
@@ -6064,9 +5827,9 @@
}
},
"node_modules/joi": {
"version": "18.0.2",
"resolved": "https://registry.npmjs.org/joi/-/joi-18.0.2.tgz",
"integrity": "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==",
"version": "18.1.2",
"resolved": "https://registry.npmjs.org/joi/-/joi-18.1.2.tgz",
"integrity": "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -6076,7 +5839,7 @@
"@hapi/pinpoint": "^2.0.1",
"@hapi/tlds": "^1.1.1",
"@hapi/topo": "^6.0.2",
"@standard-schema/spec": "^1.0.0"
"@standard-schema/spec": "^1.1.0"
},
"engines": {
"node": ">= 20"
@@ -6098,14 +5861,14 @@
"license": "MIT"
},
"node_modules/jsdom": {
"version": "29.0.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.0.tgz",
"integrity": "sha512-9FshNB6OepopZ08unmmGpsF7/qCjxGPbo3NbgfJAnPeHXnsODE9WWffXZtRFRFe0ntzaAOcSKNJFz8wiyvF1jQ==",
"version": "29.0.2",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz",
"integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.0.1",
"@asamuzakjp/dom-selector": "^7.0.2",
"@asamuzakjp/css-color": "^5.1.5",
"@asamuzakjp/dom-selector": "^7.0.6",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.1",
"@exodus/bytes": "^1.15.0",
@@ -6119,7 +5882,7 @@
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.24.3",
"undici": "^7.24.5",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
@@ -6152,9 +5915,9 @@
}
},
"node_modules/jsdom/node_modules/undici": {
"version": "7.24.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.3.tgz",
"integrity": "sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==",
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6295,19 +6058,21 @@
}
},
"node_modules/langium": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz",
"integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/langium/-/langium-4.2.2.tgz",
"integrity": "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==",
"license": "MIT",
"dependencies": {
"chevrotain": "~11.0.3",
"chevrotain-allstar": "~0.3.0",
"@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.0.8"
"vscode-uri": "~3.1.0"
},
"engines": {
"node": ">=16.0.0"
"node": ">=20.10.0",
"npm": ">=10.2.3"
}
},
"node_modules/langsmith": {
@@ -6613,16 +6378,16 @@
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.22",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz",
"integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/longest-streak": {
@@ -7072,27 +6837,28 @@
}
},
"node_modules/mermaid": {
"version": "11.12.2",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.2.tgz",
"integrity": "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==",
"version": "11.14.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz",
"integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==",
"license": "MIT",
"dependencies": {
"@braintree/sanitize-url": "^7.1.1",
"@iconify/utils": "^3.0.1",
"@mermaid-js/parser": "^0.6.3",
"@iconify/utils": "^3.0.2",
"@mermaid-js/parser": "^1.1.0",
"@types/d3": "^7.4.3",
"cytoscape": "^3.29.3",
"@upsetjs/venn.js": "^2.0.0",
"cytoscape": "^3.33.1",
"cytoscape-cose-bilkent": "^4.1.0",
"cytoscape-fcose": "^2.2.0",
"d3": "^7.9.0",
"d3-sankey": "^0.12.3",
"dagre-d3-es": "7.0.13",
"dayjs": "^1.11.18",
"dompurify": "^3.2.5",
"katex": "^0.16.22",
"dagre-d3-es": "7.0.14",
"dayjs": "^1.11.19",
"dompurify": "^3.3.1",
"katex": "^0.16.25",
"khroma": "^2.1.0",
"lodash-es": "^4.17.21",
"marked": "^16.2.1",
"lodash-es": "^4.17.23",
"marked": "^16.3.0",
"roughjs": "^4.6.6",
"stylis": "^4.3.6",
"ts-dedent": "^2.2.0",
@@ -8317,10 +8083,13 @@
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/punycode": {
"version": "2.3.1",
@@ -9006,9 +8775,9 @@
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
"license": "MIT"
},
"node_modules/tapable": {
@@ -10270,9 +10039,9 @@
"license": "MIT"
},
"node_modules/vscode-uri": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz",
"integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==",
"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": {
@@ -10289,15 +10058,15 @@
}
},
"node_modules/wait-on": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.5.tgz",
"integrity": "sha512-J3WlS0txVHkhLRb2FsmRg3dkMTCV1+M6Xra3Ho7HzZDHpE7DCOnoSoCJsZotrmW3uRMhvIJGSKUKrh/MeF4iag==",
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.5.tgz",
"integrity": "sha512-qgnbHDfDTRIp73ANEJNRW/7kn8CrDUcvZz18xotJQku/P4saTGkbIzvnMZebPmVvVNUiRq1qWAPyqCH+W4H8KA==",
"dev": true,
"license": "MIT",
"dependencies": {
"axios": "^1.12.1",
"joi": "^18.0.1",
"lodash": "^4.17.21",
"axios": "^1.15.0",
"joi": "^18.1.2",
"lodash": "^4.18.1",
"minimist": "^1.2.8",
"rxjs": "^7.8.2"
},
@@ -10305,7 +10074,7 @@
"wait-on": "bin/wait-on"
},
"engines": {
"node": ">=12.0.0"
"node": ">=20.0.0"
}
},
"node_modules/webidl-conversions": {
+4 -4
View File
@@ -39,7 +39,7 @@
"langchain": "^1.2.10",
"lru-cache": "^11.2.4",
"lucide-react": "^0.562.0",
"mermaid": "^11.12.2",
"mermaid": "^11.14.0",
"mnemonist": "^0.39.0",
"pandemonium": "^2.4.0",
"react": "^18.3.1",
@@ -49,7 +49,7 @@
"react-zoom-pan-pinch": "^3.7.0",
"remark-gfm": "^4.0.1",
"sigma": "^3.0.2",
"tailwindcss": "^4.1.18",
"tailwindcss": "^4.2.2",
"uuid": "^13.0.0",
"zod": "^3.25.76"
},
@@ -67,11 +67,11 @@
"@vercel/node": "^5.5.16",
"@vitejs/plugin-react": "^5.1.0",
"@vitest/coverage-v8": "^3.2.4",
"jsdom": "^29.0.0",
"jsdom": "^29.0.2",
"tree-sitter-wasms": "^0.1.13",
"typescript": "^5.4.5",
"vite": "^5.2.0",
"vitest": "^3.2.4",
"wait-on": "^8.0.5"
"wait-on": "^9.0.5"
}
}
+1
View File
@@ -16,6 +16,7 @@ export default defineConfig({
alias: {
'@': path.resolve(__dirname, './src'),
'@shared': path.resolve(__dirname, '../shared'),
'gitnexus-shared': path.resolve(__dirname, '../gitnexus-shared/src/index.ts'),
// Fix for Rollup failing to resolve this deep import from @langchain/anthropic
'@anthropic-ai/sdk/lib/transform-json-schema': path.resolve(
__dirname,
+4
View File
@@ -9,6 +9,10 @@ tsconfig.json
.gitignore
node_modules/
# Vendor build artifacts (created during install, not shipped)
vendor/**/node_modules
vendor/**/build
# Package lock (consumers use their own)
package-lock.json
+40
View File
@@ -2,6 +2,46 @@
All notable changes to GitNexus will be documented in this file.
## [1.6.2] - 2026-04-18
### Added
- **Docker support** — containerized ingestion and MCP serving for reproducible runs on CI and container platforms (#848)
- **Language-agnostic heritage extractor** — config+factory pattern for class-heritage extraction (EXTENDS / IMPLEMENTS), completing the extractor refactor alongside method/field/call/variable (#890)
- **Language-agnostic call extractor** — config+factory pattern that collapses ~225 lines of inline parse-worker logic into declarative per-language configs (#877)
- **Language-agnostic variable extractor** — structured metadata for `Const` / `Static` / `Variable` nodes via config+factory pattern (#878)
- **AST-aware embedding chunking** — offset-based splitting preserves symbol boundaries, improving semantic search precision on large files (#889)
- **HTTP consumer detection for jQuery and axios object-form** — `$.ajax` / `$.get` / `$.post` and `axios({ url, method })` now recognized as HTTP call sites (#887)
### Fixed
- **Python external dotted imports** — avoid spurious same-file matches when an import path like `foo.bar.baz` refers to a third-party module (#899)
- **Worker warnings no longer terminate ingestion** — non-fatal parser warnings keep the pipeline running instead of aborting the run (#900, #261)
- **Global-install upgrade `ENOTEMPTY`** — devendored `tree-sitter-proto` install lifecycle + preinstall cleanup so `npm i -g gitnexus@latest` succeeds on top of an older install (#843, #846)
- **`env.cacheDir`** now defaults to a user-writable location, unblocking ingestion on systems where the install directory is read-only (#845)
- **Content-hash staleness detection for embeddings** — zero-node rebuilds no longer skip vector-index creation, fixing semantic search after selective re-analysis (#831)
- **`tree-sitter-c-sharp` version pin** — locked to 0.23.1 to avoid a breaking change in a transitive prerelease (#834)
- **`release-drafter` v7 CI** — replaced the removed `disable-releaser` flag with `dry-run` so release-note drafts still work
- **`npm arborist` crash from `tree-sitter-dart`** — switched the dependency URL format so `npm install` no longer crashes on clean installs
- **Service-group `ManifestExtractor`** — `config.links` now wires the manifest extractor properly, restoring cross-link discovery that had silently dropped to zero
### Changed
- **SemanticModel wired as a first-class resolution input (SM-20)** — `call-processor`, `resolution-context`, `type-env`, and `heritage-map` now consult `table.model.*` directly; 37 internal call sites migrated off the SymbolTable wrapper (#885)
- **Per-strategy `ImportSemantics` hooks** — `named` / `wildcard-transitive` / `wildcard-leaf` / `namespace` strategies split into composable hooks, replacing the monolithic conditional (Strategies 1–4 of #886)
- **Class extraction configs moved to `configs/` subdirectory** — per-language class configs now co-locate with the other extractor configs, completing the extractor layer's directory convention (#879)
- **CLI AI-context trimmed** — duplicated CLAUDE.md block removed from the shipped context, reducing token usage in LLM-consuming workflows (#904)
- **LLM context files optimized** — AI-consumed documentation tuned for accuracy and token efficiency (#857)
- **Workflow concurrency standardized** — all CI workflows adopt the consistent concurrency key pattern documented in CONTRIBUTING.md; release-note labeling automated (#837)
- **E2E status-ready timeout raised** — 45s accommodates parallel-worker startup variance on CI (#908)
### Chore / Dependencies
- **tree-sitter 0.25 upgrade readiness** — daily Dependabot monitor for the upcoming major-version bump (#847)
- Dependency bumps: `glob` 11.1.0 → 13.0.6 (#867), `commander` 12.1.0 → 14.0.3 (#868), `@huggingface/transformers` (#869), `@modelcontextprotocol/sdk` (#866), `lru-cache` 11.2.7 → 11.3.5 (#870), `mnemonist` 0.39.8 → 0.40.3 (#871), `@ladybugdb/core` (#873)
- gitnexus-web dependency bumps: `mermaid` 11.12.2 → 11.14.0 (#860), `tailwindcss` (#861), `jsdom` 29.0.0 → 29.0.2 (#863), `wait-on` 8.0.5 → 9.0.5 (#859), `@vitest/coverage-v8` (#864)
- GitHub Actions bumps: `actions/checkout` 4.3.1 → 6.0.2 (#842), `actions/upload-artifact` 4.6.2 → 7.0.1 (#838), `actions/setup-node` 4.4.0 → 6.3.0 (#841), `actions/cache` 5.0.4 → 5.0.5 (#840), `actions/github-script` 7.0.1 → 9.0.0 (#850), `dorny/paths-filter` 3.0.2 → 4.0.1 (#839), `amannn/action-semantic-pull-request` 6.1.1 (#853), `release-drafter/release-drafter` 6.0.0 → 7.2.0 (#852), `marocchino/sticky-pull-request-comment` 3.0.4 (#851), `softprops/action-gh-release` 2.5.0 → 3.0.0 (#849)
## [1.6.1] - 2026-04-13
### Added
+1 -1
View File
@@ -1,6 +1,6 @@
FROM node:20-bookworm
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
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/*
COPY . .
RUN npm ci --ignore-scripts \
&& node scripts/patch-tree-sitter-swift.cjs \
+23
View File
@@ -234,6 +234,29 @@ Installed automatically by both `gitnexus analyze` (per-repo) and `gitnexus setu
- Node.js >= 18
- Git repository (uses git for commit tracking)
## Release candidates
Stable releases publish to the default `latest` dist-tag. When a pull request
with non-documentation changes merges into `main`, an automated workflow also
publishes a prerelease build under the `rc` dist-tag, so early adopters can
try in-flight fixes without waiting for the next stable cut. (Docs-only
merges are skipped.)
```bash
# Try the latest release candidate (pre-stable — may change at any time)
npm install -g gitnexus@rc
# — or —
npx gitnexus@rc analyze
```
Release-candidate versions follow the standard semver prerelease format
`X.Y.Z-rc.N`, where `X.Y.Z` is the next stable target (bumped from the
current `latest` by patch by default; `minor` or `major` when kicking off a
bigger cycle) and `N` increments per published rc. Example sequence:
`1.6.2-rc.1`, `1.6.2-rc.2`, …, then once `1.6.2` ships stable,
`1.6.3-rc.1`. See the [Releases page](https://github.com/abhigyanpatwari/GitNexus/releases)
for the full list; stable `latest` is unaffected.
## Troubleshooting
### `Cannot destructure property 'package' of 'node.target' as it is null`
+272 -411
View File
File diff suppressed because it is too large Load Diff
+9 -7
View File
@@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.6.1",
"version": "1.6.3-rc.9",
"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",
@@ -46,32 +46,32 @@
"test:integration": "vitest run test/integration",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"postinstall": "node scripts/patch-tree-sitter-swift.cjs",
"postinstall": "node scripts/patch-tree-sitter-swift.cjs && node scripts/build-tree-sitter-proto.cjs",
"prepare": "node scripts/build.js",
"prepack": "node scripts/build.js"
},
"dependencies": {
"@huggingface/transformers": "^3.0.0",
"@huggingface/transformers": "^4.1.0",
"@ladybugdb/core": "^0.15.2",
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
"cli-progress": "^3.12.0",
"commander": "^12.0.0",
"commander": "^14.0.3",
"cors": "^2.8.5",
"express": "^4.19.2",
"glob": "^11.0.0",
"glob": "^13.0.6",
"graphology": "^0.25.4",
"graphology-indices": "^0.17.0",
"graphology-utils": "^2.3.0",
"ignore": "^7.0.5",
"js-yaml": "^4.1.1",
"lru-cache": "^11.0.0",
"mnemonist": "^0.39.0",
"mnemonist": "^0.40.3",
"onnxruntime-node": "^1.24.0",
"pandemonium": "^2.4.0",
"tree-sitter": "^0.21.1",
"tree-sitter-c": "0.23.2",
"tree-sitter-c-sharp": "^0.23.1",
"tree-sitter-c-sharp": "0.23.1",
"tree-sitter-cpp": "^0.23.4",
"tree-sitter-go": "^0.23.0",
"tree-sitter-java": "^0.23.5",
@@ -84,6 +84,8 @@
"uuid": "^13.0.0"
},
"optionalDependencies": {
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.0",
"tree-sitter-dart": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4",
"tree-sitter-kotlin": "^0.3.8",
"tree-sitter-proto": "file:./vendor/tree-sitter-proto",
@@ -0,0 +1,82 @@
#!/usr/bin/env node
/**
* Build tree-sitter-proto native binding.
*
* Why this script exists:
* tree-sitter-proto is vendored under gitnexus/vendor/tree-sitter-proto/
* and declared as a `file:` optionalDependency. Previously, the vendored
* package had its own `dependencies` and `install` script, which caused
* npm to create `vendor/tree-sitter-proto/node_modules/` and
* `vendor/tree-sitter-proto/build/` during install. Those directories
* blocked `rmdir` on global-install upgrade, producing:
*
* ENOTEMPTY: directory not empty, rmdir
* '.../gitnexus/vendor/tree-sitter-proto/node_modules/node-addon-api'
*
* (See https://github.com/abhigyanpatwari/GitNexus/issues/836.)
*
* We stripped `dependencies` and the `install` script from the vendored
* package.json, hoisted `node-addon-api` and `node-gyp-build` into
* gitnexus's own optionalDependencies, and moved native compilation here.
*
* What this does:
* Runs `npx node-gyp rebuild` inside `node_modules/tree-sitter-proto/`
* (which npm creates as a copy of vendor/tree-sitter-proto/ when
* resolving the file: dep). Build output lands in
* `node_modules/tree-sitter-proto/build/Release/tree_sitter_proto_binding.node`
* — under npm-managed territory, safe on upgrade.
*
* Mirrors scripts/patch-tree-sitter-swift.cjs. Best-effort: if any
* precondition fails (optional dep absent, no toolchain, --ignore-scripts),
* warn and exit 0 so gitnexus install still succeeds.
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const protoDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-proto');
const bindingGyp = path.join(protoDir, 'binding.gyp');
const bindingNode = path.join(protoDir, 'build', 'Release', 'tree_sitter_proto_binding.node');
try {
if (!fs.existsSync(bindingGyp)) {
// tree-sitter-proto is an optionalDependency; absent when install
// skipped optional deps or the file: dep was not resolved.
process.exit(0);
}
// Skip if the native binding already exists (idempotent re-run).
if (fs.existsSync(bindingNode)) {
process.exit(0);
}
// Pre-flight: the hoisted build deps must be resolvable.
try {
require.resolve('node-addon-api');
require.resolve('node-gyp-build');
} catch (resolveErr) {
console.warn(
'[tree-sitter-proto] Skipping build: hoisted build deps not resolvable (%s).',
resolveErr.message,
);
console.warn(
'[tree-sitter-proto] Proto parsing will be unavailable. Install without --no-optional and with scripts enabled to build.',
);
process.exit(0);
}
console.log('[tree-sitter-proto] Building native binding...');
execSync('npx node-gyp rebuild', {
cwd: protoDir,
stdio: 'pipe',
timeout: 180000,
});
console.log('[tree-sitter-proto] Native binding built successfully');
} catch (err) {
console.warn('[tree-sitter-proto] Could not build native binding:', err.message);
console.warn(
'[tree-sitter-proto] Proto (.proto) parsing will be unavailable. Non-proto gitnexus functionality is unaffected.',
);
// Exit 0: optionalDependency failures must not fail the gitnexus install.
process.exit(0);
}
-58
View File
@@ -101,19 +101,6 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s
- When exploring unfamiliar code, use \`gitnexus_query({query: "concept"})\` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use \`gitnexus_context({name: "symbolName"})\`.
## When Debugging
1. \`gitnexus_query({query: "<error or symptom>"})\` — find execution flows related to the issue
2. \`gitnexus_context({name: "<suspect function>"})\` — see all callers, callees, and process participation
3. \`READ gitnexus://repo/${projectName}/process/{processName}\` — trace the full execution flow step by step
4. For regressions: \`gitnexus_detect_changes({scope: "compare", base_ref: "main"})\` — see what your branch changed
## When Refactoring
- **Renaming**: MUST use \`gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})\` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with \`dry_run: false\`.
- **Extracting/Splitting**: MUST run \`gitnexus_context({name: "target"})\` to see all incoming/outgoing refs, then \`gitnexus_impact({target: "target", direction: "upstream"})\` to find all external callers before moving code.
- After any refactor: run \`gitnexus_detect_changes({scope: "all"})\` to verify only expected files changed.
## Never Do
- NEVER edit a function, class, or method without first running \`gitnexus_impact\` on it.
@@ -121,25 +108,6 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s
- NEVER rename symbols with find-and-replace — use \`gitnexus_rename\` which understands the call graph.
- NEVER commit changes without running \`gitnexus_detect_changes()\` to check affected scope.
## Tools Quick Reference
| Tool | When to use | Command |
|------|-------------|---------|
| \`query\` | Find code by concept | \`gitnexus_query({query: "auth validation"})\` |
| \`context\` | 360-degree view of one symbol | \`gitnexus_context({name: "validateUser"})\` |
| \`impact\` | Blast radius before editing | \`gitnexus_impact({target: "X", direction: "upstream"})\` |
| \`detect_changes\` | Pre-commit scope check | \`gitnexus_detect_changes({scope: "staged"})\` |
| \`rename\` | Safe multi-file rename | \`gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})\` |
| \`cypher\` | Custom graph queries | \`gitnexus_cypher({query: "MATCH ..."})\` |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources
| Resource | Use for |
@@ -149,32 +117,6 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s
| \`gitnexus://repo/${projectName}/processes\` | All execution flows |
| \`gitnexus://repo/${projectName}/process/{name}\` | Step-by-step execution trace |
## Self-Check Before Finishing
Before completing any code modification task, verify:
1. \`gitnexus_impact\` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. \`gitnexus_detect_changes()\` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
\`\`\`bash
npx gitnexus analyze
\`\`\`
If the index previously included embeddings, preserve them by adding \`--embeddings\`:
\`\`\`bash
npx gitnexus analyze --embeddings
\`\`\`
To check whether embeddings exist, inspect \`.gitnexus/meta.json\` — the \`stats.embeddings\` field shows the count (0 means no embeddings). **Running analyze without \`--embeddings\` will delete any previously generated embeddings.**
> Claude Code users: A PostToolUse hook handles this automatically after \`git commit\` and \`git merge\`.
${
groupNames && groupNames.length > 0
? `## Cross-Repo Groups
+3
View File
@@ -147,9 +147,11 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
const origLog = console.log.bind(console);
const origWarn = console.warn.bind(console);
const origError = console.error.bind(console);
let barCurrentValue = 0;
const barLog = (...args: any[]) => {
process.stdout.write('\x1b[2K\r');
origLog(args.map((a) => (typeof a === 'string' ? a : String(a))).join(' '));
bar.update(barCurrentValue);
};
console.log = barLog;
console.warn = barLog;
@@ -160,6 +162,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
let phaseStart = Date.now();
const updateBar = (value: number, phaseLabel: string) => {
barCurrentValue = value;
if (phaseLabel !== lastPhaseLabel) {
lastPhaseLabel = phaseLabel;
phaseStart = Date.now();
+112
View File
@@ -0,0 +1,112 @@
/**
* Shared AST utilities for the embedding pipeline.
* Centralizes parser caching and tree-sitter node lookups
* used by both chunker.ts and structural-extractor.ts.
*/
import { getLanguageFromFilename } from 'gitnexus-shared';
import {
createParserForLanguage,
isLanguageAvailable,
resolveLanguageKey,
} from '../tree-sitter/parser-loader.js';
const parserCache = new Map<string, any>();
/**
* Ensure parser is initialized and language is loaded, then parse content.
* Returns null if language is unavailable or parsing fails.
*/
export const ensureAndParse = async (content: string, filePath: string): Promise<any | null> => {
const language = getLanguageFromFilename(filePath);
if (!language) return null;
if (!isLanguageAvailable(language)) return null;
const parserKey = resolveLanguageKey(language, filePath);
let parserInstance = parserCache.get(parserKey);
if (!parserInstance) {
parserInstance = await createParserForLanguage(language, filePath);
parserCache.set(parserKey, parserInstance);
}
return parserInstance.parse(content);
};
const FUNCTION_LIKE_TYPES = new Set([
'function_declaration',
'function_definition',
'method_declaration',
'method_definition',
'function_item',
'function_signature_item',
'arrow_function',
'function_expression',
'generator_function_declaration',
'generator_function',
'async_function_declaration',
'async_arrow_function',
'constructor_declaration',
'constructor_definition',
'compact_constructor_declaration',
'short_function_declaration',
'proc_declaration',
'func_literal',
'local_function_statement',
'anonymous_function',
'lambda_literal',
'init_declaration',
'deinit_declaration',
]);
/**
* Find the first function/method-like declaration in a snippet AST.
* Used by the chunker when parsing node.content where absolute line
* numbers don't apply.
*/
export const findFunctionNode = (root: any): any | null => {
if (FUNCTION_LIKE_TYPES.has(root.type)) return root;
for (let i = 0; i < root.namedChildCount; i++) {
const child = root.namedChild(i);
if (!child) continue;
if (FUNCTION_LIKE_TYPES.has(child.type)) return child;
const found = findFunctionNode(child);
if (found) return found;
}
return null;
};
/**
* Find the first class/struct/interface/enum-like declaration in an AST.
* Used when parsing node.content (a snippet, not a full file) where
* absolute line numbers don't apply.
*/
export const findDeclarationNode = (root: any): any | null => {
const CLASS_LIKE_TYPES = new Set([
'class_declaration',
'class_definition',
'struct_declaration',
'struct_item',
'interface_declaration',
'interface_definition',
'enum_declaration',
'enum_item',
'type_declaration', // Go: type X struct
'declaration', // Go: type X struct
'object_declaration', // Kotlin: object
'impl_item', // Rust: impl
]);
if (CLASS_LIKE_TYPES.has(root.type)) return root;
for (let i = 0; i < root.namedChildCount; i++) {
const child = root.namedChild(i);
if (!child) continue;
if (CLASS_LIKE_TYPES.has(child.type)) return child;
const found = findDeclarationNode(child);
if (found) return found;
}
return null;
};
@@ -0,0 +1,63 @@
/**
* Character-based sliding window chunking (pure, no tree-sitter dependency)
*/
import { buildLineIndex, resolveChunkLines } from './line-index.js';
export interface Chunk {
text: string;
chunkIndex: number;
startOffset: number;
endOffset: number;
startLine: number;
endLine: number;
}
export const characterChunk = (
content: string,
startLine: number,
endLine: number,
chunkSize: number = 1200,
overlap: number = 120,
): Chunk[] => {
if (content.length <= chunkSize) {
return [
{
text: content,
chunkIndex: 0,
startOffset: 0,
endOffset: content.length,
startLine,
endLine,
},
];
}
const chunks: Chunk[] = [];
let offset = 0;
const lineOffsets = buildLineIndex(content);
while (offset < content.length) {
const end = Math.min(offset + chunkSize, content.length);
const chunkText = content.slice(offset, end);
const lineRange = resolveChunkLines(lineOffsets, offset, end, startLine);
chunks.push({
text: chunkText,
chunkIndex: chunks.length,
startOffset: offset,
endOffset: end,
startLine: lineRange.startLine,
endLine: lineRange.endLine,
});
offset = end - overlap;
if (offset >= content.length) break;
if (end >= content.length) break;
if (offset <= (chunks.length > 1 ? end - chunkSize : 0)) {
offset = end;
}
}
return chunks;
};
+363
View File
@@ -0,0 +1,363 @@
/**
* Chunker Module
*
* Splits code nodes into chunks for embedding.
* - Function/Method: AST-aware chunking by statement boundaries
* - Other types: character-based sliding window fallback
* - Short content (≤ chunkSize): no chunking
*/
export { type Chunk, characterChunk } from './character-chunk.js';
import { characterChunk } from './character-chunk.js';
import type { Chunk } from './character-chunk.js';
import { ensureAndParse, findDeclarationNode, findFunctionNode } from './ast-utils.js';
import { buildLineIndex, resolveChunkLines } from './line-index.js';
/**
* Main chunkNode function: dispatches by label
*/
export const chunkNode = async (
label: string,
content: string,
filePath: string,
startLine: number,
endLine: number,
chunkSize: number = 1200,
overlap: number = 120,
): Promise<Chunk[]> => {
// Content fits in one chunk — no splitting needed
if (content.length <= chunkSize) {
return [
{
text: content,
chunkIndex: 0,
startOffset: 0,
endOffset: content.length,
startLine,
endLine,
},
];
}
// Only function-like labels get AST chunking
if (label === 'Function' || label === 'Method' || label === 'Constructor') {
try {
const astChunks = await astChunk(content, filePath, startLine, endLine, chunkSize, overlap);
if (astChunks.length > 0) return astChunks;
} catch {
// AST parsing failed — fall through to character fallback
}
}
if (label === 'Class' || label === 'Interface') {
try {
const declarationChunks = await declarationChunk(
label,
content,
filePath,
startLine,
endLine,
chunkSize,
overlap,
);
if (declarationChunks.length > 0) return declarationChunks;
} catch {
// AST parsing failed — fall through to character fallback
}
}
// Character-based fallback for everything else
return characterChunk(content, startLine, endLine, chunkSize, overlap);
};
/**
* AST-based chunking for Function/Method
* Parse snippet content, locate the function declaration node,
* split body by statement boundaries.
*/
const astChunk = async (
content: string,
filePath: string,
startLine: number,
endLine: number,
chunkSize: number,
overlap: number,
): Promise<Chunk[]> => {
const tree = await ensureAndParse(content, filePath);
if (!tree) return [];
const root = tree.rootNode;
const lineOffsets = buildLineIndex(content);
// Find the function/method declaration in the snippet AST.
// tree-sitter parses node.content (a snippet), so rows are relative (0-based).
const targetNode = findFunctionNode(root);
if (!targetNode) return [];
// Get the body (statements) via childForFieldName('body')
const bodyNode = targetNode.childForFieldName('body');
if (!bodyNode) return [];
// Extract individual statements
const statements: Array<{ startIndex: number; endIndex: number }> = [];
for (let i = 0; i < bodyNode.namedChildCount; i++) {
const child = bodyNode.namedChild(i);
if (!child) continue;
statements.push({
startIndex: child.startIndex,
endIndex: child.endIndex,
});
}
if (statements.length === 0) return [];
return chunkByUnits(
content,
lineOffsets,
startLine,
chunkSize,
overlap,
statements,
targetNode.startIndex,
targetNode.endIndex,
true,
true,
);
};
const DECLARATION_BODY_NODE_TYPES = new Set([
'class_body',
'object_type',
'declaration_list',
'interface_body',
]);
const FIELD_LIKE_MEMBER_TYPES = new Set([
'field_definition',
'public_field_definition',
'property_definition',
'property_signature',
'variable_declarator',
'lexical_declaration',
'pair',
'enum_assignment',
]);
const declarationChunk = async (
label: 'Class' | 'Interface',
content: string,
filePath: string,
startLine: number,
endLine: number,
chunkSize: number,
overlap: number,
): Promise<Chunk[]> => {
const tree = await ensureAndParse(content, filePath);
if (!tree) return [];
const targetNode = findDeclarationNode(tree.rootNode);
if (!targetNode) return [];
const bodyNode = getDeclarationBodyNode(targetNode);
if (!bodyNode) return [];
const members = collectDeclarationUnits(bodyNode, label);
if (members.length === 0) return [];
return chunkByUnits(
content,
buildLineIndex(content),
startLine,
chunkSize,
overlap,
members,
targetNode.startIndex,
targetNode.endIndex,
false,
false,
);
};
const buildChunk = (
content: string,
lineOffsets: Int32Array,
chunkIndex: number,
startOffset: number,
endOffset: number,
baseStartLine: number,
): Chunk => {
const lineRange = resolveChunkLines(lineOffsets, startOffset, endOffset, baseStartLine);
return {
text: content.slice(startOffset, endOffset),
chunkIndex,
startOffset,
endOffset,
startLine: lineRange.startLine,
endLine: lineRange.endLine,
};
};
const chunkByUnits = (
content: string,
lineOffsets: Int32Array,
baseStartLine: number,
chunkSize: number,
overlap: number,
units: Array<{ startIndex: number; endIndex: number }>,
containerStartOffset: number,
containerEndOffset: number,
includeContainerPrefixOnFirstChunk: boolean,
includeContainerSuffixOnLastChunk: boolean,
): Chunk[] => {
const chunks: Chunk[] = [];
let chunkStartUnitIdx = 0;
while (chunkStartUnitIdx < units.length) {
const chunkStartOffset =
chunkStartUnitIdx === 0 && includeContainerPrefixOnFirstChunk
? containerStartOffset
: units[chunkStartUnitIdx].startIndex;
let chunkEndUnitIdx = chunkStartUnitIdx;
let candidateEndOffset =
chunkEndUnitIdx === units.length - 1 && includeContainerSuffixOnLastChunk
? containerEndOffset
: units[chunkEndUnitIdx].endIndex;
while (chunkEndUnitIdx + 1 < units.length) {
const nextEndOffset =
chunkEndUnitIdx + 1 === units.length - 1 && includeContainerSuffixOnLastChunk
? containerEndOffset
: units[chunkEndUnitIdx + 1].endIndex;
if (nextEndOffset - chunkStartOffset > chunkSize) break;
chunkEndUnitIdx += 1;
candidateEndOffset = nextEndOffset;
}
if (candidateEndOffset - chunkStartOffset > chunkSize) {
const oversizedUnit = units[chunkStartUnitIdx];
const oversizedLineRange = resolveChunkLines(
lineOffsets,
oversizedUnit.startIndex,
oversizedUnit.endIndex,
baseStartLine,
);
const oversizedChunks = characterChunk(
content.slice(oversizedUnit.startIndex, oversizedUnit.endIndex),
oversizedLineRange.startLine,
oversizedLineRange.endLine,
chunkSize,
overlap,
).map((chunk, offsetIdx) => ({
...chunk,
chunkIndex: chunks.length + offsetIdx,
startOffset: chunk.startOffset + oversizedUnit.startIndex,
endOffset: chunk.endOffset + oversizedUnit.startIndex,
}));
chunks.push(...oversizedChunks);
chunkStartUnitIdx += 1;
continue;
}
chunks.push(
buildChunk(
content,
lineOffsets,
chunks.length,
chunkStartOffset,
candidateEndOffset,
baseStartLine,
),
);
if (chunkEndUnitIdx === units.length - 1) {
break;
}
const nextChunkStartUnitIdx = findOverlapStartIndex(
units,
chunkStartUnitIdx,
chunkEndUnitIdx,
overlap,
);
if (nextChunkStartUnitIdx <= chunkStartUnitIdx) {
chunkStartUnitIdx = chunkEndUnitIdx + 1;
} else {
chunkStartUnitIdx = nextChunkStartUnitIdx;
}
}
return chunks;
};
const findOverlapStartIndex = (
statements: Array<{ startIndex: number; endIndex: number }>,
chunkStartStmtIdx: number,
chunkEndStmtIdx: number,
overlapSize: number,
): number => {
if (overlapSize <= 0) return chunkEndStmtIdx + 1;
let overlapStartIdx = chunkEndStmtIdx;
while (overlapStartIdx > chunkStartStmtIdx) {
const overlapLength =
statements[chunkEndStmtIdx].endIndex - statements[overlapStartIdx - 1].startIndex;
if (overlapLength > overlapSize) break;
overlapStartIdx -= 1;
}
return overlapStartIdx;
};
const getDeclarationBodyNode = (node: any): any | null => {
const bodyNode = node.childForFieldName?.('body');
if (bodyNode) return bodyNode;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (!child) continue;
if (DECLARATION_BODY_NODE_TYPES.has(child.type)) return child;
}
return null;
};
const collectDeclarationUnits = (
bodyNode: any,
label: 'Class' | 'Interface',
): Array<{ startIndex: number; endIndex: number }> => {
const members: Array<{ startIndex: number; endIndex: number; groupable: boolean }> = [];
for (let i = 0; i < bodyNode.namedChildCount; i++) {
const child = bodyNode.namedChild(i);
if (!child) continue;
members.push({
startIndex: child.startIndex,
endIndex: child.endIndex,
groupable: label === 'Class' && FIELD_LIKE_MEMBER_TYPES.has(child.type),
});
}
if (members.length === 0) return [];
const grouped: Array<{ startIndex: number; endIndex: number }> = [];
let current = members[0];
for (let i = 1; i < members.length; i++) {
const next = members[i];
if (current.groupable && next.groupable) {
current = {
startIndex: current.startIndex,
endIndex: next.endIndex,
groupable: true,
};
continue;
}
grouped.push({ startIndex: current.startIndex, endIndex: current.endIndex });
current = next;
}
grouped.push({ startIndex: current.startIndex, endIndex: current.endIndex });
return grouped;
};
+5
View File
@@ -157,6 +157,11 @@ export const initEmbedder = async (
try {
// Configure transformers.js environment
env.allowLocalModels = false;
// Default cache to user-writable location. transformers.js defaults to
// ./node_modules/.cache inside its own install dir, which is unwritable
// when gitnexus is installed globally (e.g. /usr/lib/node_modules/).
// Respect HF_HOME if set, otherwise fall back to ~/.cache/huggingface.
env.cacheDir = process.env.HF_HOME ?? `${process.env.HOME}/.cache/huggingface`;
const isDev = process.env.NODE_ENV === 'development';
if (isDev) {
+305 -125
View File
@@ -3,12 +3,13 @@
*
* Orchestrates the background embedding process:
* 1. Query embeddable nodes from LadybugDB
* 2. Generate text representations
* 3. Batch embed using transformers.js
* 4. Update LadybugDB with embeddings
* 2. Generate text representations with enriched metadata
* 3. Chunk long nodes, batch embed
* 4. Update LadybugDB with chunk-aware embeddings
* 5. Create vector index for semantic search
*/
import { createHash } from 'crypto';
import {
initEmbedder,
embedBatch,
@@ -16,19 +17,54 @@ import {
embeddingToArray,
isEmbedderReady,
} from './embedder.js';
import { generateBatchEmbeddingTexts } from './text-generator.js';
import { generateEmbeddingText } from './text-generator.js';
import { chunkNode, characterChunk } from './chunker.js';
import { extractStructuralNames } from './structural-extractor.js';
import {
type EmbeddingProgress,
type EmbeddingConfig,
type EmbeddableNode,
type SemanticSearchResult,
type ModelProgress,
type EmbeddingContext,
DEFAULT_EMBEDDING_CONFIG,
EMBEDDABLE_LABELS,
isShortLabel,
LABELS_WITH_EXPORTED,
STRUCTURAL_LABELS,
collectBestChunks,
} from './types.js';
import {
EMBEDDING_TABLE_NAME,
EMBEDDING_INDEX_NAME,
CREATE_VECTOR_INDEX_QUERY,
STALE_HASH_SENTINEL,
} from '../lbug/schema.js';
import { loadVectorExtension } from '../lbug/lbug-adapter.js';
const isDev = process.env.NODE_ENV === 'development';
/**
* Compute a stable content fingerprint for an embeddable node.
* Used to detect when the underlying text has changed so stale vectors
* can be replaced (DELETE-then-INSERT, the Kuzu-sanctioned pattern for
* vector-indexed rows).
*/
export const contentHashForNode = (
node: EmbeddableNode,
config: Partial<EmbeddingConfig> = {},
): string => {
// Hash must be deterministic across runs, so exclude methodNames/fieldNames
// which are populated during the batch loop via AST extraction.
// Using only node.content ensures the hash stays stable.
const text = generateEmbeddingText(
{ ...node, methodNames: undefined, fieldNames: undefined },
node.content,
config,
);
return createHash('sha1').update(text).digest('hex');
};
/**
* Progress callback type
*/
@@ -36,37 +72,50 @@ export type EmbeddingProgressCallback = (progress: EmbeddingProgress) => void;
/**
* Query all embeddable nodes from LadybugDB
* Uses table-specific queries (File has different schema than code elements)
* Uses table-specific queries for different label types
*/
const queryEmbeddableNodes = async (
executeQuery: (cypher: string) => Promise<any[]>,
): Promise<EmbeddableNode[]> => {
const allNodes: EmbeddableNode[] = [];
// Query each embeddable table with table-specific columns
for (const label of EMBEDDABLE_LABELS) {
try {
let query: string;
if (label === 'File') {
// File nodes don't have startLine/endLine
if (label === 'Method') {
// Method has parameterCount and returnType
query = `
MATCH (n:File)
RETURN n.id AS id, n.name AS name, 'File' AS label,
n.filePath AS filePath, n.content AS content
MATCH (n:Method)
RETURN n.id AS id, n.name AS name, 'Method' AS label,
n.filePath AS filePath, n.content AS content,
n.startLine AS startLine, n.endLine AS endLine,
n.isExported AS isExported, n.description AS description,
n.parameterCount AS parameterCount, n.returnType AS returnType
`;
} else if (LABELS_WITH_EXPORTED.has(label)) {
// Function, Class, Interface have isExported and description
query = `
MATCH (n:\`${label}\`)
RETURN n.id AS id, n.name AS name, '${label}' AS label,
n.filePath AS filePath, n.content AS content,
n.startLine AS startLine, n.endLine AS endLine,
n.isExported AS isExported, n.description AS description
`;
} else {
// Code elements have startLine/endLine
// Multi-language tables (Struct, Enum, etc.) — have description but no isExported
query = `
MATCH (n:${label})
RETURN n.id AS id, n.name AS name, '${label}' AS label,
MATCH (n:\`${label}\`)
RETURN n.id AS id, n.name AS name, '${label}' AS label,
n.filePath AS filePath, n.content AS content,
n.startLine AS startLine, n.endLine AS endLine
n.startLine AS startLine, n.endLine AS endLine,
n.description AS description
`;
}
const rows = await executeQuery(query);
for (const row of rows) {
const hasExportedColumn = label === 'Method' || LABELS_WITH_EXPORTED.has(label);
allNodes.push({
id: row.id ?? row[0],
name: row.name ?? row[1],
@@ -75,10 +124,17 @@ const queryEmbeddableNodes = async (
content: row.content ?? row[4] ?? '',
startLine: row.startLine ?? row[5],
endLine: row.endLine ?? row[6],
isExported: hasExportedColumn ? (row.isExported ?? row[7]) : undefined,
description: row.description ?? (hasExportedColumn ? row[8] : row[7]),
...(label === 'Method'
? {
parameterCount: row.parameterCount ?? row[9],
returnType: row.returnType ?? row[10],
}
: {}),
});
}
} catch (error) {
// Table might not exist or be empty, continue
if (isDev) {
console.warn(`Query for ${label} nodes failed:`, error);
}
@@ -89,52 +145,52 @@ const queryEmbeddableNodes = async (
};
/**
* Batch INSERT embeddings into separate CodeEmbedding table
* Using a separate lightweight table avoids copy-on-write overhead
* that occurs when UPDATEing nodes with large content fields
* Batch INSERT chunk-aware embeddings into CodeEmbedding table
*/
const batchInsertEmbeddings = async (
export const batchInsertEmbeddings = async (
executeWithReusedStatement: (
cypher: string,
paramsList: Array<Record<string, any>>,
) => Promise<void>,
updates: Array<{ id: string; embedding: number[] }>,
updates: Array<{
nodeId: string;
chunkIndex: number;
startLine: number;
endLine: number;
embedding: number[];
contentHash?: string;
}>,
): Promise<void> => {
// MERGE instead of CREATE — idempotent, handles concurrent analyzes and partial prior runs
const cypher = `MERGE (e:CodeEmbedding {nodeId: $nodeId}) SET e.embedding = $embedding`;
const paramsList = updates.map((u) => ({ nodeId: u.id, embedding: u.embedding }));
const cypher = `CREATE (e:${EMBEDDING_TABLE_NAME} {id: $id, nodeId: $nodeId, chunkIndex: $chunkIndex, startLine: $startLine, endLine: $endLine, embedding: $embedding, contentHash: $contentHash})`;
const paramsList = updates.map((u) => ({
id: `${u.nodeId}:${u.chunkIndex}`,
nodeId: u.nodeId,
chunkIndex: u.chunkIndex,
startLine: u.startLine,
endLine: u.endLine,
embedding: u.embedding,
contentHash: u.contentHash ?? STALE_HASH_SENTINEL,
}));
await executeWithReusedStatement(cypher, paramsList);
};
/**
* Create the vector index for semantic search
* Now indexes the separate CodeEmbedding table
*/
let vectorExtensionLoaded = false;
* Now indexes the separate CodeEmbedding table.
* Delegates extension loading to lbug-adapter's loadVectorExtension(),
* which owns the VECTOR extension lifecycle and state tracking.
*/
const createVectorIndex = async (
executeQuery: (cypher: string) => Promise<any[]>,
): Promise<void> => {
// LadybugDB v0.15+ requires explicit VECTOR extension loading (once per session)
if (!vectorExtensionLoaded) {
try {
await executeQuery('INSTALL VECTOR');
await executeQuery('LOAD EXTENSION VECTOR');
vectorExtensionLoaded = true;
} catch {
// Extension may already be loaded — CREATE_VECTOR_INDEX will fail clearly if not
vectorExtensionLoaded = true;
}
}
const cypher = `
CALL CREATE_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', 'embedding', metric := 'cosine')
`;
// Delegate to the adapter which tracks loaded state and handles DB reconnect resets
await loadVectorExtension();
try {
await executeQuery(cypher);
await executeQuery(CREATE_VECTOR_INDEX_QUERY);
} catch (error) {
// Index might already exist
if (isDev) {
console.warn('Vector index creation warning:', error);
}
@@ -149,6 +205,11 @@ const createVectorIndex = async (
* @param onProgress - Callback for progress updates
* @param config - Optional configuration override
* @param skipNodeIds - Optional set of node IDs that already have embeddings (incremental mode)
* @param context - Optional repo/server context for metadata enrichment
* @param existingEmbeddings - Optional map of nodeId → contentHash for incremental mode.
* Nodes whose hash matches are skipped; nodes with a changed hash are DELETE'd
* and re-embedded; nodes not in the map are embedded fresh.
*/
export const runEmbeddingPipeline = async (
executeQuery: (cypher: string) => Promise<any[]>,
@@ -159,6 +220,8 @@ export const runEmbeddingPipeline = async (
onProgress: EmbeddingProgressCallback,
config: Partial<EmbeddingConfig> = {},
skipNodeIds?: Set<string>,
context?: EmbeddingContext,
existingEmbeddings?: Map<string, string>,
): Promise<void> => {
const finalConfig = { ...DEFAULT_EMBEDDING_CONFIG, ...config };
@@ -194,13 +257,65 @@ export const runEmbeddingPipeline = async (
// Phase 2: Query embeddable nodes
let nodes = await queryEmbeddableNodes(executeQuery);
// Incremental mode: filter out nodes that already have embeddings
if (skipNodeIds && skipNodeIds.size > 0) {
// Apply context metadata
if (context?.repoName) {
for (const node of nodes) {
node.repoName = context.repoName;
node.serverName = context.serverName;
}
}
// Incremental mode: compare content hashes, delete stale rows, skip fresh ones.
// Computed hashes for stale nodes are cached so batchInsertEmbeddings can reuse them
// (avoids double computation).
const computedStaleHashes = new Map<string, string>();
if (existingEmbeddings && existingEmbeddings.size > 0) {
const beforeCount = nodes.length;
nodes = nodes.filter((n) => !skipNodeIds.has(n.id));
const staleNodeIds: string[] = [];
nodes = nodes.filter((n) => {
const existingHash = existingEmbeddings.get(n.id);
if (existingHash === undefined) {
// New node — needs embedding
return true;
}
const currentHash = contentHashForNode(n, finalConfig);
if (currentHash !== existingHash) {
// Content changed — cache hash for reuse during insert, mark for DELETE + re-embed
computedStaleHashes.set(n.id, currentHash);
staleNodeIds.push(n.id);
return true;
}
// Hash matches — skip (fresh); no need to cache hash for skipped nodes
return false;
});
// DELETE stale embedding rows so they can be re-inserted
// (Kuzu forbids SET on vector-indexed properties; DELETE-then-INSERT is the sanctioned pattern)
if (staleNodeIds.length > 0) {
if (isDev) {
console.log(`🔄 Deleting ${staleNodeIds.length} stale embedding rows for re-embed`);
}
try {
await executeWithReusedStatement(
`MATCH (e:${EMBEDDING_TABLE_NAME} {nodeId: $nodeId}) DELETE e`,
staleNodeIds.map((nodeId) => ({ nodeId })),
);
} catch (err) {
// "does not exist" = rows already gone — safe to proceed.
// All other errors risk vector-index corruption (Kuzu requires DELETE-before-INSERT
// for vector-indexed properties) — propagate so the pipeline aborts cleanly.
const msg = err instanceof Error ? err.message : String(err);
if (!msg.includes('does not exist')) {
throw new Error(
`[embed] Failed to delete stale embedding rows — aborting to prevent vector-index corruption: ${msg}`,
);
}
}
}
if (isDev) {
console.log(
`📦 Incremental embeddings: ${beforeCount} total, ${skipNodeIds.size} cached, ${nodes.length} to embed`,
`📦 Incremental embeddings: ${beforeCount} total, ${existingEmbeddings.size} cached, ${staleNodeIds.length} stale, ${nodes.length} to embed`,
);
}
}
@@ -212,6 +327,11 @@ export const runEmbeddingPipeline = async (
}
if (totalNodes === 0) {
// Ensure the vector index exists even when no new nodes need embedding.
// A prior crash or first-time incremental run may have left CodeEmbedding
// rows without ever reaching index creation.
await createVectorIndex(executeQuery);
onProgress({
phase: 'ready',
percent: 100,
@@ -221,10 +341,12 @@ export const runEmbeddingPipeline = async (
return;
}
// Phase 3: Batch embed nodes
// Phase 3: Chunk + embed nodes
const batchSize = finalConfig.batchSize;
const totalBatches = Math.ceil(totalNodes / batchSize);
const chunkSize = finalConfig.chunkSize;
const overlap = finalConfig.overlap;
let processedNodes = 0;
let totalChunks = 0;
onProgress({
phase: 'embedding',
@@ -232,39 +354,116 @@ export const runEmbeddingPipeline = async (
nodesProcessed: 0,
totalNodes,
currentBatch: 0,
totalBatches,
totalBatches: Math.ceil(totalNodes / batchSize),
});
for (let batchIndex = 0; batchIndex < totalBatches; batchIndex++) {
const start = batchIndex * batchSize;
const end = Math.min(start + batchSize, totalNodes);
const batch = nodes.slice(start, end);
// Process in batches of nodes
for (let batchIndex = 0; batchIndex < totalNodes; batchIndex += batchSize) {
const batch = nodes.slice(batchIndex, batchIndex + batchSize);
// Generate texts for this batch
const texts = generateBatchEmbeddingTexts(batch, finalConfig);
// Chunk each node and generate text
const allTexts: string[] = [];
const allUpdates: Array<{
nodeId: string;
chunkIndex: number;
startLine: number;
endLine: number;
contentHash: string;
}> = [];
// Embed the batch
const embeddings = await embedBatch(texts);
for (const node of batch) {
const isShort = isShortLabel(node.label);
const startLine = node.startLine ?? 0;
const endLine = node.endLine ?? 0;
// Update LadybugDB with embeddings
const updates = batch.map((node, i) => ({
id: node.id,
embedding: embeddingToArray(embeddings[i]),
}));
// Extract structural names for class-like nodes via AST extractors
if (!isShort && STRUCTURAL_LABELS.has(node.label)) {
try {
const names = await extractStructuralNames(node.content, node.filePath);
node.methodNames = names.methodNames;
node.fieldNames = names.fieldNames;
} catch {
// AST extraction failed — names stay undefined, text-generator handles gracefully
}
}
await batchInsertEmbeddings(executeWithReusedStatement, updates);
// Compute content hash once per node (re-use cached value for stale nodes)
const hash = computedStaleHashes.get(node.id) ?? contentHashForNode(node, finalConfig);
let chunks: Array<{ text: string; chunkIndex: number; startLine: number; endLine: number }>;
if (isShort) {
chunks = [{ text: node.content, chunkIndex: 0, startLine, endLine }];
} else {
try {
chunks = await chunkNode(
node.label,
node.content,
node.filePath,
startLine,
endLine,
chunkSize,
overlap,
);
} catch (chunkErr) {
if (isDev) {
console.warn(
`⚠️ AST chunking failed for ${node.label} "${node.name}" (${node.filePath}), falling back to character-based chunking:`,
chunkErr,
);
}
chunks = characterChunk(node.content, startLine, endLine, chunkSize, overlap);
}
}
for (const chunk of chunks) {
const text = generateEmbeddingText(node, chunk.text, finalConfig);
allTexts.push(text);
allUpdates.push({
nodeId: node.id,
chunkIndex: chunk.chunkIndex,
startLine: chunk.startLine,
endLine: chunk.endLine,
contentHash: hash,
});
}
}
// Embed chunk texts in sub-batches to control memory
const EMBED_SUB_BATCH = 8;
for (let si = 0; si < allTexts.length; si += EMBED_SUB_BATCH) {
const subTexts = allTexts.slice(si, si + EMBED_SUB_BATCH);
const subUpdates = allUpdates.slice(si, si + EMBED_SUB_BATCH);
let embeddings: Float32Array[];
try {
embeddings = await embedBatch(subTexts);
} catch (embedErr) {
console.error(
`❌ embedBatch failed for ${subTexts.length} texts (first: "${subTexts[0]?.substring(0, 80)}..."):`,
embedErr,
);
throw embedErr;
}
const dbUpdates = subUpdates.map((u, i) => ({
...u,
embedding: embeddingToArray(embeddings[i]),
}));
await batchInsertEmbeddings(executeWithReusedStatement, dbUpdates);
}
processedNodes += batch.length;
totalChunks += allUpdates.length;
// Report progress (20-90% for embedding phase)
const embeddingProgress = 20 + (processedNodes / totalNodes) * 70;
onProgress({
phase: 'embedding',
percent: Math.round(embeddingProgress),
nodesProcessed: processedNodes,
totalNodes,
currentBatch: batchIndex + 1,
totalBatches,
currentBatch: Math.floor(batchIndex / batchSize) + 1,
totalBatches: Math.ceil(totalNodes / batchSize),
});
}
@@ -282,7 +481,6 @@ export const runEmbeddingPipeline = async (
await createVectorIndex(executeQuery);
// Complete
onProgress({
phase: 'ready',
percent: 100,
@@ -291,7 +489,9 @@ export const runEmbeddingPipeline = async (
});
if (isDev) {
console.log('✅ Embedding pipeline complete!');
console.log(
`✅ Embedding pipeline complete! (${totalChunks} chunks from ${totalNodes} nodes)`,
);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
@@ -311,15 +511,7 @@ export const runEmbeddingPipeline = async (
};
/**
* Perform semantic search using the vector index
*
* Uses CodeEmbedding table and queries each node table to get metadata
*
* @param executeQuery - Function to execute Cypher queries
* @param query - Search query text
* @param k - Number of results to return (default: 10)
* @param maxDistance - Maximum distance threshold (default: 0.5)
* @returns Array of search results ordered by relevance
* Perform semantic search using the vector index with chunk deduplication
*/
export const semanticSearch = async (
executeQuery: (cypher: string) => Promise<any[]>,
@@ -331,37 +523,46 @@ export const semanticSearch = async (
throw new Error('Embedding model not initialized. Run embedding pipeline first.');
}
// Embed the query
const queryEmbedding = await embedText(query);
const queryVec = embeddingToArray(queryEmbedding);
const queryVecStr = `[${queryVec.join(',')}]`;
// Query the vector index on CodeEmbedding to get nodeIds and distances
const vectorQuery = `
CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx',
CAST(${queryVecStr} AS FLOAT[${queryVec.length}]), ${k})
YIELD node AS emb, distance
WITH emb, distance
WHERE distance < ${maxDistance}
RETURN emb.nodeId AS nodeId, distance
ORDER BY distance
`;
const bestChunks = await collectBestChunks(k, async (fetchLimit) => {
const vectorQuery = `
CALL QUERY_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', '${EMBEDDING_INDEX_NAME}',
CAST(${queryVecStr} AS FLOAT[${queryVec.length}]), ${fetchLimit})
YIELD node AS emb, distance
WITH emb, distance
WHERE distance < ${maxDistance}
RETURN emb.nodeId AS nodeId, emb.chunkIndex AS chunkIndex,
emb.startLine AS startLine, emb.endLine AS endLine, distance
ORDER BY distance
`;
const embResults = await executeQuery(vectorQuery);
const embResults = await executeQuery(vectorQuery);
return embResults.map((row) => ({
nodeId: row.nodeId ?? row[0],
chunkIndex: row.chunkIndex ?? row[1] ?? 0,
startLine: row.startLine ?? row[2] ?? 0,
endLine: row.endLine ?? row[3] ?? 0,
distance: row.distance ?? row[4],
}));
});
if (embResults.length === 0) {
if (bestChunks.size === 0) {
return [];
}
// Group results by label for batched metadata queries
const byLabel = new Map<string, Array<{ nodeId: string; distance: number }>>();
for (const embRow of embResults) {
const nodeId = embRow.nodeId ?? embRow[0];
const distance = embRow.distance ?? embRow[1];
const byLabel = new Map<
string,
Array<{ nodeId: string; distance: number } & Record<string, any>>
>();
for (const [nodeId, chunk] of Array.from(bestChunks.entries()).slice(0, k)) {
const labelEndIdx = nodeId.indexOf(':');
const label = labelEndIdx > 0 ? nodeId.substring(0, labelEndIdx) : 'Unknown';
if (!byLabel.has(label)) byLabel.set(label, []);
byLabel.get(label)!.push({ nodeId, distance });
byLabel.get(label)!.push({ nodeId, ...chunk });
}
// Batch-fetch metadata per label
@@ -370,19 +571,11 @@ export const semanticSearch = async (
for (const [label, items] of byLabel) {
const idList = items.map((i) => `'${i.nodeId.replace(/'/g, "''")}'`).join(', ');
try {
let nodeQuery: string;
if (label === 'File') {
nodeQuery = `
MATCH (n:File) WHERE n.id IN [${idList}]
RETURN n.id AS id, n.name AS name, n.filePath AS filePath
`;
} else {
nodeQuery = `
MATCH (n:${label}) WHERE n.id IN [${idList}]
RETURN n.id AS id, n.name AS name, n.filePath AS filePath,
n.startLine AS startLine, n.endLine AS endLine
`;
}
const nodeQuery = `
MATCH (n:\`${label}\`) WHERE n.id IN [${idList}]
RETURN n.id AS id, n.name AS name, n.filePath AS filePath,
n.startLine AS startLine, n.endLine AS endLine
`;
const nodeRows = await executeQuery(nodeQuery);
const rowMap = new Map<string, any>();
for (const row of nodeRows) {
@@ -398,8 +591,8 @@ export const semanticSearch = async (
label,
filePath: nodeRow.filePath ?? nodeRow[2] ?? '',
distance: item.distance,
startLine: label !== 'File' ? (nodeRow.startLine ?? nodeRow[3]) : undefined,
endLine: label !== 'File' ? (nodeRow.endLine ?? nodeRow[4]) : undefined,
startLine: item.startLine,
endLine: item.endLine,
});
}
}
@@ -408,7 +601,6 @@ export const semanticSearch = async (
}
}
// Re-sort by distance since batch queries may have mixed order
results.sort((a, b) => a.distance - b.distance);
return results;
@@ -416,16 +608,6 @@ export const semanticSearch = async (
/**
* Semantic search with graph expansion (flattened results)
*
* Note: With multi-table schema, graph traversal is simplified.
* Returns semantic matches with their metadata.
* For full graph traversal, use execute_vector_cypher tool directly.
*
* @param executeQuery - Function to execute Cypher queries
* @param query - Search query text
* @param k - Number of initial semantic matches (default: 5)
* @param _hops - Unused (kept for API compatibility).
* @returns Semantic matches with metadata
*/
export const semanticSearchWithContext = async (
executeQuery: (cypher: string) => Promise<any[]>,
@@ -433,8 +615,6 @@ export const semanticSearchWithContext = async (
k: number = 5,
_hops: number = 1,
): Promise<any[]> => {
// For multi-table schema, just return semantic search results
// Graph traversal is complex with separate tables - use execute_vector_cypher instead
const results = await semanticSearch(executeQuery, query, k, 0.5);
return results.map((r) => ({
@@ -0,0 +1,50 @@
export interface ResolvedLineRange {
startLine: number;
endLine: number;
}
export const buildLineIndex = (content: string): Int32Array => {
const offsets: number[] = [0];
for (let i = 0; i < content.length; i++) {
if (content.charCodeAt(i) === 10) offsets.push(i + 1);
}
return new Int32Array(offsets);
};
const clampOffset = (lineOffsets: Int32Array, charOffset: number): number => {
if (lineOffsets.length === 0) return 0;
const maxOffset = lineOffsets[lineOffsets.length - 1];
if (charOffset < 0) return 0;
if (charOffset > maxOffset) return maxOffset;
return charOffset;
};
export const lineFromOffset = (lineOffsets: Int32Array, charOffset: number): number => {
if (lineOffsets.length === 0) return 0;
const clamped = clampOffset(lineOffsets, charOffset);
let lo = 0;
let hi = lineOffsets.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (lineOffsets[mid] <= clamped) lo = mid;
else hi = mid - 1;
}
return lo;
};
export const resolveChunkLines = (
lineOffsets: Int32Array,
startOffset: number,
endOffset: number,
baseStartLine: number,
): ResolvedLineRange => {
const relativeStartLine = lineFromOffset(lineOffsets, startOffset);
const effectiveEndOffset = endOffset > startOffset ? endOffset - 1 : startOffset;
const relativeEndLine = lineFromOffset(lineOffsets, effectiveEndOffset);
return {
startLine: baseStartLine + relativeStartLine,
endLine: baseStartLine + relativeEndLine,
};
};
@@ -0,0 +1,37 @@
/**
* Server Mapping Configuration
*
* Reads ~/.gitnexus/server-mapping.json to map repo names to service names.
* Used in embedding text to enrich metadata with microservice context.
*/
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
const MAPPING_FILE = path.join(os.homedir(), '.gitnexus', 'server-mapping.json');
let cachedMapping: Record<string, string> | null = null;
/**
* Read the server mapping file and return the serverName for a given repoName.
* Returns undefined if no mapping exists.
*/
export const readServerMapping = async (repoName: string): Promise<string | undefined> => {
try {
if (!cachedMapping) {
const raw = await fs.readFile(MAPPING_FILE, 'utf-8');
cachedMapping = JSON.parse(raw);
}
return cachedMapping[repoName];
} catch {
return undefined;
}
};
/**
* Clear the cached mapping (useful for testing or after file changes)
*/
export const clearServerMappingCache = (): void => {
cachedMapping = null;
};
@@ -0,0 +1,89 @@
/**
* Structural Extractor Module
*
* Reuses ingestion pipeline's AST-based MethodExtractor / FieldExtractor
* to extract method and field names for embedding text generation.
*/
import { getProviderForFile } from '../ingestion/languages/index.js';
import type { MethodExtractorContext, ExtractedMethods } from '../ingestion/method-types.js';
import type { FieldExtractorContext, ExtractedFields } from '../ingestion/field-types.js';
import type { LanguageProvider } from '../ingestion/language-provider.js';
import { buildTypeEnv } from '../ingestion/type-env.js';
import { SupportedLanguages } from 'gitnexus-shared';
import { ensureAndParse, findDeclarationNode } from './ast-utils.js';
export interface StructuralNames {
methodNames: string[];
fieldNames: string[];
}
const NOOP_SYMBOL_TABLE = {
lookupExactAll: () => [],
lookupExact: () => undefined,
lookupExactFull: () => undefined,
} as any;
/**
* Extract method and field names from a class/struct/interface node
* using the ingestion pipeline's AST extractors.
*/
export const extractStructuralNames = async (
content: string,
filePath: string,
): Promise<StructuralNames> => {
const provider = getProviderForFile(filePath);
if (!provider) return { methodNames: [], fieldNames: [] };
const tree = await ensureAndParse(content, filePath);
if (!tree) return { methodNames: [], fieldNames: [] };
// Parse node.content (a snippet) — find declaration directly, not by range
const classNode = findDeclarationNode(tree.rootNode);
if (!classNode) return { methodNames: [], fieldNames: [] };
const language = provider.id;
const methodNames = extractMethodNames(classNode, provider, filePath, language);
const fieldNames = extractFieldNames(classNode, provider, tree, filePath, language);
return { methodNames, fieldNames };
};
function extractMethodNames(
classNode: any,
provider: LanguageProvider,
filePath: string,
language: SupportedLanguages,
): string[] {
if (!provider.methodExtractor) return [];
const context: MethodExtractorContext = { filePath, language };
const result: ExtractedMethods | null = provider.methodExtractor.extract(classNode, context);
if (!result?.methods?.length) return [];
return result.methods.map((m) => m.name);
}
function extractFieldNames(
classNode: any,
provider: LanguageProvider,
tree: any,
filePath: string,
language: SupportedLanguages,
): string[] {
if (!provider.fieldExtractor) return [];
const typeEnv = buildTypeEnv(tree, language);
const context: FieldExtractorContext = {
typeEnv,
symbolTable: NOOP_SYMBOL_TABLE,
filePath,
language,
};
const result: ExtractedFields | null = provider.fieldExtractor.extract(classNode, context);
if (!result?.fields?.length) return [];
return result.fields.map((f) => f.name);
}
+190 -143
View File
@@ -1,206 +1,253 @@
/**
* Text Generator Module
*
* Pure functions to generate embedding text from code nodes.
* Combines node metadata with code snippets for semantic matching.
* Generates enriched embedding text from code nodes with metadata.
* Supports chunkable labels (Function/Method with AST chunking),
* Class-specific structural text, and short-node direct embed.
*
* Method/field names for Class nodes are extracted by the ingestion
* pipeline's AST extractors and passed via node.methodNames/node.fieldNames.
*/
import type { EmbeddableNode, EmbeddingConfig } from './types.js';
import { DEFAULT_EMBEDDING_CONFIG } from './types.js';
import { DEFAULT_EMBEDDING_CONFIG, isShortLabel } from './types.js';
/**
* Extract the filename from a file path
* Truncate description to max length at sentence/word boundary
*/
const getFileName = (filePath: string): string => {
const parts = filePath.split('/');
return parts[parts.length - 1] || filePath;
};
const truncateDescription = (text: string, maxLength: number): string => {
if (text.length <= maxLength) return text;
/**
* Extract the directory path from a file path
*/
const getDirectory = (filePath: string): string => {
const parts = filePath.split('/');
parts.pop();
return parts.join('/') || '';
};
const truncated = text.slice(0, maxLength);
/**
* Truncate content to max length, preserving word boundaries
*/
const truncateContent = (content: string, maxLength: number): string => {
if (content.length <= maxLength) {
return content;
// Try sentence boundary (. ! ?)
const sentenceEnd = Math.max(
truncated.lastIndexOf('. '),
truncated.lastIndexOf('! '),
truncated.lastIndexOf('? '),
);
if (sentenceEnd > maxLength * 0.5) {
return truncated.slice(0, sentenceEnd + 1);
}
// Find last space before maxLength to avoid cutting words
const truncated = content.slice(0, maxLength);
// Try word boundary
const lastSpace = truncated.lastIndexOf(' ');
if (lastSpace > maxLength * 0.8) {
return truncated.slice(0, lastSpace) + '...';
if (lastSpace > maxLength * 0.5) {
return truncated.slice(0, lastSpace);
}
return truncated + '...';
return truncated;
};
/**
* Clean code content for embedding
* Removes excessive whitespace while preserving structure
*/
const cleanContent = (content: string): string => {
return (
content
// Normalize line endings
.replace(/\r\n/g, '\n')
// Remove excessive blank lines (more than 2)
.replace(/\n{3,}/g, '\n\n')
// Trim each line
.split('\n')
.map((line) => line.trimEnd())
.join('\n')
.trim()
);
return content
.replace(/\r\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.split('\n')
.map((line) => line.trimEnd())
.join('\n')
.trim();
};
/**
* Generate embedding text for a Function node
* Build metadata header for a node
*/
const generateFunctionText = (node: EmbeddableNode, maxSnippetLength: number): string => {
const parts: string[] = [`Function: ${node.name}`, `File: ${getFileName(node.filePath)}`];
const buildMetadataHeader = (node: EmbeddableNode, config: Partial<EmbeddingConfig>): string => {
const parts: string[] = [];
const dir = getDirectory(node.filePath);
if (dir) {
parts.push(`Directory: ${dir}`);
// Label + name
parts.push(`${node.label}: ${node.name}`);
// Repo name
if (node.repoName) {
parts.push(`Repo: ${node.repoName}`);
}
if (node.content) {
const cleanedContent = cleanContent(node.content);
const snippet = truncateContent(cleanedContent, maxSnippetLength);
parts.push('', snippet);
// Server name (optional)
if (node.serverName) {
parts.push(`Server: ${node.serverName}`);
}
// Full file path
parts.push(`Path: ${node.filePath}`);
// Export status
if (node.isExported !== undefined) {
parts.push(`Export: ${node.isExported}`);
}
// Description (truncated)
if (node.description) {
const maxLen = config.maxDescriptionLength ?? DEFAULT_EMBEDDING_CONFIG.maxDescriptionLength;
const truncated = truncateDescription(node.description, maxLen);
if (truncated) {
parts.push(truncated);
}
}
return parts.join('\n');
};
/**
* Generate embedding text for a Class node
*/
const generateClassText = (node: EmbeddableNode, maxSnippetLength: number): string => {
const parts: string[] = [`Class: ${node.name}`, `File: ${getFileName(node.filePath)}`];
const generateCodeBodyText = (
node: EmbeddableNode,
codeBody: string,
config: Partial<EmbeddingConfig>,
): string => {
const header = buildMetadataHeader(node, config);
const cleaned = cleanContent(codeBody);
return `${header}\n\n${cleaned}`;
};
const dir = getDirectory(node.filePath);
if (dir) {
parts.push(`Directory: ${dir}`);
/**
* Generate embedding text for Class nodes
* Signature + properties + method name list only (no method bodies)
* Method/field names come from AST extractors via node.methodNames/node.fieldNames.
*/
const generateClassText = (
node: EmbeddableNode,
codeBody: string,
config: Partial<EmbeddingConfig>,
): string => {
return generateStructuralTypeText(node, codeBody, config);
};
const generateStructuralTypeText = (
node: EmbeddableNode,
codeBody: string,
config: Partial<EmbeddingConfig>,
): string => {
const header = buildMetadataHeader(node, config);
const parts: string[] = [header];
if (node.methodNames?.length) {
parts.push(`Methods: ${node.methodNames.join(', ')}`);
}
if (node.fieldNames?.length) {
parts.push(`Properties: ${node.fieldNames.join(', ')}`);
}
if (node.content) {
const cleanedContent = cleanContent(node.content);
const snippet = truncateContent(cleanedContent, maxSnippetLength);
parts.push('', snippet);
const declarationOnly = extractDeclarationOnly(cleanContent(node.content));
if (declarationOnly) {
parts.push('', declarationOnly);
}
const cleanedChunk = cleanContent(codeBody);
if (cleanedChunk && cleanedChunk !== cleanContent(node.content)) {
parts.push('', cleanedChunk);
}
return parts.join('\n');
};
/**
* Generate embedding text for a Method node
*/
const generateMethodText = (node: EmbeddableNode, maxSnippetLength: number): string => {
const parts: string[] = [`Method: ${node.name}`, `File: ${getFileName(node.filePath)}`];
const dir = getDirectory(node.filePath);
if (dir) {
parts.push(`Directory: ${dir}`);
}
if (node.content) {
const cleanedContent = cleanContent(node.content);
const snippet = truncateContent(cleanedContent, maxSnippetLength);
parts.push('', snippet);
}
return parts.join('\n');
};
const DECL_START_RE =
/^(?:(?:export|pub|data|abstract)\s+)*(?:type\s+\w+\s+struct|(?:class|struct|enum|interface)\s)/;
/**
* Generate embedding text for an Interface node
* Extract class/interface/struct declaration lines, skipping method bodies.
* - Brace-based languages: detects method signatures (lines with `(` and `{`)
* and skips until depth returns to class body level.
* - Non-brace languages (Python/Ruby): returns empty string (patterns handle extraction).
*/
const generateInterfaceText = (node: EmbeddableNode, maxSnippetLength: number): string => {
const parts: string[] = [`Interface: ${node.name}`, `File: ${getFileName(node.filePath)}`];
export const extractDeclarationOnly = (content: string): string => {
const lines = content.split('\n');
const declLines: string[] = [];
let depth = 0;
let started = false;
let classDepth = 0;
let skipDepth = 0;
const dir = getDirectory(node.filePath);
if (dir) {
parts.push(`Directory: ${dir}`);
for (const [idx, line] of lines.entries()) {
const trimmed = line.trim();
if (!started) {
if (DECL_START_RE.test(trimmed)) {
// Non-brace language check: current line or next 3 lines must have `{`
const nextLines = lines.slice(idx + 1, idx + 4);
if (!trimmed.includes('{') && !nextLines.some((l) => l.includes('{'))) {
return '';
}
started = true;
declLines.push(trimmed);
for (const ch of trimmed) {
if (ch === '{') depth++;
else if (ch === '}') depth--;
}
if (depth > 0) classDepth = depth;
}
continue;
}
// Always update depth (even when skipping)
const opens = (trimmed.match(/{/g) || []).length;
const closes = (trimmed.match(/}/g) || []).length;
const prevDepth = depth;
depth += opens - closes;
if (skipDepth > 0) {
if (depth <= classDepth) {
skipDepth = 0;
// Closing brace of class
if (depth <= 0) {
declLines.push(trimmed);
break;
}
}
continue;
}
// Detect method signature: line has both `(` and `{` and goes deeper than class body
const hasParens = trimmed.includes('(');
const hasOpenBrace = opens > 0;
if (hasParens && hasOpenBrace && prevDepth + opens > classDepth) {
if (opens === closes && trimmed.endsWith(';')) {
// Property with function/object initializer like `config = { timeout: 5000 };` — keep
declLines.push(trimmed);
}
// else: single-line or multi-line method — skip entirely
if (opens !== closes) {
skipDepth = classDepth;
}
continue;
}
declLines.push(trimmed);
if (depth <= 0 && declLines.length > 1) break;
}
if (node.content) {
const cleanedContent = cleanContent(node.content);
const snippet = truncateContent(cleanedContent, maxSnippetLength);
parts.push('', snippet);
}
return parts.join('\n');
};
/**
* Generate embedding text for a File node
* Uses file name and first N characters of content
*/
const generateFileText = (node: EmbeddableNode, maxSnippetLength: number): string => {
const parts: string[] = [`File: ${node.name}`, `Path: ${node.filePath}`];
if (node.content) {
const cleanedContent = cleanContent(node.content);
// For files, use a shorter snippet since they can be very long
const snippet = truncateContent(cleanedContent, Math.min(maxSnippetLength, 300));
parts.push('', snippet);
}
return parts.join('\n');
return declLines.join('\n').trim();
};
/**
* Generate embedding text for any embeddable node
* Dispatches to the appropriate generator based on node label
*
* @param node - The node to generate text for
* @param config - Optional configuration for max snippet length
* @returns Text suitable for embedding
*/
export const generateEmbeddingText = (
node: EmbeddableNode,
codeBody: string,
config: Partial<EmbeddingConfig> = {},
): string => {
const maxSnippetLength = config.maxSnippetLength ?? DEFAULT_EMBEDDING_CONFIG.maxSnippetLength;
switch (node.label) {
case 'Function':
return generateFunctionText(node, maxSnippetLength);
case 'Class':
return generateClassText(node, maxSnippetLength);
case 'Method':
return generateMethodText(node, maxSnippetLength);
case 'Interface':
return generateInterfaceText(node, maxSnippetLength);
case 'File':
return generateFileText(node, maxSnippetLength);
default:
// Fallback for any other embeddable type
return `${node.label}: ${node.name}\nPath: ${node.filePath}`;
if (isShortLabel(node.label)) {
const header = buildMetadataHeader(node, config);
const cleaned = cleanContent(node.content);
return `${header}\n\n${cleaned}`;
}
if (node.label === 'Class') {
return generateClassText(node, codeBody, config);
}
if (node.label === 'Interface') {
return generateStructuralTypeText(node, codeBody, config);
}
return generateCodeBodyText(node, codeBody, config);
};
/**
* Generate embedding texts for a batch of nodes
*
* @param nodes - Array of nodes to generate text for
* @param config - Optional configuration
* @returns Array of texts in the same order as input nodes
* Export truncation helper for testing
*/
export const generateBatchEmbeddingTexts = (
nodes: EmbeddableNode[],
config: Partial<EmbeddingConfig> = {},
): string[] => {
return nodes.map((node) => generateEmbeddingText(node, config));
};
export { truncateDescription };
+175 -3
View File
@@ -5,10 +5,40 @@
*/
/**
* Node labels that should be embedded for semantic search
* These are code elements that benefit from semantic matching
* Node labels that need chunking (have code body, potentially long)
*/
export const EMBEDDABLE_LABELS = ['Function', 'Class', 'Method', 'Interface', 'File'] as const;
export const CHUNKABLE_LABELS = [
'Function',
'Method',
'Constructor',
'Class',
'Interface',
'Struct',
'Enum',
'Trait',
'Impl',
'Macro',
'Namespace',
] as const;
/**
* Node labels that are short (no chunking needed, embed directly)
*/
export const SHORT_LABELS = [
'TypeAlias',
'Typedef',
'Const',
'Property',
'Record',
'Union',
'Static',
'Variable',
] as const;
/**
* All embeddable labels (union of CHUNKABLE + SHORT)
*/
export const EMBEDDABLE_LABELS = [...CHUNKABLE_LABELS, ...SHORT_LABELS] as const;
export type EmbeddableLabel = (typeof EMBEDDABLE_LABELS)[number];
@@ -18,6 +48,39 @@ export type EmbeddableLabel = (typeof EMBEDDABLE_LABELS)[number];
export const isEmbeddableLabel = (label: string): label is EmbeddableLabel =>
EMBEDDABLE_LABELS.includes(label as EmbeddableLabel);
/**
* Check if a label needs chunking
*/
export const isChunkableLabel = (label: string): boolean =>
(CHUNKABLE_LABELS as readonly string[]).includes(label);
/**
* Check if a label is a short type (no chunking)
*/
export const isShortLabel = (label: string): boolean =>
(SHORT_LABELS as readonly string[]).includes(label);
/**
* Node labels that have structural names (methods/fields) extractable via AST
*/
export const STRUCTURAL_LABELS: ReadonlySet<string> = new Set([
'Class',
'Struct',
'Interface',
'Enum',
]);
/**
* Node labels that have isExported column in their schema
*/
export const LABELS_WITH_EXPORTED = new Set([
'Function',
'Class',
'Interface',
'Method',
'CodeElement',
]) as ReadonlySet<string>;
/**
* Embedding pipeline phases
*/
@@ -57,6 +120,12 @@ export interface EmbeddingConfig {
device: 'auto' | 'dml' | 'cuda' | 'cpu' | 'wasm';
/** Maximum characters of code snippet to include */
maxSnippetLength: number;
/** Maximum code chunk size in characters (for chunking long code) */
chunkSize: number;
/** Overlap between chunks in characters */
overlap: number;
/** Maximum description length in characters */
maxDescriptionLength: number;
}
/**
@@ -70,6 +139,9 @@ export const DEFAULT_EMBEDDING_CONFIG: EmbeddingConfig = {
dimensions: 384,
device: 'auto',
maxSnippetLength: 500,
chunkSize: 1200,
overlap: 120,
maxDescriptionLength: 150,
};
/**
@@ -96,6 +168,34 @@ export interface EmbeddableNode {
content: string;
startLine?: number;
endLine?: number;
isExported?: boolean;
description?: string;
parameterCount?: number;
returnType?: string;
repoName?: string;
serverName?: string;
methodNames?: string[];
fieldNames?: string[];
}
/**
* Cached embedding entry restored from LadybugDB before a graph rebuild
*/
export interface CachedEmbedding {
nodeId: string;
chunkIndex: number;
startLine: number;
endLine: number;
embedding: number[];
contentHash?: string;
}
/**
* Context info for embedding pipeline (repo/server metadata enrichment)
*/
export interface EmbeddingContext {
repoName?: string;
serverName?: string;
}
/**
@@ -108,3 +208,75 @@ export interface ModelProgress {
loaded?: number;
total?: number;
}
export interface ChunkSearchRow {
nodeId: string;
chunkIndex: number;
startLine: number;
endLine: number;
distance: number;
}
export interface BestChunkMatch {
chunkIndex: number;
startLine: number;
endLine: number;
distance: number;
}
/**
* Deduplicate vector search chunk results by nodeId,
* keeping the chunk with smallest distance for each node.
*/
export const dedupBestChunks = (
rows: ChunkSearchRow[],
limit?: number,
): Map<string, BestChunkMatch> => {
const best = new Map<string, BestChunkMatch>();
for (const row of rows) {
const existing = best.get(row.nodeId);
if (!existing || row.distance < existing.distance) {
best.set(row.nodeId, {
chunkIndex: row.chunkIndex,
startLine: row.startLine,
endLine: row.endLine,
distance: row.distance,
});
}
if (limit !== undefined && best.size >= limit) break;
}
return best;
};
const DEFAULT_FETCH_MULTIPLIER = 4;
const DEFAULT_FETCH_BUFFER = 8;
const DEFAULT_MAX_FETCH = 200;
/**
* Fetch vector-search chunks until we have enough unique nodeIds
* or can tell the result set is exhausted.
*/
export const collectBestChunks = async (
limit: number,
fetchRows: (fetchLimit: number) => Promise<ChunkSearchRow[]>,
maxFetch: number = DEFAULT_MAX_FETCH,
): Promise<Map<string, BestChunkMatch>> => {
if (limit <= 0) return new Map();
let fetchLimit = Math.max(limit * DEFAULT_FETCH_MULTIPLIER, limit + DEFAULT_FETCH_BUFFER);
let previousFetchLimit = 0;
while (fetchLimit > previousFetchLimit) {
const rows = await fetchRows(fetchLimit);
const bestChunks = dedupBestChunks(rows, limit);
if (bestChunks.size >= limit || rows.length < fetchLimit) {
return bestChunks;
}
previousFetchLimit = fetchLimit;
fetchLimit = fetchLimit >= maxFetch ? fetchLimit * 2 : Math.min(maxFetch, fetchLimit * 2);
}
return new Map();
};
@@ -17,6 +17,9 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js';
* - Express `router.get(...)` / `app.post(...)` providers
* - `fetch(url)` / `fetch(url, { method: 'POST' })` consumers
* - `axios.get(url)` / `axios.delete(url)` consumers
* - `axios({ method, url })` object-form consumers
* - jQuery `$.get(url)` / `$.post(url, ...)` shorthand consumers
* - jQuery `$.ajax({ url, method | type })` consumers
*
* Because the JavaScript and TypeScript tree-sitter grammars share
* node type names for every construct we query, pattern sources are
@@ -103,6 +106,48 @@ const AXIOS_SPEC: PatternSpec<Record<string, never>> = {
`,
};
// ─── Consumer: jQuery shorthand $.get(url) / $.post(url, ...) ────────
// `$` is a valid JS identifier, so tree-sitter parses `$.get(...)` as a
// call_expression whose function is a member_expression on identifier `$`.
const JQUERY_SHORTHAND_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(call_expression
function: (member_expression
object: (identifier) @obj (#eq? @obj "$")
property: (property_identifier) @http_method (#match? @http_method "^(get|post)$"))
arguments: (arguments . [(string) (template_string)] @path))
`,
};
// ─── Consumer: jQuery $.ajax({ url, method|type }) ───────────────────
// The query captures the options object only; key/value pairs are read
// programmatically via `readStringProp` below, which tolerates any key
// order and accepts either `method:` or `type:` (jQuery supports both).
const JQUERY_AJAX_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(call_expression
function: (member_expression
object: (identifier) @obj (#eq? @obj "$")
property: (property_identifier) @fn (#eq? @fn "ajax"))
arguments: (arguments (object) @options))
`,
};
// ─── Consumer: axios({ method, url }) object form ────────────────────
// Distinct from AXIOS_SPEC above because the call target is an identifier
// (`axios`) rather than a member expression (`axios.get`). As with the
// jQuery ajax form, option keys are resolved programmatically.
const AXIOS_OBJECT_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(call_expression
function: (identifier) @fn (#eq? @fn "axios")
arguments: (arguments (object) @options))
`,
};
interface NodePatternBundle {
controller: CompiledPatterns<Record<string, never>>;
methodDecorator: CompiledPatterns<Record<string, never>>;
@@ -110,6 +155,9 @@ interface NodePatternBundle {
fetchNoOptions: CompiledPatterns<Record<string, never>>;
fetchWithOptions: CompiledPatterns<Record<string, never>>;
axios: CompiledPatterns<Record<string, never>>;
jqueryShorthand: CompiledPatterns<Record<string, never>>;
jqueryAjax: CompiledPatterns<Record<string, never>>;
axiosObject: CompiledPatterns<Record<string, never>>;
}
function compileBundle(language: unknown, name: string): NodePatternBundle {
@@ -126,6 +174,9 @@ function compileBundle(language: unknown, name: string): NodePatternBundle {
fetchNoOptions: mk(FETCH_NO_OPTIONS_SPEC, 'fetch-no-options'),
fetchWithOptions: mk(FETCH_WITH_OPTIONS_SPEC, 'fetch-with-options'),
axios: mk(AXIOS_SPEC, 'axios'),
jqueryShorthand: mk(JQUERY_SHORTHAND_SPEC, 'jquery-shorthand'),
jqueryAjax: mk(JQUERY_AJAX_SPEC, 'jquery-ajax'),
axiosObject: mk(AXIOS_OBJECT_SPEC, 'axios-object'),
};
}
@@ -160,6 +211,28 @@ function joinPath(prefix: string, sub: string): string {
return `/${cleanPrefix}/${cleanSub}`;
}
/**
* Walk `pair` children of an `object` literal and return the unquoted
* string/template_string value for the first pair whose key matches one
* of `keyNames`. Returns null when no matching pair is present or the
* value is not a string literal. Used by the jQuery ajax / axios object
* consumers to resolve `url` / `method` / `type` keys in any order.
*/
function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string[]): string | null {
for (let i = 0; i < objectNode.namedChildCount; i++) {
const pair = objectNode.namedChild(i);
if (!pair || pair.type !== 'pair') continue;
const keyNode = pair.childForFieldName('key');
const valueNode = pair.childForFieldName('value');
if (!keyNode || !valueNode) continue;
if (!keyNames.includes(keyNode.text)) continue;
if (valueNode.type !== 'string' && valueNode.type !== 'template_string') continue;
const lit = unquoteLiteral(valueNode.text);
if (lit !== null) return lit;
}
return null;
}
/**
* For a standalone `decorator` node (child of class_body / program),
* find the related `class_declaration` node that it decorates. In
@@ -351,6 +424,62 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
});
}
// Consumer: jQuery shorthand $.get(url) / $.post(url, ...)
for (const match of runCompiledPatterns(bundle.jqueryShorthand, tree)) {
const methodNode = match.captures.http_method;
const pathNode = match.captures.path;
if (!methodNode || !pathNode) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
out.push({
role: 'consumer',
framework: 'jquery',
method: methodNode.text.toUpperCase(),
path,
name: null,
confidence: 0.7,
});
}
// Consumer: jQuery $.ajax({ url, method|type }). jQuery accepts either
// `method:` or `type:`; both default to GET when absent.
for (const match of runCompiledPatterns(bundle.jqueryAjax, tree)) {
const optionsNode = match.captures.options;
if (!optionsNode) continue;
const path = readStringProp(optionsNode, ['url']);
if (path === null) continue;
const rawMethod = readStringProp(optionsNode, ['method', 'type']);
const method = (rawMethod ?? 'GET').toUpperCase();
out.push({
role: 'consumer',
framework: 'jquery',
method,
path,
name: null,
confidence: 0.7,
});
}
// Consumer: axios({ method, url }) object form. Structurally distinct
// from axios.<verb>(url) (identifier vs member_expression call), so no
// dedup against the member-form loop above is required.
for (const match of runCompiledPatterns(bundle.axiosObject, tree)) {
const optionsNode = match.captures.options;
if (!optionsNode) continue;
const path = readStringProp(optionsNode, ['url']);
if (path === null) continue;
const rawMethod = readStringProp(optionsNode, ['method']);
const method = (rawMethod ?? 'GET').toUpperCase();
out.push({
role: 'consumer',
framework: 'axios',
method,
path,
name: null,
confidence: 0.7,
});
}
return out;
}
@@ -79,17 +79,50 @@ export class ManifestExtractor {
links: GroupManifestLink[],
dbExecutors?: Map<string, CypherExecutor>,
): Promise<ManifestExtractResult> {
// Resolve all (repo, link) pairs in parallel. The previous sequential
// await-per-link produced 2N round-trips; parallel resolution uses the
// per-repo executor pool directly and scales linearly with manifest size.
//
// Memoization: a manifest can list the same contract multiple times
// (e.g. a consumer and provider declaration, or cross-referenced groups).
// Key on (repo, type, contract) — the canonical input to the Cypher
// query — so duplicate links resolve to one DB hit.
type ResolvedSymbol = { filePath: string; name: string; uid: string } | null;
const resolveCache = new Map<string, Promise<ResolvedSymbol>>();
const resolveOnce = (repo: string, link: GroupManifestLink): Promise<ResolvedSymbol> => {
const key = `${repo}\u0000${link.type}\u0000${link.contract}`;
let pending = resolveCache.get(key);
if (!pending) {
pending = this.resolveSymbol(repo, link, dbExecutors);
resolveCache.set(key, pending);
}
return pending;
};
const perLink = await Promise.all(
links.map(async (link) => {
const contractId = this.buildContractId(link.type, link.contract);
const providerRepo = link.role === 'provider' ? link.from : link.to;
const consumerRepo = link.role === 'provider' ? link.to : link.from;
const [providerSymbol, consumerSymbol] = await Promise.all([
resolveOnce(providerRepo, link),
resolveOnce(consumerRepo, link),
]);
return { link, contractId, providerRepo, consumerRepo, providerSymbol, consumerSymbol };
}),
);
const contracts: StoredContract[] = [];
const crossLinks: CrossLink[] = [];
for (const link of links) {
const contractId = this.buildContractId(link.type, link.contract);
const providerRepo = link.role === 'provider' ? link.from : link.to;
const consumerRepo = link.role === 'provider' ? link.to : link.from;
const providerSymbol = await this.resolveSymbol(providerRepo, link, dbExecutors);
const consumerSymbol = await this.resolveSymbol(consumerRepo, link, dbExecutors);
for (const {
link,
contractId,
providerRepo,
consumerRepo,
providerSymbol,
consumerSymbol,
} of perLink) {
const providerRef = providerSymbol || { filePath: '', name: link.contract };
const consumerRef = consumerSymbol || { filePath: '', name: link.contract };
// When the resolver finds a real graph symbol we keep its uid, otherwise
+56 -1
View File
@@ -7,6 +7,7 @@ import type { GroupConfig, RepoHandle, RepoSnapshot, StoredContract, CrossLink }
import { HttpRouteExtractor } from './extractors/http-route-extractor.js';
import { GrpcExtractor } from './extractors/grpc-extractor.js';
import { TopicExtractor } from './extractors/topic-extractor.js';
import { ManifestExtractor } from './extractors/manifest-extractor.js';
import { runExactMatch } from './matching.js';
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
import type { CypherExecutor } from './contract-extractor.js';
@@ -60,10 +61,28 @@ function defaultResolveHandle(allEntries: RegistryEntry[]) {
};
}
/**
* Dedupe cross-links that point from the same consumer endpoint to the same
* provider endpoint for the same contract. Preserves first-seen order so the
* caller controls precedence (e.g., pass manifest links first).
*/
function dedupeCrossLinks(links: CrossLink[]): CrossLink[] {
const seen = new Set<string>();
const out: CrossLink[] = [];
for (const link of links) {
const key = `${link.from.repo}::${link.from.symbolUid}|${link.to.repo}::${link.to.symbolUid}|${link.type}|${link.contractId}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(link);
}
return out;
}
export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promise<SyncResult> {
const missingRepos: string[] = [];
const repoSnapshots: Record<string, RepoSnapshot> = {};
let autoContracts: StoredContract[] = [];
let manifestCrossLinks: CrossLink[] = [];
let dbExecutors: Map<string, CypherExecutor> | undefined;
const eo = opts?.extractorOverride;
@@ -158,8 +177,44 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
}
}
// Process manifest links declared in group.yaml.
// ManifestExtractor is fully implemented but was never wired into this
// pipeline — config.links were parsed and validated but silently dropped.
// Placed after the DB try/finally: resolveSymbol falls back to synthetic
// UIDs when dbExecutors is undefined or a pool is closed, so cross-links
// are always generated regardless of whether real DB executors are available.
if (config.links.length > 0) {
// Warn about dangling links that reference repos not declared in config.repos.
// They still generate cross-links via synthetic UIDs (determinism is preserved),
// but the operator probably meant something that now silently does nothing useful.
const knownRepos = new Set(Object.keys(config.repos));
for (const link of config.links) {
const dangling = [link.from, link.to].filter((r) => !knownRepos.has(r));
if (dangling.length > 0) {
console.warn(
`[group/sync] manifest link ${link.type}:${link.contract} references repos not in config.repos: ${dangling.join(', ')} — cross-links will use synthetic UIDs`,
);
}
}
const manifestEx = new ManifestExtractor();
const manifestResult = await manifestEx.extractFromManifest(config.links, dbExecutors);
autoContracts.push(...manifestResult.contracts);
manifestCrossLinks = manifestResult.crossLinks;
if (opts?.verbose) {
console.log(
` manifest: ${manifestCrossLinks.length} cross-links from ${config.links.length} declared links`,
);
}
}
const { matched, unmatched } = runExactMatch(autoContracts);
const crossLinks: CrossLink[] = matched;
// Dedupe cross-links. Manifest contracts participate in runExactMatch, so a
// manifest-declared link can also emit a matchType:'exact' CrossLink with the
// same endpoints. Prefer the manifest version — it reflects operator intent
// and carries matchType:'manifest' which downstream consumers may rely on.
const crossLinks = dedupeCrossLinks([...manifestCrossLinks, ...matched]);
const allContracts: StoredContract[] = autoContracts;
const registry: ContractRegistry = {
@@ -0,0 +1,12 @@
// gitnexus/src/core/ingestion/call-extractors/configs/c-cpp.ts
import { SupportedLanguages } from 'gitnexus-shared';
import type { CallExtractionConfig } from '../../call-types.js';
export const cCallConfig: CallExtractionConfig = {
language: SupportedLanguages.C,
};
export const cppCallConfig: CallExtractionConfig = {
language: SupportedLanguages.CPlusPlus,
};
@@ -0,0 +1,9 @@
// gitnexus/src/core/ingestion/call-extractors/configs/csharp.ts
import { SupportedLanguages } from 'gitnexus-shared';
import type { CallExtractionConfig } from '../../call-types.js';
export const csharpCallConfig: CallExtractionConfig = {
language: SupportedLanguages.CSharp,
typeAsReceiverHeuristic: true,
};
@@ -0,0 +1,8 @@
// gitnexus/src/core/ingestion/call-extractors/configs/dart.ts
import { SupportedLanguages } from 'gitnexus-shared';
import type { CallExtractionConfig } from '../../call-types.js';
export const dartCallConfig: CallExtractionConfig = {
language: SupportedLanguages.Dart,
};
@@ -0,0 +1,8 @@
// gitnexus/src/core/ingestion/call-extractors/configs/go.ts
import { SupportedLanguages } from 'gitnexus-shared';
import type { CallExtractionConfig } from '../../call-types.js';
export const goCallConfig: CallExtractionConfig = {
language: SupportedLanguages.Go,
};
@@ -0,0 +1,59 @@
// gitnexus/src/core/ingestion/call-extractors/configs/jvm.ts
import { SupportedLanguages } from 'gitnexus-shared';
import type { CallExtractionConfig, ExtractedCallSite } from '../../call-types.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
// ---------------------------------------------------------------------------
// Java method_reference (::) parsing — absorbs call-sites/java.ts
// ---------------------------------------------------------------------------
/**
* Parse Java `method_reference` nodes (`expr::method`, `Type::new`,
* `this::m`, `super::m`).
*/
function parseJavaMethodReference(callNode: SyntaxNode): ExtractedCallSite | null {
if (callNode.type !== 'method_reference') return null;
const recv = callNode.namedChild(0);
if (!recv) return null;
// Type::new → constructor call
for (const c of callNode.children) {
if (c.type === 'new') {
if (recv.type !== 'identifier') return null;
return { calledName: recv.text, callForm: 'constructor' };
}
}
// expr::method → member call with receiver
const rhs = callNode.child(callNode.childCount - 1);
if (!rhs || rhs.type !== 'identifier') return null;
const methodName = rhs.text;
if (recv.type === 'identifier') {
return { calledName: methodName, callForm: 'member', receiverName: recv.text };
}
if (recv.type === 'this') {
return { calledName: methodName, callForm: 'member', receiverName: 'this' };
}
if (recv.type === 'super') {
return { calledName: methodName, callForm: 'member', receiverName: 'super' };
}
return null;
}
// ---------------------------------------------------------------------------
// Configs
// ---------------------------------------------------------------------------
export const javaCallConfig: CallExtractionConfig = {
language: SupportedLanguages.Java,
extractLanguageCallSite: parseJavaMethodReference,
typeAsReceiverHeuristic: true,
};
export const kotlinCallConfig: CallExtractionConfig = {
language: SupportedLanguages.Kotlin,
typeAsReceiverHeuristic: true,
};
@@ -0,0 +1,8 @@
// gitnexus/src/core/ingestion/call-extractors/configs/php.ts
import { SupportedLanguages } from 'gitnexus-shared';
import type { CallExtractionConfig } from '../../call-types.js';
export const phpCallConfig: CallExtractionConfig = {
language: SupportedLanguages.PHP,
};
@@ -0,0 +1,8 @@
// gitnexus/src/core/ingestion/call-extractors/configs/python.ts
import { SupportedLanguages } from 'gitnexus-shared';
import type { CallExtractionConfig } from '../../call-types.js';
export const pythonCallConfig: CallExtractionConfig = {
language: SupportedLanguages.Python,
};
@@ -0,0 +1,8 @@
// gitnexus/src/core/ingestion/call-extractors/configs/ruby.ts
import { SupportedLanguages } from 'gitnexus-shared';
import type { CallExtractionConfig } from '../../call-types.js';
export const rubyCallConfig: CallExtractionConfig = {
language: SupportedLanguages.Ruby,
};
@@ -0,0 +1,8 @@
// gitnexus/src/core/ingestion/call-extractors/configs/rust.ts
import { SupportedLanguages } from 'gitnexus-shared';
import type { CallExtractionConfig } from '../../call-types.js';
export const rustCallConfig: CallExtractionConfig = {
language: SupportedLanguages.Rust,
};
@@ -0,0 +1,8 @@
// gitnexus/src/core/ingestion/call-extractors/configs/swift.ts
import { SupportedLanguages } from 'gitnexus-shared';
import type { CallExtractionConfig } from '../../call-types.js';
export const swiftCallConfig: CallExtractionConfig = {
language: SupportedLanguages.Swift,
};
@@ -0,0 +1,12 @@
// gitnexus/src/core/ingestion/call-extractors/configs/typescript-javascript.ts
import { SupportedLanguages } from 'gitnexus-shared';
import type { CallExtractionConfig } from '../../call-types.js';
export const typescriptCallConfig: CallExtractionConfig = {
language: SupportedLanguages.TypeScript,
};
export const javascriptCallConfig: CallExtractionConfig = {
language: SupportedLanguages.JavaScript,
};

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