- Introduced a new job `validate` in the GitHub Actions workflow to validate the desktop package.
- Added steps for checking out the repository, setting up Node.js, installing dependencies, type checking, and running unit tests.
- Updated the `package` job to depend on the `validate` job.
chore: update package-lock.json and package.json for vitest integration
- Added `vitest` as a development dependency in `package.json`.
- Updated `typecheck` script to include a new TypeScript configuration for vitest.
- Updated `package-lock.json` to reflect the new dependencies.
refactor: modularize runtime path handling in main process
- Created a new module `runtime-paths.ts` to handle static path normalization and request handling.
- Refactored `main.ts` to utilize the new `normalizeStaticPath`, `getRequestedPath`, and `getPackagedRendererEntry` functions.
test: add unit tests for runtime path utilities
- Implemented tests for `normalizeStaticPath`, `getRequestedPath`, and `getPackagedRendererEntry` using Vitest.
- Ensured coverage for directory traversal attempts and null-byte paths.
chore: configure Vitest for testing
- Added `vitest.config.ts` for Vitest configuration.
- Created `tsconfig.vitest.json` to include types for Vitest in TypeScript compilation.
Co-authored-by: Copilot <copilot@github.com>
vite.config.ts reads engines.node from ../gitnexus/package.json,
but Dockerfile.web only copied gitnexus-shared and gitnexus-web,
causing the build to fail with "Cannot find module" during
`npm run build --prefix gitnexus-web`.
Co-authored-by: wangjichao <wangjichao@inke.cn>
In a reusable workflow, github.event_name inherits the caller's
event (e.g. "push"), not "workflow_call". This caused the
type=raw tag to be disabled when docker.yml was called from
release-candidate.yml, producing no Docker tags at all and
failing the build.
Fix: check `inputs.tag != ''` instead, since inputs.tag is only
populated for workflow_call invocations.
Co-authored-by: wangjichao <wangjichao@inke.cn>
Extend the PHP tree-sitter plugin to emit consumer HttpDetections for
three common PHP HTTP call shapes, matching Node plugin parity:
- Laravel HTTP client: Http::get/post/put/delete/patch($url)
- Guzzle / generic: $client->get/post/...($url)
- file_get_contents($url) when the URL is absolute http(s)://
String-literal URLs only. Paths built via binary concatenation
(`$base . '/path'`), sprintf, or config lookups are intentionally
deferred — they need constant-folding of the enclosing scope to be
useful and are tracked as follow-up work.
Refs #992
Co-authored-by: Jonas Vanderhaegen <jonasvanderh+claude.ai@gmail.com>
* fix(bm25): return FTS-matched symbols instead of arbitrary LIMIT 3 nodes
Previously, bm25Search fetched up to 3 arbitrary symbols from the matched
file using MATCH (n) WHERE n.filePath = $filePath LIMIT 3 (no ORDER BY).
This meant the specific function or class that actually scored highest in
the BM25 index could be completely absent from the results.
Fix: propagate nodeId from each FTS hit through searchFTSFromLbug, then
use those nodeIds in bm25Search to look up the exact matched nodes via
WHERE n.id IN $nodeIds. Falls back to the old filePath-based lookup when
nodeIds are unavailable.
Also switches the per-file score aggregation from naive sum-of-all to
sum-of-top-3, which prevents files with many mediocre matches (e.g. test
files) from outranking files with a single highly-relevant symbol.
* test(bm25): add unit tests for top-3 aggregation and nodeIds propagation
Covers the new logic paths added in the previous commit:
- top-3 score aggregation (file with 5+ matches → only top-3 contribute)
- nodeIds propagation through BM25SearchResult
- empty nodeId filtering
- cross-table merge for the same file
- result ranking by aggregated score
Also fixes in-place entries.sort() mutation (bm25-index.ts:125) to use
[...entries].sort() so the Map value is not silently modified.
* style: apply prettier formatting
* fix(test): use importOriginal to avoid missing export errors in vi.mock
* fix(bm25): align queryFTSViaExecutor nodeId extraction to match lbug-adapter
Use node.nodeId || node.id || '' in queryFTSViaExecutor to match the
fallback logic in lbug-adapter.ts:1040. Without this, the MCP pool path
could silently return empty nodeIds if LadybugDB surfaces the node id
under node.nodeId rather than node.id.
---------
Co-authored-by: jisue0224 <>
findFunctionNode and findDeclarationNode had no depth limit, causing
stack overflow on deeply nested or auto-generated ASTs, especially
when --stack-size is not applied (e.g. heap already large enough to
skip ensureHeap re-exec).
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(embeddings): structural chunking with data-driven CHUNKING_RULES dispatch
Replace hardcoded label comparisons with a CHUNKING_RULES lookup table
that drives chunking strategy and text generation. Key changes:
- Data-driven dispatch: CHUNKING_RULES table maps labels to chunking
mode (ast-function / ast-declaration), prefix/suffix, field grouping,
and structural text mode
- Struct support: add Struct to AST declaration chunking with field
grouping (same as Class)
- Multi-chunk context: preceding chunk tail (prevTail) injected into
embedding text for cross-chunk coherence
- Version-gated hashes: EMBEDDING_TEXT_VERSION prefix in content hashes
invalidates stale vectors when text template changes
- Compact container context: first declaration line preserved in every
structural chunk for identity
* fix(embeddings): address PR review findings for CHUNKING_RULES refactor
- Remove LABEL_ENUM from STRUCTURAL_LABELS to avoid wasted AST parses
- Add maintenance note about extractStructuralNames and EMBEDDING_TEXT_VERSION
- Clarify CHUNK_MODE_CHARACTER is a no-op in CHUNKING_RULES
- Strengthen EMBEDDING_TEXT_VERSION test assertion to exact value
---------
Co-authored-by: wangjichao <wangjichao@inke.cn>
* Initial plan
* fix: guard docker job and add tag validation in docker.yml
- Add `&& needs.publish.outputs.vtag != ''` to the `docker` job's
`if:` in release-candidate.yml so it is skipped when publish
produces no vtag, preventing an opaque buildx "tag is needed" error.
- Add an early "Validate tag input" step in docker.yml that fails fast
with a clear ::error:: message when inputs.tag is empty, covering
direct workflow_call invocations that bypass the release-candidate
guard.
Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b9afe2df-85ea-4a87-bf30-77f0e945a64d
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
* fix: scope docker.yml tag validation to workflow_call only
Direct tag-push triggers (on: push, tags: v*) populate the tag from
GITHUB_REF and have inputs.tag empty, so the unconditional validation
step would fail every direct tag-push run. Restrict the new step to
workflow_call invocations, which is the only path where an empty tag
is actually a problem.
Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/4b7e3bfa-15c0-4186-affa-95cd71e50153
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>
* feat(ingestion): shadow-mode parity harness + static dashboard (#923, RFC #909 Ring 2 PKG)
Side-car observability for the RFC #909 registry rollout. Callers that
dual-run legacy-DAG + `Registry.lookup` feed their result pairs into
the harness; the harness diffs each pair via shared `diffResolutions`
(#918), aggregates via `aggregateDiffs`, and persists a per-language
parity report that the static dashboard can render offline.
## Shipped
### `gitnexus/src/core/ingestion/shadow-harness.ts` (new)
```ts
createShadowHarness(): ShadowHarness
```
API:
- `enabled` — `true` iff `GITNEXUS_SHADOW_MODE` is truthy at
construction. Captured once; later env-var mutations don't flip it.
- `record({ language, callsite, legacy, newResult, primary })` —
accumulator. No-op when `enabled === false` (near-zero overhead).
- `size()` — diagnostic counter.
- `snapshot(now?)` — deterministic `ShadowParityReport` from the
accumulated diffs.
- `persist(outputDir, now?)` — writes BOTH a timestamped
`<runId>.json` and a `latest.json` pointer. Creates outputDir if
absent. Returns the per-run file path.
- `clear()` — resets the accumulator; preserves `enabled`.
Activation: `GITNEXUS_SHADOW_MODE` accepts `'true'` / `'1'` / `'yes'`
(case-insensitive, trimmed); same truthy convention as
`REGISTRY_PRIMARY_<LANG>` from #924. Typos → disabled (fail-safe).
Persisted payload (`PersistedShadowReport`) is schema-versioned (`v1`):
```jsonc
{
"schemaVersion": 1,
"runId": "YYYYMMDD-HHMMSS-xxxxxxxx",
"generatedAt": "ISO 8601",
"primaryByLanguage": { "python": "legacy", ... },
"report": { /* ShadowParityReport from #918 aggregateDiffs */ }
}
```
`runId` prefix is the timestamp so files sort chronologically; the
entropy suffix prevents collisions within a clock-second.
### `gitnexus/shadow-parity-dashboard/index.html` (new)
Minimal static dashboard — one HTML file, zero build step, zero runtime
deps. Fetches `./latest.json` and renders:
- Overall summary cards (total calls, both agree, disagree, overall parity %)
- Per-language table: language tag ("primary: legacy" / "primary:
registry" pill) + total / agree / only-legacy / only-new / disagree
/ both-empty / parity%
- Parity cells colored by threshold: ≥95% green, ≥80% amber, <80% red
- Light / dark via `prefers-color-scheme`
- Empty-state message when no records yet
File-serving is static: `cp .gitnexus/shadow-parity/latest.json
gitnexus/shadow-parity-dashboard/` + open in a browser.
## Tests (14, all passing)
- **Flag detection** (5): default off · truthy variants case-insensitive ·
falsy / typo → off · record() is no-op when disabled · env flip
AFTER construction doesn't enable (constructed-once semantics)
- **Record + snapshot** (4): multi-language accumulation ·
per-language rows with correct outcomes · snapshot determinism ·
`clear()` resets accumulator + `primaryByLanguage`
- **Persistence** (5): mkdir-p on missing outputDir · per-run +
latest.json match byte-for-byte · schema v1 payload shape ·
runId timestamp prefix sorts chronologically · empty report
persists gracefully
Tests use a per-test tmpdir (`fs.mkdtemp`), cleaned in `afterEach`,
so parallel vitest runs don't collide. `GITNEXUS_SHADOW_MODE` is
saved + restored per-test.
## What's deliberately NOT in this PR (call-out in harness docstring)
- **Dual-run dispatch.** The harness is a side-car — it does NOT
invoke either resolution path. Call-processor integration that
actually runs both legacy + registry paths lands as a follow-up.
Without that integration, `record()` is never called in production
today. The harness is tested in isolation with synthetic inputs.
- **CI artifact publishing.** Config work to upload
`latest.json` + the dashboard HTML per CI run. Tracked separately;
the harness + dashboard are ready when the CI job wires in.
- **Fixture-level drill-down.** The issue mentions per-fixture AST
snippet + evidence trace drill-down. MVP dashboard shows per-language
rows only; drill-down extends the static JSON format + the dashboard
JS in a focused follow-up.
## Verification
- `tsc --noEmit` clean (both `gitnexus-shared` and `gitnexus`)
- 14/14 new tests pass
- Full scope-resolution / shadow / model / flag suite: **335/335 pass**
## Part of
- Parent: #909
- Depends on (code): #917 (registries), #918 (diff + aggregate)
- Unblocks Ring 3 language flips: the parity dashboard becomes the
checkpoint before flipping `REGISTRY_PRIMARY_<LANG>=true` for a
language — once per-language parity stabilizes, the flip ships.
* chore: prettier format on shadow-parity-dashboard index.html
Bridges the CLI's existing per-language `ImportResolverFn`s (16 languages
already implemented) to the shared `FinalizeHooks.resolveImportTarget`
contract consumed by `finalize()` (#915) and
`finalizeScopeModel` (#921).
No resolver logic is reimplemented — the adapter wraps
`provider.importResolver` from each `LanguageProvider` verbatim.
## Shipped
### `import-target-adapter.ts` (new)
```ts
buildImportTargetWorkspace(providers, resolveCtx): ImportTargetWorkspace
resolveImportTargetAcrossLanguages(targetRaw, fromFile, workspaceIndex): string | null
```
- `ImportTargetWorkspace` is the opaque `workspaceIndex` shape the
adapter recognizes: `{ perLanguage: Map<SupportedLanguages,
{ resolver, ctx }> }`. Callers build it once per ingestion run from
the active language providers.
- `resolveImportTargetAcrossLanguages` is the `FinalizeHook`
implementation. It:
1. Reads `getLanguageFromFilename(fromFile)`.
2. Looks up the per-language entry.
3. Calls the existing `ImportResolverFn` — same signature, same
code path the legacy DAG uses today.
4. Picks `result.files[0]` (covers both `'files'` and `'package'`
result kinds; the legacy pipeline's richer multi-file + dirSuffix
semantics stay accessible through `importResolver` directly).
5. Returns `null` on any null result, empty files[], unknown
extension, missing workspace, or resolver exception.
- Exceptions from resolvers are swallowed — the finalize algorithm
treats `null` as `linkStatus: 'unresolved'`, which is the right
fallback for malformed inputs.
### What's deliberately NOT here
- **Re-implementation of any per-language resolver.** Wraps the
existing `importResolver` field on each provider.
- **Dynamic-import handling.** The shared finalize algorithm short-
circuits `ParsedImport { kind: 'dynamic-unresolved' }` before
calling `resolveImportTarget`, so the adapter never sees them.
- **`importPathPreprocessor`.** Preprocessing belongs inside the
provider's `interpretImport` hook that produces
`ParsedImport.targetRaw`; the adapter forwards that verbatim.
## Tests (12, all passing)
- **`buildImportTargetWorkspace`** (3): registers providers with
importResolver · skips providers without · threads shared ctx
into every entry
- **`resolveImportTargetAcrossLanguages`** (9): forwards targetRaw +
fromFile · dispatches by extension · null resolver result →
null · `package`-kind takes first file · empty files[] → null ·
no registered resolver → null · unknown extension → null ·
undefined/malformed workspace → null · resolver throw → null
Real per-language resolver correctness is covered by the existing
per-language resolver test suites — the adapter is the bridge layer.
## Verification
- `tsc --noEmit` clean (both `gitnexus-shared` and `gitnexus`)
- `gitnexus-shared` build clean
- 12/12 new tests pass
- Full scope-resolution / shadow / model / flag suite: **333/333 pass**
## Integration flow
```ts
const workspace = buildImportTargetWorkspace(providers, resolveCtx);
const indexes = finalizeScopeModel(parsedFiles, {
hooks: { resolveImportTarget: resolveImportTargetAcrossLanguages },
workspaceIndex: workspace,
});
model.attachScopeIndexes(indexes);
```
## Closes part of #909. Unblocks
- Ring 3 language migrations (#926+): a language flipping to
`REGISTRY_PRIMARY_<LANG>=true` now has correct import-target
resolution out of the box via its existing `importResolver`.
- #923 shadow harness — can run the dual-path comparison knowing
both sides use the same per-language resolution semantics.
Ties the Ring 2 pipeline together. Takes the `ParsedFile[]` produced by
#920's parse-worker integration, feeds them to shared `finalize()`
(#915), and bundles every workspace-wide index for attachment onto
`MutableSemanticModel`. Thin integration glue per issue #884's boundary
— all algorithm lives in `gitnexus-shared`.
## Shipped
### `model/scope-resolution-indexes.ts` (new)
```ts
interface ScopeResolutionIndexes {
readonly scopeTree: ScopeTree;
readonly defs: DefIndex;
readonly qualifiedNames: QualifiedNameIndex;
readonly moduleScopes: ModuleScopeIndex;
readonly methodDispatch: MethodDispatchIndex;
readonly imports: ReadonlyMap<ScopeId, readonly ImportEdge[]>;
readonly bindings: ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>;
readonly referenceSites: readonly ReferenceSite[];
readonly sccs: readonly FinalizedScc[];
readonly stats: FinalizeStats;
}
```
The bundle produced by the orchestrator, consumed by the resolution
phase. `ReferenceIndex` is deliberately NOT here — it's populated in
the next phase (#925).
### `model/semantic-model.ts` — extended
- `SemanticModel.scopes?: ScopeResolutionIndexes` — undefined until
attached; once attached, frozen.
- `MutableSemanticModel.attachScopeIndexes(indexes)` — one-shot write.
Throws on second call; `Object.freeze`s the bundle on write. `clear()`
resets the slot back to `undefined` so re-ingestion can re-attach.
### `finalize-orchestrator.ts` (new)
```ts
finalizeScopeModel(parsedFiles, options?): ScopeResolutionIndexes
```
Orchestration steps:
1. Map `ParsedFile[]` → `FinalizeInput` (`FinalizeFile` is a structural
subset, so no shape-shifting).
2. Call shared `finalize()` with provider hooks (defaults provided for
the zero-provider case today).
3. Build the four workspace indexes (`DefIndex`, `QualifiedNameIndex`,
`ModuleScopeIndex`, `ScopeTree`) from per-file unions.
4. Build an empty `MethodDispatchIndex` as a placeholder (owners=[],
both callbacks return []). Real MRO wiring lands with the
per-language adapters in #922.
5. Bundle + return.
**Empty-input safety.** Zero parsedFiles → valid but empty bundle with
all zero-sized indexes and `stats.totalFiles === 0`. Downstream code
can consult `model.scopes` without branching on presence — only on
`stats`.
**Hook defaults** (`withDefaultHooks`) for missing provider hooks:
- `resolveImportTarget: () => null` — every import goes `unresolved`
- `expandsWildcardTo: () => []` — wildcards don't materialize
- `mergeBindings: (a, b) => [...a, ...b]` — append without precedence
Providers override these in #922 (per-language import adapters).
## Tests (10, all passing)
- **Empty input** (1): zero parsedFiles → valid empty bundle
- **Single file** (2): all per-file indexes populated · referenceSites
aggregated
- **Cross-file imports** (3): resolveImportTarget threads through +
links · default-null resolver → unresolved · stats reflect graph
- **MutableSemanticModel integration** (4): undefined initially · attach
once · Object.freeze applied · throws on re-attach · clear() resets
## Verification
- `tsc --noEmit` clean in both packages
- `gitnexus-shared` build clean
- 10/10 new tests pass
- Full scope-resolution / shadow / model / flag suite: **321/321 pass**
## What's deferred (not this PR, per RFC #909 scope)
- **Per-language hook adapters** (#922): `resolveImportTarget` +
`expandsWildcardTo` + `mergeBindings` wired per language.
- **MethodDispatchIndex wiring via HeritageMap**: populate MRO + implements
via the existing CLI-package HeritageMap strategies. Likely companion
to #922 or a focused follow-up.
- **Pipeline invocation**: actually calling `finalizeScopeModel` from
the real ingestion pipeline. The orchestrator is callable today; the
ingestion entry point wiring lands with the shadow harness (#923).
- **`ReferenceIndex` population**: RFC §3.2 Phase 4 / #925.
## Closes part of #909. Unblocks
- #923 shadow harness — now has a fully materialized `model.scopes` to
query against the legacy DAG for parity measurement
- #925 ReferenceIndex → LadybugDB emission — consumes `model.scopes`
- Ring 3 language migrations (#926+) — a language flipping to
`REGISTRY_PRIMARY_<LANG>=true` can now expect `model.scopes` to be
populated when the pipeline wires the orchestrator in
Plumbs the ScopeExtractor (#919) into the real parsing pipeline.
`ParsedFile` artifacts now flow from workers to the parsing-processor
without changing any legacy-DAG behavior.
## Shipped
### `gitnexus/src/core/ingestion/scope-extractor-bridge.ts` (new)
- `extractParsedFile(provider, sourceText, filePath, onWarn?)`
- Short-circuits (returns `undefined`) when the provider has not
implemented `emitScopeCaptures`. True for every language today —
this is the default no-op path.
- Invokes the hook + `ScopeExtractor.extract`, returns a `ParsedFile`.
- **Swallows exceptions on both sides.** Failures route through the
optional `onWarn` callback (or `console.warn`) and return
`undefined`. Scope-extraction errors NEVER break legacy parsing on
the same file.
- Standalone module (not nested in `parse-worker.ts`) so tests can
import it directly without triggering the worker's top-level
`parentPort!.on(...)`.
### `gitnexus/src/core/ingestion/workers/parse-worker.ts`
- `ParseWorkerResult.parsedFiles: ParsedFile[]` added.
- `processFileGroup` calls `extractParsedFile` AFTER tree parse,
BEFORE legacy extraction. Worker provides an `onWarn` callback that
routes bridge warnings through `parentPort.postMessage({ type:
'warning', message })`.
- `mergeResult` includes `parsedFiles` in the sub-batch merge.
- Initial + reset accumulator templates include `parsedFiles: []`.
### `gitnexus/src/core/ingestion/parsing-processor.ts`
- `WorkerExtractedData.parsedFiles: ParsedFile[]` added.
- Empty-result branch and the across-chunk aggregation both include
`parsedFiles`. Aggregation is tolerant of workers that don't emit
the field (older builds / partial rollouts).
### Ring 1 tweak: `emitScopeCaptures` sync return
`readonly CaptureMatch[]` (was `Promise<readonly CaptureMatch[]>`).
Tree-sitter and COBOL's regex tagger are both synchronous; no
foreseeable need for async work inside this hook. Sync lets the
already-sync worker pipeline invoke it inline without cascading
`async` up through the batch driver + IPC handler.
## Tests (9 new; full suite 311/311)
`gitnexus/test/unit/scope-resolution/parse-worker-scope-integration.test.ts`:
- Not-migrated (2): undefined-returning hook · never-invokes-extractor
- Migrated (3): happy path · argument threading · honors
`shouldCreateScope` override
- Error resilience (4): hook throws · extractor throws (no Module) ·
extractor throws (sibling overlap) · `onWarn` gets routed
message with filePath + error body
## Verification
- `tsc --noEmit` clean in both packages
- `gitnexus-shared` build clean
- 311/311 combined scope-resolution / shadow / model / flag suite
- 9/9 new bridge tests
## What's NOT in this PR (still deferred to #921)
- Actually using the `parsedFiles` — that's the finalize orchestrator.
- `ModuleScopeIndex.byFilePath` materialization — belongs alongside
the rest of the SemanticModel indexes in #921.
## Closes part of #909. Unblocks
- #921 finalize-orchestrator — consumes `WorkerExtractedData.parsedFiles`
Adds the per-language feature flag primitive that gates the Ring 3
registry-primary rollout. Single source of truth for whether a given
language uses `Registry.lookup` (new) or the legacy DAG (current).
## Shipped
### `gitnexus/src/core/ingestion/registry-primary-flag.ts`
- `isRegistryPrimary(lang): boolean` — reads
`REGISTRY_PRIMARY_<UPPER(enum-value)>` from `process.env`.
- `envVarNameFor(lang): string` — exposed for CI tooling that
cross-references flag flips (and for test assertions).
- `primaryLanguages(): ReadonlySet<SupportedLanguages>` — all
currently-on languages; useful for startup logging + the #923
shadow dashboard which distinguishes "primary: legacy" vs
"primary: registry" rows.
### Contract
- Default: `false` for every language. A language must explicitly
opt in by setting its env var.
- Truthy: `'true'`, `'1'`, `'yes'` (case-insensitive, whitespace-
trimmed). Anything else — typos, empty string, `'off'` — is
`false`. Fail-safe posture: a misspelled flag doesn't accidentally
flip a language.
- No per-process caching. `process.env` is read per call; overhead
is negligible (one lookup per file at resolution time), and
test isolation is lexical (no cache-reset coordination).
### Env-var mapping
Uses the enum VALUE, not the TS key, for the env-var suffix:
- `SupportedLanguages.Python` → `REGISTRY_PRIMARY_PYTHON`
- `SupportedLanguages.CPlusPlus` → `REGISTRY_PRIMARY_CPP` (value `'cpp'`)
- `SupportedLanguages.CSharp` → `REGISTRY_PRIMARY_CSHARP`
Users flip languages by their canonical name, not the TS symbol.
## Tests (16, all passing)
- `envVarNameFor` (3): upper-casing · enum-VALUE-not-KEY mapping ·
all-languages uniqueness smoke-test
- `isRegistryPrimary` (9): default false · `'true'` / `'1'` / `'yes'`
truthy · mixed-case + whitespace-padded · falsy-looking values ·
unrecognized tokens (typo-safe) · per-language isolation · no
stale cache on mid-process mutation · CPlusPlus mapping
- `primaryLanguages` (3): empty · exact membership · Set instanceof
Tests scrub every `REGISTRY_PRIMARY_*` env var in `beforeEach` +
`afterEach` so parallel vitest runs on the same process don't bleed state.
## What's NOT in this PR (deferred by design)
The actual integration in `call-processor.ts` belongs in #921
(finalize-orchestrator). Reason: the "new path" requires a populated
`SemanticModel` to call `Registry.lookup` against, and the model
becomes accessible only after #921 orchestrates finalize. Wiring a
dead branch now would just get rewritten then.
This PR ships the flag primitive in isolation so #921 has a clean,
tested utility to consult — and so `#923` (shadow harness) has a
stable boolean to read for its "which row is primary?" rendering.
## Closes part of #909. Unblocks
- #921 finalize-orchestrator — can now consult `isRegistryPrimary`
at resolution time
- #923 shadow harness — can distinguish primary-flipped rows
* feat(ingestion): ScopeExtractor driver — 5-pass CaptureMatch → ParsedFile (#919, RFC #909 Ring 2 PKG)
Kicks off Ring 2 PKG. Implements RFC §5.3 + §3.2 Phase 1: the central,
source-agnostic driver that turns a language provider's `CaptureMatch[]`
into a `ParsedFile` — the per-file artifact the finalize orchestrator
(#921) feeds into the shared `finalize()` algorithm (#915).
## Files
### New shared contracts
- `gitnexus-shared/src/scope-resolution/parsed-file.ts`
Per-file extraction artifact: scopes, parsedImports, localDefs,
referenceSites. Structural superset of `FinalizeFile` so the
finalize orchestrator threads `ParsedFile` through unchanged.
- `gitnexus-shared/src/scope-resolution/reference-site.ts`
Pre-resolution usage fact: name, atRange, inScope, kind, optional
callForm/explicitReceiver/arity. Converted to `Reference` records
by the resolution phase (populates `ReferenceIndex`).
### Ring 1 collateral tweak
- `language-provider.ts: emitScopeCaptures` now returns
`Promise<readonly CaptureMatch[]>` (was `readonly Capture[]`).
Pre-grouping per tree-sitter match is the provider's job — the
extractor expects coherent matches, not flat captures. No
consumers yet (all languages still on legacy DAG), so no breakage.
Docstring updated.
### New CLI module
- `gitnexus/src/core/ingestion/scope-extractor.ts`
Single entry point: `extract(matches, filePath, provider): ParsedFile`.
Five-pass pipeline:
Pass 1 — Build scope tree. `@scope.*` → `ScopeDraft[]` via
range-containment parent derivation. Honors
`provider.shouldCreateScope` (skip-but-reparent-children) and
`provider.resolveScopeKind`. Throws `ScopeTreeInvariantError`
via `buildScopeTree` on malformed input.
Pass 2 — Attach declarations + local bindings. `@declaration.*`
→ `SymbolDefinition` + `BindingRef { origin: 'local' }`.
Default attachment: innermost containing scope. Hoisting via
`provider.bindingScopeFor`.
Pass 3 — Collect raw imports. `@import.*` → `ParsedImport` via
`provider.interpretImport`. Attached to ParsedFile
(finalize resolves owning scope in Phase 2).
Pass 4 — Collect type bindings. `@type-binding.*` →
`TypeRef` via `provider.interpretTypeBinding` →
`scope.typeBindings`. Hoistable via `bindingScopeFor`.
Pass 5 — Collect reference sites. `@reference.*` →
`ReferenceSite[]`. Call form from declarative sub-tag
(`@reference.call.member`) or `provider.classifyCallForm`.
### Tests
- `gitnexus/test/unit/scope-resolution/scope-extractor.test.ts`
23 tests organized by pass + one end-to-end fixture exercising
all 5 passes together. MockProvider emits synthetic
`CaptureMatch[]` with no AST — extractor is pure given those.
## Design notes
- **Source-agnostic.** No `Tree` / `SyntaxNode` types leak into the
driver. Works for tree-sitter providers and COBOL's regex tagger.
- **One AST walk per language.** Providers do the walk inside
`emitScopeCaptures`; this driver does zero traversal.
- **Invariants delegated.** `ScopeTree.buildScopeTree` enforces
structural rules (non-Module has parent, parent contains child,
siblings don't overlap). The extractor doesn't try to repair
malformed captures.
- **Sub-tag whitelist.** `@reference.receiver`, `@declaration.name`,
`@import.source`, etc. are known sub-tags — excluded from anchor
selection so the broadest-range heuristic doesn't mis-identify them
as anchors for their topic. Bug surfaced in the end-to-end fixture
test (member call with a large-range receiver) and was fixed before
commit.
## Verification
- `tsc --noEmit` clean (both `gitnexus-shared` and `gitnexus`)
- `gitnexus-shared` build clean
- 23/23 new tests pass
- Full scope-resolution / model / shadow suite: **285/285 pass**
## Closes part of #909. Unblocks
- #920 parse-worker integration (emit ParsedFile from the worker)
- #921 finalize orchestrator (consume ParsedFile[] workspace-wide)
- #922 per-language import adapters
* chore(ingestion): address #919 review findings on the extractor
Addresses all 5 items from the PR #965 review in-PR.
## Structural changes
- **Extract `ScopeExtractorHooks` as the narrow dependency surface.**
The extractor now declares its dependency on a `Pick`-narrowed subset
of `LanguageProvider` (just the 6 scope-resolution hooks it actually
reads). Test mocks implement exactly that interface — no more
`as unknown as LanguageProvider` cast hiding missing-field bugs.
Adding a new hook read becomes a compile error, not a silent test
pass. (Finding 3.2)
- **Remove dead `ownerDefIdFor` stub + `isOwnerKind` helper.** The
function always returned `undefined` with `void innermost; void
drafts;` suppressors — an incomplete-implementation signal. The code
path was also misleading: creating a clone of the def with
`ownerId: undefined` is structurally identical to keeping the
original. Pass 2 now keeps the def as-is. Contract is documented in
a code comment: providers that need `ownerId` set it from their
declaration hook; `finalize` (via #914 `MethodDispatchIndex`) fills
in method/field `ownerId` in a post-extraction pass that has full
def visibility. (Finding 2.1)
- **Standardize `filePath` threading across passes 4 and 5.** Pass 4
was reading `drafts[0]!.filePath`; pass 5 was reading
`anyFilePathFromScopeTree(scopeTree)`. Both equivalent but
inconsistent. Both now take `filePath` as a parameter from the
top-level `extract()` call. The `anyFilePathFromScopeTree` helper is
removed. (Finding 2.2)
## Documentation
- **Snapshot-semantics comment on `scopeTree` + `positionIndex`.** The
hooks called during Passes 2-5 receive a `scopeTree` built BEFORE any
bindings/ownedDefs/typeBindings were written. Hooks MUST NOT rely on
`scope.bindings` etc. being populated — they're for parent/range/kind
queries only. Added a doc block at the `scopeTree`/`positionIndex`
construction site so future Ring 3 implementers don't write a
`classifyCallForm` that reads bindings. (Finding 2.3)
## Tests
- **Regression for the anchor-vs-receiver bug** (Finding 3.1): a
member-call match where `@reference.receiver` spans columns 0-10
(wider) and the call name spans 11-15 (narrower). Without the
`KNOWN_SUB_TAGS` exclusion, the broadest-range heuristic would have
picked the receiver; the test pins that the call name is the one
that ends up in `referenceSites[0].name`.
- **Mock provider now types exactly `ScopeExtractorHooks`**, no more
double-cast. Any future hook added to `extract()` that isn't in
`ScopeExtractorHooks` is a compile error.
## Verification
- `tsc --noEmit` clean in both `gitnexus-shared` and `gitnexus`
- `gitnexus-shared` build clean
- 24/24 scope-extractor tests pass (+1 regression)
- Full scope-resolution / model / shadow suite: **286/286 pass**
* chore(shared): apply Ring 2 SHARED review follow-ups in one diff
Aggregates all actionable follow-ups from the 9 Ring 2 SHARED PRs
(#949–#963) before proceeding to Ring 2 PKG. No behavior changes;
docstring edits, test refinements, and one structural cleanup.
## #913 (DefIndex / ModuleScopeIndex / QualifiedNameIndex)
- Rename `freezeIndex` → `wrapIndex` across all three index builders.
The old name implied `Object.freeze` on the wrapper, which we never
applied; `wrapIndex` more accurately describes the lightweight
readonly-interface wrap. Safety surface (frozen bucket arrays,
frozen miss-empty array, readonly Maps) is unchanged.
- Document in `buildModuleScopeIndex` JSDoc that callers must
pre-normalize `filePath` keys (no path-separator canonicalization
happens here). Prevents silent cross-platform misses.
- Add an explicit hit-path freeze assertion in
`qualified-name-index.test.ts` (the existing test covered only the
miss-path `EMPTY` array).
## #914 (MethodDispatchIndex)
- Differentiate the C3 and BFS test cases: both tests now use
distinct MRO orderings so they prove the materializer stores
whatever order the `computeMro` callback produces (not that C3 and
BFS yield identical output).
- Add `implementsOfCalls` counter in the first-write-wins test, and
document the call-count contract in `MethodDispatchInput.implementsOf`
JSDoc: `implementsOf` fires **per occurrence** in `input.owners`
(not per unique owner); `computeMro` fires at most once per unique
owner. Callers with expensive `implementsOf` implementations should
pre-dedupe `owners`.
## #916 (resolveTypeRef)
- Document the deliberate exclusion of `'Type'` from `TYPE_KINDS`
(verified no extractor in `gitnexus/src/core/ingestion/` emits
`type: 'Type'` for annotation-relevant symbols).
- Rename the namespace-origin test from `'resolves ...'` to
`'returns null for a namespace-origin binding whose def is not a
type kind'`, matching the failure-case intent.
## #918 (shadow diff + aggregate)
- Remove the partial re-export `export type { ShadowAgreement, ShadowDiff };`
from `aggregate.ts` — it omitted `ShadowCallsite` and diverged
from the top-level barrel. Consumers import all three from the
`gitnexus-shared` entry point.
- Fix the invalid `'wildcard'` evidence kind in `diff.test.ts` fixture
(that kind is not a valid `ResolutionEvidence.kind`). Replaced with
`'global-name'`, a real kind the test treats identically.
## #912 (ScopeTree / PositionIndex / makeScopeId)
- Document the touching-boundary semantics on `PositionIndex.atPosition`:
when siblings share a boundary point, the right (later-start) sibling
wins per the existing innermost-wins sort contract.
- Resolve the layer-inversion flagged by review: move `ScopeLookup`
from `resolve-type-ref.ts` to `types.ts` (its natural home in the
data-model layer). `scope-tree.ts` now imports `ScopeLookup` from
`types.js` directly; the old re-export from `resolve-type-ref.ts`
is removed per repo convention (`feedback_no_reexport`). Barrel
export moved alongside.
## #917 (ClassRegistry / MethodRegistry / FieldRegistry)
- Replace the dangling "try a name-match among class-like defs"
comment in `lookupReceiverType` with explicit prose that callers
must pre-resolve via `resolveTypeRef` if they want richer semantics.
No behavior change — the function already returned `undefined` on
ambiguous/missing qnames.
- Fix `tieBreakKey.origin` default for pure Step-2 candidates.
Type-binding-only hits no longer falsely inherit `'local'` from
`ensureCandidate`'s neutral default; they now demote to `'import'`
on their first type-binding hit, and only a later Step-1 lexical
hit can upgrade them back to `'local'`. Keeps the Appendix B
cascade faithful to the true origin.
- Document `'global-name'` in `evidence.ts`: currently reserved for
Ring 3's byName global index; `lookupCore` never emits it today.
The weight stays live so `composeEvidence` remains exhaustive over
the origin union.
- Rename the mislabeled Step-7 test from `'confidence DESC is the
primary key'` (which actually tested hard-shadow baseline) to
`'inner scope shadows outer, yielding single result'`, and add a
separate test that actually exercises multi-candidate confidence
ordering (local vs wildcard at the same scope).
## Verification
- `tsc --noEmit` clean (both `gitnexus-shared` and `gitnexus`)
- `gitnexus-shared` build clean
- Combined scope-resolution / model / shadow suite: **260/260 pass**
(+1 from the new multi-candidate ordering test in #917)
## Not addressed (non-actionable)
- #949 CI "failure with zero failing tests": pre-existing Swift Node 22
grammar flake unrelated to #910 scope.
- #950: the two non-blocking findings were already addressed in
follow-up commit `cbac32ba` (ParsedImport discriminated union +
`ScopeId | null` on the two hooks).
- #915: the five in-scope findings were already addressed in
follow-up commit `54515a7e` (dead code, unused params, multi-hop
docs, cap-hit test, stats granularity).
- #915 LanguageProvider.resolveImportTarget signature divergence +
`findDefById` O(F×D) perf: tracked separately as follow-up issues
for the Ring 3 migration window.
* chore(shared): address ce:review findings on the follow-up diff
ce:review (interactive) on PR #964 surfaced two P2s and several P3s. This
commit applies all `safe_auto` fixes + both manual tests in-line so the
PR ships with a cleaner review trail.
## P2 fixes
- **Complete `freezeIndex` → `wrapIndex` rename.** The prior commit renamed
3 of 5 sibling index files; `method-dispatch-index.ts` and
`position-index.ts` still carried the old name. Now all 5 helpers use
the consistent `wrapIndex` naming.
(maintainability + project-standards reviewers both flagged this.)
- **Add regression tests for the `recordTypeBindingHit` origin demotion.**
The prior commit introduced the `tieBreakKey.origin = 'import'`
demotion for Step-2-only candidates without a direct test. Added:
- `registries.test.ts`: two Step-2-only siblings under the same
interface, asserting deterministic DefId.localeCompare tie-break
AND the stronger invariant that composeEvidence never emits a
where-found signal for Step-2-only candidates (no `signals.origin`).
- `position-index.test.ts`: touching-boundary test proving the
right-sibling-wins rule documented in the new JSDoc.
(testing + kieran-typescript + api-contract reviewers all flagged these gaps.)
## P3 fixes
- Fix wrong comment in `recordTypeBindingHit` that claimed Step 1 could
later upgrade a demoted origin. Step 1 runs BEFORE Step 2 — the actual
upgrade path is Step 3 (`seedFromOwnerScopedContributor`). Comment now
describes execution order correctly.
- Fix inaccurate "re-exported there" comment in `index.ts`. `types.ts`
*defines* ScopeLookup natively; it's not a re-export. Phrasing now
says "defined in types.ts and exported from the type-export block
above — not from this module."
- Update stale `scope-tree.ts` file-header prose that still referenced
`ScopeLookup` as living in #916/resolve-type-ref.ts. Now points to
`./types.js` with a cross-ref to both #916 and #917 consumers.
- Expand `atPosition` touching-boundary JSDoc to name the mechanism
(backward scan through start-sorted array) so readers can trace the
binary-search code to the claim.
- Add breadcrumb to `aggregate.ts` module header pointing future readers
to `./diff.ts` / the top-level barrel for `ShadowAgreement`,
`ShadowCallsite`, and `ShadowDiff`.
- Remove unnecessary non-null assertion in `recordTypeBindingHit`. Local
`const existingMroDepth = ...` lets TS narrow to `number` in the
else-branch, eliminating the `!` without behavior change.
## Verification
- `tsc --noEmit` clean (both `gitnexus-shared` and `gitnexus`)
- `gitnexus-shared` build clean
- Combined scope-resolution / model / shadow suite: **262/262 pass** (+2
from the new origin-demotion + touching-boundary regression tests)
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.
* 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.
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.
* 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.
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.
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.
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).
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.
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