mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* refactor(ingestion): delete legacy call-resolution DAG + heritage processor (#942) RING4-1: all 16 production languages (incl. Vue #940) are registry-primary, so the legacy resolution legs only ran under the now-removed CI parity gate. Calls and inheritance now resolve exclusively through scope-resolution (Registry.lookup, preEmitInheritanceEdges, emitHeritageEdges, buildMro → MethodDispatchIndex). Removed: - Call-resolution DAG: call-processor.ts legacy body (processCalls, processCallsFromExtracted, resolveCallTarget + all resolver/dispatch/chain helpers), model/resolve.ts MRO-via-HeritageMap, model/heritage-map.ts, type-env DAG types; inferImplicitReceiver/selectDispatch LanguageProvider hooks + Ruby impls; DispatchDecision/ImplicitReceiverOverride/ReceiverEnriched. - Legacy heritage path: heritage-processor.ts, heritage-types.ts, heritage-extractors/, @heritage.* tree-sitter queries, heritageExtractor/ heritageDefaultEdge/interfaceNamePattern wiring, worker + parse-impl heritage passes (parse-worker/parsing-processor lockstep), cross-file-impl DAG pass. - Scope-parity infrastructure entirely (no legacy↔registry parity left to run): scripts/run-parity.ts, scripts/ci-list-migrated-languages.ts, ci-scope-parity.yml, test:parity, and the scope-parity ci.yml gate. Resolver integration tests still run via the normal tests job. Kept (shared infra, NOT call-DAG-only): type-env.ts buildTypeEnv (field extraction / structure phase / embeddings), model/resolve.ts c3Linearize + gatherAncestors (mro-processor mroPhase), route/fetch/exported-type-map helpers in call-processor.ts, preEmitInheritanceEdges (legacy-edge dedup simplified). Acceptance: grep for resolveCallTarget/inferImplicitReceiver/selectDispatch/ buildHeritageMap/HeritageMap/processHeritage/heritageExtractor/@heritage. is zero across src + test. tsc clean (both packages); resolver integration suite green (bit-compatible EXTENDS/IMPLEMENTS/CALLS); scope-capture fingerprints unchanged (python re-baselined: removed redundant ignored captures). ARCHITECTURE.md updated to scope-resolution-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(review): apply autofix feedback (#942) ce-code-review autofix pass on the RING4-1 deletion: - parse-cache.ts: bump SCHEMA_BUMP 2→3 — ParseWorkerResult lost its `heritage` field, so stale on-disk caches must invalidate (prevents a rollback replaying a heritage-less cache into legacy code) [api-contract P2]. - parse-impl.ts: drop 3 now-unused type imports (ExtractedCall, ExtractedAssignment, FileConstructorBindings) left by the deferred-block removal — would fail the eslint CI gate [correctness+maintainability P1]. - AGENTS.md / CLAUDE.md / scope-resolver.ts contract doc: fix stale pointers to the deleted "§ Call-Resolution DAG" section + removed hooks; preserve the language-neutrality rule [project-standards P1]. - registry-primary-flag.ts / cross-file.ts / parse-impl.ts: refresh stale comments referencing deleted symbols (legacy DAG, runCrossFileBindingPropagation). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(ingestion): remove the vestigial isRegistryPrimary flag (#942) With the legacy call-resolution DAG deleted, the per-language `REGISTRY_PRIMARY_<LANG>` / `isRegistryPrimary` / `MIGRATED_LANGUAGES` flag had only one meaningful state — every production language resolves via scope-resolution — and an explicit `=0` override could only *disable* resolution with no fallback (a footgun the review flagged). Removing it. - Delete `registry-primary-flag.ts` and the now-dead `shadow-harness.ts` (legacy↔registry shadow-parity tool) + its test. - Collapse the three flag gates to their behavior-preserving outcome (`SCOPE_RESOLVERS == MIGRATED_LANGUAGES`, so this is a no-op): - scope-resolution phase now runs for every registered `SCOPE_RESOLVERS` entry (was `∩ MIGRATED_LANGUAGES`). - import-processor `addImportGraphEdge` + parse-impl `shouldAccumulate`: the legacy emit/accumulate paths were already inert for migrated languages (scope-resolution owns IMPORTS via the imports-to-edges bridge); drop the flag term. - Collapse flag-branching tests to the scope-resolution path and delete the csharp legacy-`=0`-leg describe blocks; remove the ruby/rust-scope env-forcing hooks (no-ops now). - Refresh docs/comments (ARCHITECTURE.md "one registration", scope-resolver cookbook, phase deps) — adding a language is now a single `SCOPE_RESOLVERS` registration. Verified: tsc clean (both packages); resolver integration tests green (747 assertions across cobol/csharp/ruby/rust/typescript/go, IMPORTS edges intact); grep for the flag symbols is zero across src + test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(format): prettier formatting on #942 changes Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ci): drop legacy heritage-capture tests + re-baseline scope-capture fingerprints (#942) Two CI failures from the #942 cleanup, surfaced by the tri-review + CI: - tree-sitter-languages.test.ts: two tests asserted `@heritage.*` captures (Rust trait-impl, Dart extends/implements/with) that this PR removed. The acceptance grep used `@heritage\.` (with `@`); these reference the runtime capture name `heritage.trait` (no `@`), so they slipped the earlier sweep. Inheritance is now covered by the resolver integration suite. (fixed macos-latest) - Re-baselined the scope-capture bench fingerprints for csharp/rust/ruby/java/ javascript/kotlin (baselines.json) + python (python-scope/baseline-fingerprint.txt). The earlier test-cleanup reworded comments inside the lang-resolution fixture files (Shapes.cs, child.rs, derived.rb, IA.java/Plain.java, Service.js, F.kt, app.py) to scrub deleted-symbol references for the acceptance grep; those are the bench corpus, so capture node positions shifted. Capture LOGIC is unchanged — verified `--check` passes for all 14 langs + python. (fixed benchmarks) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs/chore: scrub remaining REGISTRY_PRIMARY + deleted-symbol references (#942) Tri-review P3 follow-ups (verified): - TESTING.md: rewrite the "Scope-resolution parity" section — the legacy dual-leg (REGISTRY_PRIMARY_<LANG>=0/1) and `npm run test:parity` no longer exist; resolver tests run once on the sole scope-resolution path in the normal tests job. - scripts/bench-scope-resolution.ts: drop the inert `REGISTRY_PRIMARY_PYTHON=1` env set + usage hint (the flag is gone). - ruby/scope-resolver.ts, php/captures.ts: re-point doc-comments off the deleted heritage-map.ts / heritage-processor.ts to the current behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ci): prettier format + regenerate scope-capture goldens (#942) Two more CI failures, same root cause as the bench re-baseline (the test-cleanup reworded comments in lang-resolution bench/golden-corpus fixtures): - quality/format: prettier on tree-sitter-languages.test.ts (blank line left by the deleted heritage-capture tests) + TESTING.md (the rewritten section). - tests/ubuntu/coverage: `csharp-captures-golden` (and python/ruby/rust) drifted because the edited fixtures feed the per-language capture-golden snapshots too (not just the bench). Regenerated via UPDATE_GOLDEN=1. Verified safe: only the edited-fixture entries changed; csharp `captureGroups` unchanged (38) — digest shifted from comment-position only; capture LOGIC untouched. 1168 scope- resolution tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(resolvers): drop createResolverParityIt wrapper, use vitest it directly The parity-aware `it` wrapper became a no-op when #942 removed the legacy call-resolution DAG (it just returned vitest's `it`). Remove it entirely so the resolver tests call vitest's `it` directly instead of shadowing it with a local `const it` (or `pit`/`rustParityIt`): - helpers.ts: delete createResolverParityIt + its now-unused vitestIt import and VitestIt type. - 16 files: drop `const it = createResolverParityIt('x')` and import `it` from vitest instead. - ruby.test.ts (pit) + rust.test.ts (rustParityIt): rename calls to `it`. - Scrub every comment that described the removed wrapper / dual-mode parity skip / legacy_skip gate (vue-scope, js/ts/dart/php/python headers, rust x2, cpp, swift x4, rust-coverage). Genuine test rationale is kept; only the vestigial two-leg framing is dropped. Accurate "legacy DAG (removed in #942)" historical notes are retained. No fixtures touched (no bench/golden re-baseline). tsc clean; rust+ruby resolver suites green (323 tests, incl. #1992 worker-path parity after a local dist build). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9f3bcee7fc
commit
083aedbc41
146 changed files with 895 additions and 19270 deletions
87
.github/workflows/ci-scope-parity.yml
vendored
87
.github/workflows/ci-scope-parity.yml
vendored
|
|
@ -1,87 +0,0 @@
|
|||
name: Scope Resolution Parity
|
||||
|
||||
# Reusable workflow — called from ci.yml. Does NOT declare concurrency;
|
||||
# it inherits the caller's concurrency group per the convention documented
|
||||
# in CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
#
|
||||
# ── Purpose (RFC #909 Ring 3, §6.4 "Observability gates") ──────────────
|
||||
# For every language in `MIGRATED_LANGUAGES` (exported from
|
||||
# `gitnexus/src/core/ingestion/registry-primary-flag.ts`), run the
|
||||
# resolver integration test at `test/integration/resolvers/<slug>.test.ts`
|
||||
# TWICE on every PR:
|
||||
#
|
||||
# 1. `REGISTRY_PRIMARY_<LANG>=0` — legacy DAG path (guarantees we haven't
|
||||
# broken the old path while migrating). Known legacy gaps may be skipped
|
||||
# through the resolver test helper's expected-failure list.
|
||||
# 2. `REGISTRY_PRIMARY_<LANG>=1` — registry-primary path (guarantees the
|
||||
# new path carries the same behavior — the parity gate).
|
||||
#
|
||||
# BOTH must pass. The source of truth is the TypeScript constant — adding
|
||||
# a language to that `Set` is the ONLY contributor action; CI auto-
|
||||
# discovers it, runs parity, and the language's default production path
|
||||
# flips to registry-primary in the same change.
|
||||
#
|
||||
# When the set is empty (e.g. mid-Ring-3 for every language), the parity
|
||||
# matrix is skipped and the workflow reports success — no-op until a
|
||||
# language is explicitly claimed migrated.
|
||||
#
|
||||
# ── Consolidation (chore/vitest-speed-strategy) ────────────────────────
|
||||
# Previously each language was a separate GitHub Actions matrix job,
|
||||
# meaning N languages × 1 checkout+install+build per shard. The build
|
||||
# cost dwarfed the test cost (~5 min setup for ~15 sec test execution).
|
||||
#
|
||||
# Now a single job runs `scripts/run-parity.ts` which loops through all
|
||||
# migrated languages sequentially (2 vitest invocations per language:
|
||||
# legacy + registry-primary). All failures are collected and reported
|
||||
# at the end (equivalent to the old fail-fast: false behavior).
|
||||
#
|
||||
# Adding a new language to MIGRATED_LANGUAGES still requires no workflow
|
||||
# edit — the script auto-discovers the set at runtime.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
discover:
|
||||
name: Discover migrated languages
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
has-any: ${{ steps.read.outputs.has-any }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
|
||||
- name: Extract MIGRATED_LANGUAGES from registry-primary-flag.ts
|
||||
id: read
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LANGS=$(npx tsx scripts/ci-list-migrated-languages.ts)
|
||||
COUNT=$(printf '%s' "$LANGS" | jq 'length')
|
||||
HAS_ANY="false"
|
||||
if [[ "$COUNT" -gt 0 ]]; then HAS_ANY="true"; fi
|
||||
echo "has-any=$HAS_ANY" >> "$GITHUB_OUTPUT"
|
||||
echo "Discovered $COUNT migrated language(s): $LANGS"
|
||||
echo "Parity will run: $HAS_ANY"
|
||||
|
||||
parity:
|
||||
name: scope-resolution parity
|
||||
needs: discover
|
||||
if: needs.discover.outputs.has-any == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
|
||||
- name: Run parity for all migrated languages
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
run: npx tsx scripts/run-parity.ts
|
||||
30
.github/workflows/ci.yml
vendored
30
.github/workflows/ci.yml
vendored
|
|
@ -27,9 +27,8 @@ concurrency:
|
|||
# Each concern lives in its own workflow file for maintainability:
|
||||
# ci-quality.yml — typecheck (tsc --noEmit)
|
||||
# ci-tests.yml — unit + integration tests with coverage + cross-platform
|
||||
# (includes the scope-resolution resolver tests)
|
||||
# ci-e2e.yml — E2E tests (only when gitnexus-web/ changes)
|
||||
# ci-scope-parity.yml — RFC #909 Ring 3 parity gate: legacy DAG + registry-primary
|
||||
# both pass, per migrated language in the JSON registry
|
||||
#
|
||||
# Shared setup is DRY via .github/actions/setup-gitnexus composite action.
|
||||
|
||||
|
|
@ -49,11 +48,6 @@ jobs:
|
|||
permissions:
|
||||
contents: read
|
||||
|
||||
scope-parity:
|
||||
uses: ./.github/workflows/ci-scope-parity.yml
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# ── Save PR metadata for the reporting workflow ─────────────────
|
||||
# The ci-report.yml workflow (triggered by workflow_run) needs the
|
||||
# PR number and job results to post a comment. We save them as an
|
||||
|
|
@ -62,7 +56,7 @@ jobs:
|
|||
save-pr-meta:
|
||||
name: Save PR Metadata
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
needs: [quality, tests, e2e, scope-parity]
|
||||
needs: [quality, tests, e2e]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
|
|
@ -73,14 +67,12 @@ jobs:
|
|||
QUALITY: ${{ needs.quality.result }}
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
E2E: ${{ needs.e2e.result }}
|
||||
SCOPE_PARITY: ${{ needs.scope-parity.result }}
|
||||
run: |
|
||||
mkdir -p pr-meta
|
||||
echo "$PR_NUMBER" > pr-meta/pr_number
|
||||
echo "$QUALITY" > pr-meta/quality_result
|
||||
echo "$TESTS" > pr-meta/tests_result
|
||||
echo "$E2E" > pr-meta/e2e_result
|
||||
echo "$SCOPE_PARITY" > pr-meta/scope_parity_result
|
||||
# TODO(post-merge): remove backward-compat copies once ci-report.yml
|
||||
# on main reads underscore names.
|
||||
# Backward-compat: ci-report.yml on main still reads hyphenated
|
||||
|
|
@ -103,7 +95,7 @@ jobs:
|
|||
# Single required check for branch protection.
|
||||
ci-status:
|
||||
name: CI Gate
|
||||
needs: [quality, tests, e2e, scope-parity]
|
||||
needs: [quality, tests, e2e]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
|
@ -117,14 +109,15 @@ jobs:
|
|||
# reusable workflow, so `needs.tests.result` below blocks the merge
|
||||
# on an ABI mismatch. (`jobs.<id>.result` cannot be exposed as a
|
||||
# workflow_call output, so the gate is enforced transitively here.)
|
||||
# The scope-resolution resolver tests also run inside the `tests`
|
||||
# workflow (RING4-1 #942 removed the separate scope-parity gate),
|
||||
# so a resolver regression makes TESTS != success and blocks here.
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
E2E: ${{ needs.e2e.result }}
|
||||
SCOPE_PARITY: ${{ needs.scope-parity.result }}
|
||||
run: |
|
||||
echo "Quality: $QUALITY"
|
||||
echo "Tests: $TESTS"
|
||||
echo "E2E: $E2E"
|
||||
echo "Scope parity: $SCOPE_PARITY"
|
||||
# A failed `abi-assert` job (#1922) inside the tests reusable
|
||||
# workflow makes TESTS != success, so this clause also blocks the
|
||||
# merge on a tree-sitter ABI mismatch.
|
||||
|
|
@ -137,14 +130,3 @@ jobs:
|
|||
echo "::error::E2E job failed"
|
||||
exit 1
|
||||
fi
|
||||
# scope-parity is a reusable workflow. With an empty migrated-
|
||||
# languages list, its parity matrix is skipped and the outer
|
||||
# workflow still reports `success`. If any entry's legacy-DAG or
|
||||
# registry-primary run fails, the workflow reports `failure`.
|
||||
# Accept only `success`; `skipped` would mean the entire
|
||||
# discover job was skipped too (upstream failure), which should
|
||||
# still block.
|
||||
if [[ "$SCOPE_PARITY" != "success" ]]; then
|
||||
echo "::error::Scope-resolution parity gate failed (RFC #909 Ring 3)"
|
||||
exit 1
|
||||
fi
|
||||
|
|
|
|||
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
|
|
@ -257,7 +257,7 @@ jobs:
|
|||
# ── Phase 3: reusable CI gate ──────────────────────────────────────────────
|
||||
# Runs for both rc (when guard says go) and stable. No `secrets:` passed —
|
||||
# ci.yml and its entire reusable-workflow chain (ci-quality, ci-tests,
|
||||
# ci-e2e, ci-scope-parity, ci-report) reference zero `secrets.*` values;
|
||||
# ci-e2e, ci-report) reference zero `secrets.*` values;
|
||||
# passing any would be unused surface. GITHUB_TOKEN is implicit.
|
||||
ci:
|
||||
needs: [route, rc-guard]
|
||||
|
|
|
|||
|
|
@ -39,8 +39,7 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.
|
|||
## Reference docs
|
||||
|
||||
- **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)**
|
||||
- **Call-resolution DAG (legacy path):** 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`.
|
||||
- **Scope-resolution pipeline (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. Replaces the legacy DAG for languages in `MIGRATED_LANGUAGES` (see `registry-primary-flag.ts`). A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. CI parity gate runs BOTH paths per migrated language on every PR.
|
||||
- **Call & inheritance resolution (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. All languages resolve calls and inheritance through the scope-resolution pipeline (`Registry.lookup`, `preEmitInheritanceEdges`, `emitHeritageEdges`, `buildMro` → `MethodDispatchIndex`). **Shared code in `gitnexus/src/core/ingestion/` must not name languages** — plug language behavior in via `LanguageProvider` / `ScopeResolver` hooks. A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. (The legacy call-resolution DAG + `@heritage` capture path were removed in RING4-1 #942.)
|
||||
- **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.
|
||||
|
||||
|
|
|
|||
120
ARCHITECTURE.md
120
ARCHITECTURE.md
|
|
@ -65,7 +65,7 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
|
|||
| 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` |
|
||||
| Call resolution/inheritance/MRO | `src/core/ingestion/scope-resolution/` (pipeline, passes, graph-bridge) |
|
||||
| Type extraction | `src/core/ingestion/type-extractors/` |
|
||||
| Worker pool | `src/core/ingestion/workers/` |
|
||||
| Web UI | `gitnexus-web/src/` |
|
||||
|
|
@ -147,105 +147,18 @@ export const myPhase: PipelinePhase<MyPhaseOutput> = {
|
|||
|
||||
---
|
||||
|
||||
## Call-Resolution DAG
|
||||
## Semantic model
|
||||
|
||||
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.
|
||||
`SemanticModel` (`gitnexus/src/core/ingestion/model/semantic-model.ts`) is the authoritative store for every symbol-indexed lookup (by `nodeId`, `simpleName`, `qualifiedName`, or `filePath`). The scope-resolution pipeline reads from here: `findOwnedMember`, `pickOverload`, and `findExportedDefByName` all consult `model.methods` / `model.fields` / `model.symbols`.
|
||||
|
||||
### 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` |
|
||||
|
||||
### Coexistence with the scope-resolution pipeline
|
||||
|
||||
The Call-Resolution DAG is the **legacy path**. RFC #909 Ring 3 introduces a parallel **scope-resolution pipeline** (next section) that replaces stages 1–6 with a scope-indexed registry lookup. Both paths ship side-by-side and are gated per-language via `MIGRATED_LANGUAGES` + the `REGISTRY_PRIMARY_<LANG>` env var.
|
||||
|
||||
- **Unmigrated language** → Call-Resolution DAG runs; scope-resolution phase is a no-op.
|
||||
- **Migrated language** (currently: Python, C#) → scope-resolution owns CALLS/ACCESSES/USES emission; the legacy DAG gates off for that language via `isRegistryPrimary(lang)` checks in `call-processor.ts` and `import-processor.ts`.
|
||||
- `import-processor` still populates `importMap` for migrated languages — heritage's `ctx.resolve` reads it to disambiguate parent classes. Only edge emission is gated.
|
||||
- CI runs BOTH paths for every migrated language on every PR (`.github/workflows/ci-scope-parity.yml`); both must pass.
|
||||
|
||||
#### Same-graph guarantee
|
||||
|
||||
Edges emitted by the scope-resolution pipeline and edges emitted by the legacy DAG are indistinguishable to downstream consumers (MCP tools, HTTP API, embeddings, group bridge):
|
||||
|
||||
- **Node identity** — both paths use `generateId(...)` from `lib/utils.ts`, the same qualified-name keyspace, and the same node labels (`File`, `Folder`, `Class`, `Method`, `Function`, …). Overload disambiguation suffixes `parameterTypes` into the id consistently — see `scope-resolution/graph-bridge/ids.ts` and the legacy emitter in `call-processor.ts`.
|
||||
- **Edge vocabulary** — both paths emit the same reasons: `'import-resolved' | 'global' | 'local-call' | 'same-file' | 'interface-dispatch' | 'read' | 'write'`. Migrating a language must not change which reasons consumers see for previously-resolved edges.
|
||||
- **Confidence tier** — both paths attach a numeric `confidence` to each edge using the same scale.
|
||||
|
||||
The CI parity workflow (`.github/workflows/ci-scope-parity.yml`) runs both paths against every migrated language's fixture corpus and fails on any divergence.
|
||||
|
||||
#### Semantic-model source of truth
|
||||
|
||||
Two independent invariants.
|
||||
|
||||
**ParsedFile = the AST-level truth.** `ParsedFile` (`gitnexus-shared/src/scope-resolution/parsed-file.ts`) is the single per-file artifact both resolution paths consume. Scope-resolution passes MUST NOT build a parallel parse representation. If a per-language hook needs AST-level facts that `ParsedFile` doesn't expose, it should reuse the orchestrator's `treeCache` (`RunScopeResolutionInput.treeCache`) rather than re-invoking `parser.parse(...)` on its own — the C# `populateNamespaceSiblings` hook is the reference implementation of this pattern.
|
||||
|
||||
**SemanticModel = the symbol-level truth.** `SemanticModel` (`gitnexus/src/core/ingestion/model/semantic-model.ts`) is the authoritative store for every symbol-indexed lookup (by `nodeId`, `simpleName`, `qualifiedName`, or `filePath`). Both paths read from here:
|
||||
|
||||
- Legacy Call-Resolution DAG → `call-processor` Tier 1/2/3 via `model.symbols.lookupExactAll`, `model.methods.lookupMethodByName`, `model.types.lookupClassByName`, `lookupMethodByOwnerWithMRO`.
|
||||
- Scope-resolution pipeline → `findOwnedMember`, `pickOverload`, `findExportedDefByName` all consult `model.methods` / `model.fields` / `model.symbols`.
|
||||
`ParsedFile` (`gitnexus-shared/src/scope-resolution/parsed-file.ts`) is the single per-file artifact the scope-resolution pipeline consumes. Scope-resolution passes MUST NOT build a parallel parse representation. If a per-language hook needs AST-level facts that `ParsedFile` doesn't expose, it should reuse the orchestrator's `treeCache` (`RunScopeResolutionInput.treeCache`) rather than re-invoking `parser.parse(...)` on its own — the C# `populateNamespaceSiblings` hook is the reference implementation of this pattern.
|
||||
|
||||
The scope-resolution pipeline additionally carries `WorkspaceResolutionIndex` for `Scope`-valued lookups (`classScopeByDefId`, `moduleScopeByFile`) that `SemanticModel` structurally cannot hold. No symbol-indexed duplicates exist outside `SemanticModel`.
|
||||
|
||||
**Write / read phase contract.** The model is mutable during three ordered phases and read-only afterward:
|
||||
|
||||
```
|
||||
Phase 1: legacy parse ──► symbolTable.add fans into types/methods/fields
|
||||
Phase 1: parse ──► symbolTable.add fans into types/methods/fields
|
||||
Phase 2: scope-resolution ──► reconcileOwnership() registers corrected ownerIds
|
||||
Phase 3: finalize ──► model.attachScopeIndexes(bundle) — one-shot freeze
|
||||
─────────────────────────── phase boundary ───────────────────────────
|
||||
|
|
@ -255,7 +168,7 @@ The scope-resolution pipeline additionally carries `WorkspaceResolutionIndex` fo
|
|||
|
||||
`runScopeResolution` narrows `MutableSemanticModel` → `SemanticModel` at the phase boundary so downstream passes physically cannot mutate the model even accidentally.
|
||||
|
||||
**Transitional: reconciliation pass.** `reconcileOwnership` (`scope-resolution/pipeline/reconcile-ownership.ts`) is a shim for languages whose legacy extractor doesn't resolve `enclosingClassId` at parse time (Python class-body methods are the canonical case). It walks `parsed.localDefs[i].ownerId` after `populateOwners` and registers any missed methods/fields into the model. Idempotent — safe to re-run, safe alongside languages whose legacy extractor already carries `ownerId` (C#).
|
||||
**Reconciliation pass.** `reconcileOwnership` (`scope-resolution/pipeline/reconcile-ownership.ts`) is a shim for languages whose parse-time extractor doesn't resolve `enclosingClassId` at parse time (Python class-body methods are the canonical case). It walks `parsed.localDefs[i].ownerId` after `populateOwners` and registers any missed methods/fields into the model. Idempotent — safe to re-run, safe alongside languages whose extractor already carries `ownerId` (C#).
|
||||
|
||||
The architectural end state is for every language's parse-time extractor to emit the correct `ownerId` directly, making reconciliation a no-op (tracked as a follow-up refactor). The dev-mode validator `validateOwnershipParity` surfaces any drift via `onWarn` under `NODE_ENV !== 'production' && VALIDATE_SEMANTIC_MODEL !== '0'`.
|
||||
|
||||
|
|
@ -265,7 +178,7 @@ References: `semantic-model.ts` file-head (full write/read contract); `contract/
|
|||
|
||||
## Scope-Resolution Pipeline (RFC #909 Ring 3)
|
||||
|
||||
Language-agnostic registry-primary resolver. Replaces the Call-Resolution DAG for migrated languages. Adding a language is one interface implementation (`ScopeResolver`) plus two registrations — no changes to shared code, no new pipeline phase.
|
||||
Language-agnostic scope-resolution resolver. This is the resolution path for every language — it owns CALLS/ACCESSES/USES emission and inheritance edges. Adding a language is one interface implementation (`ScopeResolver`) plus one registration in the `SCOPE_RESOLVERS` map — no changes to shared code, no new pipeline phase. (RING4-1 #942 removed the legacy call-resolution DAG and the per-language `MIGRATED_LANGUAGES` flag, so `SCOPE_RESOLVERS` registration is all that's needed.)
|
||||
|
||||
### Pipeline stages
|
||||
|
||||
|
|
@ -286,7 +199,7 @@ Language-agnostic registry-primary resolver. Replaces the Call-Resolution DAG fo
|
|||
```
|
||||
|
||||
Orchestrator: `runScopeResolution(input, provider)` in `scope-resolution/pipeline/run.ts`.
|
||||
Pipeline phase: `scopeResolutionPhase` in `scope-resolution/pipeline/phase.ts` — iterates `SCOPE_RESOLVERS ∩ MIGRATED_LANGUAGES`, reads per-file Trees from the parse phase's `scopeTreeCache`, disposes the cache at the end.
|
||||
Pipeline phase: `scopeResolutionPhase` in `scope-resolution/pipeline/phase.ts` — iterates the registered `SCOPE_RESOLVERS`, reads per-file Trees from the parse phase's `scopeTreeCache`, disposes the cache at the end.
|
||||
|
||||
### `ScopeResolver` contract
|
||||
|
||||
|
|
@ -312,7 +225,6 @@ Single interface a language implements to plug into the pipeline. Contract fully
|
|||
|
||||
1. Implement `ScopeResolver` in `languages/<lang>/scope-resolver.ts`.
|
||||
2. Add entry to `SCOPE_RESOLVERS` in `scope-resolution/pipeline/registry.ts`.
|
||||
3. Add the language to `MIGRATED_LANGUAGES` in `registry-primary-flag.ts` when the shadow-harness corpus parity ≥ 99% fixtures / ≥ 98% corpus.
|
||||
|
||||
CI auto-discovers the set via `tsx`. No workflow edit required.
|
||||
|
||||
|
|
@ -328,7 +240,6 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
|
|||
| `scope-resolution/graph-bridge/*.ts` | CLI-local translation from resolved references → `KnowledgeGraph` edges |
|
||||
| `scope-resolution/scope/*.ts` | Generic scope-chain walkers + namespace targets |
|
||||
| `scope-resolution/workspace-index.ts` | Build-once O(1) lookup index |
|
||||
| `registry-primary-flag.ts` | `MIGRATED_LANGUAGES` set + `isRegistryPrimary(lang)` |
|
||||
| `languages/python/index.ts` | Python `ScopeResolver` hooks + known-limitation docs |
|
||||
| `languages/python/captures.ts` | `emitPythonScopeCaptures` (honors cross-phase Tree cache) |
|
||||
| `languages/csharp/index.ts` | C# `ScopeResolver` hooks + known-limitation docs |
|
||||
|
|
@ -351,7 +262,7 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
|
|||
```
|
||||
Unified Graph Schema (44 node types, 21 relationship types)
|
||||
↑
|
||||
Unified Resolution (3-tier name lookup + MRO walk)
|
||||
Scope-Resolution Pipeline (registry lookup + 3-tier import resolution + MRO)
|
||||
↑
|
||||
Language Providers (import semantics, type config, export checker, MRO strategy)
|
||||
↑
|
||||
|
|
@ -376,7 +287,7 @@ Each language implements `LanguageProvider` (`language-provider.ts`). Key fields
|
|||
|
||||
### 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`.
|
||||
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`, `@reference.inherits`. Downstream extraction needs no language branching. Defined in `tree-sitter-queries.ts`.
|
||||
|
||||
### Import resolution
|
||||
|
||||
|
|
@ -403,20 +314,21 @@ Unified 3-tier algorithm (`model/resolution-context.ts`), per-language `importSe
|
|||
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
|
||||
4. Resolve imports
|
||||
5. Collect `BindingAccumulator` entries for cross-file propagation
|
||||
|
||||
Inheritance edges are emitted later, by the scope-resolution phase (`preEmitInheritanceEdges` + `emitHeritageEdges`), not during `parse`.
|
||||
|
||||
Workers: `workers/worker-pool.ts`, `workers/parse-worker.ts`.
|
||||
|
||||
### Heritage and MRO
|
||||
### Inheritance and MRO
|
||||
|
||||
All languages emit unified `ExtractedHeritage` (child, parent, `EXTENDS`/`IMPLEMENTS`). MRO phase walks the heritage graph using per-language strategy:
|
||||
Inheritance is captured by the `@reference.inherits` tag and emitted by the scope-resolution phase: `preEmitInheritanceEdges` resolves each base in scope, then `emitHeritageEdges` writes the `EXTENDS`/`IMPLEMENTS` edges. The phase then computes method resolution order via each `ScopeResolver`'s `buildMro` hook, feeding a `MethodDispatchIndex` used for owner-scoped lookups. Per-language strategy:
|
||||
- **`first-wins`** — Java, C#, C++, TS, Ruby, Go
|
||||
- **`c3`** — Python (C3 linearization)
|
||||
- **`ruby-mixin`** — Ruby (mixin-aware linearization)
|
||||
- **`none`** — single-inheritance languages
|
||||
|
||||
Unified walk: `lookupMethodByOwnerWithMRO()` in `model/resolve.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Full analysis flow
|
||||
|
|
|
|||
|
|
@ -35,7 +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).
|
||||
- **Call & inheritance resolution:** See ARCHITECTURE.md § Scope-Resolution Pipeline. Shared pipeline code in `gitnexus/src/core/ingestion/` must not name languages — use `LanguageProvider` / `ScopeResolver` hooks instead (see AGENTS.md). (The legacy call-resolution DAG was removed in #942.)
|
||||
- **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
|
||||
|
|
|
|||
74
TESTING.md
74
TESTING.md
|
|
@ -4,11 +4,11 @@ How we structure tests and which commands to run locally and in CI.
|
|||
|
||||
## Packages
|
||||
|
||||
| Package | Path | Runner | Notes |
|
||||
| -------------- | -------------- | -------- | ------------------------------ |
|
||||
| CLI + MCP core | `gitnexus/` | Vitest | Primary test surface in CI |
|
||||
| Web UI | `gitnexus-web/`| Vitest | Unit/component tests |
|
||||
| Web UI E2E | `gitnexus-web/`| Playwright | Run when changing UI flows |
|
||||
| Package | Path | Runner | Notes |
|
||||
| -------------- | --------------- | ---------- | -------------------------- |
|
||||
| CLI + MCP core | `gitnexus/` | Vitest | Primary test surface in CI |
|
||||
| Web UI | `gitnexus-web/` | Vitest | Unit/component tests |
|
||||
| Web UI E2E | `gitnexus-web/` | Playwright | Run when changing UI flows |
|
||||
|
||||
## Test lanes
|
||||
|
||||
|
|
@ -16,25 +16,25 @@ How we structure tests and which commands to run locally and in CI.
|
|||
|
||||
From `gitnexus/`:
|
||||
|
||||
| Command | What it runs | When to use |
|
||||
| ------------------------ | ---------------------------------------------------- | ------------------------------- |
|
||||
| `npm test` | Full suite (all 3 vitest projects) | Before opening a PR |
|
||||
| `npm run test:unit` | Unit tests only (`test/unit/`) | Tight development loop |
|
||||
| `npm run test:integration` | Integration tests (`test/integration/`) | After changing pipelines, DB, workers |
|
||||
| `npm run test:coverage` | Full suite + v8 coverage with thresholds | Checking coverage impact |
|
||||
| `npm run test:parity` | Scope-resolution parity for all migrated languages | After changing resolver or scope code |
|
||||
| `npm run test:cross-platform` | Platform-sensitive subset only | Debugging a Windows/macOS issue |
|
||||
| `npm run test:watch` | Vitest in watch mode | Active development |
|
||||
| Command | What it runs | When to use |
|
||||
| ----------------------------- | -------------------------------------------------- | ------------------------------------- |
|
||||
| `npm test` | Full suite (all 3 vitest projects) | Before opening a PR |
|
||||
| `npm run test:unit` | Unit tests only (`test/unit/`) | Tight development loop |
|
||||
| `npm run test:integration` | Integration tests (`test/integration/`) | After changing pipelines, DB, workers |
|
||||
| `npm run test:coverage` | Full suite + v8 coverage with thresholds | Checking coverage impact |
|
||||
| `npm run test:parity` | Scope-resolution parity for all migrated languages | After changing resolver or scope code |
|
||||
| `npm run test:cross-platform` | Platform-sensitive subset only | Debugging a Windows/macOS issue |
|
||||
| `npm run test:watch` | Vitest in watch mode | Active development |
|
||||
|
||||
### `gitnexus-web/` commands
|
||||
|
||||
From `gitnexus-web/`:
|
||||
|
||||
| Command | What it runs | When to use |
|
||||
| ---------------------- | --------------------------------- | ------------------------------ |
|
||||
| `npm test` | Unit/component tests (vitest) | After changing web code |
|
||||
| `npm run test:coverage`| Unit tests + coverage | Checking coverage impact |
|
||||
| `npm run test:e2e` | Playwright browser tests | After changing UI flows (requires `gitnexus serve` + `npm run dev`) |
|
||||
| Command | What it runs | When to use |
|
||||
| ----------------------- | ----------------------------- | ------------------------------------------------------------------- |
|
||||
| `npm test` | Unit/component tests (vitest) | After changing web code |
|
||||
| `npm run test:coverage` | Unit tests + coverage | Checking coverage impact |
|
||||
| `npm run test:e2e` | Playwright browser tests | After changing UI flows (requires `gitnexus serve` + `npm run dev`) |
|
||||
|
||||
### Before opening a PR
|
||||
|
||||
|
|
@ -59,11 +59,11 @@ Skip with `git commit --no-verify` (use sparingly).
|
|||
|
||||
`gitnexus/vitest.config.ts` defines three projects for safety isolation:
|
||||
|
||||
| Project | Files | Parallelism | Purpose |
|
||||
| ---------- | ----------------------------- | ----------- | ---------------------------------------------- |
|
||||
| `lbug-db` | Native LadybugDB integration tests (explicit list) | Sequential | Prevents file-lock conflicts from native mmap addon |
|
||||
| `cli-e2e` | `skills-e2e.test.ts` | Sequential | CLI process spawning requires serial execution |
|
||||
| `default` | Everything else | Parallel | Fast execution for pure logic and parser tests |
|
||||
| Project | Files | Parallelism | Purpose |
|
||||
| --------- | -------------------------------------------------- | ----------- | --------------------------------------------------- |
|
||||
| `lbug-db` | Native LadybugDB integration tests (explicit list) | Sequential | Prevents file-lock conflicts from native mmap addon |
|
||||
| `cli-e2e` | `skills-e2e.test.ts` | Sequential | CLI process spawning requires serial execution |
|
||||
| `default` | Everything else | Parallel | Fast execution for pure logic and parser tests |
|
||||
|
||||
When adding a new test that uses native LadybugDB (`@ladybugdb/core`), add it to the `lbug-db` project's explicit include list and the `default` project's exclude list.
|
||||
|
||||
|
|
@ -74,21 +74,11 @@ When adding a new test that uses native LadybugDB (`@ladybugdb/core`), add it to
|
|||
- **Resolver / parity** — Language-specific call-resolution tests in `test/integration/resolvers/`.
|
||||
- **E2E (web)** — Critical user paths only; prefer `data-testid` attributes for stable selectors. Tests run against real backend (`gitnexus serve`) and Vite dev server.
|
||||
|
||||
## Scope-resolution parity
|
||||
## Scope-resolution tests
|
||||
|
||||
Migrated languages (listed in `MIGRATED_LANGUAGES` in `src/core/ingestion/registry-primary-flag.ts`) are tested in both legacy and registry-primary modes on every PR.
|
||||
Every language resolves calls and inheritance through the scope-resolution pipeline — the legacy call-resolution DAG and the per-language `REGISTRY_PRIMARY_<LANG>` flag were removed in RING4-1 (#942). Each language's resolver test lives at `test/integration/resolvers/<slug>.test.ts` and runs once, on the single scope-resolution path, as part of the normal `tests` job (`vitest test/**/*.test.ts`).
|
||||
|
||||
For each migrated language, CI runs the resolver test file twice:
|
||||
1. `REGISTRY_PRIMARY_<LANG>=0` — legacy DAG path
|
||||
2. `REGISTRY_PRIMARY_<LANG>=1` — registry-primary path
|
||||
|
||||
Both must pass. Known legacy gaps are listed in `LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES` in `test/integration/resolvers/helpers.ts` and are automatically skipped in legacy mode.
|
||||
|
||||
Adding a language to `MIGRATED_LANGUAGES` automatically enrolls it in parity — no workflow or config edit needed. The test file must exist at `test/integration/resolvers/<slug>.test.ts`.
|
||||
|
||||
Run parity locally: `cd gitnexus && npm run test:parity`
|
||||
|
||||
Run for a single language: `cd gitnexus && npx tsx scripts/run-parity.ts --language python`
|
||||
Adding a language: register its `ScopeResolver` in `scope-resolution/pipeline/registry.ts` (`SCOPE_RESOLVERS`) and add the resolver test file — no workflow or config edit needed.
|
||||
|
||||
## Cross-platform testing
|
||||
|
||||
|
|
@ -120,12 +110,12 @@ To check the cross-platform list is up to date, run `npm run test:cross-platform
|
|||
|
||||
GitHub Actions (`.github/workflows/ci.yml`) orchestrate:
|
||||
|
||||
| Workflow | Jobs | Purpose |
|
||||
| --------------------- | ------------------------------ | ------------------------------------------------ |
|
||||
| `ci-quality.yml` | format, lint, typecheck, typecheck-web, workflow-convention | Code quality gates |
|
||||
| Workflow | Jobs | Purpose |
|
||||
| --------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| `ci-quality.yml` | format, lint, typecheck, typecheck-web, workflow-convention | Code quality gates |
|
||||
| `ci-tests.yml` | ubuntu/coverage, cross-platform (Win/Mac), packaged-install-smoke | Full suite + coverage on Ubuntu; platform-sensitive subset on Win/Mac |
|
||||
| `ci-scope-parity.yml` | discover, parity | Scope-resolution parity for all migrated languages |
|
||||
| `ci-e2e.yml` | e2e (chromium) | Playwright E2E, gated on `gitnexus-web/**` changes |
|
||||
| `ci-scope-parity.yml` | discover, parity | Scope-resolution parity for all migrated languages |
|
||||
| `ci-e2e.yml` | e2e (chromium) | Playwright E2E, gated on `gitnexus-web/**` changes |
|
||||
|
||||
The `CI Gate` job in `ci.yml` is the single required check for branch protection. It requires quality, tests, e2e, and scope-parity to all pass.
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
06687dff942d531c4d453b5906a8666c90db4867eb43ed18304aa59a8a93ef9d
|
||||
c03f87cd8cd1ee716dea93cceb27109ee5b4584bc9fe426eea3f18fd6f9854cc
|
||||
|
|
|
|||
|
|
@ -20,18 +20,18 @@
|
|||
"scaling_budget": 1.5,
|
||||
"_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12.",
|
||||
"_rebaselined": "#1965 / #1923 F4: uninitialized non-leading multi-declarators now emit @declaration.variable captures; cpp-adl-inner-callable-outer-noncallable data::Pair a, b adds the legitimate fixture drift. Linear (~1.06).",
|
||||
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures — pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture — pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae."
|
||||
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift \u2014 no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures \u2014 pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture \u2014 pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae."
|
||||
},
|
||||
"csharp": {
|
||||
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.)",
|
||||
"fingerprint": "68ef32c126d5c6de5d8184c6ad0a6104043036daf9805947db8b21741b883f43",
|
||||
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"fingerprint": "7e8845040540ae69ef564ebf597305ade31e4480cee1dc910ea0fcfc26794910",
|
||||
"scaling_budget": 1.5
|
||||
},
|
||||
"rust": {
|
||||
"fingerprint": "b00aea0f2dbff6a77d3aa709f7f90e8a70649f7e789a8de725d9b1958ebe12bc",
|
||||
"fingerprint": "ac610bbe97666bf285923479dd7b43a2fe4c5354aae8df1bcbafdc04fb220f82",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) — legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical.",
|
||||
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED — @declaration.macro/@reference.macro + MacroRegistry → USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures — pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f."
|
||||
"_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) \u2014 legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f."
|
||||
},
|
||||
"php": {
|
||||
"fingerprint": "f9c8eaf6d1084f9b95a9fb97ccce5e618a24d936c85fb8af4b96c73a560f7a7f",
|
||||
|
|
@ -39,10 +39,10 @@
|
|||
"_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04)."
|
||||
},
|
||||
"ruby": {
|
||||
"fingerprint": "bf6b13a366e4116da3772f9a9fdd50517eb11da73918451392e014a2c905b2dd",
|
||||
"fingerprint": "61d6e5f049e5e6c4871c210d28d15348f2396345751a98ccfff2f4b54f727aff",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1956 synth-widening: + ruby-qualified-base fixture; synth now reduces a scope_resolution superclass (class C < Mod::Super) to its trailing constant (matching the #1940 legacy leg), at parity. Linear (~1.03). (Earlier #1956: heritage-bearing scale source.)",
|
||||
"_note": "F62: + scope_resolution class/module declaration captures — fixture count 78→81, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) — pure fixture-corpus drift, scope-extractor captures unchanged; 81→82."
|
||||
"_rebaselined": "#1956 synth-widening: + ruby-qualified-base fixture; synth now reduces a scope_resolution superclass (class C < Mod::Super) to its trailing constant (matching the #1940 legacy leg), at parity. Linear (~1.03). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_note": "F62: + scope_resolution class/module declaration captures \u2014 fixture count 78\u219281, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) \u2014 pure fixture-corpus drift, scope-extractor captures unchanged; 81\u219282."
|
||||
},
|
||||
"swift": {
|
||||
"fingerprint": "53325c6345161c5a495f997297af5a24fb718fd3e6647040160f8ab2a2c8e4c0",
|
||||
|
|
@ -56,9 +56,9 @@
|
|||
"_rebaselined": "#1970 review + tri-review follow-ups: constructor-call retag, cascade calls, built-in suppression, enum scope, #1926 F24/F25, named-ctor dedup (crash fix), container-name binding suppression; heritage file-affinity resolution. Fixtures: member-call-contexts, constructor-body, named-constructor-body, heritage-name-collision, construct-cascade."
|
||||
},
|
||||
"java": {
|
||||
"fingerprint": "b63f9be458f7ece854e7b007159d7bf65b4b66a86e83a6c0656fc93ebd5d83da",
|
||||
"fingerprint": "d5cf68e9faf92fffd928c1ee6e584c72cc65918d1f1b5078abb3bfe09ac699bf",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC<T>), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.)"
|
||||
"_rebaselined": "#1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC<T>), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged."
|
||||
},
|
||||
"typescript": {
|
||||
"fingerprint": "3f44a4a6892698df2d145c8ff2812c3b318807648983c88aca28fbd694f172f9",
|
||||
|
|
@ -67,15 +67,15 @@
|
|||
"_note": "#1968: F44, F85, F87 \u2014 fingerprint drift expected."
|
||||
},
|
||||
"javascript": {
|
||||
"fingerprint": "a8ddfb15620ae55e50651fc21ab14c4a1f874d9b19e208cc6cbf0a8daac8ec5b",
|
||||
"fingerprint": "d72f03c6c502235d2d4b74d66baa5c7d361f040d7a1b72e84acad61210d05ae8",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#1951: bench coverage added (was ungated); scale source heritage-bearing (extends Base); js/kotlin O(n^2) findNodeAtRange-per-match fixed to threaded captured node, now linear.",
|
||||
"_rebaselined": "#1956 synth-widening: + javascript-qualified-base fixture; synthesizeJsInheritanceReferences now handles a member_expression base (class S extends ns.Base -> Base), matching the #1940 legacy leg + the TS terminalTsTypeNameNode property_identifier case, at parity. Linear (~1.05)."
|
||||
"_rebaselined": "#1956 synth-widening: + javascript-qualified-base fixture; synthesizeJsInheritanceReferences now handles a member_expression base (class S extends ns.Base -> Base), matching the #1940 legacy leg + the TS terminalTsTypeNameNode property_identifier case, at parity. Linear (~1.05). | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged."
|
||||
},
|
||||
"kotlin": {
|
||||
"fingerprint": "5121a11855cd9cc44a357ae3ff50953de80cdd743f00e8924c31503b132bcd84",
|
||||
"fingerprint": "9b212eca24959cc1705933df213f4ec739c5c1b4580c7929347d37bf4d72ba8a",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#1951: bench coverage added (was ungated); scale source heritage-bearing (: Base()); js/kotlin O(n^2) findNodeAtRange-per-match fixed to threaded captured node, now linear.",
|
||||
"_rebaselined": "#1956 synth-widening: + kotlin-qualified-base fixture; synthesizeKotlinInheritanceReferences now handles the explicit_delegation form (class F : Iface by d -> Iface), matching the #1940 legacy leg, at parity. Linear (~0.87)."
|
||||
"_rebaselined": "#1956 synth-widening: + kotlin-qualified-base fixture; synthesizeKotlinInheritanceReferences now handles the explicit_delegation form (class F : Iface by d -> Iface), matching the #1940 legacy leg, at parity. Linear (~0.87). | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@
|
|||
"test:integration": "vitest run test/integration",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:parity": "tsx scripts/run-parity.ts",
|
||||
"test:cross-platform": "tsx scripts/run-cross-platform.ts",
|
||||
"postinstall": "node scripts/materialize-vendor-grammars.cjs && node scripts/build-tree-sitter-dart.cjs && node scripts/build-tree-sitter-proto.cjs && node scripts/build-tree-sitter-swift.cjs",
|
||||
"prepare": "node scripts/build.js",
|
||||
|
|
|
|||
|
|
@ -4,10 +4,8 @@
|
|||
* isolating the resolution cost from parse / heritage / pipeline
|
||||
* overhead.
|
||||
*
|
||||
* Usage: REGISTRY_PRIMARY_PYTHON=1 npx tsx scripts/bench-scope-resolution.ts
|
||||
* Usage: npx tsx scripts/bench-scope-resolution.ts
|
||||
*/
|
||||
process.env.REGISTRY_PRIMARY_PYTHON = '1';
|
||||
|
||||
import { generateId } from '../src/lib/utils.js';
|
||||
import { createKnowledgeGraph } from '../src/core/graph/graph.js';
|
||||
import { runScopeResolution } from '../src/core/ingestion/scope-resolution/index.js';
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
/**
|
||||
* CI helper — emits the `MIGRATED_LANGUAGES` set as a JSON matrix array for
|
||||
* GitHub Actions (`.github/workflows/ci-scope-parity.yml`).
|
||||
*
|
||||
* Consumed by the `discover` job in that workflow. Each entry has:
|
||||
* - `slug`: lowercase language id, matching `test/integration/resolvers/<slug>.test.ts`.
|
||||
* - `envvar`: uppercase suffix used to build the `REGISTRY_PRIMARY_<envvar>` toggle.
|
||||
*
|
||||
* Run with `npx tsx scripts/ci-list-migrated-languages.ts`. The script
|
||||
* writes a single JSON array to stdout (no wrapper object) so the
|
||||
* workflow can pipe it straight into `$GITHUB_OUTPUT`.
|
||||
*/
|
||||
|
||||
import { MIGRATED_LANGUAGES } from '../src/core/ingestion/registry-primary-flag.js';
|
||||
|
||||
const entries = [...MIGRATED_LANGUAGES].map((slug) => {
|
||||
const s = String(slug);
|
||||
return {
|
||||
slug: s,
|
||||
envvar: s.toUpperCase().replace(/-/g, '_'),
|
||||
};
|
||||
});
|
||||
|
||||
process.stdout.write(JSON.stringify(entries));
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
/**
|
||||
* Consolidated scope-resolution parity runner.
|
||||
*
|
||||
* Replaces the per-language matrix in ci-scope-parity.yml with a single
|
||||
* job that runs all migrated languages sequentially in one process. This
|
||||
* eliminates 8× redundant checkout + npm ci + build cycles (the old
|
||||
* workflow created a separate GitHub Actions job per language).
|
||||
*
|
||||
* For each language in MIGRATED_LANGUAGES:
|
||||
* 1. Run its resolver test with REGISTRY_PRIMARY_<LANG>=0 (legacy DAG)
|
||||
* 2. Run its resolver test with REGISTRY_PRIMARY_<LANG>=1 (registry-primary)
|
||||
*
|
||||
* Both modes must pass. Failures are collected and reported at the end
|
||||
* so all regressions are visible in a single CI run (equivalent to the
|
||||
* old workflow's fail-fast: false behavior).
|
||||
*
|
||||
* Vitest output streams to the console in real time (stdio: 'inherit')
|
||||
* so CI logs show the actual test output directly. No per-invocation
|
||||
* timeout — the CI job-level timeout (30 min) is the outer guard.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/run-parity.ts
|
||||
* npx tsx scripts/run-parity.ts --language python # single language
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { MIGRATED_LANGUAGES } from '../src/core/ingestion/registry-primary-flag.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
interface ParityFailure {
|
||||
lang: string;
|
||||
mode: 'legacy' | 'registry-primary';
|
||||
}
|
||||
|
||||
function envVarName(slug: string): string {
|
||||
return `REGISTRY_PRIMARY_${slug.toUpperCase().replace(/-/g, '_')}`;
|
||||
}
|
||||
|
||||
function testFilePaths(slug: string): string[] {
|
||||
const resolverDir = path.resolve(ROOT, 'test/integration/resolvers');
|
||||
const files = fs.readdirSync(resolverDir);
|
||||
const direct = `${slug}.test.ts`;
|
||||
const prefixed = `${slug}-`;
|
||||
return files
|
||||
.filter((name) => name === direct || (name.startsWith(prefixed) && name.endsWith('.test.ts')))
|
||||
.sort()
|
||||
.map((name) => `test/integration/resolvers/${name}`);
|
||||
}
|
||||
|
||||
function runVitest(testFile: string, env: Record<string, string>): boolean {
|
||||
try {
|
||||
execFileSync('npx', ['vitest', 'run', testFile], {
|
||||
cwd: ROOT,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse CLI args
|
||||
const args = process.argv.slice(2);
|
||||
const langFlag = args.indexOf('--language');
|
||||
const singleLang = langFlag >= 0 ? args[langFlag + 1] : undefined;
|
||||
|
||||
if (langFlag >= 0 && singleLang === undefined) {
|
||||
console.error('--language requires a value');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const languages = singleLang ? [singleLang] : [...MIGRATED_LANGUAGES].map(String);
|
||||
|
||||
// Verify test files exist before running
|
||||
const missingFiles: string[] = [];
|
||||
const filesByLanguage = new Map<string, string[]>();
|
||||
for (const lang of languages) {
|
||||
const files = testFilePaths(lang);
|
||||
filesByLanguage.set(lang, files);
|
||||
if (files.length === 0) {
|
||||
missingFiles.push(`test/integration/resolvers/${lang}*.test.ts (${lang})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingFiles.length > 0) {
|
||||
console.error('Missing resolver test files:');
|
||||
for (const f of missingFiles) console.error(` ${f}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Scope-resolution parity: ${languages.length} language(s)`);
|
||||
console.log(`Languages: ${languages.join(', ')}\n`);
|
||||
|
||||
const failures: ParityFailure[] = [];
|
||||
|
||||
for (const lang of languages) {
|
||||
const files = filesByLanguage.get(lang) ?? [];
|
||||
const envVar = envVarName(lang);
|
||||
|
||||
console.log(`\n── ${lang} — legacy DAG (${envVar}=0) ──`);
|
||||
for (const file of files) {
|
||||
if (!runVitest(file, { [envVar]: '0' })) {
|
||||
failures.push({ lang, mode: 'legacy' });
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n── ${lang} — registry-primary (${envVar}=1) ──`);
|
||||
for (const file of files) {
|
||||
if (!runVitest(file, { [envVar]: '1' })) {
|
||||
failures.push({ lang, mode: 'registry-primary' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
const total = [...filesByLanguage.values()].reduce((sum, files) => sum + files.length * 2, 0);
|
||||
const passed = total - failures.length;
|
||||
|
||||
console.log('\n═══════════════════════════════════════');
|
||||
console.log('PARITY SUMMARY');
|
||||
console.log('═══════════════════════════════════════');
|
||||
console.log(`Passed: ${passed}/${total}`);
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.log(`\nFAILURES (${failures.length}):`);
|
||||
for (const f of failures) {
|
||||
console.log(` ✗ ${f.lang} [${f.mode}]`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\nAll parity checks passed.');
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -7,8 +7,8 @@
|
|||
* call-processor so that the classification logic lives in one place.
|
||||
*
|
||||
* Heritage (mixins: include/extend/prepend) was previously routed here
|
||||
* but is now handled by heritageExtractor.extractFromCall before the
|
||||
* call router runs. The router still returns 'skip' for these calls.
|
||||
* but is now emitted by the scope-resolution pipeline. The router still
|
||||
* returns 'skip' for these calls so they don't become spurious call edges.
|
||||
*
|
||||
* NOTE: This file is intentionally duplicated in gitnexus-web/ because the
|
||||
* two packages have separate build targets (Node native vs WASM/browser).
|
||||
|
|
@ -82,10 +82,10 @@ export function routeRubyCall(calledName: string, callNode: SyntaxNode): RubyCal
|
|||
return { kind: 'import', importPath, isRelative };
|
||||
}
|
||||
|
||||
// ── include / extend / prepend — heritage (now handled by heritageExtractor) ─
|
||||
// Call-based heritage is intercepted by heritageExtractor.extractFromCall
|
||||
// before the call router runs. Return SKIP_RESULT so these calls don't
|
||||
// fall through to normal call processing.
|
||||
// ── include / extend / prepend — heritage (emitted by scope-resolution) ─
|
||||
// Call-based heritage (Ruby mixins) is emitted by the scope-resolution
|
||||
// pipeline. Return SKIP_RESULT so these calls don't fall through to normal
|
||||
// call processing and become spurious call edges.
|
||||
if (calledName === 'include' || calledName === 'extend' || calledName === 'prepend') {
|
||||
return SKIP_RESULT;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,100 +78,3 @@ export interface CallExtractionConfig {
|
|||
*/
|
||||
typeAsReceiverHeuristic?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Call-resolution DAG types
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The call-resolution pipeline is a typed DAG:
|
||||
//
|
||||
// extract-call ──▶ classify-form ──▶ infer-receiver ──▶ select-dispatch ──▶ resolve-target ──▶ emit-edge
|
||||
//
|
||||
// Provider hooks plug in at infer-receiver and select-dispatch; shared stages
|
||||
// stay language-agnostic. Stages 1-2 run in the parse worker; stages 3-6 run
|
||||
// on the main thread. DAG-internal types below are main-thread-only and never
|
||||
// serialize to the graph.
|
||||
|
||||
/**
|
||||
* DAG stage 3 output: call record with receiver type and source discriminant.
|
||||
*
|
||||
* `receiverTypeName` is resolved via TypeEnv → constructor-map → class-as-receiver →
|
||||
* mixed-chain, or synthesized by `inferImplicitReceiver`. `receiverSource` tags
|
||||
* which path won and drives MRO strategy selection in stage 4.
|
||||
*
|
||||
* Invariants:
|
||||
* - `receiverSource` MUST match how `receiverTypeName` was resolved; every
|
||||
* discriminant must have a live reader and writer.
|
||||
* - `hint` is opaque to shared stages; only the same provider's `selectDispatch` reads it.
|
||||
*
|
||||
* @see language-provider.ts § inferImplicitReceiver, selectDispatch
|
||||
*/
|
||||
export interface ReceiverEnriched {
|
||||
readonly calledName: string;
|
||||
readonly callForm: 'free' | 'member' | 'constructor' | undefined;
|
||||
readonly receiverName: string | undefined;
|
||||
readonly receiverTypeName: string | undefined;
|
||||
readonly receiverSource:
|
||||
| 'none'
|
||||
| 'typed-binding'
|
||||
| 'constructor-map'
|
||||
| 'class-as-receiver'
|
||||
| 'mixed-chain'
|
||||
| 'implicit-self';
|
||||
/** Free-form hint from the provider hook; opaque to shared stages. */
|
||||
readonly hint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider hook output for `LanguageProvider.inferImplicitReceiver` (DAG stage 3).
|
||||
*
|
||||
* Overlay applied to `ReceiverEnriched` when an implicit receiver is synthesized.
|
||||
* Ruby example: bare `serialize` inside `Account#call_serialize` →
|
||||
* `{ callForm: 'member', receiverName: 'self', receiverTypeName: 'Account',
|
||||
* receiverSource: 'implicit-self', hint: 'instance' }`
|
||||
*
|
||||
* Invariants:
|
||||
* - `receiverSource` is always `'implicit-self'` — the only variant this type produces.
|
||||
* - `callForm` is always `'member'` — the rewrite converts bare-call to method invocation.
|
||||
* - `hint` is opaque to shared stages; consumed by the same language's `selectDispatch`.
|
||||
*/
|
||||
export interface ImplicitReceiverOverride {
|
||||
readonly callForm: 'free' | 'member' | 'constructor';
|
||||
readonly receiverName: string;
|
||||
readonly receiverTypeName: string;
|
||||
readonly receiverSource: Extract<ReceiverEnriched['receiverSource'], 'implicit-self'>;
|
||||
/** Free-form language tag (e.g. Ruby sets 'singleton' for `def self.foo`
|
||||
* method bodies). Consumed by the same language's `selectDispatch` hook. */
|
||||
readonly hint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DAG stage 4 output: dispatch strategy for resolving the target method.
|
||||
*
|
||||
* Encodes which resolver branch to try first and an optional fallback.
|
||||
* Stage 5 delegates to `resolveMemberCall`, `resolveFreeCall`, or
|
||||
* `resolveStaticCall` based on `primary`.
|
||||
*
|
||||
* - `primary`: `'owner-scoped'` = MRO walk, `'free'` = arity-tiered global lookup,
|
||||
* `'constructor'` = type instantiation.
|
||||
* - `fallback`: Only `'free-arity-narrowed'` exists; used by Ruby implicit-self
|
||||
* to degrade to arity-tiered free lookup when the MRO walk misses.
|
||||
* - `ancestryView`: Ruby `'ruby-mixin'` only. `'singleton'` walks extend providers
|
||||
* only; a miss NEVER falls through to file-scoped lookup (enforced in
|
||||
* resolveCallTarget). `'instance'` is the default.
|
||||
*
|
||||
* Common patterns:
|
||||
* - `{primary: 'constructor'}` — constructor call
|
||||
* - `{primary: 'owner-scoped'}` — member call with known type
|
||||
* - `{primary: 'owner-scoped', fallback: 'free-arity-narrowed', ancestryView: 'instance'}` — Ruby implicit-self
|
||||
* - `{primary: 'owner-scoped', ancestryView: 'singleton'}` — Ruby class-method call
|
||||
*
|
||||
* @see language-provider.ts § selectDispatch
|
||||
* @see call-processor.ts § defaultDispatchDecision, resolveCallTarget
|
||||
*/
|
||||
export interface DispatchDecision {
|
||||
readonly primary: 'owner-scoped' | 'free' | 'constructor';
|
||||
readonly fallback?: 'free-arity-narrowed';
|
||||
readonly ancestryView?: 'instance' | 'singleton';
|
||||
readonly hint?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ export function finalizeScopeModel(
|
|||
|
||||
// ── Step 3: MethodDispatchIndex. Today we lack per-language MRO
|
||||
// strategies wired into this orchestrator (that belongs with the
|
||||
// HeritageMap bridge, a separate piece of work). Ship an EMPTY index
|
||||
// MRO bridge, a separate piece of work). Ship an EMPTY index
|
||||
// so the bundle shape is consistent; the callbacks return `[]` for
|
||||
// every owner and `implementsOf` returns `[]`. Populating this
|
||||
// properly is tracked alongside the per-language provider hooks.
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
// gitnexus/src/core/ingestion/heritage-extractors/configs/cpp.ts
|
||||
|
||||
import type { SupertypeShapeDescriptor } from '../../heritage-types.js';
|
||||
|
||||
/**
|
||||
* C++ base-class supertype shapes (legacy CPP_QUERIES bank only).
|
||||
*
|
||||
* A `base_class_clause` entry can be a bare `type_identifier`, a
|
||||
* `template_type` (`Base<T>`), or a `qualified_identifier` (`ns::Base`,
|
||||
* possibly itself wrapping a `template_type`). Entries may be prefixed by an
|
||||
* `access_specifier`; tree-sitter's bracketed alternation matches the base
|
||||
* node regardless of the preceding access specifier, so a single pattern
|
||||
* covers both `: Base` and `: public Base`.
|
||||
*
|
||||
* NOTE: the registry/scope-resolution path emits its own normalized
|
||||
* `@reference.inherits` captures (see languages/cpp/captures.ts); this
|
||||
* descriptor only repairs the legacy heritage-query bank.
|
||||
*/
|
||||
export const cppHeritageShapes: SupertypeShapeDescriptor = {
|
||||
shapes: ['type_identifier', 'template_type', 'qualified_identifier'],
|
||||
};
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
// gitnexus/src/core/ingestion/heritage-extractors/configs/csharp.ts
|
||||
|
||||
import type { SupertypeShapeDescriptor } from '../../heritage-types.js';
|
||||
|
||||
/**
|
||||
* C# base-list supertype shapes.
|
||||
*
|
||||
* Every `base_list` entry (class/record/struct/interface) is captured as
|
||||
* `@heritage.extends`; EXTENDS-vs-IMPLEMENTS is decided downstream by
|
||||
* `resolveExtendsType`, so the query does not pre-split. Entries can be a bare
|
||||
* `identifier`, a `generic_name` (`IFoo<T>`), a `qualified_name` (`ns.Base`,
|
||||
* and also `global::System.IDisposable` — a dotted name qualified by an
|
||||
* `alias_qualified_name` parses as a `qualified_name` whose first part is the
|
||||
* alias), an `alias_qualified_name` (a *bare* alias-qualified base with no
|
||||
* dotted suffix: `global::IDisposable`, `MyAlias::Foo`), or a
|
||||
* `primary_constructor_base_type` (`Base(args)` on a record).
|
||||
*
|
||||
* NOTE: `scoped_type` is intentionally NOT listed. Per
|
||||
* tree-sitter-c-sharp/src/node-types.json, `scoped_type` is a `type` subtype
|
||||
* that wraps a `ref_type` (the `scoped ref`/`scoped in` parameter modifier);
|
||||
* it is referenced only by the hidden `type` supertype and never appears as a
|
||||
* `base_list` entry, so adding it would be a dead, untested shape. The
|
||||
* alias-qualified base shapes that *do* occur are `qualified_name` (dotted) and
|
||||
* `alias_qualified_name` (bare) — verified by parsing
|
||||
* `class A : System.Exception, global::System.IDisposable, MyAlias::Foo {}`.
|
||||
* `normalizeSupertypeName` collapses `alias_qualified_name` to the simple name
|
||||
* via its `name` field (an `identifier`). See
|
||||
* `test/integration/heritage-supertype-shapes.test.ts`.
|
||||
*/
|
||||
export const csharpHeritageShapes: SupertypeShapeDescriptor = {
|
||||
shapes: [
|
||||
'identifier',
|
||||
'generic_name',
|
||||
'qualified_name',
|
||||
'alias_qualified_name',
|
||||
'primary_constructor_base_type',
|
||||
],
|
||||
};
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
// gitnexus/src/core/ingestion/heritage-extractors/configs/go.ts
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { HeritageExtractionConfig, SupertypeShapeDescriptor } from '../../heritage-types.js';
|
||||
|
||||
/**
|
||||
* Go embed supertype shapes.
|
||||
*
|
||||
* Struct embedding (anonymous `field_declaration` type) and interface-in-
|
||||
* interface embedding (`interface_type → type_elem`) can both name a bare
|
||||
* `type_identifier`, a `qualified_type` (`pkg.Base`), or a `generic_type`
|
||||
* (`Gen[T]`). Named struct fields also match the field pattern and are
|
||||
* filtered out at runtime by {@link goHeritageConfig.shouldSkipExtends}.
|
||||
*/
|
||||
export const goHeritageShapes: SupertypeShapeDescriptor = {
|
||||
shapes: ['type_identifier', 'qualified_type', 'generic_type'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Go heritage extraction config.
|
||||
*
|
||||
* Go struct embedding: the tree-sitter query matches ALL field_declarations
|
||||
* with type_identifier, but only anonymous fields (no name) are embedded.
|
||||
* Named fields like `Breed string` also match — skip them.
|
||||
*
|
||||
* The shouldSkipExtends hook checks if the extends node's parent is a
|
||||
* field_declaration with a named field child, indicating a regular
|
||||
* (non-embedded) field that should not produce a heritage record.
|
||||
*
|
||||
* It also skips type-set constraint operands. An interface constraint like
|
||||
* `interface { int | float64 }` parses as an `interface_type` containing a
|
||||
* single `type_elem` whose named children are the union operands (`int`,
|
||||
* `float64`), separated by unnamed `|` tokens — each operand reaches its
|
||||
* `type_elem` parent directly via `.parent` (no intermediate binary node).
|
||||
* These operands are NOT embedded supertypes, so a multi-operand `type_elem`
|
||||
* (more than one named child) is skipped.
|
||||
*
|
||||
* Residual: a single-element `type_elem` (`interface { ~int }` or
|
||||
* `interface { SomeConstraint }`) is structurally indistinguishable from a
|
||||
* genuine interface embed by element count alone, so it is left to match. This
|
||||
* is acceptable — a one-element type-set is rare in real Go and a spurious
|
||||
* embed edge to a builtin/constraint name is harmless (it resolves to nothing).
|
||||
*/
|
||||
export const goHeritageConfig: HeritageExtractionConfig = {
|
||||
language: SupportedLanguages.Go,
|
||||
|
||||
shouldSkipExtends(extendsNode) {
|
||||
const parent = extendsNode.parent;
|
||||
if (parent == null) return false;
|
||||
// Named struct field (e.g. `Breed string`) — not an embed.
|
||||
if (parent.type === 'field_declaration' && parent.childForFieldName?.('name') != null) {
|
||||
return true;
|
||||
}
|
||||
// Multi-element interface type-set (`int | float64`) — constraint operands,
|
||||
// not embeds. Single-element type_elem is left to match (see JSDoc residual).
|
||||
if (parent.type === 'type_elem' && parent.namedChildCount > 1) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
// gitnexus/src/core/ingestion/heritage-extractors/configs/java.ts
|
||||
|
||||
import type { SupertypeShapeDescriptor } from '../../heritage-types.js';
|
||||
|
||||
/**
|
||||
* Java supertype shapes.
|
||||
*
|
||||
* A Java extends/implements position (`superclass`, `super_interfaces →
|
||||
* type_list`, and `interface_declaration → extends_interfaces → type_list`)
|
||||
* can hold a bare `type_identifier`, a `generic_type` (`Foo<T>`), or a
|
||||
* `scoped_type_identifier` (`pkg.Foo`). The grammar's `_type` is a hidden
|
||||
* supertype, so the concrete shapes are enumerated rather than matching `_type`.
|
||||
*/
|
||||
export const javaHeritageShapes: SupertypeShapeDescriptor = {
|
||||
shapes: ['type_identifier', 'generic_type', 'scoped_type_identifier'],
|
||||
};
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
// gitnexus/src/core/ingestion/heritage-extractors/configs/javascript.ts
|
||||
|
||||
import type { SupertypeShapeDescriptor } from '../../heritage-types.js';
|
||||
|
||||
/**
|
||||
* JavaScript supertype shapes.
|
||||
*
|
||||
* `class_heritage` directly holds the parent expression: a bare `identifier`
|
||||
* or a `member_expression` (qualified `ns.Base`).
|
||||
*/
|
||||
export const javascriptHeritageShapes: SupertypeShapeDescriptor = {
|
||||
shapes: ['identifier', 'member_expression'],
|
||||
};
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
// gitnexus/src/core/ingestion/heritage-extractors/configs/kotlin.ts
|
||||
|
||||
import type { SupertypeShapeDescriptor } from '../../heritage-types.js';
|
||||
|
||||
/**
|
||||
* Kotlin delegation-specifier supertype shapes.
|
||||
*
|
||||
* The supertype node nested under a `delegation_specifier` is a `user_type`
|
||||
* (`: Bar`), a `constructor_invocation` (`: Bar()`), or an
|
||||
* `explicit_delegation` (`: Bar by baz`). For the delegation form the inner
|
||||
* `user_type` (the `Bar`) is the supertype, not the delegate expression — the
|
||||
* runtime normalizer descends into it.
|
||||
*/
|
||||
export const kotlinHeritageShapes: SupertypeShapeDescriptor = {
|
||||
shapes: ['user_type', 'constructor_invocation', 'explicit_delegation'],
|
||||
};
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
// gitnexus/src/core/ingestion/heritage-extractors/configs/python.ts
|
||||
|
||||
import type { SupertypeShapeDescriptor } from '../../heritage-types.js';
|
||||
|
||||
/**
|
||||
* Python superclass shapes.
|
||||
*
|
||||
* `class_definition → superclasses (argument_list)` entries can be a bare
|
||||
* `identifier`, an `attribute` (qualified `models.Model` — take `.attribute`),
|
||||
* or a `subscript` (`Generic[T]` — take `.value`).
|
||||
*/
|
||||
export const pythonHeritageShapes: SupertypeShapeDescriptor = {
|
||||
shapes: ['identifier', 'attribute', 'subscript'],
|
||||
};
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
// gitnexus/src/core/ingestion/heritage-extractors/configs/ruby.ts
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type {
|
||||
HeritageExtractionConfig,
|
||||
HeritageInfo,
|
||||
SupertypeShapeDescriptor,
|
||||
} from '../../heritage-types.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
/**
|
||||
* Ruby `class A < B` superclass shapes, and the class-name shapes for
|
||||
* `class Foo::Bar`. Both positions accept a bare `constant` or a
|
||||
* `scope_resolution` (`Base::Sup` / `Foo::Bar`); the normalizer reduces the
|
||||
* scope_resolution to its trailing constant.
|
||||
*/
|
||||
export const rubyHeritageShapes: SupertypeShapeDescriptor = {
|
||||
shapes: ['constant', 'scope_resolution'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Maximum parent depth for enclosing class/module walk.
|
||||
* Prevents runaway walks on malformed/deeply-nested ASTs.
|
||||
*/
|
||||
const MAX_PARENT_DEPTH = 50;
|
||||
|
||||
/**
|
||||
* Walk up the AST from a call node to find the enclosing class or module name.
|
||||
* Ruby include/extend/prepend calls must be inside a class or module body.
|
||||
*/
|
||||
function findEnclosingClassName(callNode: SyntaxNode): string | null {
|
||||
let current = callNode.parent;
|
||||
let depth = 0;
|
||||
while (current && ++depth <= MAX_PARENT_DEPTH) {
|
||||
if (current.type === 'class' || current.type === 'module') {
|
||||
const nameNode = current.childForFieldName?.('name');
|
||||
if (nameNode) return nameNode.text;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Ruby heritage call names that express mixin inclusion. */
|
||||
const RUBY_HERITAGE_CALL_NAMES: ReadonlySet<string> = new Set(['include', 'extend', 'prepend']);
|
||||
|
||||
/**
|
||||
* Ruby heritage extraction config.
|
||||
*
|
||||
* Ruby expresses inheritance in two ways, and only one of them has
|
||||
* dedicated tree-sitter heritage captures:
|
||||
*
|
||||
* 1. Class inheritance (`class A < B`) produces standard
|
||||
* `@heritage.extends` captures and flows through the generic
|
||||
* capture-based `extract` hook (not defined here — the factory
|
||||
* handles it).
|
||||
* 2. Mixin calls (`include`/`extend`/`prepend`) have no dedicated
|
||||
* heritage captures; they surface as ordinary call sites. The
|
||||
* `callBasedHeritage` hook below intercepts them before the call
|
||||
* router, absorbing the mixin routing logic that previously lived
|
||||
* in call-routing.ts (routeRubyCall).
|
||||
*/
|
||||
export const rubyHeritageConfig: HeritageExtractionConfig = {
|
||||
language: SupportedLanguages.Ruby,
|
||||
|
||||
callBasedHeritage: {
|
||||
callNames: RUBY_HERITAGE_CALL_NAMES,
|
||||
|
||||
extract(calledName, callNode, _filePath): HeritageInfo[] {
|
||||
const enclosingClass = findEnclosingClassName(callNode);
|
||||
if (!enclosingClass) return [];
|
||||
|
||||
const results: HeritageInfo[] = [];
|
||||
const argList = callNode.childForFieldName?.('arguments');
|
||||
for (const arg of argList?.children ?? []) {
|
||||
if (arg.type === 'constant' || arg.type === 'scope_resolution') {
|
||||
results.push({
|
||||
className: enclosingClass,
|
||||
parentName: arg.text,
|
||||
kind: calledName, // 'include' | 'extend' | 'prepend'
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
// gitnexus/src/core/ingestion/heritage-extractors/configs/rust.ts
|
||||
|
||||
import type { SupertypeShapeDescriptor } from '../../heritage-types.js';
|
||||
|
||||
/**
|
||||
* Rust trait-impl supertype shapes.
|
||||
*
|
||||
* An `impl_item` trait position can be `type_identifier`, `generic_type`
|
||||
* (`Trait<T>`), or `scoped_type_identifier` (`ns::Trait`). The innermost
|
||||
* `name` field is the trait name.
|
||||
*/
|
||||
export const rustHeritageShapes: SupertypeShapeDescriptor = {
|
||||
shapes: ['type_identifier', 'generic_type', 'scoped_type_identifier'],
|
||||
};
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
// gitnexus/src/core/ingestion/heritage-extractors/configs/typescript.ts
|
||||
|
||||
import type { SupertypeShapeDescriptor } from '../../heritage-types.js';
|
||||
|
||||
/**
|
||||
* TypeScript supertype shapes.
|
||||
*
|
||||
* The class `extends_clause` value is an expression — `identifier` or
|
||||
* `member_expression` (qualified `ns.Base`); generics ride a separate
|
||||
* `type_arguments` field, so there is no `generic_type` here. The class
|
||||
* `implements_clause` and the `interface_declaration → extends_type_clause`
|
||||
* use type-space nodes: `type_identifier`, `generic_type`, and
|
||||
* `nested_type_identifier` (`ns.Base`).
|
||||
*/
|
||||
|
||||
/** Shapes valid in a class `extends_clause` value position. */
|
||||
export const typescriptExtendsShapes: SupertypeShapeDescriptor = {
|
||||
shapes: ['identifier', 'member_expression'],
|
||||
};
|
||||
|
||||
/** Shapes valid in `implements_clause` / interface `extends_type_clause`. */
|
||||
export const typescriptInterfaceShapes: SupertypeShapeDescriptor = {
|
||||
shapes: ['type_identifier', 'generic_type', 'nested_type_identifier'],
|
||||
};
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
// gitnexus/src/core/ingestion/heritage-extractors/generic.ts
|
||||
|
||||
/**
|
||||
* Generic table-driven heritage extractor factory.
|
||||
*
|
||||
* Follows the same config+factory pattern as method-extractors/generic.ts,
|
||||
* field-extractors/generic.ts, call-extractors/generic.ts, and
|
||||
* variable-extractors/generic.ts.
|
||||
*
|
||||
* Languages with custom extraction hooks (Go: shouldSkipExtends, Ruby:
|
||||
* callBasedHeritage) pass a full HeritageExtractionConfig. Languages
|
||||
* that use the default capture-based extraction can pass just the
|
||||
* SupportedLanguages enum value — no per-language config file needed.
|
||||
*/
|
||||
|
||||
import type { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { CaptureMap } from '../language-provider.js';
|
||||
import type {
|
||||
HeritageExtractionConfig,
|
||||
HeritageExtractor,
|
||||
HeritageExtractorContext,
|
||||
HeritageInfo,
|
||||
} from '../heritage-types.js';
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import { normalizeSupertypeName } from './supertype-alternation.js';
|
||||
|
||||
/**
|
||||
* Create a HeritageExtractor from a declarative config or a language enum.
|
||||
*
|
||||
* When a full HeritageExtractionConfig is provided, custom hooks
|
||||
* (shouldSkipExtends, callBasedHeritage) drive the extraction.
|
||||
* When only a SupportedLanguages value is provided, the factory produces
|
||||
* a default extractor that handles the standard @heritage.* captures.
|
||||
*/
|
||||
export function createHeritageExtractor(
|
||||
config: HeritageExtractionConfig | SupportedLanguages,
|
||||
): HeritageExtractor {
|
||||
const actualConfig: HeritageExtractionConfig =
|
||||
typeof config === 'string' ? { language: config } : config;
|
||||
const callNameSet = actualConfig.callBasedHeritage?.callNames;
|
||||
|
||||
return {
|
||||
language: actualConfig.language,
|
||||
|
||||
extract(captureMap: CaptureMap, context: HeritageExtractorContext): HeritageInfo[] {
|
||||
const classNode = captureMap['heritage.class'];
|
||||
if (!classNode) return [];
|
||||
|
||||
// Normalize the declared class name too: most grammars expose a plain
|
||||
// identifier here (text == normalized), but a few use a qualified/scoped
|
||||
// node (e.g. Ruby `class Foo::Bar`), which must collapse to the simple
|
||||
// name so it matches the symbol table.
|
||||
const className = normalizeSupertypeName(classNode);
|
||||
if (!className) return [];
|
||||
const results: HeritageInfo[] = [];
|
||||
|
||||
const extendsNode = captureMap['heritage.extends'];
|
||||
if (extendsNode) {
|
||||
if (!actualConfig.shouldSkipExtends?.(extendsNode)) {
|
||||
const parentName = normalizeSupertypeName(extendsNode);
|
||||
if (parentName) results.push({ className, parentName, kind: 'extends' });
|
||||
}
|
||||
}
|
||||
|
||||
const implementsNode = captureMap['heritage.implements'];
|
||||
if (implementsNode) {
|
||||
const parentName = normalizeSupertypeName(implementsNode);
|
||||
if (parentName) results.push({ className, parentName, kind: 'implements' });
|
||||
}
|
||||
|
||||
const traitNode = captureMap['heritage.trait'];
|
||||
if (traitNode) {
|
||||
const parentName = normalizeSupertypeName(traitNode);
|
||||
if (parentName) results.push({ className, parentName, kind: 'trait-impl' });
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
|
||||
...(callNameSet
|
||||
? {
|
||||
extractFromCall(
|
||||
calledName: string,
|
||||
callNode: SyntaxNode,
|
||||
context: HeritageExtractorContext,
|
||||
): HeritageInfo[] | null {
|
||||
if (!callNameSet.has(calledName)) return null;
|
||||
return actualConfig.callBasedHeritage!.extract(calledName, callNode, context.filePath);
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,235 +0,0 @@
|
|||
// gitnexus/src/core/ingestion/heritage-extractors/supertype-alternation.ts
|
||||
|
||||
/**
|
||||
* Shared, language-agnostic heritage supertype handling.
|
||||
*
|
||||
* Two halves of the same contract live here:
|
||||
*
|
||||
* 1. {@link buildSupertypeAlternation} — given a per-language shape descriptor
|
||||
* (the set of tree-sitter node-type shapes a supertype can take), returns
|
||||
* the tree-sitter S-expression alternation fragment that captures any of
|
||||
* them under a single tag, e.g.
|
||||
* `[(type_identifier) (generic_type) (scoped_type_identifier)] @heritage.extends`
|
||||
* Idiomatic tree-sitter alternation is `[(a) (b) (c)]` (one-of), which the
|
||||
* heritage query blocks in tree-sitter-queries.ts interpolate inline.
|
||||
*
|
||||
* 2. {@link normalizeSupertypeName} — given the supertype node that actually
|
||||
* matched, reduces it to the INNERMOST simple identifier. Generics
|
||||
* (`Base<T>`), qualified/scoped names (`pkg.Base`, `ns::Base`), and
|
||||
* delegation wrappers (`Bar by baz`) all collapse to the bare name
|
||||
* (`Base` / `Bar`). This mirrors the C++ registry path
|
||||
* (languages/cpp/captures.ts `extractBaseLookupName`) so that the V1
|
||||
* simple-name `ctx.resolve(name)` contract keeps holding for every
|
||||
* language — no `pkg.Base` or `Base<T>` ever reaches resolution.
|
||||
*
|
||||
* No language names appear in this file. Both functions are parameterized by
|
||||
* node-type data (the descriptor and the matched node's own `.type`), per the
|
||||
* shared-ingestion rule in AGENTS.md.
|
||||
*/
|
||||
|
||||
import type { SupertypeShapeDescriptor } from '../heritage-types.js';
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
|
||||
/**
|
||||
* Build a tree-sitter alternation fragment capturing any of the descriptor's
|
||||
* supertype shapes under `tag`.
|
||||
*
|
||||
* A single shape produces `(shape) @tag`; multiple shapes produce the
|
||||
* bracketed one-of `[(a) (b) …] @tag`. Duplicate shapes are de-duplicated so
|
||||
* callers can compose shape lists freely. The returned string is a fragment
|
||||
* meant to be embedded inside a larger container pattern.
|
||||
*/
|
||||
export function buildSupertypeAlternation(
|
||||
descriptor: SupertypeShapeDescriptor,
|
||||
tag: string,
|
||||
): string {
|
||||
const seen = new Set<string>();
|
||||
const unique: string[] = [];
|
||||
for (const shape of descriptor.shapes) {
|
||||
if (!seen.has(shape)) {
|
||||
seen.add(shape);
|
||||
unique.push(shape);
|
||||
}
|
||||
}
|
||||
if (unique.length === 0) {
|
||||
throw new Error('buildSupertypeAlternation: descriptor has no shapes');
|
||||
}
|
||||
const exprs = unique.map((shape) => `(${shape})`);
|
||||
const oneOf = exprs.length === 1 ? exprs[0] : `[${exprs.join(' ')}]`;
|
||||
return `${oneOf} @${tag}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field names that, when present, point at the meaningful inner part of a
|
||||
* qualified / generic / scoped / attribute / delegation supertype node. Tried
|
||||
* in order; the first that resolves to a child wins. Mirrors the cpp
|
||||
* `getBaseClassName`/`extractBaseLookupName` field preferences but covers the
|
||||
* union of fields used across grammars:
|
||||
* - name : generic_type, generic_name, scoped_type_identifier,
|
||||
* qualified_name, qualified_identifier, qualified_type,
|
||||
* template_type, nested_type_identifier
|
||||
* - type : Go generic_type, C# primary_constructor_base_type, Rust generic_type
|
||||
* - property : TS/JS member_expression (qualified `ns.Base`)
|
||||
* - attribute : Python attribute (`models.Model`)
|
||||
* - value : Python subscript (`Generic[T]`)
|
||||
*/
|
||||
const INNER_NAME_FIELDS = ['name', 'type', 'property', 'attribute', 'value'] as const;
|
||||
|
||||
/**
|
||||
* Read-only snapshots of the four module-private node-type sets that drive
|
||||
* {@link normalizeSupertypeName}'s branch selection. Exported ONLY so a unit
|
||||
* test can enumerate the real members and assert each one still fires the
|
||||
* branch it documents — a typo'd/removed/extra member would otherwise fall
|
||||
* through silently. Not part of the runtime contract; do not consume in
|
||||
* production code.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export const SUPERTYPE_NODE_TYPE_SETS = {
|
||||
innerNameFields: INNER_NAME_FIELDS,
|
||||
get leafTypes(): ReadonlySet<string> {
|
||||
return LEAF_TYPES;
|
||||
},
|
||||
get skippedInnerTypes(): ReadonlySet<string> {
|
||||
return SKIPPED_INNER_TYPES;
|
||||
},
|
||||
get leadingNameTypes(): ReadonlySet<string> {
|
||||
return LEADING_NAME_TYPES;
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** Node types whose own `.text` is already the simple identifier. */
|
||||
const LEAF_TYPES: ReadonlySet<string> = new Set([
|
||||
'type_identifier',
|
||||
'identifier',
|
||||
'constant',
|
||||
'field_identifier',
|
||||
'namespace_identifier',
|
||||
'package_identifier',
|
||||
'simple_identifier',
|
||||
'property_identifier',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Child node types to skip during the children-walk fallback: generic
|
||||
* argument lists (hold type params, not the name) and delegate/call subtrees.
|
||||
*
|
||||
* `value_arguments` covers the Kotlin `constructor_invocation` shape
|
||||
* (`: Bar()` → `constructor_invocation` wrapping `user_type` + `value_arguments`):
|
||||
* the right-to-left walk would otherwise land on the argument list first, so
|
||||
* skipping it lets the walk fall through to the leading `user_type`. This is
|
||||
* the intentional handling for `constructor_invocation` — it is deliberately
|
||||
* NOT a leading-name type (see {@link LEADING_NAME_TYPES}), because its name is
|
||||
* still recovered by the trailing-name walk once the arguments are skipped.
|
||||
*/
|
||||
const SKIPPED_INNER_TYPES: ReadonlySet<string> = new Set([
|
||||
'type_arguments',
|
||||
'type_argument_list',
|
||||
'template_argument_list',
|
||||
'argument_list',
|
||||
'value_arguments',
|
||||
'call_expression',
|
||||
'call_suffix',
|
||||
'annotated_lambda',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Node types whose supertype name is their FIRST named child rather than their
|
||||
* last. The trailing-name walk is correct for qualified/scoped shapes
|
||||
* (qualifier-first, name-last), but some wrappers put the supertype first and a
|
||||
* delegate expression after it.
|
||||
*
|
||||
* Kotlin `explicit_delegation` (`: Bar by baz`, `by baz.qux`, `by baz()`) has
|
||||
* shape `(user_type) (by) (<delegate-expression>)`: the supertype is the
|
||||
* leading `user_type`, and the delegate (which can be an identifier, a
|
||||
* navigation `baz.qux`, or a call `baz()`) trails it. A plain right-to-left
|
||||
* walk would pick the delegate's trailing name (`qux` / `baz`) instead of the
|
||||
* supertype, so these node types recurse into their first named child only.
|
||||
*
|
||||
* Structural, not language-named: any grammar exposing a leading-name wrapper
|
||||
* can be added here.
|
||||
*/
|
||||
const LEADING_NAME_TYPES: ReadonlySet<string> = new Set(['explicit_delegation']);
|
||||
|
||||
/** Guard against pathological/cyclic ASTs while descending into a supertype. */
|
||||
const MAX_NORMALIZE_DEPTH = 24;
|
||||
|
||||
/**
|
||||
* Reduce a matched supertype node to its innermost simple name.
|
||||
*
|
||||
* Strategy (node-type-driven, matching the cpp reference):
|
||||
* 1. Leaf identifier types return `.text` directly.
|
||||
* 2. Try field-based access (name/type/property/attribute/value) and recurse
|
||||
* into the first field that resolves. Some grammars expose the parts only
|
||||
* via fields (Java generic_type→name, Go qualified_type→name, etc.).
|
||||
* 3. Leading-name wrappers ({@link LEADING_NAME_TYPES}, e.g. Kotlin
|
||||
* `explicit_delegation` `Bar by baz`) carry the supertype as their FIRST
|
||||
* named child and a delegate expression after it — recurse into the first
|
||||
* child so the delegate's name never wins.
|
||||
* 4. Fall back to a children walk when fields are empty (e.g. C++
|
||||
* qualified_identifier in 0.23.x can carry the name only as a child). The
|
||||
* LAST named child is preferred because qualified/scoped shapes put the
|
||||
* qualifier first and the actual name last; delegate/argument subtrees
|
||||
* ({@link SKIPPED_INNER_TYPES}) are skipped so e.g. a Kotlin
|
||||
* `constructor_invocation` (`Bar()`) resolves to `Bar`.
|
||||
*/
|
||||
export function normalizeSupertypeName(node: SyntaxNode | null | undefined): string {
|
||||
return normalize(node, 0);
|
||||
}
|
||||
|
||||
function normalize(node: SyntaxNode | null | undefined, depth: number): string {
|
||||
if (!node || depth > MAX_NORMALIZE_DEPTH) return '';
|
||||
|
||||
if (LEAF_TYPES.has(node.type)) {
|
||||
return node.text;
|
||||
}
|
||||
|
||||
// Field-based access first — most grammars expose the inner name via a field.
|
||||
for (const field of INNER_NAME_FIELDS) {
|
||||
const child = node.childForFieldName?.(field);
|
||||
if (child) {
|
||||
const inner = normalize(child, depth + 1);
|
||||
if (inner.length > 0) return inner;
|
||||
}
|
||||
}
|
||||
|
||||
// Leading-name wrappers (e.g. Kotlin `explicit_delegation`: `Bar by baz`)
|
||||
// put the supertype FIRST and a delegate expression after it. Recurse into
|
||||
// the first named child only so we pick `Bar`, never the delegate's name.
|
||||
if (LEADING_NAME_TYPES.has(node.type)) {
|
||||
const first = node.namedChild(0);
|
||||
const inner = normalize(first, depth + 1);
|
||||
if (inner.length > 0) return inner;
|
||||
}
|
||||
|
||||
// Children fallback: walk named children right-to-left so qualified/scoped
|
||||
// shapes (qualifier first, name last) resolve to the trailing name.
|
||||
for (let i = node.namedChildCount - 1; i >= 0; i--) {
|
||||
const child = node.namedChild(i);
|
||||
if (!child) continue;
|
||||
// Skip generic-argument lists and delegate/call subtrees — they hold
|
||||
// type arguments or the delegate expression, not the supertype name.
|
||||
// (Kotlin `constructor_invocation`/`explicit_delegation` wrap the
|
||||
// user_type first and a value_arguments / call_expression second.)
|
||||
if (SKIPPED_INNER_TYPES.has(child.type)) continue;
|
||||
const inner = normalize(child, depth + 1);
|
||||
if (inner.length > 0) return inner;
|
||||
}
|
||||
|
||||
// Last resort: trim obvious generic/qualifier syntax from the raw text so we
|
||||
// never leak `Base<T>` / `pkg.Base` / `ns::Base` to downstream resolution.
|
||||
return simplifyRawName(node.text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort textual fallback when the AST shape is unrecognized: drop any
|
||||
* generic argument list and keep the final qualified segment.
|
||||
*
|
||||
* Exported for unit coverage of the raw-name reduction (`Base<T>` → `Base`,
|
||||
* `pkg.Base` → `Base`, `ns::Base` → `Base`).
|
||||
*/
|
||||
export function simplifyRawName(text: string): string {
|
||||
const withoutGenerics = text.replace(/[<\[].*$/s, '').trim();
|
||||
const segments = withoutGenerics.split(/::|\./);
|
||||
return segments[segments.length - 1]?.trim() ?? '';
|
||||
}
|
||||
|
|
@ -1,499 +0,0 @@
|
|||
/**
|
||||
* Heritage Processor
|
||||
*
|
||||
* Extracts class inheritance relationships:
|
||||
* - EXTENDS: Class extends another Class (TS, JS, Python, C#, C++)
|
||||
* - IMPLEMENTS: Class implements an Interface (TS, C#, Java, Kotlin, PHP)
|
||||
*
|
||||
* Languages like C# use a single `base_list` for both class and interface parents.
|
||||
* We resolve the correct edge type by checking the symbol table: if the parent is
|
||||
* registered as an Interface, we emit IMPLEMENTS; otherwise EXTENDS. For unresolved
|
||||
* external symbols, the fallback heuristic is language-gated:
|
||||
* - C# / Java: apply the `I[A-Z]` naming convention (e.g. IDisposable → IMPLEMENTS)
|
||||
* - Swift: default to IMPLEMENTS (protocol conformance is more common than class inheritance)
|
||||
* - All other languages: default to EXTENDS
|
||||
*/
|
||||
|
||||
import { KnowledgeGraph } from '../graph/types.js';
|
||||
import { ASTCache } from './ast-cache.js';
|
||||
import Parser from 'tree-sitter';
|
||||
import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
|
||||
import { generateId } from '../../lib/utils.js';
|
||||
import { getLanguageFromFilename, type NodeLabel, type SupportedLanguages } from 'gitnexus-shared';
|
||||
import { isRegistryPrimary } from './registry-primary-flag.js';
|
||||
import { isVerboseIngestionEnabled } from './utils/verbose.js';
|
||||
import { yieldToEventLoop } from './utils/event-loop.js';
|
||||
import { parseSourceSafe } from '../tree-sitter/safe-parse.js';
|
||||
import { getProvider } from './languages/index.js';
|
||||
import { getTreeSitterBufferSize } from './constants.js';
|
||||
import type {
|
||||
ExtractedHeritage,
|
||||
HeritageResolutionStrategy,
|
||||
HeritageStrategyLookup,
|
||||
} from './model/heritage-map.js';
|
||||
import { resolveExtendsType } from './model/heritage-map.js';
|
||||
import type { ResolutionContext } from './model/resolution-context.js';
|
||||
import { TIER_CONFIDENCE } from './model/resolution-context.js';
|
||||
import type { HeritageInfo } from './heritage-types.js';
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
/**
|
||||
* Derive the heritage-resolution strategy for a language from its
|
||||
* `LanguageProvider`. This is the production wiring that `buildHeritageMap`
|
||||
* and the standalone `resolveExtendsType` call site use — the model layer
|
||||
* itself stays unaware of the provider registry.
|
||||
*/
|
||||
export const getHeritageStrategyForLanguage: HeritageStrategyLookup = (
|
||||
lang: SupportedLanguages,
|
||||
): HeritageResolutionStrategy => {
|
||||
const provider = getProvider(lang);
|
||||
return {
|
||||
interfaceNamePattern: provider.interfaceNamePattern,
|
||||
defaultEdge: provider.heritageDefaultEdge ?? 'EXTENDS',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a symbol ID for heritage, with fallback to generated ID.
|
||||
* Uses ctx.resolve() → pick first candidate's nodeId → generate synthetic ID.
|
||||
*/
|
||||
interface ResolvedHeritage {
|
||||
readonly id: string;
|
||||
readonly confidence: number;
|
||||
}
|
||||
|
||||
const resolveHeritageId = (
|
||||
name: string,
|
||||
filePath: string,
|
||||
ctx: ResolutionContext,
|
||||
fallbackLabel: string,
|
||||
fallbackKey?: string,
|
||||
): ResolvedHeritage => {
|
||||
const resolved = ctx.resolve(name, filePath);
|
||||
if (resolved && resolved.candidates.length > 0) {
|
||||
// For global with multiple candidates, refuse (a wrong edge is worse than no edge)
|
||||
if (resolved.tier === 'global' && resolved.candidates.length > 1) {
|
||||
return {
|
||||
id: generateId(fallbackLabel, fallbackKey ?? name),
|
||||
confidence: TIER_CONFIDENCE['global'],
|
||||
};
|
||||
}
|
||||
return { id: resolved.candidates[0].nodeId, confidence: TIER_CONFIDENCE[resolved.tier] };
|
||||
}
|
||||
// Unresolved: use global-tier confidence as fallback
|
||||
return {
|
||||
id: generateId(fallbackLabel, fallbackKey ?? name),
|
||||
confidence: TIER_CONFIDENCE['global'],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a single HeritageInfo to a graph edge, using the same resolution
|
||||
* logic as processHeritageFromExtracted. This bridges the heritage extractor
|
||||
* output format to the graph-resolution side.
|
||||
*/
|
||||
const resolveAndAddHeritageEdge = (
|
||||
graph: KnowledgeGraph,
|
||||
item: HeritageInfo,
|
||||
filePath: string,
|
||||
language: SupportedLanguages,
|
||||
ctx: ResolutionContext,
|
||||
): void => {
|
||||
if (item.kind === 'extends') {
|
||||
const { type: relType, idPrefix } = resolveExtendsType(
|
||||
item.parentName,
|
||||
filePath,
|
||||
ctx,
|
||||
getHeritageStrategyForLanguage(language),
|
||||
);
|
||||
|
||||
const child = resolveHeritageId(
|
||||
item.className,
|
||||
filePath,
|
||||
ctx,
|
||||
'Class',
|
||||
`${filePath}:${item.className}`,
|
||||
);
|
||||
const parent = resolveHeritageId(item.parentName, filePath, ctx, idPrefix);
|
||||
|
||||
if (child.id && parent.id && child.id !== parent.id) {
|
||||
graph.addRelationship({
|
||||
id: generateId(relType, `${child.id}->${parent.id}`),
|
||||
sourceId: child.id,
|
||||
targetId: parent.id,
|
||||
type: relType,
|
||||
confidence: Math.sqrt(child.confidence * parent.confidence),
|
||||
reason: '',
|
||||
});
|
||||
}
|
||||
} else if (item.kind === 'implements') {
|
||||
const cls = resolveHeritageId(
|
||||
item.className,
|
||||
filePath,
|
||||
ctx,
|
||||
'Class',
|
||||
`${filePath}:${item.className}`,
|
||||
);
|
||||
const iface = resolveHeritageId(item.parentName, filePath, ctx, 'Interface');
|
||||
|
||||
if (cls.id && iface.id) {
|
||||
graph.addRelationship({
|
||||
id: generateId('IMPLEMENTS', `${cls.id}->${iface.id}`),
|
||||
sourceId: cls.id,
|
||||
targetId: iface.id,
|
||||
type: 'IMPLEMENTS',
|
||||
confidence: Math.sqrt(cls.confidence * iface.confidence),
|
||||
reason: '',
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
item.kind === 'trait-impl' ||
|
||||
item.kind === 'include' ||
|
||||
item.kind === 'extend' ||
|
||||
item.kind === 'prepend'
|
||||
) {
|
||||
// Fallback label for an unresolved child name. Rust `trait-impl` children
|
||||
// are structs; Ruby mixin children are classes or modules (Trait). For
|
||||
// Ruby mixin kinds the common case resolves through the type registry
|
||||
// post-plan-001, so the fallback only fires for true-unresolved references
|
||||
// (e.g. mixin inside a singleton_class). `Class` is strictly better than
|
||||
// `Struct` there because it matches the label the structure phase would
|
||||
// emit for a Ruby `class` — the dominant shape. Ruby modules that fail
|
||||
// to resolve still lose their `Trait` label in the synthesized id, but
|
||||
// they fail to resolve rarely and the tradeoff is documented.
|
||||
const childFallbackLabel: NodeLabel = item.kind === 'trait-impl' ? 'Struct' : 'Class';
|
||||
const strct = resolveHeritageId(
|
||||
item.className,
|
||||
filePath,
|
||||
ctx,
|
||||
childFallbackLabel,
|
||||
`${filePath}:${item.className}`,
|
||||
);
|
||||
const trait = resolveHeritageId(item.parentName, filePath, ctx, 'Trait');
|
||||
|
||||
if (strct.id && trait.id) {
|
||||
graph.addRelationship({
|
||||
id: generateId('IMPLEMENTS', `${strct.id}->${trait.id}:${item.kind}`),
|
||||
sourceId: strct.id,
|
||||
targetId: trait.id,
|
||||
type: 'IMPLEMENTS',
|
||||
confidence: Math.sqrt(strct.confidence * trait.confidence),
|
||||
reason: item.kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const processHeritage = async (
|
||||
graph: KnowledgeGraph,
|
||||
files: { path: string; content: string }[],
|
||||
astCache: ASTCache,
|
||||
ctx: ResolutionContext,
|
||||
onProgress?: (current: number, total: number) => void,
|
||||
) => {
|
||||
const parser = await loadParser();
|
||||
const logSkipped = isVerboseIngestionEnabled();
|
||||
const skippedByLang = logSkipped ? new Map<string, number>() : null;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
onProgress?.(i + 1, files.length);
|
||||
if (i % 20 === 0) await yieldToEventLoop();
|
||||
|
||||
// 1. Check language support
|
||||
const language = getLanguageFromFilename(file.path);
|
||||
if (!language) continue;
|
||||
// Registry-primary gate: the scope-based phase owns inheritance (EXTENDS/
|
||||
// IMPLEMENTS) for this language, so the legacy `@heritage` pass skips it —
|
||||
// mirrors `call-processor`/`import-processor` (#1951).
|
||||
if (isRegistryPrimary(language)) continue;
|
||||
if (!isLanguageAvailable(language)) {
|
||||
if (skippedByLang) {
|
||||
skippedByLang.set(language, (skippedByLang.get(language) ?? 0) + 1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const provider = getProvider(language);
|
||||
const queryStr = provider.treeSitterQueries;
|
||||
if (!queryStr) continue;
|
||||
|
||||
// 2. Load the language
|
||||
await loadLanguage(language, file.path);
|
||||
|
||||
// 3. Get AST
|
||||
let tree = astCache.get(file.path);
|
||||
if (!tree) {
|
||||
// Use larger bufferSize for files > 32KB
|
||||
// Per-language source preprocessor (length-preserving, e.g. UE macro
|
||||
// stripping for C++). MUST mirror parsing-processor on cache miss so
|
||||
// re-parses see the same input as the cached AST.
|
||||
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
|
||||
try {
|
||||
tree = parseSourceSafe(parser, parseContent, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(parseContent),
|
||||
});
|
||||
} catch (parseError) {
|
||||
// Skip files that can't be parsed
|
||||
continue;
|
||||
}
|
||||
// Cache re-parsed tree for potential future use
|
||||
astCache.set(file.path, tree);
|
||||
}
|
||||
|
||||
let query;
|
||||
let matches;
|
||||
try {
|
||||
const treeSitterLang = parser.getLanguage();
|
||||
query = new Parser.Query(treeSitterLang, queryStr);
|
||||
matches = query.matches(tree.rootNode);
|
||||
} catch (queryError) {
|
||||
logger.warn({ queryError }, `Heritage query error for ${file.path}:`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. Process heritage matches via provider heritage extractor
|
||||
const heritageExtractor = provider.heritageExtractor;
|
||||
matches.forEach((match) => {
|
||||
const captureMap: Record<string, any> = {};
|
||||
match.captures.forEach((c) => {
|
||||
captureMap[c.name] = c.node;
|
||||
});
|
||||
|
||||
if (!captureMap['heritage.class']) return;
|
||||
if (!heritageExtractor) return;
|
||||
|
||||
const heritageItems = heritageExtractor.extract(captureMap, {
|
||||
filePath: file.path,
|
||||
language,
|
||||
});
|
||||
|
||||
for (const item of heritageItems) {
|
||||
resolveAndAddHeritageEdge(graph, item, file.path, language, ctx);
|
||||
}
|
||||
});
|
||||
|
||||
// Tree is now owned by the LRU cache — no manual delete needed
|
||||
}
|
||||
|
||||
if (skippedByLang && skippedByLang.size > 0) {
|
||||
for (const [lang, count] of skippedByLang.entries()) {
|
||||
logger.warn(
|
||||
`[ingestion] Skipped ${count} ${lang} file(s) in heritage processing — ${lang} parser not available.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fast path: resolve pre-extracted heritage from workers.
|
||||
* No AST parsing — workers already extracted className + parentName + kind.
|
||||
*/
|
||||
export const processHeritageFromExtracted = async (
|
||||
graph: KnowledgeGraph,
|
||||
extractedHeritage: ExtractedHeritage[],
|
||||
ctx: ResolutionContext,
|
||||
onProgress?: (current: number, total: number) => void,
|
||||
) => {
|
||||
const total = extractedHeritage.length;
|
||||
|
||||
for (let i = 0; i < extractedHeritage.length; i++) {
|
||||
if (i % 500 === 0) {
|
||||
onProgress?.(i, total);
|
||||
await yieldToEventLoop();
|
||||
}
|
||||
|
||||
const h = extractedHeritage[i];
|
||||
|
||||
if (h.kind === 'extends') {
|
||||
const fileLanguage = getLanguageFromFilename(h.filePath);
|
||||
if (!fileLanguage) continue;
|
||||
const { type: relType, idPrefix } = resolveExtendsType(
|
||||
h.parentName,
|
||||
h.filePath,
|
||||
ctx,
|
||||
getHeritageStrategyForLanguage(fileLanguage),
|
||||
);
|
||||
|
||||
const child = resolveHeritageId(
|
||||
h.className,
|
||||
h.filePath,
|
||||
ctx,
|
||||
'Class',
|
||||
`${h.filePath}:${h.className}`,
|
||||
);
|
||||
const parent = resolveHeritageId(h.parentName, h.filePath, ctx, idPrefix);
|
||||
|
||||
if (child.id && parent.id && child.id !== parent.id) {
|
||||
graph.addRelationship({
|
||||
id: generateId(relType, `${child.id}->${parent.id}`),
|
||||
sourceId: child.id,
|
||||
targetId: parent.id,
|
||||
type: relType,
|
||||
confidence: Math.sqrt(child.confidence * parent.confidence),
|
||||
reason: '',
|
||||
});
|
||||
}
|
||||
} else if (h.kind === 'implements') {
|
||||
const cls = resolveHeritageId(
|
||||
h.className,
|
||||
h.filePath,
|
||||
ctx,
|
||||
'Class',
|
||||
`${h.filePath}:${h.className}`,
|
||||
);
|
||||
const iface = resolveHeritageId(h.parentName, h.filePath, ctx, 'Interface');
|
||||
|
||||
if (cls.id && iface.id) {
|
||||
graph.addRelationship({
|
||||
id: generateId('IMPLEMENTS', `${cls.id}->${iface.id}`),
|
||||
sourceId: cls.id,
|
||||
targetId: iface.id,
|
||||
type: 'IMPLEMENTS',
|
||||
confidence: Math.sqrt(cls.confidence * iface.confidence),
|
||||
reason: '',
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
h.kind === 'trait-impl' ||
|
||||
h.kind === 'include' ||
|
||||
h.kind === 'extend' ||
|
||||
h.kind === 'prepend'
|
||||
) {
|
||||
// See the per-item call above (processHeritageFromExtractedItem) for
|
||||
// rationale: `Class` is the correct fallback for Ruby mixin kinds,
|
||||
// `Struct` stays the Rust `trait-impl` default.
|
||||
const childFallbackLabel: NodeLabel = h.kind === 'trait-impl' ? 'Struct' : 'Class';
|
||||
const strct = resolveHeritageId(
|
||||
h.className,
|
||||
h.filePath,
|
||||
ctx,
|
||||
childFallbackLabel,
|
||||
`${h.filePath}:${h.className}`,
|
||||
);
|
||||
const trait = resolveHeritageId(h.parentName, h.filePath, ctx, 'Trait');
|
||||
|
||||
if (strct.id && trait.id) {
|
||||
graph.addRelationship({
|
||||
id: generateId('IMPLEMENTS', `${strct.id}->${trait.id}:${h.kind}`),
|
||||
sourceId: strct.id,
|
||||
targetId: trait.id,
|
||||
type: 'IMPLEMENTS',
|
||||
confidence: Math.sqrt(strct.confidence * trait.confidence),
|
||||
reason: h.kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.(total, total);
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk source files with the same heritage captures as parse-worker, producing
|
||||
* {@link ExtractedHeritage} rows without mutating the graph. Used on the
|
||||
* sequential pipeline path so `buildHeritageMap(..., ctx)` can run before
|
||||
* `processCalls` (worker path defers calls until heritage from all chunks exists).
|
||||
*
|
||||
* This prepass extracts BOTH capture-based heritage (`@heritage.*` — extends /
|
||||
* implements / trait-impl) AND call-based heritage (`@call.name` routed through
|
||||
* `heritageExtractor.extractFromCall` — Ruby `include` / `extend` / `prepend`).
|
||||
* Without the second pass, sequential-mode `sequentialHeritageMap` would not
|
||||
* know about Ruby mixin ancestry before `processCalls` resolves calls against
|
||||
* it, silently dropping mixed-in methods from the graph. This function stays
|
||||
* read-only — `processCalls` still owns emission of heritage graph edges via
|
||||
* its `rubyHeritage` return path.
|
||||
*/
|
||||
export async function extractExtractedHeritageFromFiles(
|
||||
files: { path: string; content: string }[],
|
||||
astCache: ASTCache,
|
||||
): Promise<ExtractedHeritage[]> {
|
||||
const parser = await loadParser();
|
||||
const out: ExtractedHeritage[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const language = getLanguageFromFilename(file.path);
|
||||
if (!language || !isLanguageAvailable(language)) continue;
|
||||
|
||||
const provider = getProvider(language);
|
||||
const queryStr = provider.treeSitterQueries;
|
||||
if (!queryStr) continue;
|
||||
|
||||
await loadLanguage(language, file.path);
|
||||
|
||||
let tree = astCache.get(file.path);
|
||||
if (!tree) {
|
||||
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
|
||||
try {
|
||||
tree = parseSourceSafe(parser, parseContent, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(parseContent),
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
astCache.set(file.path, tree);
|
||||
}
|
||||
|
||||
let matches;
|
||||
try {
|
||||
const lang = parser.getLanguage();
|
||||
const query = new Parser.Query(lang, queryStr);
|
||||
matches = query.matches(tree.rootNode);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const callBasedEnabled = !!provider.heritageExtractor?.extractFromCall;
|
||||
|
||||
for (const match of matches) {
|
||||
const captureMap: Record<string, any> = {};
|
||||
match.captures.forEach((c) => {
|
||||
captureMap[c.name] = c.node;
|
||||
});
|
||||
|
||||
if (captureMap['heritage.class']) {
|
||||
if (provider.heritageExtractor) {
|
||||
const heritageItems = provider.heritageExtractor.extract(captureMap, {
|
||||
filePath: file.path,
|
||||
language,
|
||||
});
|
||||
for (const item of heritageItems) {
|
||||
out.push({
|
||||
filePath: file.path,
|
||||
className: item.className,
|
||||
parentName: item.parentName,
|
||||
kind: item.kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Call-based heritage (e.g. Ruby include/extend/prepend). Matches the
|
||||
// routing the worker path performs inline in parse-worker.ts — see the
|
||||
// `provider.heritageExtractor?.extractFromCall` branch there. We only
|
||||
// need call-based records here; other @call captures are consumed by
|
||||
// processCalls later in the sequential loop.
|
||||
if (callBasedEnabled && captureMap['call'] && captureMap['call.name']) {
|
||||
const calledName: string = captureMap['call.name'].text;
|
||||
const heritageItems = provider.heritageExtractor!.extractFromCall!(
|
||||
calledName,
|
||||
captureMap['call'],
|
||||
{ filePath: file.path, language },
|
||||
);
|
||||
if (heritageItems) {
|
||||
for (const item of heritageItems) {
|
||||
out.push({
|
||||
filePath: file.path,
|
||||
className: item.className,
|
||||
parentName: item.parentName,
|
||||
kind: item.kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
// gitnexus/src/core/ingestion/heritage-types.ts
|
||||
|
||||
/**
|
||||
* Types for the language-agnostic heritage extraction pipeline.
|
||||
*
|
||||
* Follows the same pattern as call-types.ts, variable-types.ts, and
|
||||
* method-types.ts: defines the domain interfaces consumed by
|
||||
* createHeritageExtractor() and the per-language configs.
|
||||
*
|
||||
* Heritage extraction handles extends/implements/trait-impl captures from
|
||||
* tree-sitter queries, plus call-based heritage for languages like Ruby
|
||||
* (include/extend/prepend expressed as method calls).
|
||||
*/
|
||||
|
||||
import type { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { SyntaxNode } from './utils/ast-helpers.js';
|
||||
import type { CaptureMap } from './language-provider.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extracted result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Per-match heritage extraction result. The parse worker adds filePath to
|
||||
* produce the final {@link ExtractedHeritage} that enters the resolution
|
||||
* pipeline (heritage-processor.ts / heritage-map.ts).
|
||||
*/
|
||||
export interface HeritageInfo {
|
||||
className: string;
|
||||
parentName: string;
|
||||
/** 'extends' | 'implements' | 'trait-impl' | 'include' | 'extend' | 'prepend' */
|
||||
kind: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface HeritageExtractorContext {
|
||||
filePath: string;
|
||||
language: SupportedLanguages;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extractor interface (produced by createHeritageExtractor)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface HeritageExtractor {
|
||||
readonly language: SupportedLanguages;
|
||||
|
||||
/**
|
||||
* Extract heritage records from tree-sitter @heritage.* captures.
|
||||
*
|
||||
* @param captureMap The capture map from a single tree-sitter match
|
||||
* @param context File path and language context
|
||||
* @returns Array of heritage records (may be empty if captures don't match)
|
||||
*/
|
||||
extract(captureMap: CaptureMap, context: HeritageExtractorContext): HeritageInfo[];
|
||||
|
||||
/**
|
||||
* Extract heritage from a call node (for languages where heritage is
|
||||
* expressed as method calls, e.g., Ruby include/extend/prepend).
|
||||
*
|
||||
* @param calledName The method name (e.g. 'include', 'extend', 'prepend')
|
||||
* @param callNode The tree-sitter call AST node
|
||||
* @param context File path and language context
|
||||
* @returns Heritage records if the call is heritage-related, or null to
|
||||
* fall through to the call router / normal call handling.
|
||||
*/
|
||||
extractFromCall?(
|
||||
calledName: string,
|
||||
callNode: SyntaxNode,
|
||||
context: HeritageExtractorContext,
|
||||
): HeritageInfo[] | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config interface (one per language / language group)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Declarative description of the supertype node-type shapes a language can
|
||||
* place in a heritage position (extends / implements / trait / struct embed).
|
||||
*
|
||||
* The query builder (supertype-alternation.ts) turns the `shapes` list into a
|
||||
* tree-sitter alternation fragment `[(shape1) (shape2) …] @<tag>` that is
|
||||
* interpolated into the language's heritage query blocks in
|
||||
* tree-sitter-queries.ts. The runtime name-normalizer
|
||||
* (normalizeSupertypeName) walks whichever shape actually matched and reduces
|
||||
* it to the innermost simple identifier so downstream `ctx.resolve(name)`
|
||||
* keeps working.
|
||||
*
|
||||
* Shapes are bare tree-sitter node-type names (e.g. 'type_identifier',
|
||||
* 'generic_type', 'scoped_type_identifier'). No language names appear here —
|
||||
* the descriptor is parameterized data, consumed by language-agnostic code.
|
||||
*/
|
||||
export interface SupertypeShapeDescriptor {
|
||||
/** Tree-sitter node-type names that may appear in a supertype position. */
|
||||
readonly shapes: readonly string[];
|
||||
}
|
||||
|
||||
export interface HeritageExtractionConfig {
|
||||
language: SupportedLanguages;
|
||||
|
||||
/**
|
||||
* Called for heritage.extends captures. Return true to skip this extends
|
||||
* capture. Used by Go to skip named struct fields that match the
|
||||
* field_declaration pattern but are not anonymous embeddings.
|
||||
*
|
||||
* Default: never skip (all extends captures are valid).
|
||||
*/
|
||||
shouldSkipExtends?: (extendsNode: SyntaxNode) => boolean;
|
||||
|
||||
/**
|
||||
* Call-based heritage extraction for languages where heritage is expressed
|
||||
* as method calls (e.g., Ruby include/extend/prepend).
|
||||
*
|
||||
* callNames: set of method names that trigger heritage extraction.
|
||||
* extract: extract heritage items from the call node + method name.
|
||||
*/
|
||||
callBasedHeritage?: {
|
||||
readonly callNames: ReadonlySet<string>;
|
||||
extract(calledName: string, callNode: SyntaxNode, filePath: string): HeritageInfo[];
|
||||
};
|
||||
}
|
||||
|
|
@ -26,7 +26,6 @@ import type {
|
|||
import type { NamedBinding } from './named-bindings/types.js';
|
||||
import type { SyntaxNode } from './utils/ast-helpers.js';
|
||||
import { isDev } from './utils/env.js';
|
||||
import { isRegistryPrimary } from './registry-primary-flag.js';
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
// Type: Map<FilePath, Set<ResolvedFilePath>>
|
||||
|
|
@ -109,7 +108,12 @@ function createImportEdgeHelpers(graph: KnowledgeGraph, importMap: ImportMap) {
|
|||
|
||||
const addImportGraphEdge = (filePath: string, resolvedPath: string) => {
|
||||
const language = getLanguageFromFilename(filePath);
|
||||
if (language !== null && isRegistryPrimary(language)) return;
|
||||
// Legacy IMPORTS-edge emission. Superseded for every language by the
|
||||
// scope-resolution imports-to-edges bridge (RING4-1 #942 removed the legacy
|
||||
// resolution path). Skipped for all known languages; the only remaining
|
||||
// path is null-language files, which never resolve imports — so this is
|
||||
// effectively inert and kept solely to avoid a behavioral diff.
|
||||
if (language !== null) return;
|
||||
const sourceId = generateId('File', filePath);
|
||||
const targetId = generateId('File', resolvedPath);
|
||||
const relId = generateId('IMPORTS', `${filePath}->${resolvedPath}`);
|
||||
|
|
|
|||
|
|
@ -27,16 +27,10 @@ import type {
|
|||
} from 'gitnexus-shared';
|
||||
import type { LanguageTypeConfig } from './type-extractors/types.js';
|
||||
import type { CallRouter } from './call-routing.js';
|
||||
import type {
|
||||
CallExtractor,
|
||||
DispatchDecision,
|
||||
ImplicitReceiverOverride,
|
||||
ReceiverEnriched,
|
||||
} from './call-types.js';
|
||||
import type { CallExtractor } from './call-types.js';
|
||||
import type { ClassExtractor } from './class-types.js';
|
||||
import type { ExportChecker } from './export-detection.js';
|
||||
import type { FieldExtractor } from './field-extractor.js';
|
||||
import type { HeritageExtractor } from './heritage-types.js';
|
||||
import type { MethodExtractor } from './method-types.js';
|
||||
import type { VariableExtractor } from './variable-types.js';
|
||||
import type { ImportResolverFn } from './import-resolvers/types.js';
|
||||
|
|
@ -248,13 +242,7 @@ interface LanguageProviderConfig {
|
|||
* Default: undefined (standard label assignment). */
|
||||
readonly labelOverride?: (functionNode: SyntaxNode, defaultLabel: NodeLabel) => NodeLabel | null;
|
||||
|
||||
// ── Heritage & MRO ────────────────────────────────────────────────
|
||||
/** Default edge type when parent symbol is ambiguous (interface vs class).
|
||||
* Default: 'EXTENDS'. */
|
||||
readonly heritageDefaultEdge?: 'EXTENDS' | 'IMPLEMENTS';
|
||||
/** Regex to detect interface names by convention (e.g., /^I[A-Z]/ for C#/Java).
|
||||
* When matched, IMPLEMENTS edge is used instead of heritageDefaultEdge. */
|
||||
readonly interfaceNamePattern?: RegExp;
|
||||
// ── MRO ───────────────────────────────────────────────────────────
|
||||
/** MRO strategy for multiple inheritance resolution.
|
||||
* Default: 'first-wins'. */
|
||||
readonly mroStrategy?: MroStrategy;
|
||||
|
|
@ -282,13 +270,6 @@ interface LanguageProviderConfig {
|
|||
* Uses the same provider-driven strategy pattern as method/field extraction so
|
||||
* namespace/package/module rules stay language-specific. */
|
||||
readonly classExtractor?: ClassExtractor;
|
||||
/** Heritage extractor for extracting extends/implements/trait-impl relationships
|
||||
* from tree-sitter @heritage.* captures and call-based heritage (e.g., Ruby
|
||||
* include/extend/prepend). Produced by createHeritageExtractor() — pass a
|
||||
* SupportedLanguages value for default behaviour or a full
|
||||
* HeritageExtractionConfig for languages with custom hooks (Go, Ruby).
|
||||
* All tree-sitter providers MUST supply this. */
|
||||
readonly heritageExtractor?: HeritageExtractor;
|
||||
/** Extract a semantic description for a definition node (e.g., PHP Eloquent
|
||||
* property arrays, relation method descriptions).
|
||||
* Default: undefined (no description extraction). */
|
||||
|
|
@ -302,69 +283,6 @@ interface LanguageProviderConfig {
|
|||
* Default: undefined (no route files). */
|
||||
readonly isRouteFile?: (filePath: string) => boolean;
|
||||
|
||||
// ── Call-resolution DAG hooks ─────────────────────────────────────
|
||||
/**
|
||||
* DAG stage 3 hook: synthesize an implicit receiver when the call site omits one.
|
||||
*
|
||||
* Runs after shared inference (TypeEnv → constructor-map → class-as-receiver →
|
||||
* mixed-chain). Return an `ImplicitReceiverOverride` to overlay all fields onto
|
||||
* `ReceiverEnriched`; return null to keep current state and proceed to stage 4.
|
||||
*
|
||||
* Constraints: MUST return null when an explicit receiver is already set, at
|
||||
* top-level scope, or for built-in methods. Do not mutate input params.
|
||||
* `hint` is opaque to shared stages; consumed by this language's `selectDispatch`.
|
||||
*
|
||||
* Ruby example: bare `serialize` in `Account#call_serialize` →
|
||||
* `{ callForm: 'member', receiverName: 'self', receiverTypeName: 'Account',
|
||||
* receiverSource: 'implicit-self', hint: 'instance' }`
|
||||
*
|
||||
* @see call-types.ts § ImplicitReceiverOverride
|
||||
* @see selectDispatch (stage 4, reads the hint)
|
||||
*
|
||||
* Default: undefined (no implicit-receiver inference).
|
||||
*/
|
||||
readonly inferImplicitReceiver?: (params: {
|
||||
readonly calledName: string;
|
||||
readonly callForm: 'free' | 'member' | 'constructor' | undefined;
|
||||
readonly receiverName: string | undefined;
|
||||
readonly receiverTypeName: string | undefined;
|
||||
readonly callNode: SyntaxNode;
|
||||
readonly filePath: string;
|
||||
}) => ImplicitReceiverOverride | null;
|
||||
|
||||
/**
|
||||
* DAG stage 4 hook: decide dispatch strategy (primary path, fallback, MRO view).
|
||||
*
|
||||
* Runs after stage 3. Return a `DispatchDecision` to override shared defaults;
|
||||
* return null to use `defaultDispatchDecision` (constructor→`'constructor'`,
|
||||
* member→`'owner-scoped'`, free→`'free'`). Most languages return null.
|
||||
*
|
||||
* The hook is responsible for its own gating. `ancestryView` only affects
|
||||
* `'ruby-mixin'` strategy. Singleton-ancestry miss NEVER falls through to
|
||||
* file-scoped fallback in stage 5 (enforced in resolveCallTarget).
|
||||
*
|
||||
* Ruby examples:
|
||||
* - `receiverSource='implicit-self', hint='instance'` →
|
||||
* `{primary: 'owner-scoped', fallback: 'free-arity-narrowed', ancestryView: 'instance'}`
|
||||
* - `receiverSource='class-as-receiver'` →
|
||||
* `{primary: 'owner-scoped', ancestryView: 'singleton'}` (miss null-routes)
|
||||
* - `receiverSource='implicit-self', hint='singleton'` →
|
||||
* `{primary: 'owner-scoped', fallback: 'free-arity-narrowed', ancestryView: 'singleton'}`
|
||||
*
|
||||
* @see call-types.ts § DispatchDecision
|
||||
* @see call-processor.ts § defaultDispatchDecision, resolveCallTarget
|
||||
*
|
||||
* Default: undefined (use `defaultDispatchDecision`).
|
||||
*/
|
||||
readonly selectDispatch?: (params: {
|
||||
readonly calledName: string;
|
||||
readonly callForm: 'free' | 'member' | 'constructor' | undefined;
|
||||
readonly receiverName: string | undefined;
|
||||
readonly receiverTypeName: string | undefined;
|
||||
readonly receiverSource: ReceiverEnriched['receiverSource'];
|
||||
readonly hint: string | undefined;
|
||||
}) => DispatchDecision | null;
|
||||
|
||||
// ── Noise filtering ────────────────────────────────────────────────
|
||||
/** Built-in/stdlib names that should be filtered from the call graph for this language.
|
||||
* Default: undefined (no language-specific filtering). */
|
||||
|
|
@ -400,9 +318,8 @@ interface LanguageProviderConfig {
|
|||
* routing (scope / declaration / import / type-binding / reference)
|
||||
* lands on coherent records.
|
||||
*
|
||||
* Required for any provider participating in scope-based resolution.
|
||||
* Providers that have not yet migrated continue to run through the
|
||||
* legacy DAG path (feature-flagged per `REGISTRY_PRIMARY_<LANG>`).
|
||||
* Required for any provider participating in scope-based resolution
|
||||
* (the sole resolution path).
|
||||
*
|
||||
* **Sync return.** Tree-sitter query execution and COBOL's regex
|
||||
* tagger are both synchronous; no current or foreseeable provider
|
||||
|
|
@ -410,7 +327,7 @@ interface LanguageProviderConfig {
|
|||
* `parse-worker.ts` (#920) invoke it inline in its already-sync
|
||||
* per-file loop without cascading `async` through the batch pipeline.
|
||||
*
|
||||
* Default: undefined (language continues to use legacy DAG).
|
||||
* Default: undefined (no scope-based captures emitted for this language).
|
||||
*/
|
||||
readonly emitScopeCaptures?: (
|
||||
sourceText: string,
|
||||
|
|
@ -624,21 +541,18 @@ interface LanguageProviderConfig {
|
|||
/** Runtime type — same as LanguageProviderConfig but with defaults guaranteed present. */
|
||||
export interface LanguageProvider extends Omit<
|
||||
LanguageProviderConfig,
|
||||
'importSemantics' | 'heritageDefaultEdge' | 'mroStrategy'
|
||||
'importSemantics' | 'mroStrategy'
|
||||
> {
|
||||
readonly importSemantics: ImportSemantics;
|
||||
readonly heritageDefaultEdge: 'EXTENDS' | 'IMPLEMENTS';
|
||||
readonly mroStrategy: MroStrategy;
|
||||
/** Check if a name is a built-in/stdlib function that should be filtered from the call graph. */
|
||||
readonly isBuiltInName: (name: string) => boolean;
|
||||
}
|
||||
|
||||
const DEFAULTS: Pick<LanguageProvider, 'importSemantics' | 'heritageDefaultEdge' | 'mroStrategy'> =
|
||||
{
|
||||
importSemantics: 'named',
|
||||
heritageDefaultEdge: 'EXTENDS',
|
||||
mroStrategy: 'first-wins',
|
||||
};
|
||||
const DEFAULTS: Pick<LanguageProvider, 'importSemantics' | 'mroStrategy'> = {
|
||||
importSemantics: 'named',
|
||||
mroStrategy: 'first-wins',
|
||||
};
|
||||
|
||||
/** Define a language provider — required fields must be supplied, optional fields get sensible defaults. */
|
||||
export function defineLanguage(config: LanguageProviderConfig): LanguageProvider {
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ import { createVariableExtractor } from '../variable-extractors/generic.js';
|
|||
import { cVariableConfig, cppVariableConfig } from '../variable-extractors/configs/c-cpp.js';
|
||||
import { createCallExtractor } from '../call-extractors/generic.js';
|
||||
import { cCallConfig, cppCallConfig } from '../call-extractors/configs/c-cpp.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import { stripUeMacros } from '../cpp-ue-preprocessor.js';
|
||||
import {
|
||||
emitCScopeCaptures,
|
||||
|
|
@ -392,7 +391,6 @@ export const cProvider = defineLanguage({
|
|||
}),
|
||||
variableExtractor: createVariableExtractor(cVariableConfig),
|
||||
classExtractor: cClassExtractor,
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.C),
|
||||
labelOverride: cppLabelOverride,
|
||||
builtInNames: C_BUILT_INS,
|
||||
|
||||
|
|
@ -463,7 +461,6 @@ export const cppProvider = defineLanguage({
|
|||
}),
|
||||
variableExtractor: createVariableExtractor(cppVariableConfig),
|
||||
classExtractor: cppClassExtractor,
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.CPlusPlus),
|
||||
labelOverride: cppLabelOverride,
|
||||
builtInNames: C_BUILT_INS,
|
||||
extractTemplateConstraints: extractCppTemplateConstraintsForProvider,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import { createMethodExtractor } from '../method-extractors/generic.js';
|
|||
import { csharpMethodConfig } from '../method-extractors/configs/csharp.js';
|
||||
import { createVariableExtractor } from '../variable-extractors/generic.js';
|
||||
import { csharpVariableConfig } from '../variable-extractors/configs/csharp.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import {
|
||||
emitCsharpScopeCaptures,
|
||||
interpretCsharpImport,
|
||||
|
|
@ -190,14 +189,12 @@ export const csharpProvider = defineLanguage({
|
|||
exportChecker: csharpExportChecker,
|
||||
importResolver: createImportResolver(csharpImportConfig),
|
||||
namedBindingExtractor: extractCSharpNamedBindings,
|
||||
interfaceNamePattern: /^I[A-Z]/,
|
||||
mroStrategy: 'implements-split',
|
||||
callExtractor: createCallExtractor(csharpCallConfig),
|
||||
fieldExtractor: createFieldExtractor(csharpFieldConfig),
|
||||
methodExtractor: createMethodExtractor(csharpMethodConfig),
|
||||
variableExtractor: createVariableExtractor(csharpVariableConfig),
|
||||
classExtractor: createClassExtractor(csharpClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.CSharp),
|
||||
builtInNames: BUILT_INS,
|
||||
|
||||
// ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
|
||||
|
|
|
|||
|
|
@ -271,11 +271,12 @@ export function emitCsharpScopeCaptures(
|
|||
* Synthesize `@reference.inherits` captures from C# base lists so the
|
||||
* registry-primary scope-resolution path emits EXTENDS / IMPLEMENTS edges
|
||||
* (mirrors C++ `emitCppInheritanceCaptures`). Without this, C# inheritance
|
||||
* edges came only from the legacy `@heritage.*` path, which is dropped for
|
||||
* registry-primary languages in the worker pipeline (issue #1951).
|
||||
* edges came only from the legacy heritage-capture leg (removed in #942),
|
||||
* which is dropped for registry-primary languages in the worker pipeline
|
||||
* (issue #1951).
|
||||
*
|
||||
* Scope covers every `base_list`-bearing declaration the legacy `@heritage`
|
||||
* leg matches: `class_declaration`, `interface_declaration`,
|
||||
* Scope covers every `base_list`-bearing declaration the legacy heritage
|
||||
* leg matched: `class_declaration`, `interface_declaration`,
|
||||
* `record_declaration`, and `struct_declaration`. Records and structs were
|
||||
* dropped before (#1951): a `record R(...) : Base(args), IFoo` or
|
||||
* `struct S : IFoo, ns.IBar` produced no registry-primary inheritance edge
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ import { createVariableExtractor } from '../variable-extractors/generic.js';
|
|||
import { dartVariableConfig } from '../variable-extractors/configs/dart.js';
|
||||
import { createCallExtractor } from '../call-extractors/generic.js';
|
||||
import { dartCallConfig } from '../call-extractors/configs/dart.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import {
|
||||
emitDartScopeCaptures,
|
||||
interpretDartImport,
|
||||
|
|
@ -126,7 +125,6 @@ export const dartProvider = defineLanguage({
|
|||
methodExtractor: createMethodExtractor(dartMethodConfig),
|
||||
variableExtractor: createVariableExtractor(dartVariableConfig),
|
||||
classExtractor: createClassExtractor(dartClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.Dart),
|
||||
enclosingFunctionFinder: dartEnclosingFunctionFinder,
|
||||
builtInNames: DART_BUILT_INS,
|
||||
|
||||
|
|
|
|||
|
|
@ -27,8 +27,6 @@ import { createVariableExtractor } from '../variable-extractors/generic.js';
|
|||
import { goVariableConfig } from '../variable-extractors/configs/go.js';
|
||||
import { createCallExtractor } from '../call-extractors/generic.js';
|
||||
import { goCallConfig } from '../call-extractors/configs/go.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import { goHeritageConfig } from '../heritage-extractors/configs/go.js';
|
||||
import {
|
||||
emitGoScopeCaptures,
|
||||
goArityCompatibility,
|
||||
|
|
@ -141,7 +139,6 @@ export const goProvider = defineLanguage({
|
|||
methodExtractor: createMethodExtractor(goMethodConfig),
|
||||
variableExtractor: createVariableExtractor(goVariableConfig),
|
||||
classExtractor: createClassExtractor(goClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(goHeritageConfig),
|
||||
builtInNames: GO_BUILT_INS,
|
||||
|
||||
// ── RFC #909 Ring 3: scope-based resolution hooks ──────────
|
||||
|
|
|
|||
|
|
@ -183,9 +183,9 @@ export function emitGoScopeCaptures(
|
|||
* Synthesize `@reference.inherits` captures for Go struct embedding so the
|
||||
* registry-primary scope-resolution path emits inheritance edges (mirrors C#
|
||||
* `synthesizeCsharpInheritanceReferences` / C++ `emitCppInheritanceCaptures`).
|
||||
* Without this, Go embedding edges came only from the legacy `@heritage.*`
|
||||
* path, which is dropped for registry-primary languages in the worker pipeline
|
||||
* (issue #1951).
|
||||
* Without this, Go embedding edges came only from the legacy heritage-capture
|
||||
* leg (removed in #942), which is dropped for registry-primary languages in the
|
||||
* worker pipeline (issue #1951).
|
||||
*
|
||||
* Scope EXACTLY matches the legacy Go heritage query + its `shouldSkipExtends`
|
||||
* hook (`heritage-extractors/configs/go.ts`), whose supertype alternation is
|
||||
|
|
@ -204,7 +204,7 @@ export function emitGoScopeCaptures(
|
|||
*
|
||||
* The base shapes covered (issue #1951 — these were previously DROPPED by the
|
||||
* registry-primary synth, so production silently omitted their edges even though
|
||||
* the legacy `@heritage` leg, config-driven since #1940, captured them):
|
||||
* the legacy heritage leg, config-driven since #1940, captured them):
|
||||
* - bare `type_identifier` (`Base`) → the node itself
|
||||
* - `qualified_type` (`pkg.Base`) → `name:` tail
|
||||
* - `generic_type` (`Box[T]`) → `type:` base
|
||||
|
|
@ -276,7 +276,7 @@ function emitGoEmbedInheritance(baseNode: SyntaxNode | null, out: CaptureMatch[]
|
|||
|
||||
/**
|
||||
* Reduce a Go embed base node to its trailing bare `type_identifier`, matching
|
||||
* the node shapes the legacy `@heritage` query accepts (`goHeritageShapes`) and
|
||||
* the node shapes the legacy heritage query accepted (`goHeritageShapes`) and
|
||||
* the reduction `normalizeSupertypeName` performs (verified by real-parse to
|
||||
* yield an identical `.text` for each shape):
|
||||
* - `type_identifier` → the node itself (`Base`)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import { createMethodExtractor } from '../method-extractors/generic.js';
|
|||
import { javaMethodConfig } from '../method-extractors/configs/jvm.js';
|
||||
import { createVariableExtractor } from '../variable-extractors/generic.js';
|
||||
import { javaVariableConfig } from '../variable-extractors/configs/jvm.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import type { SymbolDefinition } from 'gitnexus-shared';
|
||||
import {
|
||||
emitJavaScopeCaptures,
|
||||
|
|
@ -111,14 +110,12 @@ export const javaProvider = defineLanguage({
|
|||
exportChecker: javaExportChecker,
|
||||
importResolver: createImportResolver(javaImportConfig),
|
||||
namedBindingExtractor: extractJavaNamedBindings,
|
||||
interfaceNamePattern: /^I[A-Z]/,
|
||||
mroStrategy: 'implements-split',
|
||||
callExtractor: createCallExtractor(javaCallConfig),
|
||||
fieldExtractor: createFieldExtractor(javaConfig),
|
||||
methodExtractor: createMethodExtractor(javaMethodConfig),
|
||||
variableExtractor: createVariableExtractor(javaVariableConfig),
|
||||
classExtractor: createClassExtractor(javaClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.Java),
|
||||
|
||||
// ── RFC #909 Ring 3: scope-based resolution hooks ──
|
||||
emitScopeCaptures: emitJavaScopeCaptures,
|
||||
|
|
|
|||
|
|
@ -223,8 +223,8 @@ export function emitJavaScopeCaptures(
|
|||
* Synthesize `@reference.inherits` captures from Java class heritage so the
|
||||
* registry-primary scope-resolution path emits EXTENDS / IMPLEMENTS edges
|
||||
* (mirrors C++ `emitCppInheritanceCaptures`). Without this, Java inheritance
|
||||
* edges came only from the legacy `@heritage.*` path, which is dropped for
|
||||
* registry-primary languages in the worker pipeline (issue #1951).
|
||||
* edges came only from the legacy heritage-capture leg (removed in #942), which
|
||||
* is dropped for registry-primary languages in the worker pipeline (issue #1951).
|
||||
*
|
||||
* Scope covers `class_declaration` (`superclass` extends + `interfaces`
|
||||
* implements clauses) AND `interface_declaration` (`extends_interfaces` →
|
||||
|
|
@ -235,19 +235,19 @@ export function emitJavaScopeCaptures(
|
|||
* edge while the legacy leg emitted it — the exact =0/=N parity break #1951
|
||||
* targets. Enum/record heritage stays unemitted (no legacy arm). Generic
|
||||
* bases (`extends Box<T>`, `implements IFoo<T>`) ARE emitted here: the legacy
|
||||
* `@heritage` query was widened to capture the inner `type_identifier` of a
|
||||
* heritage query was widened to capture the inner `type_identifier` of a
|
||||
* `generic_type` (tree-sitter-queries.ts), so both paths now agree on SIMPLE
|
||||
* (unqualified) generic bases — the more-correct behavior, consistent with
|
||||
* C#/Rust (#1951). Qualified bases (`a.b.Base`, `a.b.Box<T>`, `a.b.IFoo<T>`) are
|
||||
* ALSO now at parity (#1956 tri-review U2): the synth resolves them by their
|
||||
* `scoped_type_identifier` tail, and the legacy `@heritage` query was widened
|
||||
* `scoped_type_identifier` tail, and the legacy heritage query was widened
|
||||
* with matching `scoped_type_identifier` arms (plain + generic-wrapped). The
|
||||
* EXTENDS-vs-IMPLEMENTS split is decided downstream from the resolved target's
|
||||
* symbol kind (`preEmitInheritanceEdges`): a superclass resolves to a class
|
||||
* (EXTENDS), an implemented interface resolves to an interface (IMPLEMENTS).
|
||||
* An `interface IA extends IB` base resolves to an Interface too, so it is
|
||||
* emitted as IMPLEMENTS — matching the legacy `interface_declaration` arm,
|
||||
* which tags the bases `@heritage.impl` (`kind: 'implements'`) and likewise
|
||||
* which tagged the bases as implements (`kind: 'implements'`) and likewise
|
||||
* resolves them as interfaces. The synth therefore does not need to know the
|
||||
* declaration's own kind; it only emits inherits sites and lets the resolved
|
||||
* target decide the edge type.
|
||||
|
|
@ -279,7 +279,7 @@ function synthesizeJavaInheritanceReferences(root: SyntaxNode): CaptureMatch[] {
|
|||
// whose bases reuse `javaBaseLookupNameNode` (handles type_identifier /
|
||||
// generic_type / scoped_type_identifier). These resolve to Interface
|
||||
// targets, so `preEmitInheritanceEdges` emits them as IMPLEMENTS, at
|
||||
// parity with the legacy `interface_declaration` @heritage.impl arm.
|
||||
// parity with the legacy `interface_declaration` implements arm.
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const extendsInterfaces = node.namedChild(i);
|
||||
if (extendsInterfaces === null || extendsInterfaces.type !== 'extends_interfaces') continue;
|
||||
|
|
|
|||
|
|
@ -2,14 +2,8 @@
|
|||
* Java `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
|
||||
* the generic `runScopeResolution` orchestrator (RFC #909 Ring 3).
|
||||
*
|
||||
* ## Registry-primary parity status
|
||||
*
|
||||
* Java is in `MIGRATED_LANGUAGES` — the scope-resolution registry is
|
||||
* the primary call-resolution path. Parity: 178/178 (100%).
|
||||
*
|
||||
* **CI visibility:** The parity CI workflow (`ci-scope-parity.yml`)
|
||||
* runs Java tests in both `REGISTRY_PRIMARY_JAVA=0` and `=1` modes
|
||||
* automatically.
|
||||
* Java resolves via the scope-resolution registry — the sole
|
||||
* call-resolution path.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, TypeRef } from 'gitnexus-shared';
|
||||
|
|
|
|||
|
|
@ -639,19 +639,19 @@ function synthesizeConstructorFieldBindings(root: SyntaxNode, out: CaptureMatch[
|
|||
* Synthesize `@reference.inherits` captures from JavaScript class heritage so
|
||||
* the registry-primary scope-resolution path emits EXTENDS edges (mirrors C#
|
||||
* `synthesizeCsharpInheritanceReferences` / C++ `emitCppInheritanceCaptures`).
|
||||
* Without this, JS inheritance edges came only from the legacy `@heritage.*`
|
||||
* path, which the worker pipeline drops for registry-primary languages,
|
||||
* yielding 0 inheritance edges in worker mode (issue #1951).
|
||||
* Without this, JS inheritance edges came only from the legacy heritage-capture
|
||||
* leg (removed in #942), which the worker pipeline drops for registry-primary
|
||||
* languages, yielding 0 inheritance edges in worker mode (issue #1951).
|
||||
*
|
||||
* Scope is intentionally limited to a `class_declaration`'s `class_heritage`
|
||||
* base, matching the legacy JavaScript `@heritage` query's class scope and its
|
||||
* base, matching the legacy JavaScript heritage query's class scope and its
|
||||
* supertype shape descriptor (`javascriptHeritageShapes`:
|
||||
* `['identifier', 'member_expression']`). JavaScript classes have a single
|
||||
* `extends` base and no `implements`, so every emission is an EXTENDS (decided
|
||||
* downstream from the resolved target's symbol kind in
|
||||
* `preEmitInheritanceEdges`).
|
||||
*
|
||||
* Bases handled (at parity with the legacy `@heritage` leg, #1951):
|
||||
* Bases handled (at parity with the legacy heritage leg, #1951):
|
||||
* - `(identifier)` base (`extends Base`) — bare simple name.
|
||||
* - `(member_expression)` base (`extends ns.Base`, `extends a.b.Base`) —
|
||||
* qualified; reduced to its trailing `property_identifier` (`Base`) so the
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import { createMethodExtractor } from '../method-extractors/generic.js';
|
|||
import { kotlinMethodConfig } from '../method-extractors/configs/jvm.js';
|
||||
import { createVariableExtractor } from '../variable-extractors/generic.js';
|
||||
import { kotlinVariableConfig } from '../variable-extractors/configs/jvm.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import {
|
||||
emitKotlinScopeCaptures,
|
||||
interpretKotlinImport,
|
||||
|
|
@ -169,7 +168,6 @@ export const kotlinProvider = defineLanguage({
|
|||
methodExtractor: createMethodExtractor(kotlinMethodConfig),
|
||||
variableExtractor: createVariableExtractor(kotlinVariableConfig),
|
||||
classExtractor: createClassExtractor(kotlinClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.Kotlin),
|
||||
builtInNames: BUILT_INS,
|
||||
labelOverride: (functionNode, defaultLabel) => {
|
||||
if (defaultLabel !== 'Function') return defaultLabel;
|
||||
|
|
|
|||
|
|
@ -195,10 +195,11 @@ export function emitKotlinScopeCaptures(
|
|||
* delegation specifiers so the registry-primary scope-resolution path emits
|
||||
* EXTENDS / IMPLEMENTS edges (mirrors C# `synthesizeCsharpInheritanceReferences`
|
||||
* and C++ `emitCppInheritanceCaptures`). Without this, Kotlin inheritance edges
|
||||
* came only from the legacy `@heritage.*` path, which the worker pipeline drops
|
||||
* for registry-primary languages → 0 inheritance edges in worker mode (#1951).
|
||||
* came only from the legacy heritage-capture leg (removed in #942), which the
|
||||
* worker pipeline drops for registry-primary languages → 0 inheritance edges in
|
||||
* worker mode (#1951).
|
||||
*
|
||||
* Scope mirrors the legacy KOTLIN_QUERIES `@heritage.extends` patterns exactly
|
||||
* Scope mirrors the legacy KOTLIN_QUERIES heritage patterns exactly
|
||||
* (the config-driven `kotlinHeritageShapes`: `user_type`,
|
||||
* `constructor_invocation`, `explicit_delegation`). Each `delegation_specifier`
|
||||
* child of a `class_declaration`, in one of three forms —
|
||||
|
|
@ -256,7 +257,7 @@ function synthesizeKotlinInheritanceReferences(rootNode: SyntaxNode): CaptureMat
|
|||
* The bare simple-name `type_identifier` of a `user_type`. Strips generic
|
||||
* type arguments (`Base<T>` → `Base`) and qualifier tails (`pkg.Base` → `Base`)
|
||||
* by taking the LAST direct `type_identifier` child, matching the legacy
|
||||
* `(user_type (type_identifier) @heritage.extends)` capture and V1's
|
||||
* heritage capture of a `user_type`'s `type_identifier` and V1's
|
||||
* simple-name `findClassBindingInScope` contract.
|
||||
*/
|
||||
function kotlinUserTypeNameNode(userType: SyntaxNode): SyntaxNode | null {
|
||||
|
|
|
|||
|
|
@ -19,14 +19,12 @@ import { isKotlinStaticOnly } from './owners.js';
|
|||
/**
|
||||
* Kotlin scope resolver for RFC #909 Ring 3.
|
||||
*
|
||||
* **Migration status:** Kotlin is in `MIGRATED_LANGUAGES`. Default
|
||||
* production resolution flows through the scope-resolution pipeline;
|
||||
* the legacy DAG is consulted only when the per-language env var
|
||||
* (`REGISTRY_PRIMARY_KOTLIN=0`) explicitly forces the legacy parity
|
||||
* run for CI comparison.
|
||||
* Kotlin resolves via the scope-resolution registry — production
|
||||
* resolution flows through the scope-resolution pipeline as the sole
|
||||
* call-resolution path.
|
||||
*
|
||||
* **Forced-mode parity (`REGISTRY_PRIMARY_KOTLIN=1`):** 208/208
|
||||
* fixtures pass after the migration sub-issues #1758–#1763, the
|
||||
* **Coverage:** 208/208 fixtures pass after the migration sub-issues
|
||||
* #1758–#1763, the
|
||||
* companion/instance dispatch fix #1756, and the lambda scopes
|
||||
* fix #1757. Covers core import, receiver, companion, default-param,
|
||||
* vararg, constructor, local assignment-chain, collection-iteration,
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ import { createVariableExtractor } from '../variable-extractors/generic.js';
|
|||
import { phpVariableConfig } from '../variable-extractors/configs/php.js';
|
||||
import { createCallExtractor } from '../call-extractors/generic.js';
|
||||
import { phpCallConfig } from '../call-extractors/configs/php.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
|
||||
const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
'echo',
|
||||
|
|
@ -295,7 +294,6 @@ export const phpProvider = defineLanguage({
|
|||
methodExtractor: createMethodExtractor(phpMethodConfig),
|
||||
variableExtractor: createVariableExtractor(phpVariableConfig),
|
||||
classExtractor: createClassExtractor(phpClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.PHP),
|
||||
descriptionExtractor: phpDescriptionExtractor,
|
||||
isRouteFile: isPhpRouteFile,
|
||||
builtInNames: BUILT_INS,
|
||||
|
|
|
|||
|
|
@ -308,11 +308,11 @@ export function emitPhpScopeCaptures(
|
|||
* the registry-primary scope-resolution path emits EXTENDS / IMPLEMENTS edges
|
||||
* (mirrors C# `synthesizeCsharpInheritanceReferences` / C++
|
||||
* `emitCppInheritanceCaptures`). Without this, PHP inheritance edges came only
|
||||
* from the legacy `@heritage.*` path, which the worker pipeline drops for
|
||||
* registry-primary languages (issue #1951).
|
||||
* from the legacy heritage-capture leg (removed in #942), which the worker
|
||||
* pipeline drops for registry-primary languages (issue #1951).
|
||||
*
|
||||
* Scope matches the legacy PHP heritage query (tree-sitter-queries.ts
|
||||
* PHP_QUERIES @heritage.extends / @heritage.implements / @heritage.trait):
|
||||
* PHP_QUERIES extends / implements / trait-use captures):
|
||||
*
|
||||
* 1. `class_declaration` > `base_clause` > [(name) (qualified_name)] — extends
|
||||
* 2. `class_declaration` > `class_interface_clause` > [(name) (qualified_name)] — implements
|
||||
|
|
@ -325,9 +325,8 @@ export function emitPhpScopeCaptures(
|
|||
* lookup name is normalized to its bare simple identifier (`Foo\Bar\Base` →
|
||||
* `Base`) to match the V1 simple-name `findClassBindingInScope` contract.
|
||||
*
|
||||
* NOTE (#1951 trait-use parity): legacy emits trait-use as an IMPLEMENTS edge
|
||||
* (`heritage.trait` → `trait-impl` → IMPLEMENTS in heritage-processor.ts), and
|
||||
* the central pass matches it — `preEmitInheritanceEdges` (run.ts) maps a
|
||||
* NOTE (#1951 trait-use parity): a PHP `use Trait;` is emitted as an IMPLEMENTS
|
||||
* edge — `preEmitInheritanceEdges` (run.ts) maps a
|
||||
* resolved `Interface` OR `Trait` target to IMPLEMENTS (`type === 'Interface'
|
||||
* || type === 'Trait' ? 'IMPLEMENTS' : 'EXTENDS'`), so `use Trait` resolves to
|
||||
* IMPLEMENTS on both the legacy and registry-primary paths.
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import { createVariableExtractor } from '../variable-extractors/generic.js';
|
|||
import { pythonVariableConfig } from '../variable-extractors/configs/python.js';
|
||||
import { createCallExtractor } from '../call-extractors/generic.js';
|
||||
import { pythonCallConfig } from '../call-extractors/configs/python.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import type { CaptureMap } from '../language-provider.js';
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import {
|
||||
|
|
@ -134,7 +133,6 @@ export const pythonProvider = defineLanguage({
|
|||
methodExtractor: createMethodExtractor(pythonMethodConfig),
|
||||
variableExtractor: createVariableExtractor(pythonVariableConfig),
|
||||
classExtractor: createClassExtractor(pythonClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.Python),
|
||||
descriptionExtractor: pythonDescriptionExtractor,
|
||||
builtInNames: BUILT_INS,
|
||||
labelOverride: pythonFunctionDefinitionLabel,
|
||||
|
|
|
|||
|
|
@ -178,13 +178,13 @@ export function emitPythonScopeCaptures(
|
|||
* (mirrors C#'s `synthesizeCsharpInheritanceReferences` / C++'s
|
||||
* `emitCppInheritanceCaptures` / TypeScript's `synthesizeTsInheritanceReferences`).
|
||||
* Without this, Python inheritance edges came only from the legacy
|
||||
* `@heritage.*` path, which is dropped for registry-primary languages in the
|
||||
* worker pipeline (issue #1951).
|
||||
* heritage-capture leg (removed in #942), which is dropped for registry-primary
|
||||
* languages in the worker pipeline (issue #1951).
|
||||
*
|
||||
* Scope matches the legacy Python heritage leg (config-driven since #1940):
|
||||
* every direct base in the `superclasses` `argument_list`, resolved to its bare
|
||||
* simple name. Three base shapes that the previous synth DROPPED — and so
|
||||
* silently omitted in production while the legacy `@heritage` leg captured them
|
||||
* silently omitted in production while the legacy heritage leg captured them
|
||||
* — are now handled (#1951):
|
||||
*
|
||||
* - `class C(pkg.Base)` → `attribute` (trailing `.attribute` id → `Base`)
|
||||
|
|
|
|||
|
|
@ -19,29 +19,6 @@ const PYTHON_SCOPE_QUERY = `
|
|||
(class_definition
|
||||
name: (identifier) @declaration.name) @declaration.class
|
||||
|
||||
;; Heritage — bare identifier
|
||||
;; NOTE: captures.ts on main already synthesizes @reference.inherits for
|
||||
;; qualified bases via #1951/#1956. These @heritage.* patterns are redundant
|
||||
;; with that synthesis but kept as documentation and a safety net for the
|
||||
;; generic heritage extractor path. They produce topicOf edges that the
|
||||
;; resolution pipeline ignores when the synthesis path wins.
|
||||
(class_definition
|
||||
name: (identifier) @heritage.class
|
||||
superclasses: (argument_list
|
||||
(identifier) @heritage.extends)) @heritage
|
||||
|
||||
;; Heritage — qualified base (module.Class)
|
||||
(class_definition
|
||||
name: (identifier) @heritage.class
|
||||
superclasses: (argument_list
|
||||
(attribute) @heritage.extends)) @heritage
|
||||
|
||||
;; Heritage — subscripted/generic base (Generic[T])
|
||||
(class_definition
|
||||
name: (identifier) @heritage.class
|
||||
superclasses: (argument_list
|
||||
(subscript) @heritage.extends)) @heritage
|
||||
|
||||
(function_definition
|
||||
name: (identifier) @declaration.name) @declaration.function
|
||||
|
||||
|
|
|
|||
|
|
@ -28,11 +28,6 @@ import { createVariableExtractor } from '../variable-extractors/generic.js';
|
|||
import { rubyVariableConfig } from '../variable-extractors/configs/ruby.js';
|
||||
import { createCallExtractor } from '../call-extractors/generic.js';
|
||||
import { rubyCallConfig } from '../call-extractors/configs/ruby.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import { rubyHeritageConfig } from '../heritage-extractors/configs/ruby.js';
|
||||
import { maybeRewriteRubyBareCallToSelf } from '../utils/ruby-self-call.js';
|
||||
import { findEnclosingClassInfo } from '../utils/ast-helpers.js';
|
||||
import type { DispatchDecision, ImplicitReceiverOverride } from '../call-types.js';
|
||||
import {
|
||||
emitRubyScopeCaptures,
|
||||
rubyArityCompatibility,
|
||||
|
|
@ -140,7 +135,8 @@ const BUILT_INS: ReadonlySet<string> = new Set([
|
|||
/**
|
||||
* Remaps `class << self` (singleton_class) to its enclosing class/module for
|
||||
* receiver inference. A `singleton_class` node is not itself a type — walking
|
||||
* up to the real owner lets `inferImplicitReceiver` set `hint='singleton'`.
|
||||
* up to the real owner resolves the singleton's enclosing class for the
|
||||
* `resolveEnclosingOwner` scope-resolution hook.
|
||||
* Returns null for orphaned singleton_class (no enclosing class/module found).
|
||||
* All other container types are returned as-is.
|
||||
*/
|
||||
|
|
@ -201,71 +197,12 @@ export const rubyProvider = defineLanguage({
|
|||
}),
|
||||
variableExtractor: createVariableExtractor(rubyVariableConfig),
|
||||
classExtractor: createClassExtractor(rubyClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(rubyHeritageConfig),
|
||||
labelOverride: rubyLabelOverride,
|
||||
// Ruby MRO is kind-aware: prepend providers beat the class's own method,
|
||||
// which in turn beats include providers. See `lookupMethodByOwnerWithMRO`
|
||||
// in `model/resolve.ts` for the walk order.
|
||||
// which in turn beats include providers. The graph-level MRO phase
|
||||
// (mro-processor.ts) and per-resolver buildMro consume this strategy.
|
||||
mroStrategy: 'ruby-mixin',
|
||||
|
||||
// ── DAG hooks ────────────────────────────────────────────────────
|
||||
//
|
||||
// DAG stage 3: rewrite bare calls (e.g. `serialize` in Account#call_serialize)
|
||||
// as `self.serialize` so they route through owner-scoped MRO instead of
|
||||
// global free-call lookup. `dispatchKind` goes into `hint` for stage 4.
|
||||
inferImplicitReceiver: ({
|
||||
calledName,
|
||||
callForm,
|
||||
receiverName,
|
||||
receiverTypeName,
|
||||
callNode,
|
||||
filePath,
|
||||
}): ImplicitReceiverOverride | null => {
|
||||
// Only fire when no receiver has been resolved already.
|
||||
if (receiverName || receiverTypeName) return null;
|
||||
const enclosing = findEnclosingClassInfo(callNode, filePath, rubyResolveEnclosingOwner);
|
||||
const rewrite = maybeRewriteRubyBareCallToSelf(
|
||||
calledName,
|
||||
callForm,
|
||||
callNode,
|
||||
enclosing?.className ?? null,
|
||||
{ isBuiltInName: (n) => BUILT_INS.has(n), mroStrategy: 'ruby-mixin' },
|
||||
);
|
||||
if (!rewrite) return null;
|
||||
return {
|
||||
callForm: rewrite.callForm,
|
||||
receiverName: rewrite.receiverName,
|
||||
receiverTypeName: rewrite.receiverTypeName,
|
||||
receiverSource: 'implicit-self',
|
||||
hint: rewrite.dispatchKind, // 'instance' | 'singleton'
|
||||
};
|
||||
},
|
||||
|
||||
// DAG stage 4: two Ruby dispatch overrides —
|
||||
// implicit-self: MRO walk first, fallback to free-arity-narrowed on miss.
|
||||
// class-as-receiver: singleton ancestry (extend providers only); miss null-routes.
|
||||
selectDispatch: ({ receiverSource, hint }): DispatchDecision | null => {
|
||||
if (receiverSource === 'implicit-self') {
|
||||
// hint='instance' → instance ancestry (prepend→direct→include, see mro-strategy.ts § 'ruby-mixin')
|
||||
// hint='singleton' → singleton ancestry (extend providers only; miss null-routes)
|
||||
const ancestryView: 'instance' | 'singleton' =
|
||||
hint === 'singleton' ? 'singleton' : 'instance';
|
||||
return {
|
||||
primary: 'owner-scoped',
|
||||
fallback: 'free-arity-narrowed',
|
||||
ancestryView,
|
||||
};
|
||||
}
|
||||
if (receiverSource === 'class-as-receiver') {
|
||||
// Class constant receiver (e.g. Account.log): singleton ancestry only; miss null-routes.
|
||||
return {
|
||||
primary: 'owner-scoped',
|
||||
ancestryView: 'singleton',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
builtInNames: BUILT_INS,
|
||||
// ── RFC #909 Ring 3: scope-based resolution hooks ──────────
|
||||
emitScopeCaptures: emitRubyScopeCaptures,
|
||||
|
|
|
|||
|
|
@ -470,11 +470,11 @@ export function emitRubyScopeCaptures(
|
|||
// Emit `@reference.inherits` captures so the registry-primary scope-
|
||||
// resolution path produces EXTENDS edges (issue #1951). This mirrors the
|
||||
// C#/C++ inheritance synthesis: Ruby's superclass edges previously came
|
||||
// only from the legacy `@heritage.extends` query, which the worker
|
||||
// pipeline drops for registry-primary languages → 0 inheritance edges in
|
||||
// worker mode. Mixins (include/extend/prepend) are NOT touched here — they
|
||||
// only from the legacy heritage-capture query (removed in #942), which the
|
||||
// worker pipeline drops for registry-primary languages → 0 inheritance edges
|
||||
// in worker mode. Mixins (include/extend/prepend) are NOT touched here — they
|
||||
// flow through `emitHeritageEdges` (the `__heritage__:` import path above),
|
||||
// an independent lane that stays intact when legacy @heritage is gated off.
|
||||
// an independent lane that stays intact when the legacy heritage leg is gated off.
|
||||
out.push(...synthesizeRubySuperclassReferences(tree.rootNode));
|
||||
|
||||
return out;
|
||||
|
|
@ -488,18 +488,16 @@ export function emitRubyScopeCaptures(
|
|||
* Scope is `class` nodes whose `superclass` field holds either a bare
|
||||
* `constant` base (`class D < Super`) or a qualified/scoped
|
||||
* `scope_resolution` base (`class C < Outer::Super`, `class E < A::B::C`) —
|
||||
* exactly the two shapes the config-driven legacy `@heritage.extends`
|
||||
* alternation now captures (heritage-extractors/configs/ruby.ts
|
||||
* `rubyHeritageShapes: ['constant', 'scope_resolution']`):
|
||||
*
|
||||
* (class
|
||||
* name: (constant) @heritage.class
|
||||
* superclass: (superclass
|
||||
* [(constant) (scope_resolution)] @heritage.extends)) @heritage
|
||||
* exactly the two shapes the config-driven legacy heritage alternation
|
||||
* captured (heritage-extractors/configs/ruby.ts
|
||||
* `rubyHeritageShapes: ['constant', 'scope_resolution']`). In prose: the
|
||||
* legacy query matched a `class` whose name is a `constant` and whose
|
||||
* `superclass` is either a `constant` or a `scope_resolution`, capturing the
|
||||
* superclass constant as the inherited base.
|
||||
*
|
||||
* Previously this pass emitted only for a direct `(constant)` child, so the
|
||||
* production registry-primary path silently dropped `Outer::Super`
|
||||
* superclasses while the legacy @heritage leg captured them — the exact
|
||||
* superclasses while the legacy heritage leg captured them — the exact
|
||||
* EXTENDS/IMPLEMENTS-drop bug of #1951.
|
||||
*
|
||||
* THE PARITY CONTRACT: the `@reference.name` bare text must equal the legacy
|
||||
|
|
|
|||
|
|
@ -171,8 +171,8 @@ function buildRubyMro(
|
|||
|
||||
// Step 4: Reorder MRO per Ruby semantics.
|
||||
// Order: prepend (reversed) → direct extends chain → include (reversed).
|
||||
// `extend` is excluded — it belongs to singleton dispatch only (legacy
|
||||
// `getInstanceAncestry` in heritage-map.ts explicitly drops extend entries).
|
||||
// `extend` is excluded — it belongs to singleton dispatch only (the
|
||||
// instance-ancestry walk drops extend entries).
|
||||
// Reversed because Ruby declaration order means last-declared wins
|
||||
// (prepend B; prepend A → B checked before A).
|
||||
for (const defId of defIdByGraphId.values()) {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ import { createVariableExtractor } from '../variable-extractors/generic.js';
|
|||
import { rustVariableConfig } from '../variable-extractors/configs/rust.js';
|
||||
import { createCallExtractor } from '../call-extractors/generic.js';
|
||||
import { rustCallConfig } from '../call-extractors/configs/rust.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import {
|
||||
emitRustScopeCaptures,
|
||||
rustArityCompatibility,
|
||||
|
|
@ -180,7 +179,6 @@ export const rustProvider = defineLanguage({
|
|||
}),
|
||||
variableExtractor: createVariableExtractor(rustVariableConfig),
|
||||
classExtractor: createClassExtractor(rustClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.Rust),
|
||||
builtInNames: BUILT_INS,
|
||||
// ── RFC #909 Ring 3: scope-based resolution hooks ──────────
|
||||
emitScopeCaptures: emitRustScopeCaptures,
|
||||
|
|
|
|||
|
|
@ -170,8 +170,8 @@ export function emitRustScopeCaptures(
|
|||
/**
|
||||
* Synthesize `@reference.inherits` captures from Rust trait `impl` blocks so
|
||||
* the registry-primary scope-resolution path can emit the IMPLEMENTS edge for
|
||||
* `impl Trait for Struct` (mirrors the legacy `@heritage.trait`/`@heritage.class`
|
||||
* path, which the worker pipeline drops for registry-primary languages — #1951).
|
||||
* `impl Trait for Struct` (mirrors the legacy heritage-capture leg, removed in
|
||||
* #942, which the worker pipeline drops for registry-primary languages — #1951).
|
||||
*
|
||||
* Rust inheritance is structurally unlike a base list on a type declaration:
|
||||
* the relationship lives on `impl_item { trait: T, type: S }`, meaning
|
||||
|
|
@ -217,8 +217,8 @@ function synthesizeRustInheritanceReferences(root: SyntaxNode): CaptureMatch[] {
|
|||
|
||||
/**
|
||||
* Normalize a `trait:` / `type:` impl_item field to the base's trailing bare
|
||||
* `type_identifier`, matching exactly the node shapes the legacy `@heritage`
|
||||
* query accepts (kept at parity — see the `impl_item` heritage arm in
|
||||
* `type_identifier`, matching exactly the node shapes the legacy heritage
|
||||
* query accepted (kept at parity — see the `impl_item` heritage arm in
|
||||
* tree-sitter-queries.ts):
|
||||
* - `type_identifier` → the node itself
|
||||
* - `scoped_type_identifier name: (type_identifier)` → the trailing `name:` id
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { generateId } from '../../../../lib/utils.js';
|
|||
* sites synthesized in `captures.ts` carry the trait `T` as `site.name` (target)
|
||||
* and the struct `S` as `site.explicitReceiver.name` (source); this hook reads
|
||||
* them back and emits the IMPLEMENTS edge with source `S`, target `T`, and the
|
||||
* legacy `'trait-impl'` reason — matching the legacy `@heritage` DAG (#1951).
|
||||
* legacy `'trait-impl'` reason — matching the legacy heritage DAG (#1951).
|
||||
*
|
||||
* Resolution is scope-aware and import-aware, mirroring the shared
|
||||
* `preEmitInheritanceEdges` pass: both `S` and `T` resolve from the `impl`
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
*
|
||||
* Key Swift traits:
|
||||
* - importSemantics: 'wildcard-leaf' (Swift imports entire modules)
|
||||
* - heritageDefaultEdge: 'IMPLEMENTS' (protocols are more common than class inheritance)
|
||||
* - implicitImportWirer: all files in the same SPM target see each other
|
||||
*/
|
||||
|
||||
|
|
@ -31,7 +30,6 @@ import { createVariableExtractor } from '../variable-extractors/generic.js';
|
|||
import { swiftVariableConfig } from '../variable-extractors/configs/swift.js';
|
||||
import { createCallExtractor } from '../call-extractors/generic.js';
|
||||
import { swiftCallConfig } from '../call-extractors/configs/swift.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import {
|
||||
emitSwiftScopeCaptures,
|
||||
interpretSwiftImport,
|
||||
|
|
@ -329,7 +327,6 @@ export const swiftProvider = defineLanguage({
|
|||
exportChecker: swiftExportChecker,
|
||||
importResolver: createImportResolver(swiftImportConfig),
|
||||
importSemantics: 'wildcard-leaf',
|
||||
heritageDefaultEdge: 'IMPLEMENTS',
|
||||
callExtractor: createCallExtractor(swiftCallConfig),
|
||||
fieldExtractor: createFieldExtractor(swiftFieldConfig),
|
||||
methodExtractor: createMethodExtractor({
|
||||
|
|
@ -338,7 +335,6 @@ export const swiftProvider = defineLanguage({
|
|||
}),
|
||||
variableExtractor: createVariableExtractor(swiftVariableConfig),
|
||||
classExtractor: createClassExtractor(swiftClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.Swift),
|
||||
implicitImportWirer: wireSwiftImplicitImports,
|
||||
orderSameNameTypeCandidates: orderSwiftSameNameTypeCandidates,
|
||||
builtInNames: BUILT_INS,
|
||||
|
|
|
|||
|
|
@ -289,8 +289,8 @@ export function emitSwiftScopeCaptures(
|
|||
// inheritance specifiers and synthesize `@reference.inherits` captures so
|
||||
// the registry-primary path emits EXTENDS / IMPLEMENTS (mirrors C++ /
|
||||
// C# / Java). Without this, Swift inheritance edges came only from the
|
||||
// legacy `@heritage.*` path, which the worker pipeline drops for
|
||||
// registry-primary languages (issue #1951).
|
||||
// legacy heritage-capture leg (removed in #942), which the worker pipeline
|
||||
// drops for registry-primary languages (issue #1951).
|
||||
out.push(...synthesizeSwiftInheritanceReferences(tree.rootNode));
|
||||
|
||||
return out;
|
||||
|
|
@ -301,10 +301,10 @@ export function emitSwiftScopeCaptures(
|
|||
* specifiers so the registry-primary scope-resolution path emits
|
||||
* EXTENDS / IMPLEMENTS edges (mirrors `synthesizeCsharpInheritanceReferences`
|
||||
* / `emitCppInheritanceCaptures`). Without this, Swift inheritance edges came
|
||||
* only from the legacy `@heritage.*` path, dropped for registry-primary
|
||||
* languages in the worker pipeline (issue #1951).
|
||||
* only from the legacy heritage-capture leg (removed in #942), dropped for
|
||||
* registry-primary languages in the worker pipeline (issue #1951).
|
||||
*
|
||||
* Scope matches the legacy SWIFT_QUERIES `@heritage` blocks exactly: a
|
||||
* Scope matches the legacy SWIFT_QUERIES heritage blocks exactly: a
|
||||
* `class_declaration` (class / struct / enum / actor / extension all share
|
||||
* this node) or a `protocol_declaration`, each with an
|
||||
* `(inheritance_specifier inherits_from: (user_type (type_identifier)))`.
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ import {
|
|||
typescriptCallConfig,
|
||||
javascriptCallConfig,
|
||||
} from '../call-extractors/configs/typescript-javascript.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import {
|
||||
ARRAY_METHOD_HOC_BLOCKLIST_SET,
|
||||
DEFAULT_EXPORT_IDENTIFIER_BLOCKLIST_SET,
|
||||
|
|
@ -347,7 +346,6 @@ export const typescriptProvider = defineLanguage({
|
|||
}),
|
||||
variableExtractor: createVariableExtractor(typescriptVariableConfig),
|
||||
classExtractor: createClassExtractor(typescriptClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.TypeScript),
|
||||
builtInNames: BUILT_INS,
|
||||
|
||||
// ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
|
||||
|
|
@ -409,7 +407,6 @@ export const javascriptProvider = defineLanguage({
|
|||
}),
|
||||
variableExtractor: createVariableExtractor(javascriptVariableConfig),
|
||||
classExtractor: createClassExtractor(javascriptClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.JavaScript),
|
||||
builtInNames: BUILT_INS,
|
||||
|
||||
// ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
|
||||
|
|
|
|||
|
|
@ -429,13 +429,13 @@ export function emitTsScopeCaptures(
|
|||
* the registry-primary scope-resolution path emits EXTENDS / IMPLEMENTS edges
|
||||
* (mirrors C# `synthesizeCsharpInheritanceReferences` / JS
|
||||
* `synthesizeJsInheritanceReferences`). Without this, TS inheritance edges came
|
||||
* only from the legacy `@heritage.*` path, which the worker pipeline drops for
|
||||
* registry-primary languages — yielding 0 inheritance edges in worker mode
|
||||
* (issue #1951).
|
||||
* only from the legacy heritage-capture leg (removed in #942), which the worker
|
||||
* pipeline drops for registry-primary languages — yielding 0 inheritance edges
|
||||
* in worker mode (issue #1951).
|
||||
*
|
||||
* Scope is intentionally limited to a `class_declaration`'s `class_heritage`
|
||||
* `extends_clause` value + `implements_clause` types, matching the legacy
|
||||
* TypeScript `@heritage` query's class scope (TYPESCRIPT_QUERIES). Generic
|
||||
* TypeScript heritage query's class scope (TYPESCRIPT_QUERIES). Generic
|
||||
* bases agree across both paths: `extends Base<T>` is captured by the legacy
|
||||
* `extends_clause value: (identifier)` already (the `type_arguments` are a
|
||||
* sibling field), and `implements IFoo<T>` is captured by a legacy clause
|
||||
|
|
@ -443,14 +443,12 @@ export function emitTsScopeCaptures(
|
|||
* path keeps parity on SIMPLE (unqualified) generic bases too (#1951).
|
||||
* Qualified bases (`ns.Base`, `ns.Base<T>`, `ns.IFoo<T>`) are ALSO now at parity
|
||||
* (#1956 tri-review U2): the synth resolves them by their member_expression /
|
||||
* nested_type_identifier tail, and the legacy `@heritage` query was widened with
|
||||
* nested_type_identifier tail, and the legacy heritage query was widened with
|
||||
* matching arms (member_expression for extends, nested_type_identifier plain +
|
||||
* generic-wrapped for implements).
|
||||
*
|
||||
* `interface_declaration` / `abstract_class_declaration` heritage is NOT emitted
|
||||
* — the legacy query captures neither, so the registry path keeps parity with
|
||||
* the legacy DAG under the CI scope-parity gate (REGISTRY_PRIMARY_TYPESCRIPT=0
|
||||
* vs =1). The EXTENDS-vs-IMPLEMENTS split is decided downstream from the
|
||||
* by the synth. The EXTENDS-vs-IMPLEMENTS split is decided downstream from the
|
||||
* resolved target's symbol kind in `preEmitInheritanceEdges` (class-extends →
|
||||
* EXTENDS, implements-interface / interface-target → IMPLEMENTS), so all bases
|
||||
* are emitted with the same `inherits` kind here. The base lookup name is
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import { createVariableExtractor } from '../variable-extractors/generic.js';
|
|||
import { typescriptVariableConfig } from '../variable-extractors/configs/typescript-javascript.js';
|
||||
import { createCallExtractor } from '../call-extractors/generic.js';
|
||||
import { typescriptCallConfig } from '../call-extractors/configs/typescript-javascript.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import {
|
||||
interpretTsImport,
|
||||
interpretTsTypeBinding,
|
||||
|
|
@ -90,7 +89,6 @@ export const vueProvider = defineLanguage({
|
|||
fieldExtractor: typescriptFieldExtractor,
|
||||
variableExtractor: createVariableExtractor(typescriptVariableConfig),
|
||||
classExtractor: vueClassExtractor,
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.TypeScript),
|
||||
builtInNames: VUE_BUILT_INS,
|
||||
// Scope-resolution pipeline hooks (RFC #909 Ring 3)
|
||||
emitScopeCaptures: emitVueScopeCaptures,
|
||||
|
|
|
|||
|
|
@ -1,418 +0,0 @@
|
|||
/**
|
||||
* Heritage Map
|
||||
*
|
||||
* Unified inheritance data structure built from accumulated
|
||||
* {@link ExtractedHeritage} records **after all chunks complete** (between
|
||||
* chunk processing and call resolution). Consumes `ExtractedHeritage[]` and
|
||||
* resolves type names to nodeIds via `lookupClassByName`, NOT graph-edge
|
||||
* queries.
|
||||
*
|
||||
* Combines two concerns:
|
||||
* 1. **Parent/ancestor lookup** (MRO-aware method resolution)
|
||||
* 2. **Implementor lookup** (interface dispatch — which files contain
|
||||
* classes implementing a given interface)
|
||||
*/
|
||||
|
||||
import type { ResolutionContext } from './resolution-context.js';
|
||||
import { getLanguageFromFilename, type SupportedLanguages } from 'gitnexus-shared';
|
||||
import {
|
||||
isDeferredResolutionProfileEnabled,
|
||||
logDeferredProfile,
|
||||
} from '../utils/deferred-resolution-profile.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ExtractedHeritage — the shape produced by the parse worker / heritage
|
||||
// extractor. Defined here so `model/` has no upward imports; consumers
|
||||
// import this type from the model module.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ExtractedHeritage {
|
||||
filePath: string;
|
||||
className: string;
|
||||
parentName: string;
|
||||
/** 'extends' | 'implements' | 'trait-impl' | 'include' | 'extend' | 'prepend' */
|
||||
kind: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Heritage resolution strategy (the per-language knobs that drive
|
||||
// `resolveExtendsType` below). Pulled out as an explicit strategy object so
|
||||
// the model layer depends on a plain data shape rather than on the language
|
||||
// provider registry.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface HeritageResolutionStrategy {
|
||||
/** If set and the parent name matches, force IMPLEMENTS even when the
|
||||
* symbol is unresolved (e.g. `/^I[A-Z]/` for C# / Java). */
|
||||
readonly interfaceNamePattern?: RegExp;
|
||||
/** Fallback edge for unresolved parents when the name pattern doesn't
|
||||
* match (Swift uses 'IMPLEMENTS' for protocol conformance). */
|
||||
readonly defaultEdge: 'EXTENDS' | 'IMPLEMENTS';
|
||||
}
|
||||
|
||||
/** Callback used by `buildHeritageMap` to look up the resolution strategy
|
||||
* for a given language. Injected by callers so the model module doesn't
|
||||
* depend on `../languages/index.js`. */
|
||||
export type HeritageStrategyLookup = (lang: SupportedLanguages) => HeritageResolutionStrategy;
|
||||
|
||||
/**
|
||||
* Determine whether a heritage.extends capture is actually an IMPLEMENTS
|
||||
* relationship. Consults the symbol table first (authoritative — Tier 1 /
|
||||
* Tier 2 resolution); falls back to the injected {@link HeritageResolutionStrategy}
|
||||
* heuristics for external symbols not present in the graph.
|
||||
*/
|
||||
export const resolveExtendsType = (
|
||||
parentName: string,
|
||||
currentFilePath: string,
|
||||
ctx: ResolutionContext,
|
||||
strategy: HeritageResolutionStrategy,
|
||||
): { type: 'EXTENDS' | 'IMPLEMENTS'; idPrefix: string } => {
|
||||
const resolved = ctx.resolve(parentName, currentFilePath);
|
||||
if (resolved && resolved.candidates.length > 0) {
|
||||
const isInterface = resolved.candidates[0].type === 'Interface';
|
||||
return isInterface
|
||||
? { type: 'IMPLEMENTS', idPrefix: 'Interface' }
|
||||
: { type: 'EXTENDS', idPrefix: 'Class' };
|
||||
}
|
||||
// Unresolved symbol — fall back to strategy heuristics.
|
||||
if (strategy.interfaceNamePattern?.test(parentName)) {
|
||||
return { type: 'IMPLEMENTS', idPrefix: 'Interface' };
|
||||
}
|
||||
if (strategy.defaultEdge === 'IMPLEMENTS') {
|
||||
return { type: 'IMPLEMENTS', idPrefix: 'Interface' };
|
||||
}
|
||||
return { type: 'EXTENDS', idPrefix: 'Class' };
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Maximum ancestor chain depth to prevent runaway traversal. */
|
||||
const MAX_ANCESTOR_DEPTH = 32;
|
||||
|
||||
/**
|
||||
* Direct parent entry with the heritage kind that produced it. Preserved
|
||||
* so kind-aware consumers (Ruby MRO, see `lookupMethodByOwnerWithMRO`) can
|
||||
* walk prepend/include providers in the correct order. Flat-string consumers
|
||||
* use `getParents` / `getAncestors` and see only the parent nodeIds.
|
||||
*/
|
||||
export interface ParentEntry {
|
||||
readonly parentId: string;
|
||||
/** 'extends' | 'implements' | 'trait-impl' | 'include' | 'extend' | 'prepend' */
|
||||
readonly kind: string;
|
||||
}
|
||||
|
||||
export interface HeritageMap {
|
||||
/** Direct parents of `childNodeId` (extends + implements + trait-impl). */
|
||||
getParents(childNodeId: string): string[];
|
||||
/** Full ancestor chain (BFS, bounded depth, cycle-safe). */
|
||||
getAncestors(childNodeId: string): string[];
|
||||
/**
|
||||
* Direct parents with heritage kind preserved, insertion-ordered. Used by
|
||||
* kind-aware consumers (Ruby MRO) that need to distinguish prepend /
|
||||
* include / extend / extends for walk-order decisions.
|
||||
*
|
||||
* Insertion order mirrors the order `ExtractedHeritage` records were fed
|
||||
* into `buildHeritageMap`, which in turn mirrors tree-sitter match order.
|
||||
* For Ruby, this matches source declaration order for `prepend` / `include`
|
||||
* statements — the MRO walk reverses this (last-declared-first) at the
|
||||
* consumer side.
|
||||
*/
|
||||
getParentEntries(childNodeId: string): readonly ParentEntry[];
|
||||
/**
|
||||
* Ordered ancestry for instance method dispatch (Ruby-aware): includes
|
||||
* `extends`, `implements`, `trait-impl`, `include`, `prepend` kinds.
|
||||
* Excludes `extend` (singleton-only). Order is caller-determined in Unit 3.
|
||||
* For non-Ruby callers (first-wins, c3, etc.), this matches `getAncestors`.
|
||||
*/
|
||||
getInstanceAncestry(childNodeId: string): readonly ParentEntry[];
|
||||
/**
|
||||
* Ordered ancestry for singleton / class-method dispatch (Ruby-aware):
|
||||
* only `extend` kind parents. For non-Ruby languages this is always empty.
|
||||
*/
|
||||
getSingletonAncestry(childNodeId: string): readonly ParentEntry[];
|
||||
/**
|
||||
* File paths of classes that directly implement or extend-as-interface the
|
||||
* given interface/abstract-class **name**. Replaces the standalone
|
||||
* `ImplementorMap` — used by interface-dispatch in call resolution.
|
||||
*/
|
||||
getImplementorFiles(interfaceName: string): ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/** Shared empty set returned when no implementors are found. */
|
||||
const EMPTY_SET: ReadonlySet<string> = new Set();
|
||||
|
||||
/** Default strategy used when `buildHeritageMap` is called without an
|
||||
* explicit `getHeritageStrategy` callback — the fallback for a language
|
||||
* whose provider sets no interface-name pattern and no non-default
|
||||
* `heritageDefaultEdge`. */
|
||||
const DEFAULT_HERITAGE_STRATEGY: HeritageResolutionStrategy = { defaultEdge: 'EXTENDS' };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a HeritageMap from accumulated ExtractedHeritage records.
|
||||
*
|
||||
* Resolves class/interface/struct/trait names to nodeIds via
|
||||
* `ctx.model.types.lookupClassByName`. When a name resolves to multiple
|
||||
* candidates, all are recorded (partial-class / cross-file scenario).
|
||||
* Unresolvable names are silently skipped — a missing parent is better
|
||||
* than a wrong edge.
|
||||
*
|
||||
* Also builds the implementor index (interface name → implementing file
|
||||
* paths) used by interface-dispatch in call resolution.
|
||||
*/
|
||||
export const buildHeritageMap = (
|
||||
heritage: readonly ExtractedHeritage[],
|
||||
ctx: ResolutionContext,
|
||||
getHeritageStrategy?: HeritageStrategyLookup,
|
||||
): HeritageMap => {
|
||||
// childNodeId → insertion-ordered array of { parentId, kind }.
|
||||
// Ordered array (not Set) because Ruby MRO walk depends on declaration
|
||||
// order. A parallel `seen` map dedupes `(parentId, kind)` pairs without
|
||||
// losing order.
|
||||
const directParents = new Map<string, ParentEntry[]>();
|
||||
const seenParents = new Map<string, Set<string>>();
|
||||
|
||||
// interfaceName → Set<filePath> (implementor lookup for interface dispatch)
|
||||
const implementorFiles = new Map<string, Set<string>>();
|
||||
|
||||
const profileHeritage = isDeferredResolutionProfileEnabled();
|
||||
let maxNameCartesian = 0;
|
||||
let ambiguousHeritageRecords = 0;
|
||||
let unresolvedChildLookups = 0;
|
||||
let unresolvedParentLookups = 0;
|
||||
|
||||
for (const h of heritage) {
|
||||
// ── Parent lookup (nodeId-based) ────────────────────────────────
|
||||
const childDefs = ctx.model.types.lookupClassByName(h.className);
|
||||
const parentDefs = ctx.model.types.lookupClassByName(h.parentName);
|
||||
|
||||
// Unresolved-side counters live in a separate guard so they observe
|
||||
// records the ambiguity block below skips. On JVM monorepos the
|
||||
// pathological fan-out case is precisely "many same-named children
|
||||
// with an unresolved external supertype" (or the inverse) — both
|
||||
// sides non-empty is the case `ambiguousHeritageRecords` already
|
||||
// covers; the unresolved cases were silently dropped from the
|
||||
// metric before this counter.
|
||||
if (profileHeritage) {
|
||||
if (childDefs.length === 0) unresolvedChildLookups++;
|
||||
if (parentDefs.length === 0) unresolvedParentLookups++;
|
||||
}
|
||||
|
||||
if (profileHeritage && childDefs.length > 0 && parentDefs.length > 0) {
|
||||
const product = childDefs.length * parentDefs.length;
|
||||
if (product > 1) ambiguousHeritageRecords++;
|
||||
if (product > maxNameCartesian) maxNameCartesian = product;
|
||||
}
|
||||
|
||||
if (childDefs.length > 0 && parentDefs.length > 0) {
|
||||
for (const child of childDefs) {
|
||||
for (const parent of parentDefs) {
|
||||
// Skip self-references
|
||||
if (child.nodeId === parent.nodeId) continue;
|
||||
|
||||
let parents = directParents.get(child.nodeId);
|
||||
if (!parents) {
|
||||
parents = [];
|
||||
directParents.set(child.nodeId, parents);
|
||||
}
|
||||
let seen = seenParents.get(child.nodeId);
|
||||
if (!seen) {
|
||||
seen = new Set();
|
||||
seenParents.set(child.nodeId, seen);
|
||||
}
|
||||
// Dedup by `parentId + kind` so the same parent under two different
|
||||
// kinds (e.g. a module that is both included and prepended — legal
|
||||
// Ruby though unusual) is recorded twice; the consumer needs both
|
||||
// kinds in the walk. A single (parent, kind) pair is deduped.
|
||||
const key = `${parent.nodeId}|${h.kind}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
parents.push({ parentId: parent.nodeId, kind: h.kind });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Implementor index (name-based) ──────────────────────────────
|
||||
//
|
||||
// Known limitation: Rust `kind: 'trait-impl'` entries are intentionally NOT
|
||||
// added to the implementor index. Interface dispatch resolution currently
|
||||
// does not traverse Rust trait objects, so recording them here would
|
||||
// inflate the index without a consumer. Revisit if/when trait-object
|
||||
// dispatch is added.
|
||||
//
|
||||
// Known limitation: `getImplementorFiles` is keyed by interface **name**
|
||||
// (string), so two interfaces with the same unqualified name in different
|
||||
// packages (e.g. `pkgA.IRepository` vs `pkgB.IRepository`) collide.
|
||||
let isImpl = false;
|
||||
if (h.kind === 'implements') {
|
||||
isImpl = true;
|
||||
} else if (h.kind === 'extends') {
|
||||
const lang = getLanguageFromFilename(h.filePath);
|
||||
if (lang) {
|
||||
const strategy = getHeritageStrategy?.(lang) ?? DEFAULT_HERITAGE_STRATEGY;
|
||||
const { type } = resolveExtendsType(h.parentName, h.filePath, ctx, strategy);
|
||||
isImpl = type === 'IMPLEMENTS';
|
||||
}
|
||||
}
|
||||
if (isImpl) {
|
||||
let files = implementorFiles.get(h.parentName);
|
||||
if (!files) {
|
||||
files = new Set();
|
||||
implementorFiles.set(h.parentName, files);
|
||||
}
|
||||
files.add(h.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Public API ---------------------------------------------------
|
||||
|
||||
/** Internal helper: return the entries array (may be undefined). */
|
||||
const entriesFor = (nodeId: string): readonly ParentEntry[] | undefined =>
|
||||
directParents.get(nodeId);
|
||||
|
||||
const getParentEntries = (childNodeId: string): readonly ParentEntry[] => {
|
||||
const entries = entriesFor(childNodeId);
|
||||
return entries ?? [];
|
||||
};
|
||||
|
||||
const getParents = (childNodeId: string): string[] => {
|
||||
const entries = entriesFor(childNodeId);
|
||||
if (!entries) return [];
|
||||
// Deduplicate parent ids across kinds so the flat-string contract
|
||||
// (used by non-Ruby MRO strategies and by the C3 linearizer) stays
|
||||
// identical to its pre-kind-awareness behavior.
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const e of entries) {
|
||||
if (!seen.has(e.parentId)) {
|
||||
seen.add(e.parentId);
|
||||
out.push(e.parentId);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const getAncestors = (childNodeId: string): string[] => {
|
||||
const result: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
visited.add(childNodeId); // prevent cycles through the start node
|
||||
|
||||
// BFS with bounded depth
|
||||
let frontier = getParents(childNodeId);
|
||||
let depth = 0;
|
||||
|
||||
while (frontier.length > 0 && depth < MAX_ANCESTOR_DEPTH) {
|
||||
const nextFrontier: string[] = [];
|
||||
for (const parentId of frontier) {
|
||||
if (visited.has(parentId)) continue;
|
||||
visited.add(parentId);
|
||||
result.push(parentId);
|
||||
// Expand parent's own parents for next level
|
||||
const grandparents = entriesFor(parentId);
|
||||
if (grandparents) {
|
||||
const gpSeen = new Set<string>();
|
||||
for (const gp of grandparents) {
|
||||
if (gpSeen.has(gp.parentId)) continue;
|
||||
gpSeen.add(gp.parentId);
|
||||
if (!visited.has(gp.parentId)) nextFrontier.push(gp.parentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
frontier = nextFrontier;
|
||||
depth++;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lazy-computed per-owner split of direct parents into instance-dispatch
|
||||
* (non-`extend`) and singleton-dispatch (`extend`-only) views. Memoized on
|
||||
* first request so the `.filter()` pass happens at most once per owner per
|
||||
* HeritageMap lifetime, not per call-site dispatch.
|
||||
*
|
||||
* Shared empty-array sentinels for owners with no entries in a given view
|
||||
* avoid per-call allocation when the split is asymmetric (common Ruby case:
|
||||
* a class has `include` but no `extend`, so its singleton view is empty).
|
||||
*/
|
||||
const EMPTY_PARENT_ENTRIES: readonly ParentEntry[] = [];
|
||||
const splitCache = new Map<
|
||||
string,
|
||||
{ instance: readonly ParentEntry[]; singleton: readonly ParentEntry[] }
|
||||
>();
|
||||
|
||||
const splitForOwner = (
|
||||
childNodeId: string,
|
||||
): { instance: readonly ParentEntry[]; singleton: readonly ParentEntry[] } => {
|
||||
let cached = splitCache.get(childNodeId);
|
||||
if (cached) return cached;
|
||||
const entries = entriesFor(childNodeId);
|
||||
if (!entries || entries.length === 0) {
|
||||
cached = { instance: EMPTY_PARENT_ENTRIES, singleton: EMPTY_PARENT_ENTRIES };
|
||||
} else {
|
||||
const instance: ParentEntry[] = [];
|
||||
const singleton: ParentEntry[] = [];
|
||||
for (const e of entries) {
|
||||
if (e.kind === 'extend') singleton.push(e);
|
||||
else instance.push(e);
|
||||
}
|
||||
cached = {
|
||||
instance: instance.length === 0 ? EMPTY_PARENT_ENTRIES : instance,
|
||||
singleton: singleton.length === 0 ? EMPTY_PARENT_ENTRIES : singleton,
|
||||
};
|
||||
}
|
||||
splitCache.set(childNodeId, cached);
|
||||
return cached;
|
||||
};
|
||||
|
||||
/**
|
||||
* Instance-dispatch ancestry walk. Excludes `extend` (singleton-only).
|
||||
* For kind-aware consumers (Ruby MRO): walks parents in source-insertion
|
||||
* order. The consumer is responsible for interleaving self / reversing
|
||||
* prepend order / etc. This method preserves raw declaration order.
|
||||
*
|
||||
* Result is cached per owner; repeat calls return the same array.
|
||||
*/
|
||||
const getInstanceAncestry = (childNodeId: string): readonly ParentEntry[] =>
|
||||
splitForOwner(childNodeId).instance;
|
||||
|
||||
/**
|
||||
* Singleton-dispatch ancestry walk. Only `extend` parents. For non-Ruby
|
||||
* languages this is always empty (no language currently produces `extend`
|
||||
* heritage records outside Ruby).
|
||||
*
|
||||
* Result is cached per owner; repeat calls return the same array.
|
||||
*/
|
||||
const getSingletonAncestry = (childNodeId: string): readonly ParentEntry[] =>
|
||||
splitForOwner(childNodeId).singleton;
|
||||
|
||||
const getImplementorFiles = (interfaceName: string): ReadonlySet<string> => {
|
||||
return implementorFiles.get(interfaceName) ?? EMPTY_SET;
|
||||
};
|
||||
|
||||
if (profileHeritage) {
|
||||
logDeferredProfile(
|
||||
`buildHeritageMap: ${heritage.length} heritage records, ` +
|
||||
`${ambiguousHeritageRecords} with child×parent lookup product >1, ` +
|
||||
`max product ${maxNameCartesian}, ` +
|
||||
`${unresolvedChildLookups} unresolved child lookups, ` +
|
||||
`${unresolvedParentLookups} unresolved parent lookups, ` +
|
||||
`${implementorFiles.size} interface implementor keys`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
getParents,
|
||||
getAncestors,
|
||||
getParentEntries,
|
||||
getInstanceAncestry,
|
||||
getSingletonAncestry,
|
||||
getImplementorFiles,
|
||||
};
|
||||
};
|
||||
|
|
@ -59,12 +59,6 @@ export {
|
|||
createFieldRegistry,
|
||||
} from './field-registry.js';
|
||||
|
||||
// MRO-aware method resolution (C3, first-wins, leftmost-base, implements-split,
|
||||
// qualified-syntax). Pure function that depends only on the model + HeritageMap.
|
||||
// `MroStrategy` itself lives in `gitnexus-shared`; re-exported here for
|
||||
// consumers that reach model behavior through the barrel.
|
||||
export { lookupMethodByOwnerWithMRO } from './resolve.js';
|
||||
|
||||
// Named-import types and package-dir helper. Re-exported so barrel
|
||||
// consumers don't need to reach into a specific model file.
|
||||
export {
|
||||
|
|
@ -73,16 +67,6 @@ export {
|
|||
isFileInPackageDir,
|
||||
} from './resolution-context.js';
|
||||
|
||||
// Heritage types and builder. `buildHeritageMap` + `resolveExtendsType` are
|
||||
// exported directly from `heritage-map.ts` and are not re-surfaced here to
|
||||
// keep the barrel narrow.
|
||||
export {
|
||||
type ExtractedHeritage,
|
||||
type HeritageMap,
|
||||
type HeritageResolutionStrategy,
|
||||
type HeritageStrategyLookup,
|
||||
} from './heritage-map.js';
|
||||
|
||||
// Behavior-grouped dispatch table for SymbolTable.add() routing.
|
||||
// See registration-table.ts module JSDoc for the behavior group taxonomy
|
||||
// and "how to add a new NodeLabel" checklist.
|
||||
|
|
|
|||
|
|
@ -1,30 +1,18 @@
|
|||
/**
|
||||
* Deterministic Resolution Functions
|
||||
* MRO primitives.
|
||||
*
|
||||
* Pure functions that resolve methods across the inheritance hierarchy
|
||||
* using only the SemanticModel registries and HeritageMap — NO dependency
|
||||
* on resolution-context.ts (circular dependency risk).
|
||||
* `c3Linearize` and its BFS helper `gatherAncestors` are pure functions over a
|
||||
* `parentMap` (classId → parent ids). They carry no dependency on the semantic
|
||||
* model or graph, so the model layer stays a pure leaf — `mro-processor.ts`
|
||||
* (graph-level MRO emission) imports `c3Linearize` / `gatherAncestors` from here.
|
||||
*/
|
||||
|
||||
import type { SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { SemanticModel } from './semantic-model.js';
|
||||
import type { HeritageMap } from './heritage-map.js';
|
||||
import type { MroStrategy } from 'gitnexus-shared';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MRO primitives.
|
||||
//
|
||||
// `c3Linearize` and its BFS helper `gatherAncestors` live here so the model
|
||||
// layer stays a pure leaf — mro-processor.ts (graph-level MRO emission)
|
||||
// imports `c3Linearize` from this file.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Gather all ancestor IDs in BFS / topological order.
|
||||
* Returns the linearized list of ancestor IDs (excluding the class itself).
|
||||
*
|
||||
* Uses a head-pointer BFS (`queue[head++]`) instead of `Array.shift()` to
|
||||
* avoid O(n) per-dequeue re-indexing — matching `buildParentMapFromHeritage`.
|
||||
* avoid O(n) per-dequeue re-indexing.
|
||||
*/
|
||||
function gatherAncestors(classId: string, parentMap: Map<string, string[]>): string[] {
|
||||
const visited = new Set<string>();
|
||||
|
|
@ -53,8 +41,7 @@ function gatherAncestors(classId: string, parentMap: Map<string, string[]>): str
|
|||
* Returns an array of ancestor IDs in C3 order (excluding the class itself),
|
||||
* or null if linearization fails (inconsistent or cyclic hierarchy).
|
||||
*
|
||||
* Used internally by `lookupMethodByOwnerWithMRO` for the Python MRO
|
||||
* strategy and re-exported for mro-processor.ts (graph-level MRO emission).
|
||||
* Re-exported for mro-processor.ts (graph-level MRO emission).
|
||||
*/
|
||||
export function c3Linearize(
|
||||
classId: string,
|
||||
|
|
@ -217,231 +204,3 @@ export function c3Linearize(
|
|||
// `gatherAncestors` is exported so mro-processor.ts can reuse the same
|
||||
// BFS traversal for graph-level MRO emission.
|
||||
export { gatherAncestors };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C3 linearization cache (per HeritageMap, auto-drained via WeakMap)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Per-HeritageMap cache of C3 linearization results keyed by owner nodeId.
|
||||
*
|
||||
* HeritageMap instances are immutable after construction, so C3 output is
|
||||
* stable for the lifetime of a HeritageMap. WeakMap lets the cache auto-drain
|
||||
* when the HeritageMap is garbage collected (end of ingestion run), so we
|
||||
* never need to manually invalidate it.
|
||||
*
|
||||
* `null` is a sentinel for "C3 failed for this owner" (cyclic or inconsistent
|
||||
* hierarchy) so we don't re-run the expensive linearization repeatedly.
|
||||
*/
|
||||
const c3LinearizationCache = new WeakMap<HeritageMap, Map<string, readonly string[] | null>>();
|
||||
|
||||
const getCachedC3Linearization = (
|
||||
ownerNodeId: string,
|
||||
heritageMap: HeritageMap,
|
||||
): readonly string[] | null => {
|
||||
let perHmCache = c3LinearizationCache.get(heritageMap);
|
||||
if (!perHmCache) {
|
||||
perHmCache = new Map();
|
||||
c3LinearizationCache.set(heritageMap, perHmCache);
|
||||
}
|
||||
const cached = perHmCache.get(ownerNodeId);
|
||||
if (cached !== undefined) return cached;
|
||||
const parentMap = buildParentMapFromHeritage(ownerNodeId, heritageMap);
|
||||
const result = c3Linearize(ownerNodeId, parentMap, new Map()) ?? null;
|
||||
perHmCache.set(ownerNodeId, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Heritage → parentMap conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a parentMap from HeritageMap for use with c3Linearize.
|
||||
* Traverses the parent chain starting from startNodeId, collecting all
|
||||
* parent→children relationships into a Map<string, string[]>.
|
||||
*
|
||||
* Uses a head-pointer BFS (queue[head++]) instead of Array.shift() to avoid
|
||||
* O(n) per-dequeue re-indexing. For wide/shallow hierarchies common in
|
||||
* large Java/C# codebases this keeps the walk linear in ancestor count.
|
||||
*/
|
||||
const buildParentMapFromHeritage = (
|
||||
startNodeId: string,
|
||||
heritageMap: HeritageMap,
|
||||
): Map<string, string[]> => {
|
||||
const parentMap = new Map<string, string[]>();
|
||||
const visited = new Set<string>();
|
||||
const queue: string[] = [startNodeId];
|
||||
let head = 0;
|
||||
|
||||
while (head < queue.length) {
|
||||
const nodeId = queue[head++]!;
|
||||
if (visited.has(nodeId)) continue;
|
||||
visited.add(nodeId);
|
||||
const parents = heritageMap.getParents(nodeId);
|
||||
if (parents.length > 0) {
|
||||
parentMap.set(nodeId, parents);
|
||||
for (const p of parents) {
|
||||
if (!visited.has(p)) queue.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parentMap;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MRO-aware method lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* DAG stage 5 helper: look up a method on an owner class via MRO walk.
|
||||
*
|
||||
* Low-level resolver; no dependency on SymbolTable, language registry, or
|
||||
* resolution-context (keeps model/ layer free of cross-layer imports).
|
||||
* All strategies respect `argCount` for overload narrowing.
|
||||
* `ancestryOverride` replaces the default walk; caller must compute it correctly.
|
||||
*
|
||||
* Strategy summary (full docs in gitnexus-shared/mro-strategy.ts):
|
||||
* - `first-wins` / `leftmost-base` / `implements-split`: BFS, first match wins.
|
||||
* - `c3`: C3-linearized order; falls back to BFS on cycle/inconsistency.
|
||||
* - `qualified-syntax`: returns undefined immediately (Rust requires explicit syntax).
|
||||
* - `ruby-mixin`: kind-aware walk — see inline comments below.
|
||||
*
|
||||
* Internal API: exported for call-processor resolvers and tests.
|
||||
* External callers should use resolveMemberCall instead.
|
||||
*
|
||||
* @see gitnexus-shared/mro-strategy.ts § 'ruby-mixin'
|
||||
* @see call-processor.ts § resolveMemberCall
|
||||
*/
|
||||
export const lookupMethodByOwnerWithMRO = (
|
||||
ownerNodeId: string,
|
||||
methodName: string,
|
||||
heritageMap: HeritageMap,
|
||||
model: SemanticModel,
|
||||
strategy: MroStrategy,
|
||||
argCount?: number,
|
||||
/**
|
||||
* Optional pre-computed ancestry list. When provided, overrides the default
|
||||
* per-strategy ancestry source. Primarily used by Ruby singleton dispatch:
|
||||
* the caller supplies `heritageMap.getSingletonAncestry(ownerNodeId)` as
|
||||
* node-id array so this walker resolves against `extend` providers only.
|
||||
*
|
||||
* For `ruby-mixin` strategy, passing an override switches the walker into
|
||||
* a no-prepend-no-direct linear scan (the caller has already decided the
|
||||
* order), which is the correct semantics for singleton dispatch.
|
||||
*/
|
||||
ancestryOverride?: readonly string[],
|
||||
): SymbolDefinition | undefined => {
|
||||
// ── Ruby mixin strategy ───────────────────────────────────────────
|
||||
// Kind-aware walk — does NOT short-circuit on direct owner first (prepend beats direct).
|
||||
// Instance dispatch: prepend (reverse) → direct → include (reverse) → transitive BFS.
|
||||
// Singleton dispatch: caller supplies ancestryOverride (extend providers only);
|
||||
// simple left-to-right scan. Miss NEVER falls through to file-scoped fallback.
|
||||
// See gitnexus-shared/mro-strategy.ts § 'ruby-mixin' for full strategy docs.
|
||||
if (strategy === 'ruby-mixin') {
|
||||
if (ancestryOverride) {
|
||||
// Singleton dispatch: scan pre-computed ancestry only. Miss null-routes.
|
||||
for (const ancestorId of ancestryOverride) {
|
||||
const method = model.methods.lookupMethodByOwner(ancestorId, methodName, argCount);
|
||||
if (method) return method;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Instance dispatch — kind-aware walk per the pseudocode above.
|
||||
const instanceEntries = heritageMap.getInstanceAncestry(ownerNodeId);
|
||||
// Partition into prepend parents vs other parents (extends / include /
|
||||
// implements / trait-impl), preserving declaration order within each.
|
||||
const prependParents: string[] = [];
|
||||
const otherParents: string[] = [];
|
||||
for (const e of instanceEntries) {
|
||||
if (e.kind === 'prepend') prependParents.push(e.parentId);
|
||||
else otherParents.push(e.parentId);
|
||||
}
|
||||
|
||||
// Step 1: Walk prepend parents in REVERSE declaration order (last-prepended wins).
|
||||
for (let i = prependParents.length - 1; i >= 0; i--) {
|
||||
const method = model.methods.lookupMethodByOwner(prependParents[i], methodName, argCount);
|
||||
if (method) return method;
|
||||
}
|
||||
|
||||
// Step 2: Direct owner lookup (the class's own method).
|
||||
// This is the only difference from other strategies — prepend beats direct.
|
||||
const direct = model.methods.lookupMethodByOwner(ownerNodeId, methodName, argCount);
|
||||
if (direct) return direct;
|
||||
|
||||
// Step 3: Walk extends + include parents in REVERSE declaration order.
|
||||
// (Ruby `include A; include B` puts B ahead of A in MRO.)
|
||||
for (let i = otherParents.length - 1; i >= 0; i--) {
|
||||
const method = model.methods.lookupMethodByOwner(otherParents[i], methodName, argCount);
|
||||
if (method) return method;
|
||||
}
|
||||
|
||||
// Step 4: Transitive ancestors (a mixin that itself mixes in another module).
|
||||
// Fall back to the BFS ancestor walk for depth > 1. Order is best-effort;
|
||||
// Ruby's actual MRO for transitive mixins is rare and under-specified
|
||||
// (documented in architecture docs as deferred work).
|
||||
//
|
||||
// O(1) skip-check via Sets:
|
||||
// - `walkedDirect` covers parents already visited in steps 1-3.
|
||||
// - `singletonOnly` covers direct `extend` providers: they belong to
|
||||
// the singleton MRO and must NEVER appear in instance dispatch.
|
||||
// Building Sets once before the BFS loop avoids O(n²) `Array.includes`
|
||||
// on large mixin hierarchies.
|
||||
const walkedDirect = new Set<string>(prependParents);
|
||||
for (const id of otherParents) walkedDirect.add(id);
|
||||
const singletonOnly = new Set<string>(
|
||||
heritageMap.getSingletonAncestry(ownerNodeId).map((e) => e.parentId),
|
||||
);
|
||||
for (const ancestorId of heritageMap.getAncestors(ownerNodeId)) {
|
||||
if (ancestorId === ownerNodeId) continue;
|
||||
if (walkedDirect.has(ancestorId)) continue;
|
||||
if (singletonOnly.has(ancestorId)) continue;
|
||||
const method = model.methods.lookupMethodByOwner(ancestorId, methodName, argCount);
|
||||
if (method) return method;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── Non-Ruby strategies: direct-owner-first short-circuit ─────────
|
||||
|
||||
// Direct lookup first (child override — no walk needed).
|
||||
// argCount is threaded through so arity-differing overloads on the direct
|
||||
// owner can be disambiguated before the MRO walk starts.
|
||||
const direct = model.methods.lookupMethodByOwner(ownerNodeId, methodName, argCount);
|
||||
if (direct) return direct;
|
||||
|
||||
// Rust: requires qualified syntax (<Type as Trait>::method), no auto-resolution
|
||||
if (strategy === 'qualified-syntax') return undefined;
|
||||
|
||||
// Determine ancestor walk order based on MRO strategy.
|
||||
// readonly to accept the cached (frozen) c3 linearization without copying.
|
||||
let ancestors: readonly string[];
|
||||
if (ancestryOverride) {
|
||||
ancestors = ancestryOverride;
|
||||
} else if (strategy === 'c3') {
|
||||
// C3 linearization (memoized per HeritageMap
|
||||
// so repeated calls for the same owner within an ingestion run reuse the
|
||||
// linearization instead of rebuilding the parent map and re-running C3).
|
||||
// c3Linearize returns ancestors only (excludes the owner itself),
|
||||
// matching heritageMap.getAncestors() semantics.
|
||||
const c3Result = getCachedC3Linearization(ownerNodeId, heritageMap);
|
||||
// Fall back to BFS order if C3 fails (cyclic or inconsistent hierarchy).
|
||||
// Note: BFS order may not preserve Python MRO semantics in these edge
|
||||
// cases, but cyclic/inconsistent hierarchies are invalid in Python anyway.
|
||||
ancestors = c3Result ?? heritageMap.getAncestors(ownerNodeId);
|
||||
} else {
|
||||
// first-wins, leftmost-base, implements-split: BFS order via HeritageMap
|
||||
ancestors = heritageMap.getAncestors(ownerNodeId);
|
||||
}
|
||||
|
||||
// Walk ancestors in MRO order — first match wins.
|
||||
// argCount narrows overloaded ancestors the same way as the direct lookup.
|
||||
for (const ancestorId of ancestors) {
|
||||
const method = model.methods.lookupMethodByOwner(ancestorId, methodName, argCount);
|
||||
if (method) return method;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -126,10 +126,8 @@ export interface SemanticModel {
|
|||
/**
|
||||
* Materialized scope-resolution indexes from RFC #909 Ring 2 PKG #921.
|
||||
*
|
||||
* `undefined` until the finalize-orchestrator attaches them. While
|
||||
* `undefined`, the legacy DAG is the sole resolution surface; once set,
|
||||
* resolvers whose language has `REGISTRY_PRIMARY_<LANG>=true` consult
|
||||
* these indexes instead.
|
||||
* `undefined` until the finalize-orchestrator attaches them. Once set,
|
||||
* the scope-resolution resolvers consult these indexes.
|
||||
*
|
||||
* The attach is a one-shot write (see `MutableSemanticModel`). Callers
|
||||
* holding a read-only `SemanticModel` handle see either `undefined` or
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import Parser from 'tree-sitter';
|
|||
import { loadParser, loadLanguage, isLanguageAvailable } from '../tree-sitter/parser-loader.js';
|
||||
import { getProvider } from './languages/index.js';
|
||||
import { generateId } from '../../lib/utils.js';
|
||||
import type { SymbolTableReader, SymbolTableWriter, ExtractedHeritage } from './model/index.js';
|
||||
import type { SymbolTableReader, SymbolTableWriter } from './model/index.js';
|
||||
import { ASTCache } from './ast-cache.js';
|
||||
import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared';
|
||||
import { extractVueScript, isVueSetupTopLevel } from './vue-sfc-extractor.js';
|
||||
|
|
@ -81,7 +81,6 @@ export interface WorkerExtractedData {
|
|||
imports: ExtractedImport[];
|
||||
calls: ExtractedCall[];
|
||||
assignments: ExtractedAssignment[];
|
||||
heritage: ExtractedHeritage[];
|
||||
routes: ExtractedRoute[];
|
||||
fetchCalls: ExtractedFetchCall[];
|
||||
fetchWrapperDefs: FetchWrapperDef[];
|
||||
|
|
@ -126,7 +125,6 @@ export const mergeChunkResults = (
|
|||
const allImports: ExtractedImport[] = [];
|
||||
const allCalls: ExtractedCall[] = [];
|
||||
const allAssignments: ExtractedAssignment[] = [];
|
||||
const allHeritage: ExtractedHeritage[] = [];
|
||||
const allRoutes: ExtractedRoute[] = [];
|
||||
const allFetchCalls: ExtractedFetchCall[] = [];
|
||||
const allFetchWrapperDefs: FetchWrapperDef[] = [];
|
||||
|
|
@ -167,7 +165,6 @@ export const mergeChunkResults = (
|
|||
for (const item of result.imports) allImports.push(item);
|
||||
for (const item of result.calls) allCalls.push(item);
|
||||
for (const item of result.assignments) allAssignments.push(item);
|
||||
for (const item of result.heritage) allHeritage.push(item);
|
||||
for (const item of result.routes) allRoutes.push(item);
|
||||
for (const item of result.fetchCalls) allFetchCalls.push(item);
|
||||
for (const item of result.fetchWrapperDefs ?? []) allFetchWrapperDefs.push(item);
|
||||
|
|
@ -187,7 +184,6 @@ export const mergeChunkResults = (
|
|||
imports: allImports,
|
||||
calls: allCalls,
|
||||
assignments: allAssignments,
|
||||
heritage: allHeritage,
|
||||
routes: allRoutes,
|
||||
fetchCalls: allFetchCalls,
|
||||
fetchWrapperDefs: allFetchWrapperDefs,
|
||||
|
|
@ -231,7 +227,6 @@ const processParsingWithWorkers = async (
|
|||
imports: [],
|
||||
calls: [],
|
||||
assignments: [],
|
||||
heritage: [],
|
||||
routes: [],
|
||||
fetchCalls: [],
|
||||
fetchWrapperDefs: [],
|
||||
|
|
|
|||
|
|
@ -1,271 +0,0 @@
|
|||
/**
|
||||
* Cross-file binding propagation — extracted from pipeline.ts.
|
||||
*
|
||||
* Seeds downstream files with resolved type bindings from upstream exports.
|
||||
* Files are processed in topological import order so upstream bindings
|
||||
* are available when downstream files are re-resolved.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import {
|
||||
processCalls,
|
||||
buildImportedReturnTypes,
|
||||
buildImportedRawReturnTypes,
|
||||
type ExportedTypeMap,
|
||||
} from '../call-processor.js';
|
||||
import type { createResolutionContext } from '../model/resolution-context.js';
|
||||
import { createASTCache } from '../ast-cache.js';
|
||||
import {
|
||||
type PipelineProgress,
|
||||
getLanguageFromFilename,
|
||||
type SupportedLanguages,
|
||||
} from 'gitnexus-shared';
|
||||
import { readFileContents } from '../filesystem-walker.js';
|
||||
import { isLanguageAvailable } from '../../tree-sitter/parser-loader.js';
|
||||
import { isRegistryPrimary } from '../registry-primary-flag.js';
|
||||
import { topologicalLevelSort } from '../utils/graph-sort.js';
|
||||
import type { KnowledgeGraph } from '../../graph/types.js';
|
||||
import { isDev } from '../utils/env.js';
|
||||
import type Parser from 'tree-sitter';
|
||||
|
||||
import { logger } from '../../logger.js';
|
||||
/** Max AST trees to keep in LRU cache for cross-file binding propagation. */
|
||||
const AST_CACHE_CAP = 50;
|
||||
|
||||
/** Minimum percentage of files that must benefit from cross-file seeding. */
|
||||
const CROSS_FILE_SKIP_THRESHOLD = 0.03;
|
||||
/** Hard cap on files re-processed during cross-file propagation. */
|
||||
const MAX_CROSS_FILE_REPROCESS = 2000;
|
||||
|
||||
/**
|
||||
* Cross-file binding propagation.
|
||||
* Returns the number of files re-processed.
|
||||
*/
|
||||
export async function runCrossFileBindingPropagation(
|
||||
graph: KnowledgeGraph,
|
||||
ctx: ReturnType<typeof createResolutionContext>,
|
||||
parseExportedTypeMap: ReadonlyMap<string, ReadonlyMap<string, string>>,
|
||||
allPathSet: ReadonlySet<string>,
|
||||
totalFiles: number,
|
||||
repoPath: string,
|
||||
pipelineStart: number,
|
||||
onProgress: (progress: PipelineProgress) => void,
|
||||
): Promise<number> {
|
||||
if (parseExportedTypeMap.size === 0 || ctx.namedImportMap.size === 0) return 0;
|
||||
|
||||
// Build a local mutable working copy. Per-file re-resolution below mutates
|
||||
// this map (each `processCalls` writes that file's exports back into it so
|
||||
// later iterations in the same level/loop can resolve transitive bindings).
|
||||
// Owning a local copy here keeps `ParseOutput.exportedTypeMap` truly
|
||||
// read-only at the phase boundary — no cast, no shared-mutable handoff.
|
||||
const exportedTypeMap: ExportedTypeMap = new Map();
|
||||
for (const [fp, exports] of parseExportedTypeMap) {
|
||||
exportedTypeMap.set(fp, new Map(exports));
|
||||
}
|
||||
|
||||
const { levels, cycleCount } = topologicalLevelSort(ctx.importMap);
|
||||
|
||||
if (isDev && cycleCount > 0) {
|
||||
logger.info(`🔄 ${cycleCount} files in import cycles (processed last in undefined order)`);
|
||||
}
|
||||
|
||||
let filesWithGaps = 0;
|
||||
const gapThreshold = Math.max(1, Math.ceil(totalFiles * CROSS_FILE_SKIP_THRESHOLD));
|
||||
outer: for (const level of levels) {
|
||||
for (const filePath of level) {
|
||||
const imports = ctx.namedImportMap.get(filePath);
|
||||
if (!imports) continue;
|
||||
for (const [, binding] of imports) {
|
||||
const upstream = exportedTypeMap.get(binding.sourcePath);
|
||||
if (upstream?.has(binding.exportedName)) {
|
||||
filesWithGaps++;
|
||||
break;
|
||||
}
|
||||
const def = ctx.model.symbols.lookupExactFull(binding.sourcePath, binding.exportedName);
|
||||
if (def?.returnType) {
|
||||
filesWithGaps++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (filesWithGaps >= gapThreshold) break outer;
|
||||
}
|
||||
}
|
||||
|
||||
const gapRatio = totalFiles > 0 ? filesWithGaps / totalFiles : 0;
|
||||
if (gapRatio < CROSS_FILE_SKIP_THRESHOLD && filesWithGaps < gapThreshold) {
|
||||
if (isDev) {
|
||||
logger.info(
|
||||
`⏭️ Cross-file re-resolution skipped (${filesWithGaps}/${totalFiles} files, ${(gapRatio * 100).toFixed(1)}% < ${CROSS_FILE_SKIP_THRESHOLD * 100}% threshold)`,
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Intentionally reports `phase: 'parsing'` rather than a separate
|
||||
// 'crossFile' phase: cross-file re-resolution is logically a continuation of
|
||||
// the parsing/resolution work and is bucketed under "parsing" in any
|
||||
// telemetry that groups events by phase name. Kept consistent with the
|
||||
// upstream `parse` phase's progress events so the UI shows one continuous
|
||||
// progress segment instead of a phase flicker. If a future change splits
|
||||
// this out into its own phase, also rename `parse-impl.ts` per-chunk
|
||||
// progress events accordingly.
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: 82,
|
||||
message: `Cross-file type propagation (${filesWithGaps}+ files)...`,
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
|
||||
let crossFileResolved = 0;
|
||||
const crossFileStart = Date.now();
|
||||
const astCache = createASTCache(AST_CACHE_CAP);
|
||||
// Compiled query objects keyed by language name. Shared across all processCalls
|
||||
// invocations in this phase so the same tree-sitter query string is only
|
||||
// compiled once per language instead of once per file (O(1) vs O(N)).
|
||||
const compiledQueryCache = new Map<SupportedLanguages, Parser.Query>();
|
||||
|
||||
// Snapshot total topological candidates for progress math. We walk the
|
||||
// levels once more here (fast — no I/O) so we can report meaningful
|
||||
// percentages rather than a frozen display.
|
||||
let totalCandidates = 0;
|
||||
for (const level of levels) {
|
||||
for (const filePath of level) {
|
||||
if (totalCandidates >= MAX_CROSS_FILE_REPROCESS) break;
|
||||
const imports = ctx.namedImportMap.get(filePath);
|
||||
if (!imports) continue;
|
||||
if (!allPathSet.has(filePath)) continue;
|
||||
const lang = getLanguageFromFilename(filePath);
|
||||
if (!lang || !isLanguageAvailable(lang)) continue;
|
||||
// Registry-primary languages have their call resolution handled by the
|
||||
// scope-resolution pipeline — processCalls skips them immediately. Skip
|
||||
// here too so we avoid the I/O cost (readFileContents) and map-building
|
||||
// overhead for files that would be no-ops anyway.
|
||||
if (isRegistryPrimary(lang)) continue;
|
||||
totalCandidates++;
|
||||
}
|
||||
if (totalCandidates >= MAX_CROSS_FILE_REPROCESS) break;
|
||||
}
|
||||
const cappedTotal = Math.min(totalCandidates, MAX_CROSS_FILE_REPROCESS);
|
||||
|
||||
/** Emit a progress event every PROGRESS_INTERVAL files so the UI stays alive. */
|
||||
const PROGRESS_INTERVAL = 25;
|
||||
|
||||
for (const level of levels) {
|
||||
const levelCandidates: {
|
||||
filePath: string;
|
||||
seeded: Map<string, string>;
|
||||
importedReturns: ReadonlyMap<string, string>;
|
||||
importedRawReturns: ReadonlyMap<string, string>;
|
||||
}[] = [];
|
||||
for (const filePath of level) {
|
||||
if (crossFileResolved + levelCandidates.length >= MAX_CROSS_FILE_REPROCESS) break;
|
||||
const imports = ctx.namedImportMap.get(filePath);
|
||||
if (!imports) continue;
|
||||
|
||||
const seeded = new Map<string, string>();
|
||||
for (const [localName, binding] of imports) {
|
||||
const upstream = exportedTypeMap.get(binding.sourcePath);
|
||||
if (upstream) {
|
||||
const type = upstream.get(binding.exportedName);
|
||||
if (type) seeded.set(localName, type);
|
||||
}
|
||||
}
|
||||
|
||||
const importedReturns = buildImportedReturnTypes(
|
||||
filePath,
|
||||
ctx.namedImportMap,
|
||||
ctx.model.symbols,
|
||||
);
|
||||
const importedRawReturns = buildImportedRawReturnTypes(
|
||||
filePath,
|
||||
ctx.namedImportMap,
|
||||
ctx.model.symbols,
|
||||
);
|
||||
if (seeded.size === 0 && importedReturns.size === 0) continue;
|
||||
if (!allPathSet.has(filePath)) continue;
|
||||
|
||||
const lang = getLanguageFromFilename(filePath);
|
||||
if (!lang || !isLanguageAvailable(lang)) continue;
|
||||
// Registry-primary languages have their call resolution handled by the
|
||||
// scope-resolution pipeline — processCalls skips them immediately. Skip
|
||||
// here to avoid readFileContents I/O and map-building for no-op files.
|
||||
if (isRegistryPrimary(lang)) continue;
|
||||
|
||||
levelCandidates.push({ filePath, seeded, importedReturns, importedRawReturns });
|
||||
}
|
||||
|
||||
if (levelCandidates.length === 0) continue;
|
||||
|
||||
const levelPaths = levelCandidates.map((c) => c.filePath);
|
||||
const contentMap = await readFileContents(repoPath, levelPaths);
|
||||
|
||||
for (const { filePath, seeded, importedReturns, importedRawReturns } of levelCandidates) {
|
||||
const content = contentMap.get(filePath);
|
||||
if (!content) continue;
|
||||
|
||||
const reFile = [{ path: filePath, content }];
|
||||
const bindings = new Map<string, ReadonlyMap<string, string>>();
|
||||
if (seeded.size > 0) bindings.set(filePath, seeded);
|
||||
|
||||
const importedReturnTypesMap = new Map<string, ReadonlyMap<string, string>>();
|
||||
if (importedReturns.size > 0) {
|
||||
importedReturnTypesMap.set(filePath, importedReturns);
|
||||
}
|
||||
|
||||
const importedRawReturnTypesMap = new Map<string, ReadonlyMap<string, string>>();
|
||||
if (importedRawReturns.size > 0) {
|
||||
importedRawReturnTypesMap.set(filePath, importedRawReturns);
|
||||
}
|
||||
|
||||
await processCalls(
|
||||
graph,
|
||||
reFile,
|
||||
astCache,
|
||||
ctx,
|
||||
undefined,
|
||||
exportedTypeMap,
|
||||
bindings.size > 0 ? bindings : undefined,
|
||||
importedReturnTypesMap.size > 0 ? importedReturnTypesMap : undefined,
|
||||
importedRawReturnTypesMap.size > 0 ? importedRawReturnTypesMap : undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
compiledQueryCache,
|
||||
);
|
||||
crossFileResolved++;
|
||||
|
||||
// Emit progress every PROGRESS_INTERVAL files so the UI shows real
|
||||
// movement instead of a frozen display (cross-file can take minutes
|
||||
// on large repos with many cross-file imports).
|
||||
if (crossFileResolved % PROGRESS_INTERVAL === 0 || crossFileResolved === cappedTotal) {
|
||||
const pct = cappedTotal > 0 ? Math.round((crossFileResolved / cappedTotal) * 8) : 0;
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: 82 + pct,
|
||||
message: `Cross-file type propagation (${crossFileResolved}/${cappedTotal} files)...`,
|
||||
stats: { filesProcessed: crossFileResolved, totalFiles, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (crossFileResolved >= MAX_CROSS_FILE_REPROCESS) {
|
||||
if (isDev)
|
||||
logger.info(`⚠️ Cross-file re-resolution capped at ${MAX_CROSS_FILE_REPROCESS} files`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
astCache.clear();
|
||||
|
||||
if (isDev) {
|
||||
const elapsed = Date.now() - crossFileStart;
|
||||
const totalElapsed = Date.now() - pipelineStart;
|
||||
const reResolutionPct = totalElapsed > 0 ? ((elapsed / totalElapsed) * 100).toFixed(1) : '0';
|
||||
logger.info(
|
||||
`🔗 Cross-file re-resolution: ${crossFileResolved} candidates re-processed` +
|
||||
` in ${elapsed}ms (${reResolutionPct}% of total ingestion time so far)`,
|
||||
);
|
||||
}
|
||||
|
||||
return crossFileResolved;
|
||||
}
|
||||
|
|
@ -1,20 +1,21 @@
|
|||
/**
|
||||
* Phase: crossFile
|
||||
*
|
||||
* Cross-file binding propagation: seeds downstream files with resolved
|
||||
* type bindings from upstream exports. Files are processed in topological
|
||||
* import order so upstream bindings are available when downstream files
|
||||
* are re-resolved.
|
||||
* Accumulator disposal anchor. The legacy cross-file call re-resolution that
|
||||
* this phase used to run (`runCrossFileBindingPropagation`) was owned by the
|
||||
* call-resolution DAG and skipped every registry-primary language; RING4-1
|
||||
* (#942) deleted the DAG, so the propagation is gone and this phase now only
|
||||
* disposes the `BindingAccumulator`. It is kept as a phase (rather than folded
|
||||
* into `parse`) so disposal stays sequenced after every accumulator consumer.
|
||||
*
|
||||
* @deps parse, routes, tools, orm (waits for all post-parse phases)
|
||||
* @reads exportedTypeMap, allPaths, totalFiles
|
||||
* @writes graph (refined CALLS edges via re-resolution)
|
||||
* @reads totalFiles, bindingAccumulator
|
||||
* @writes nothing (disposal only)
|
||||
*
|
||||
* **Accumulator ownership / residual risk.** This phase is the sole
|
||||
* disposer of the `BindingAccumulator` produced by `parse`. The dispose
|
||||
* call lives inside a `finally` block in `execute()` so that a throw
|
||||
* inside `runCrossFileBindingPropagation` (or anywhere else in the body)
|
||||
* still releases the accumulator's heap. The dependency declaration
|
||||
* anywhere in the body still releases the accumulator's heap. The dependency declaration
|
||||
* (`deps: ['parse', 'routes', 'tools', 'orm']`) plus the runner's
|
||||
* topological scheduling guarantee that every other consumer of the
|
||||
* accumulator has finished before this phase starts, so disposing here
|
||||
|
|
@ -33,7 +34,6 @@
|
|||
import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js';
|
||||
import { getPhaseOutput } from './types.js';
|
||||
import type { ParseOutput } from './parse.js';
|
||||
import { runCrossFileBindingPropagation } from './cross-file-impl.js';
|
||||
import { isDev } from '../utils/env.js';
|
||||
|
||||
import { logger } from '../../logger.js';
|
||||
|
|
@ -50,8 +50,7 @@ export const crossFilePhase: PipelinePhase<CrossFileOutput> = {
|
|||
ctx: PipelineContext,
|
||||
deps: ReadonlyMap<string, PhaseResult<unknown>>,
|
||||
): Promise<CrossFileOutput> {
|
||||
const { exportedTypeMap, allPathSet, totalFiles, bindingAccumulator, resolutionContext } =
|
||||
getPhaseOutput<ParseOutput>(deps, 'parse');
|
||||
const { totalFiles, bindingAccumulator } = getPhaseOutput<ParseOutput>(deps, 'parse');
|
||||
|
||||
try {
|
||||
// Telemetry must run BEFORE dispose: totalBindings, fileCount, and
|
||||
|
|
@ -70,18 +69,12 @@ export const crossFilePhase: PipelinePhase<CrossFileOutput> = {
|
|||
}
|
||||
}
|
||||
|
||||
const filesReprocessed = await runCrossFileBindingPropagation(
|
||||
ctx.graph,
|
||||
resolutionContext,
|
||||
exportedTypeMap,
|
||||
allPathSet,
|
||||
totalFiles,
|
||||
ctx.repoPath,
|
||||
ctx.pipelineStart,
|
||||
ctx.onProgress,
|
||||
);
|
||||
|
||||
return { filesReprocessed };
|
||||
// Legacy cross-file call re-resolution was owned by the call-resolution
|
||||
// DAG (registry-primary languages skipped it entirely). With the DAG
|
||||
// removed (RING4-1 #942), scope-resolution owns all CALLS edges and no
|
||||
// cross-file re-resolution pass runs here. This phase survives solely to
|
||||
// dispose the BindingAccumulator on the runner's behalf (see finally).
|
||||
return { filesReprocessed: 0 };
|
||||
} finally {
|
||||
// Single dispose call site for the accumulator — runs on both the
|
||||
// happy path and the throw path so the heap is always released
|
||||
|
|
|
|||
|
|
@ -28,25 +28,13 @@ import {
|
|||
} from '../import-processor.js';
|
||||
import { EMPTY_INDEX } from '../import-resolvers/utils.js';
|
||||
import {
|
||||
processCalls,
|
||||
processCallsFromExtracted,
|
||||
processAssignmentsFromExtracted,
|
||||
processRoutesFromExtracted,
|
||||
seedCrossFileReceiverTypes,
|
||||
buildExportedTypeMapFromGraph,
|
||||
type ExportedTypeMap,
|
||||
} from '../call-processor.js';
|
||||
import { buildHeritageMap } from '../model/heritage-map.js';
|
||||
import {
|
||||
processHeritage,
|
||||
processHeritageFromExtracted,
|
||||
extractExtractedHeritageFromFiles,
|
||||
getHeritageStrategyForLanguage,
|
||||
} from '../heritage-processor.js';
|
||||
import { createResolutionContext } from '../model/resolution-context.js';
|
||||
import { ASTCache, createASTCache } from '../ast-cache.js';
|
||||
import { type PipelineProgress, getLanguageFromFilename } from 'gitnexus-shared';
|
||||
import { isRegistryPrimary } from '../registry-primary-flag.js';
|
||||
import { readFileContents } from '../filesystem-walker.js';
|
||||
import { isLanguageAvailable } from '../../tree-sitter/parser-loader.js';
|
||||
import {
|
||||
|
|
@ -56,15 +44,12 @@ import {
|
|||
} from '../workers/worker-pool.js';
|
||||
import type { WorkerPool } from '../workers/worker-pool.js';
|
||||
import type {
|
||||
ExtractedAssignment,
|
||||
ExtractedCall,
|
||||
ExtractedDecoratorRoute,
|
||||
ExtractedFetchCall,
|
||||
ExtractedImport,
|
||||
ExtractedORMQuery,
|
||||
ExtractedRoute,
|
||||
ExtractedToolDef,
|
||||
FileConstructorBindings,
|
||||
FetchWrapperDef,
|
||||
} from '../workers/parse-worker.js';
|
||||
import type {
|
||||
|
|
@ -72,7 +57,6 @@ import type {
|
|||
ExtractedRouterInclude,
|
||||
ExtractedRouterModuleAlias,
|
||||
} from '../route-extractors/fastapi-router-bindings.js';
|
||||
import type { ExtractedHeritage } from '../model/heritage-map.js';
|
||||
import type { KnowledgeGraph } from '../../graph/types.js';
|
||||
import type { PipelineOptions } from '../pipeline.js';
|
||||
import { extractFetchCallsFromFiles } from '../call-processor.js';
|
||||
|
|
@ -456,10 +440,6 @@ export async function runChunkedParseAndResolve(
|
|||
const allRouterModuleAliases: ExtractedRouterModuleAlias[] = [];
|
||||
const allToolDefs: ExtractedToolDef[] = [];
|
||||
const allORMQueries: ExtractedORMQuery[] = [];
|
||||
const deferredWorkerCalls: ExtractedCall[] = [];
|
||||
const deferredWorkerHeritage: ExtractedHeritage[] = [];
|
||||
const deferredConstructorBindings: FileConstructorBindings[] = [];
|
||||
const deferredAssignments: ExtractedAssignment[] = [];
|
||||
// Imports accumulated across chunks. Previously processed per-chunk
|
||||
// via `processImportsFromExtracted` inside the chunk loop, which
|
||||
// forced workers to sit idle on the main thread's extraction pass
|
||||
|
|
@ -680,10 +660,9 @@ export async function runChunkedParseAndResolve(
|
|||
}
|
||||
}
|
||||
|
||||
// Per-chunk extraction passes (processImportsFromExtracted,
|
||||
// processHeritageFromExtracted, processRoutesFromExtracted,
|
||||
// synthesizeWildcardImportBindings, seedCrossFileReceiverTypes)
|
||||
// moved out of the chunk loop into a single end-of-loop pass below.
|
||||
// Per-chunk extraction passes (import resolution, route resolution,
|
||||
// wildcard-import synthesis) moved out of the chunk loop into a single
|
||||
// end-of-loop pass below.
|
||||
// Reason: per-chunk extraction blocked the chunk loop on
|
||||
// main-thread work between worker dispatches — workers sat idle
|
||||
// and total CPU utilization plateaued at 4-5% on multi-core boxes.
|
||||
|
|
@ -696,11 +675,16 @@ export async function runChunkedParseAndResolve(
|
|||
}
|
||||
const skipFile = new Set<string>();
|
||||
const checkFile = new Set<string>();
|
||||
// Legacy deferred-import accumulation. Imports for every known language
|
||||
// are resolved by the scope-resolution phase (RING4-1 #942 removed the
|
||||
// legacy resolution path), so known-language files are never accumulated
|
||||
// here; only null-language files (no parser) would be, which never have
|
||||
// resolvable imports — so this path is effectively inert.
|
||||
const shouldAccumulate = (filePath: string): boolean => {
|
||||
if (checkFile.has(filePath)) return true;
|
||||
if (skipFile.has(filePath)) return false;
|
||||
const lang = getLanguageFromFilename(filePath);
|
||||
if (lang !== null && isRegistryPrimary(lang)) {
|
||||
if (lang !== null) {
|
||||
skipFile.add(filePath);
|
||||
return false;
|
||||
}
|
||||
|
|
@ -710,26 +694,12 @@ export async function runChunkedParseAndResolve(
|
|||
for (const item of chunkWorkerData.imports) {
|
||||
if (shouldAccumulate(item.filePath)) deferredWorkerImports.push(item);
|
||||
}
|
||||
for (const item of chunkWorkerData.calls) {
|
||||
if (shouldAccumulate(item.filePath)) deferredWorkerCalls.push(item);
|
||||
}
|
||||
for (const item of chunkWorkerData.heritage) {
|
||||
if (shouldAccumulate(item.filePath)) deferredWorkerHeritage.push(item);
|
||||
}
|
||||
for (const item of chunkWorkerData.constructorBindings) {
|
||||
if (shouldAccumulate(item.filePath)) deferredConstructorBindings.push(item);
|
||||
}
|
||||
// Aggregate worker-produced ParsedFile artifacts so scope-
|
||||
// resolution can use them as a re-extraction cache (skips its
|
||||
// own tree-sitter re-parse on warm runs).
|
||||
if (chunkWorkerData.parsedFiles?.length) {
|
||||
for (const item of chunkWorkerData.parsedFiles) allParsedFiles.push(item);
|
||||
}
|
||||
if (chunkWorkerData.assignments?.length) {
|
||||
for (const item of chunkWorkerData.assignments) {
|
||||
if (shouldAccumulate(item.filePath)) deferredAssignments.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (chunkWorkerData.fileScopeBindings?.length) {
|
||||
for (const { filePath, bindings } of chunkWorkerData.fileScopeBindings) {
|
||||
|
|
@ -808,29 +778,22 @@ export async function runChunkedParseAndResolve(
|
|||
}
|
||||
|
||||
// Deferred end-of-loop extraction (moved out of the per-chunk block):
|
||||
// 1. processImportsFromExtracted on all chunks' imports
|
||||
// 2. synthesizeWildcardImportBindings (if any chunk had wildcards)
|
||||
// 3. seedCrossFileReceiverTypes on deferred calls (depends on
|
||||
// namedImportMap populated by step 1)
|
||||
// 4. processHeritageFromExtracted on all chunks' heritage
|
||||
// 5. processRoutesFromExtracted on all chunks' routes
|
||||
// 1. import resolution on all chunks' imports
|
||||
// 2. wildcard-import binding synthesis (if any chunk had wildcards)
|
||||
// 3. route resolution on all chunks' routes
|
||||
// Same logic as the prior per-chunk passes, just batched — resolution
|
||||
// sees the full repo graph instead of just current-and-earlier chunks.
|
||||
// Deferred extraction band (M2 from PR #1693 review): the 4 stages below
|
||||
// each get their own 5-10 point slice of the 70-95 range so percent
|
||||
// advances monotonically through the (potentially long) resolution work
|
||||
// instead of holding flat at 82. Stages that are skipped (zero-length
|
||||
// input) leave their band as a no-op jump — the next stage still starts
|
||||
// at its own band, preserving monotonicity.
|
||||
// Call resolution and inheritance edges are emitted by the scope-resolution
|
||||
// phase, not here (RING4-1 #942 removed the legacy deferred passes).
|
||||
// Progress band: the stages below each get a slice of the 70-95 range so
|
||||
// percent advances monotonically through the (potentially long) resolution
|
||||
// work. Skipped stages (zero-length input) leave their band as a no-op jump.
|
||||
// imports: 70 -> 75 (5)
|
||||
// heritage: 75 -> 80 (5)
|
||||
// routes: 80 -> 85 (5)
|
||||
// calls: 85 -> 95 (10)
|
||||
const deferredProfile = isDeferredResolutionProfileEnabled();
|
||||
if (deferredProfile) {
|
||||
logDeferredProfile(
|
||||
`deferred band start: imports=${deferredWorkerImports.length} heritage=${deferredWorkerHeritage.length} ` +
|
||||
`calls=${deferredWorkerCalls.length} routes=${allExtractedRoutes.length}`,
|
||||
`deferred band start: imports=${deferredWorkerImports.length} routes=${allExtractedRoutes.length}`,
|
||||
);
|
||||
}
|
||||
if (deferredWorkerImports.length > 0) {
|
||||
|
|
@ -878,61 +841,14 @@ export async function runChunkedParseAndResolve(
|
|||
hasSynthesized = true;
|
||||
endTimer(tWildcard, (ms) => `synthesizeWildcardImportBindings: ${ms.toFixed(0)}ms`);
|
||||
}
|
||||
// L5 from PR #1693 review: populate `exportedTypeMap` from the in-progress
|
||||
// graph BEFORE `seedCrossFileReceiverTypes` runs. Previously the seeding
|
||||
// branch below was reached with `exportedTypeMap.size === 0` in the
|
||||
// worker path (the map was only built at the post-parse block far below,
|
||||
// AFTER the seeding branch), so the seed dead-coded itself silently and
|
||||
// call resolution never got the cross-file receiver-type enrichment.
|
||||
// The post-parse builder still runs as a defensive fallback on the
|
||||
// sequential path; its `size === 0` guard means we don't pay the cost
|
||||
// twice on the worker path.
|
||||
// Populate `exportedTypeMap` from the in-progress graph so the post-parse
|
||||
// enrichment pass (enrichExportedTypeMap) sees cross-file export types.
|
||||
// Inheritance and call resolution are owned by the scope-resolution phase
|
||||
// (RING4-1 #942 removed the legacy heritage/call-DAG deferred passes here).
|
||||
if (exportedTypeMap.size === 0 && graph.nodeCount > 0) {
|
||||
const graphExports = buildExportedTypeMapFromGraph(graph, ctx.model.symbols);
|
||||
for (const [fp, exports] of graphExports) exportedTypeMap.set(fp, exports);
|
||||
}
|
||||
if (exportedTypeMap.size > 0 && ctx.namedImportMap.size > 0 && deferredWorkerCalls.length > 0) {
|
||||
const { enrichedCount } = seedCrossFileReceiverTypes(
|
||||
deferredWorkerCalls,
|
||||
ctx.namedImportMap,
|
||||
exportedTypeMap,
|
||||
);
|
||||
if (enrichedCount > 0) {
|
||||
// Two independent gates, not else-if: when both isDev AND
|
||||
// deferredProfile are active, BOTH lines fire — log scrapers keyed
|
||||
// on the original "🔗 E1" emoji marker keep matching, AND operators
|
||||
// grepping the [deferred-profile] prefix see no gap between the
|
||||
// wildcard-synth and heritage timings.
|
||||
if (isDev) {
|
||||
logger.info(`🔗 E1: Seeded ${enrichedCount} cross-file receiver types (all chunks)`);
|
||||
}
|
||||
if (deferredProfile) {
|
||||
logDeferredProfile(`E1: seeded ${enrichedCount} cross-file receiver types (all chunks)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (deferredWorkerHeritage.length > 0) {
|
||||
const tHeritage = startTimer(deferredProfile);
|
||||
await processHeritageFromExtracted(graph, deferredWorkerHeritage, ctx, (current, total) => {
|
||||
const ratio = total > 0 ? current / total : 1;
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: 75 + Math.round(ratio * 5),
|
||||
message: 'Resolving heritage (all chunks)...',
|
||||
detail: `${current}/${total} records`,
|
||||
stats: {
|
||||
filesProcessed: filesParsedSoFar,
|
||||
totalFiles: totalParseable,
|
||||
nodesCreated: graph.nodeCount,
|
||||
},
|
||||
});
|
||||
});
|
||||
endTimer(
|
||||
tHeritage,
|
||||
(ms) =>
|
||||
`processHeritageFromExtracted: ${ms.toFixed(0)}ms (${deferredWorkerHeritage.length} records)`,
|
||||
);
|
||||
}
|
||||
if (allExtractedRoutes.length > 0) {
|
||||
const tRoutes = startTimer(deferredProfile);
|
||||
await processRoutesFromExtracted(graph, allExtractedRoutes, ctx, (current, total) => {
|
||||
|
|
@ -955,85 +871,6 @@ export async function runChunkedParseAndResolve(
|
|||
`processRoutesFromExtracted: ${ms.toFixed(0)}ms (${allExtractedRoutes.length} routes)`,
|
||||
);
|
||||
}
|
||||
|
||||
let fullWorkerHeritageMap: ReturnType<typeof buildHeritageMap> | undefined;
|
||||
if (deferredWorkerHeritage.length > 0) {
|
||||
const tBuildHeritage = startTimer(deferredProfile);
|
||||
fullWorkerHeritageMap = buildHeritageMap(
|
||||
deferredWorkerHeritage,
|
||||
ctx,
|
||||
getHeritageStrategyForLanguage,
|
||||
);
|
||||
endTimer(tBuildHeritage, (ms) => `buildHeritageMap wall: ${ms.toFixed(0)}ms`);
|
||||
} else if (deferredProfile) {
|
||||
logDeferredProfile('buildHeritageMap: skipped (no heritage records)');
|
||||
}
|
||||
// U15 (lightweight M1): buildHeritageMap is the LAST consumer of the
|
||||
// raw `deferredWorkerHeritage` records — processCallsFromExtracted
|
||||
// below reads from the derived `fullWorkerHeritageMap` instead. Free
|
||||
// the raw heritage array now so the GC can reclaim it before the
|
||||
// (potentially long) call-resolution stage. processHeritageFromExtracted
|
||||
// earlier was a read-only consumer (pushed to graph, didn't drain).
|
||||
deferredWorkerHeritage.length = 0;
|
||||
|
||||
if (deferredWorkerCalls.length > 0) {
|
||||
if (deferredProfile) {
|
||||
logDeferredProfile(
|
||||
`processCallsFromExtracted: starting (${deferredWorkerCalls.length} call sites, heritageMap=${fullWorkerHeritageMap !== undefined})`,
|
||||
);
|
||||
}
|
||||
const tCalls = startTimer(deferredProfile);
|
||||
await processCallsFromExtracted(
|
||||
graph,
|
||||
deferredWorkerCalls,
|
||||
ctx,
|
||||
(current, total) => {
|
||||
const ratio = total > 0 ? current / total : 1;
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
// Calls is the longest deferred stage on real repos — give it the
|
||||
// 10-point tail 85-95 so the progress bar visibly advances during
|
||||
// call resolution instead of holding at 82 (M2).
|
||||
percent: 85 + Math.round(ratio * 10),
|
||||
message: 'Resolving calls (all chunks)...',
|
||||
detail: `${current}/${total} files`,
|
||||
stats: {
|
||||
filesProcessed: filesParsedSoFar,
|
||||
totalFiles: totalParseable,
|
||||
nodesCreated: graph.nodeCount,
|
||||
},
|
||||
});
|
||||
},
|
||||
deferredConstructorBindings.length > 0 ? deferredConstructorBindings : undefined,
|
||||
fullWorkerHeritageMap,
|
||||
bindingAccumulator,
|
||||
);
|
||||
endTimer(tCalls, (ms) => `processCallsFromExtracted: ${ms.toFixed(0)}ms total`);
|
||||
}
|
||||
|
||||
if (deferredAssignments.length > 0) {
|
||||
processAssignmentsFromExtracted(
|
||||
graph,
|
||||
deferredAssignments,
|
||||
ctx,
|
||||
deferredConstructorBindings.length > 0 ? deferredConstructorBindings : undefined,
|
||||
bindingAccumulator,
|
||||
);
|
||||
}
|
||||
// U15 (lightweight M1): all three arrays have had their last consumer
|
||||
// by the time we reach this point — processCallsFromExtracted drained
|
||||
// `deferredWorkerCalls` and read `deferredConstructorBindings`;
|
||||
// processAssignmentsFromExtracted drained `deferredAssignments` and
|
||||
// also read `deferredConstructorBindings`. Free them now so the
|
||||
// function-scope references die before downstream graph-build /
|
||||
// scope-resolution starts using its own working memory. Note: arrays
|
||||
// returned in the function result object (allFetchCalls,
|
||||
// allExtractedRoutes, allDecoratorRoutes, allToolDefs, allORMQueries,
|
||||
// allParsedFiles) intentionally stay live — downstream consumers
|
||||
// need them.
|
||||
deferredWorkerCalls.length = 0;
|
||||
deferredConstructorBindings.length = 0;
|
||||
deferredAssignments.length = 0;
|
||||
} finally {
|
||||
await workerPool?.terminate();
|
||||
}
|
||||
|
|
@ -1059,8 +896,11 @@ export async function runChunkedParseAndResolve(
|
|||
synthesizeWildcardImportBindings(graph, ctx);
|
||||
hasSynthesized = true;
|
||||
}
|
||||
const allSequentialHeritage: ExtractedHeritage[] = [];
|
||||
const cachedSequentialChunkFiles: Array<Array<{ path: string; content: string }>> = [];
|
||||
// Sequential fallback: imports are resolved per-chunk above (processImports).
|
||||
// Calls and inheritance are emitted by the scope-resolution phase, not here
|
||||
// (RING4-1 #942 removed the legacy call/heritage resolution passes). This
|
||||
// loop still extracts fetch routes + ORM queries, which are language-agnostic
|
||||
// edge sources independent of call resolution.
|
||||
for (const chunkPaths of sequentialChunkPaths) {
|
||||
const chunkContents = await readFileContents(repoPath, chunkPaths);
|
||||
const chunkFiles: Array<{ path: string; content: string }> = [];
|
||||
|
|
@ -1068,37 +908,7 @@ export async function runChunkedParseAndResolve(
|
|||
const content = chunkContents.get(p);
|
||||
if (content !== undefined) chunkFiles.push({ path: p, content });
|
||||
}
|
||||
cachedSequentialChunkFiles.push(chunkFiles);
|
||||
astCache = createASTCache(chunkFiles.length);
|
||||
const sequentialHeritage = await extractExtractedHeritageFromFiles(chunkFiles, astCache);
|
||||
for (const h of sequentialHeritage) allSequentialHeritage.push(h);
|
||||
astCache.clear();
|
||||
}
|
||||
const sequentialHeritageMap =
|
||||
allSequentialHeritage.length > 0
|
||||
? buildHeritageMap(allSequentialHeritage, ctx, getHeritageStrategyForLanguage)
|
||||
: undefined;
|
||||
|
||||
for (let chunkIdx = 0; chunkIdx < sequentialChunkPaths.length; chunkIdx++) {
|
||||
const chunkFiles = cachedSequentialChunkFiles[chunkIdx];
|
||||
astCache = createASTCache(chunkFiles.length);
|
||||
const rubyHeritage = await processCalls(
|
||||
graph,
|
||||
chunkFiles,
|
||||
astCache,
|
||||
ctx,
|
||||
undefined,
|
||||
exportedTypeMap,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
sequentialHeritageMap,
|
||||
bindingAccumulator,
|
||||
);
|
||||
await processHeritage(graph, chunkFiles, astCache, ctx);
|
||||
if (rubyHeritage.length > 0) {
|
||||
await processHeritageFromExtracted(graph, rubyHeritage, ctx);
|
||||
}
|
||||
const chunkFetchCalls = await extractFetchCallsFromFiles(chunkFiles, astCache);
|
||||
if (chunkFetchCalls.length > 0) {
|
||||
for (const item of chunkFetchCalls) allFetchCalls.push(item);
|
||||
|
|
@ -1107,7 +917,6 @@ export async function runChunkedParseAndResolve(
|
|||
extractORMQueriesInline(f.path, f.content, allORMQueries);
|
||||
}
|
||||
astCache.clear();
|
||||
cachedSequentialChunkFiles[chunkIdx] = [];
|
||||
}
|
||||
|
||||
// Log resolution cache stats
|
||||
|
|
@ -1171,9 +980,9 @@ export async function runChunkedParseAndResolve(
|
|||
// `resolutionContext` (`ctx`) returned below is a distinct object — it owns
|
||||
// the fully-populated, post-parse `importMap` / `namedImportMap` /
|
||||
// `packageMap` / `moduleAliasMap` / `model`, and never references
|
||||
// `importCtx`. Cross-file re-resolution in cross-file-impl.ts consumes only
|
||||
// `ctx` (via `processCalls`), so clearing the suffix index / resolveCache /
|
||||
// normalizedFileList here cannot lose import matches downstream.
|
||||
// `importCtx`. Downstream consumers (the scope-resolution phase, route
|
||||
// extraction) consume only `ctx`, never `importCtx`, so clearing the suffix
|
||||
// index / resolveCache / normalizedFileList here cannot lose import matches.
|
||||
importCtx.resolveCache.clear();
|
||||
importCtx.index = EMPTY_INDEX;
|
||||
importCtx.normalizedFileList = [];
|
||||
|
|
|
|||
|
|
@ -1,130 +0,0 @@
|
|||
/**
|
||||
* `REGISTRY_PRIMARY_<LANG>` per-language feature flags for the scope-based
|
||||
* resolution rollout (RFC §6.1 Ring 3; Ring 2 PKG #924).
|
||||
*
|
||||
* This module is the single source of truth for whether a given language
|
||||
* has been flipped to registry-primary call resolution. When a language's
|
||||
* flag is true, its files route through `Registry.lookup` (RFC §4) instead
|
||||
* of the legacy call-resolution DAG; when false (the default), the legacy
|
||||
* DAG runs unchanged.
|
||||
*
|
||||
* ## Contract
|
||||
*
|
||||
* - Env-var name per language: `REGISTRY_PRIMARY_<UPPER(enum-value)>`.
|
||||
* Example: `SupportedLanguages.Python` → `REGISTRY_PRIMARY_PYTHON`;
|
||||
* `SupportedLanguages.CPlusPlus` (value `'cpp'`) → `REGISTRY_PRIMARY_CPP`.
|
||||
* `SupportedLanguages.Cobol` (value `'cobol'`) → `REGISTRY_PRIMARY_COBOL`.
|
||||
* - Truthy values: `'true'`, `'1'`, `'yes'` (case-insensitive,
|
||||
* whitespace-trimmed). Anything else — including `undefined`, empty
|
||||
* string, or unknown tokens — is `false`.
|
||||
* - No per-process caching. `process.env` is read on every call. The
|
||||
* flag is consulted once per file at call-resolution time, so the
|
||||
* overhead is negligible; skipping caching keeps test isolation
|
||||
* trivial (no `resetFlagCache()` coordination needed).
|
||||
*
|
||||
* ## Integration site
|
||||
*
|
||||
* `call-processor.ts` integration lands in **#921** (`finalize-orchestrator`)
|
||||
* where the `SemanticModel` becomes accessible and `Registry.lookup` can
|
||||
* actually be called with a populated context. This module ships the flag
|
||||
* primitive in isolation so #921 has a clean, tested utility to consult.
|
||||
*
|
||||
* ## Shadow mode is orthogonal
|
||||
*
|
||||
* Shadow mode (`GITNEXUS_SHADOW_MODE=1`, introduced in #923) runs BOTH
|
||||
* legacy and registry paths regardless of the per-language flag, so the
|
||||
* parity dashboard has signal even for un-flipped languages. That logic
|
||||
* lives in `shadow-harness.ts` (#923), not here.
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { parseTruthyEnv } from './utils/env.js';
|
||||
|
||||
/**
|
||||
* Languages whose RFC #909 Ring 3 scope-resolution migration is complete.
|
||||
*
|
||||
* This is the single source of truth for "migrated" — the list drives:
|
||||
*
|
||||
* 1. **Production default behavior.** `isRegistryPrimary(lang)` returns
|
||||
* `true` by default for languages in this set (env-var override to
|
||||
* any falsy value still wins — e.g. `REGISTRY_PRIMARY_PYTHON=0`).
|
||||
* 2. **CI parity gate.** `.github/workflows/ci-scope-parity.yml` auto-
|
||||
* discovers this set and, for every language in it, runs the
|
||||
* resolver integration test at `test/integration/resolvers/<slug>.test.ts`
|
||||
* TWICE on every PR — once with the legacy DAG (flag forced off)
|
||||
* and once with the registry-primary path (flag forced on). BOTH
|
||||
* must pass. Adding a language is automatic — no workflow edit,
|
||||
* no JSON registry.
|
||||
* 3. **Legacy-path gating.** `call-processor.ts` / `import-processor.ts`
|
||||
* skip per-language work when `isRegistryPrimary(lang)` is `true`,
|
||||
* so this set also controls what gets silenced in the legacy DAG.
|
||||
*
|
||||
* Add a language here ONLY after shadow parity ≥ 99% fixtures / ≥ 98%
|
||||
* corpus per RFC §6.4. TypeScript is temporarily accepted under the
|
||||
* Ring 3 CI parity gate while corpus-level shadow-mode wiring is tracked
|
||||
* in #927 for this migration.
|
||||
*
|
||||
* The set is intentionally a static TypeScript literal (not a JSON import,
|
||||
* not an env lookup) so CI can discover it via `tsx` without a build step
|
||||
* and reviewers see the change inline with the code that consumes it.
|
||||
*/
|
||||
export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<SupportedLanguages>([
|
||||
SupportedLanguages.Python,
|
||||
SupportedLanguages.CSharp,
|
||||
SupportedLanguages.TypeScript,
|
||||
SupportedLanguages.Go,
|
||||
SupportedLanguages.C,
|
||||
SupportedLanguages.CPlusPlus,
|
||||
SupportedLanguages.PHP,
|
||||
SupportedLanguages.JavaScript,
|
||||
SupportedLanguages.Kotlin,
|
||||
SupportedLanguages.Java,
|
||||
SupportedLanguages.Rust,
|
||||
SupportedLanguages.Ruby,
|
||||
SupportedLanguages.Cobol,
|
||||
SupportedLanguages.Swift,
|
||||
SupportedLanguages.Dart,
|
||||
SupportedLanguages.Vue,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Return the env-var name that controls a given language's registry-
|
||||
* primary flag. Exported for test assertions and for the PR-labeling
|
||||
* CI job that cross-references per-language flag changes.
|
||||
*/
|
||||
export function envVarNameFor(lang: SupportedLanguages): string {
|
||||
return `REGISTRY_PRIMARY_${lang.toUpperCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `lang` runs through the registry-primary call-resolution path.
|
||||
*
|
||||
* Resolution order: an explicit env-var value wins (so operators and CI
|
||||
* can force either path for a given run), and the default falls back to
|
||||
* `MIGRATED_LANGUAGES.has(lang)` — so languages whose migration is
|
||||
* complete default to registry-primary without touching any env.
|
||||
*/
|
||||
export function isRegistryPrimary(lang: SupportedLanguages): boolean {
|
||||
const raw = process.env[envVarNameFor(lang)];
|
||||
if (raw !== undefined) return parseFlag(raw);
|
||||
return MIGRATED_LANGUAGES.has(lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* All languages whose registry-primary flag is currently on. Useful for
|
||||
* startup-time logging + the shadow-harness dashboard, which wants to
|
||||
* distinguish "primary: legacy" from "primary: registry" rows.
|
||||
*/
|
||||
export function primaryLanguages(): ReadonlySet<SupportedLanguages> {
|
||||
const out = new Set<SupportedLanguages>();
|
||||
for (const lang of Object.values(SupportedLanguages)) {
|
||||
if (isRegistryPrimary(lang)) out.add(lang);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Internal ───────────────────────────────────────────────────────────────
|
||||
|
||||
function parseFlag(raw: string | undefined): boolean {
|
||||
return parseTruthyEnv(raw);
|
||||
}
|
||||
|
|
@ -25,17 +25,16 @@
|
|||
* `runYourLangScopeResolution(input) = runScopeResolution(input, yourScopeResolver)`.
|
||||
* 3. Register the provider in
|
||||
* `gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts`
|
||||
* (the `SCOPE_RESOLVERS` map).
|
||||
* 4. Add `SupportedLanguages.YourLang` to `MIGRATED_LANGUAGES` in
|
||||
* `registry-primary-flag.ts`.
|
||||
* 5. Verify the resolver integration test at
|
||||
* `gitnexus/test/integration/resolvers/<lang>.test.ts` passes
|
||||
* under both `REGISTRY_PRIMARY_<LANG>=0` (legacy) and `=1`
|
||||
* (registry-primary). The CI parity gate enforces this.
|
||||
* (the `SCOPE_RESOLVERS` map). That registration is all it takes — the
|
||||
* `scopeResolutionPhase` runs every registered resolver.
|
||||
* 4. Verify the resolver integration test at
|
||||
* `gitnexus/test/integration/resolvers/<lang>.test.ts` passes (it runs
|
||||
* in the standard test suite). Scope-resolution is the only resolution
|
||||
* path — the legacy call-resolution DAG was removed in RING4-1 #942.
|
||||
*
|
||||
* No new pipeline phase, no orchestrator copy-paste, no workflow
|
||||
* change. The generic `scopeResolutionPhase` and the CI parity
|
||||
* workflow auto-discover everything via `MIGRATED_LANGUAGES`.
|
||||
* change. The generic `scopeResolutionPhase` auto-discovers everything via
|
||||
* the `SCOPE_RESOLVERS` map.
|
||||
*
|
||||
* ## ScopeResolver vs LanguageProvider
|
||||
*
|
||||
|
|
|
|||
|
|
@ -3,23 +3,20 @@
|
|||
*
|
||||
* Generic registry-primary resolution phase (RFC #909 Ring 3).
|
||||
*
|
||||
* For every language in `MIGRATED_LANGUAGES` (per-language flag set)
|
||||
* whose provider is registered in `SCOPE_RESOLVERS`:
|
||||
* For every language whose provider is registered in `SCOPE_RESOLVERS`:
|
||||
* 1. Filter scanned files by language extension.
|
||||
* 2. Read file contents.
|
||||
* 3. Drive the scope-based pipeline end-to-end via the generic
|
||||
* `runScopeResolution(input, provider)` orchestrator.
|
||||
* 4. Emit IMPORTS / CALLS / ACCESSES / INHERITS / USES edges.
|
||||
*
|
||||
* Pairs with the per-language gates in `import-processor.ts` and
|
||||
* `call-processor.ts` that skip files when their language is registry-
|
||||
* primary, so we don't double-emit edges from both code paths.
|
||||
* This is the sole resolution path — RING4-1 (#942) deleted the legacy
|
||||
* call-resolution DAG, so there is no longer a per-language flag gating
|
||||
* registry-vs-legacy.
|
||||
*
|
||||
* Adding a language is two changes:
|
||||
* - Implement `ScopeResolver` in `languages/<lang>/scope-resolver.ts`
|
||||
* and register it in `scope-resolution/pipeline/registry.ts`.
|
||||
* - Add the language to `MIGRATED_LANGUAGES` in
|
||||
* `registry-primary-flag.ts`.
|
||||
* Adding a language is one change: implement `ScopeResolver` in
|
||||
* `languages/<lang>/scope-resolver.ts` and register it in
|
||||
* `scope-resolution/pipeline/registry.ts`.
|
||||
*
|
||||
* @deps parse (needs Symbol nodes already in the graph so emit-references
|
||||
* can attach edges to existing Function/Method/Class nodes)
|
||||
|
|
@ -31,7 +28,6 @@ import type { PipelinePhase, PipelineContext, PhaseResult } from '../../pipeline
|
|||
import { getPhaseOutput } from '../../pipeline-phases/types.js';
|
||||
import type { StructureOutput } from '../../pipeline-phases/structure.js';
|
||||
import type { ParseOutput } from '../../pipeline-phases/parse.js';
|
||||
import { isRegistryPrimary } from '../../registry-primary-flag.js';
|
||||
import { SupportedLanguages, getLanguageFromFilename } from 'gitnexus-shared';
|
||||
import { readFileContents } from '../../filesystem-walker.js';
|
||||
import { runScopeResolution, type ScopeResolutionSubPhase } from './run.js';
|
||||
|
|
@ -51,7 +47,7 @@ export interface ScopeResolutionOutput {
|
|||
readonly referenceEdgesEmitted: number;
|
||||
/** Additive stream of resolver diagnostics; does not affect graph edges. */
|
||||
readonly resolutionOutcomes: readonly ResolutionOutcome[];
|
||||
/** Per-language breakdown for telemetry / shadow-parity. */
|
||||
/** Per-language breakdown for telemetry. */
|
||||
readonly perLanguage: ReadonlyMap<
|
||||
SupportedLanguages,
|
||||
{
|
||||
|
|
@ -74,17 +70,15 @@ const NOOP_OUTPUT: ScopeResolutionOutput = Object.freeze({
|
|||
export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
||||
name: 'scopeResolution',
|
||||
// Depends on `parse` because emit-references attaches edges to
|
||||
// already-existing Symbol nodes (Function/Method/Class). The legacy
|
||||
// `parse` phase still creates those nodes; we only replace the
|
||||
// import + call resolution layer.
|
||||
// already-existing Symbol nodes (Function/Method/Class) that the `parse`
|
||||
// phase creates.
|
||||
//
|
||||
// Also depends on `crossFile` — we don't read crossFile's output
|
||||
// directly (we have our own cross-file resolution), but crossFile
|
||||
// writes EXTENDS edges that `buildMro` consumes via
|
||||
// `iterRelationshipsByType('EXTENDS')`. Declaring the dep pins the
|
||||
// ordering explicitly: without it, Kahn's runner could schedule
|
||||
// scopeResolution before crossFile (both unblock after parse), and
|
||||
// the MRO walk would miss heritage edges crossFile later adds.
|
||||
// The `crossFile` dep is retained for stable ordering but is no longer
|
||||
// load-bearing: inheritance (EXTENDS/IMPLEMENTS) edges are now emitted by
|
||||
// this phase's own `preEmitInheritanceEdges` before `buildMro` runs, and
|
||||
// since RING4-1 (#942) `crossFile` only disposes the BindingAccumulator
|
||||
// (the legacy cross-file re-resolution it used to run was deleted with the
|
||||
// call-resolution DAG).
|
||||
deps: ['parse', 'crossFile', 'structure'],
|
||||
|
||||
async execute(
|
||||
|
|
@ -152,7 +146,6 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
|||
let totalScopeLangs = 0;
|
||||
const allScannedPaths = new Set(scannedFiles.map((f) => f.path));
|
||||
for (const [lang] of SCOPE_RESOLVERS) {
|
||||
if (!isRegistryPrimary(lang)) continue;
|
||||
const count = scannedFiles.filter((f) => getLanguageFromFilename(f.path) === lang).length;
|
||||
if (count > 0) {
|
||||
totalScopeLangs++;
|
||||
|
|
@ -173,8 +166,6 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
|||
}
|
||||
|
||||
for (const [lang, provider] of SCOPE_RESOLVERS) {
|
||||
if (!isRegistryPrimary(lang)) continue;
|
||||
|
||||
// Standalone providers (COBOL, JCL) don't emit graph edges yet
|
||||
// through the scope-resolution path. This is the canonical guard:
|
||||
// runScopeResolution is never called for standalone providers, which
|
||||
|
|
|
|||
|
|
@ -28,10 +28,9 @@ import { swiftScopeResolver } from '../../languages/swift/scope-resolver.js';
|
|||
import { dartScopeResolver } from '../../languages/dart/scope-resolver.js';
|
||||
import { vueScopeResolver } from '../../languages/vue/scope-resolver.js';
|
||||
|
||||
/** Map of `SupportedLanguages` → `ScopeResolver`. The phase iterates
|
||||
* this map intersected with `MIGRATED_LANGUAGES` (the per-language
|
||||
* flag set) so adding a resolver here without flipping the flag is
|
||||
* safe — the resolver sits idle until the language is migrated. */
|
||||
/** Map of `SupportedLanguages` → `ScopeResolver`. The scope-resolution phase
|
||||
* iterates this map directly — every registered resolver runs. This is the
|
||||
* single source of truth for which languages resolve via scope-resolution. */
|
||||
export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = new Map<
|
||||
SupportedLanguages,
|
||||
ScopeResolver
|
||||
|
|
|
|||
|
|
@ -100,17 +100,12 @@ function preEmitInheritanceEdges(
|
|||
): Set<string> {
|
||||
const handledSites = new Set<string>();
|
||||
const seen = new Set<string>();
|
||||
// Seed the dedup set with both inheritance edge types already in the graph
|
||||
// (e.g. emitted by the legacy heritage path in sequential mode). Keying by
|
||||
// edge type lets us add IMPLEMENTS without colliding with EXTENDS and keeps
|
||||
// this pass a no-op when the legacy path already produced the same edge.
|
||||
// Tracks inheritance edges emitted during this pass so the structural
|
||||
// interface-implementation pass (emitDetectedInterfaceImplementations) and
|
||||
// repeated `inherits` sites don't double-emit. Starts empty: this pre-pass is
|
||||
// the authoritative inheritance emitter — no EXTENDS/IMPLEMENTS edges exist in
|
||||
// the graph before it runs (the legacy heritage path was removed in #942).
|
||||
const existing = new Set<string>();
|
||||
for (const rel of graph.iterRelationshipsByType('EXTENDS')) {
|
||||
existing.add(`EXTENDS:${rel.sourceId}->${rel.targetId}`);
|
||||
}
|
||||
for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) {
|
||||
existing.add(`IMPLEMENTS:${rel.sourceId}->${rel.targetId}`);
|
||||
}
|
||||
|
||||
for (const site of scopes.referenceSites) {
|
||||
if (site.kind !== 'inherits') continue;
|
||||
|
|
@ -150,10 +145,7 @@ function preEmitInheritanceEdges(
|
|||
if (callerGraphId === undefined || targetGraphId === undefined) continue;
|
||||
// Discriminate EXTENDS vs IMPLEMENTS by the resolved target's symbol kind:
|
||||
// conforming to an interface OR mixing in a trait/protocol is IMPLEMENTS,
|
||||
// deriving from a class-like is EXTENDS. This matches the legacy heritage
|
||||
// emitters (`resolveExtendsType` maps Interface→IMPLEMENTS; the trait-impl
|
||||
// branch of `resolveAndAddHeritageEdge` maps trait use → IMPLEMENTS), so the
|
||||
// registry-primary path matches the legacy DAG. The discriminator is purely
|
||||
// deriving from a class-like is EXTENDS. The discriminator is purely
|
||||
// symbol-kind-driven (no language is named here, per AGENTS.md): a base that
|
||||
// resolves to neither an Interface nor a Trait symbol always takes the
|
||||
// EXTENDS branch, so such languages are unchanged.
|
||||
|
|
|
|||
|
|
@ -1,222 +0,0 @@
|
|||
/**
|
||||
* Shadow-mode parity harness — dual-run observability for the RFC #909
|
||||
* registry rollout (RFC §6.3; Ring 2 PKG #923).
|
||||
*
|
||||
* ## What it does
|
||||
*
|
||||
* - Exposes `record({ language, callsite, legacy, newResult })` for
|
||||
* every call site where the caller has BOTH a legacy-DAG resolution
|
||||
* and a new `Registry.lookup` resolution.
|
||||
* - Computes a `ShadowDiff` per record via shared `diffResolutions`
|
||||
* (#918) and accumulates them in a per-language bucket.
|
||||
* - At the end of a run, aggregates into a `ShadowParityReport` via
|
||||
* shared `aggregateDiffs` (#918) — per-language parity %,
|
||||
* evidence-kind breakdown of divergences, grand-total overall row.
|
||||
* - Optionally persists the report as JSON under
|
||||
* `.gitnexus/shadow-parity/` so the static dashboard at
|
||||
* `gitnexus/shadow-parity-dashboard/` can render it offline.
|
||||
*
|
||||
* ## What it does NOT do
|
||||
*
|
||||
* - **Invoke either resolution path itself.** The caller must run
|
||||
* legacy + `Registry.lookup` and pass results in. The harness is a
|
||||
* side-car, not a dispatcher — this keeps call-processor integration
|
||||
* surgical when it lands (tracked as a follow-up; the shared model
|
||||
* doesn't dual-invoke on its own).
|
||||
* - **Flip anything.** `REGISTRY_PRIMARY_<LANG>` lives in
|
||||
* `registry-primary-flag.ts` (#924); the harness records the
|
||||
* caller-supplied "which side is primary" bit for each record so the
|
||||
* dashboard can label rows, but it does not consult the flag itself.
|
||||
*
|
||||
* ## Activation
|
||||
*
|
||||
* `GITNEXUS_SHADOW_MODE=1` (or `'true'`, `'yes'`, case-insensitive,
|
||||
* trimmed) enables the harness. When disabled, `record()` is a cheap
|
||||
* no-op: no accumulation, no allocation beyond the harness object
|
||||
* itself. Callers can always construct a harness and hand it through;
|
||||
* the "off" overhead is near-zero.
|
||||
*
|
||||
* ## Persistence shape
|
||||
*
|
||||
* When `persist()` is called, the harness writes TWO files:
|
||||
*
|
||||
* - `<outputDir>/<runId>.json` — the timestamped snapshot (immutable)
|
||||
* - `<outputDir>/latest.json` — a pointer that the dashboard reads
|
||||
*
|
||||
* Both files contain the same `PersistedShadowReport` payload:
|
||||
*
|
||||
* {
|
||||
* schemaVersion: 1,
|
||||
* runId: "<iso-8601>-<rand>",
|
||||
* generatedAt: "<iso-8601>",
|
||||
* primaryByLanguage: { [lang]: "legacy" | "registry" },
|
||||
* report: <ShadowParityReport>
|
||||
* }
|
||||
*
|
||||
* Schema-version-gated so future format changes don't silently confuse
|
||||
* older dashboards. The dashboard renders `report.perLanguage` rows and
|
||||
* annotates each with `primaryByLanguage[lang]`.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
aggregateDiffs,
|
||||
diffResolutions,
|
||||
type Resolution,
|
||||
type ShadowCallsite,
|
||||
type ShadowDiff,
|
||||
type ShadowParityReport,
|
||||
type SupportedLanguages,
|
||||
} from 'gitnexus-shared';
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Which side of the dual-run is considered authoritative for this language. */
|
||||
export type PrimarySide = 'legacy' | 'registry';
|
||||
|
||||
/** One record per call site the caller dual-runs. */
|
||||
export interface ShadowRecordInput {
|
||||
readonly language: SupportedLanguages;
|
||||
readonly callsite: ShadowCallsite;
|
||||
readonly legacy: readonly Resolution[];
|
||||
readonly newResult: readonly Resolution[];
|
||||
/**
|
||||
* Which side drove the actual runtime answer for this record. Lets the
|
||||
* dashboard distinguish "registry-primary, legacy is shadow" from the
|
||||
* default "legacy-primary, registry is shadow" without re-reading
|
||||
* `REGISTRY_PRIMARY_<LANG>` env vars at render time.
|
||||
*/
|
||||
readonly primary: PrimarySide;
|
||||
}
|
||||
|
||||
/** Persisted JSON shape. Schema-versioned for future migrations. */
|
||||
export interface PersistedShadowReport {
|
||||
readonly schemaVersion: 1;
|
||||
readonly runId: string;
|
||||
readonly generatedAt: string;
|
||||
readonly primaryByLanguage: Readonly<Partial<Record<SupportedLanguages, PrimarySide>>>;
|
||||
readonly report: ShadowParityReport;
|
||||
}
|
||||
|
||||
export interface ShadowHarness {
|
||||
/** `true` iff `GITNEXUS_SHADOW_MODE` is truthy. When `false`, `record()` is a no-op. */
|
||||
readonly enabled: boolean;
|
||||
/** Accumulate a dual-run observation. No-op when `enabled === false`. */
|
||||
record(input: ShadowRecordInput): void;
|
||||
/** Number of records accumulated so far. Useful for diagnostics / tests. */
|
||||
size(): number;
|
||||
/**
|
||||
* Aggregate the accumulated records into a `ShadowParityReport`
|
||||
* without persisting. Returns a deterministic snapshot each call;
|
||||
* idempotent with respect to `record()` ordering.
|
||||
*/
|
||||
snapshot(now?: Date): ShadowParityReport;
|
||||
/**
|
||||
* Write the aggregated snapshot to JSON. Resolves to the path of the
|
||||
* per-run file. Also writes/overwrites `latest.json` alongside.
|
||||
*
|
||||
* Creates `outputDir` if it doesn't exist.
|
||||
*/
|
||||
persist(outputDir: string, now?: Date): Promise<string>;
|
||||
/** Reset the accumulator. Preserves `enabled`. */
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a harness. Reads `GITNEXUS_SHADOW_MODE` at construction time
|
||||
* (not per-`record()` call) so repeated no-op records don't re-check the
|
||||
* env var in the hot path.
|
||||
*/
|
||||
export function createShadowHarness(): ShadowHarness {
|
||||
const enabled = parseShadowModeEnv(process.env['GITNEXUS_SHADOW_MODE']);
|
||||
|
||||
interface Accumulated {
|
||||
readonly language: SupportedLanguages;
|
||||
readonly diff: ShadowDiff;
|
||||
}
|
||||
const records: Accumulated[] = [];
|
||||
const primaryByLanguage: Partial<Record<SupportedLanguages, PrimarySide>> = {};
|
||||
|
||||
const recordImpl = (input: ShadowRecordInput): void => {
|
||||
if (!enabled) return;
|
||||
const diff = diffResolutions(input.callsite, input.legacy, input.newResult);
|
||||
records.push({ language: input.language, diff });
|
||||
// Primary per-language is resolved by last-write. In practice a run
|
||||
// is single-threaded with respect to flag readings, so this is
|
||||
// deterministic; a language's primary cannot change mid-run.
|
||||
primaryByLanguage[input.language] = input.primary;
|
||||
};
|
||||
|
||||
const snapshotImpl = (now: Date = new Date()): ShadowParityReport => {
|
||||
return aggregateDiffs(records, now);
|
||||
};
|
||||
|
||||
const persistImpl = async (outputDir: string, now: Date = new Date()): Promise<string> => {
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
const report = snapshotImpl(now);
|
||||
const runId = makeRunId(now);
|
||||
const payload: PersistedShadowReport = {
|
||||
schemaVersion: 1,
|
||||
runId,
|
||||
generatedAt: now.toISOString(),
|
||||
primaryByLanguage,
|
||||
report,
|
||||
};
|
||||
const json = JSON.stringify(payload, null, 2);
|
||||
const perRunPath = path.join(outputDir, `${runId}.json`);
|
||||
const latestPath = path.join(outputDir, 'latest.json');
|
||||
await fs.writeFile(perRunPath, json, 'utf8');
|
||||
await fs.writeFile(latestPath, json, 'utf8');
|
||||
return perRunPath;
|
||||
};
|
||||
|
||||
const clearImpl = (): void => {
|
||||
records.length = 0;
|
||||
for (const key of Object.keys(primaryByLanguage)) {
|
||||
delete primaryByLanguage[key as SupportedLanguages];
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
enabled,
|
||||
record: recordImpl,
|
||||
size: () => records.length,
|
||||
snapshot: snapshotImpl,
|
||||
persist: persistImpl,
|
||||
clear: clearImpl,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Env-var parser for `GITNEXUS_SHADOW_MODE`. Accepts the same truthy
|
||||
* conventions as `REGISTRY_PRIMARY_<LANG>` from #924: `'true'` / `'1'` /
|
||||
* `'yes'`, case-insensitive, whitespace-trimmed. Anything else — including
|
||||
* `undefined`, `''`, `'false'`, `'off'`, typos — is false.
|
||||
*/
|
||||
function parseShadowModeEnv(raw: string | undefined): boolean {
|
||||
if (raw === undefined) return false;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
return normalized === 'true' || normalized === '1' || normalized === 'yes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic run id derived from the timestamp plus 4 random bytes
|
||||
* of entropy. The timestamp comes first so files sort chronologically;
|
||||
* the entropy suffix prevents collisions when multiple runs share a
|
||||
* clock-second. Shape: `YYYYMMDD-HHMMSS-xxxxxxxx`.
|
||||
*/
|
||||
function makeRunId(now: Date): string {
|
||||
const y = now.getUTCFullYear().toString().padStart(4, '0');
|
||||
const m = (now.getUTCMonth() + 1).toString().padStart(2, '0');
|
||||
const d = now.getUTCDate().toString().padStart(2, '0');
|
||||
const h = now.getUTCHours().toString().padStart(2, '0');
|
||||
const min = now.getUTCMinutes().toString().padStart(2, '0');
|
||||
const s = now.getUTCSeconds().toString().padStart(2, '0');
|
||||
const entropy = Math.floor(Math.random() * 0xffffffff)
|
||||
.toString(16)
|
||||
.padStart(8, '0');
|
||||
return `${y}${m}${d}-${h}${min}${s}-${entropy}`;
|
||||
}
|
||||
|
|
@ -5,50 +5,12 @@
|
|||
* slightly different node types. These queries are designed to be
|
||||
* compatible with the standard tree-sitter grammars.
|
||||
*
|
||||
* Heritage (extends/implements/embed/trait) supertype positions are NOT
|
||||
* hand-written per shape. Each language declares its supertype node-type
|
||||
* shapes in heritage-extractors/configs/<lang>.ts; buildSupertypeAlternation()
|
||||
* turns those into a tree-sitter `[(a) (b) …] @heritage.*` alternation that is
|
||||
* interpolated into the heritage blocks below. The matching runtime
|
||||
* name-normalizer lives in heritage-extractors/supertype-alternation.ts. This
|
||||
* keeps qualified/generic/scoped/interface supertypes from being silently
|
||||
* dropped (they previously matched only the bare `(type_identifier)`).
|
||||
* Heritage (extends/implements/embed/trait) is NOT captured here. The legacy
|
||||
* heritage-capture leg was removed (issue #942); inheritance edges are
|
||||
* produced by the registry-primary scope-resolution path, which synthesizes
|
||||
* `@reference.inherits` captures in each language's `languages/<lang>/captures.ts`.
|
||||
*/
|
||||
|
||||
import { buildSupertypeAlternation } from './heritage-extractors/supertype-alternation.js';
|
||||
import { javaHeritageShapes } from './heritage-extractors/configs/java.js';
|
||||
import { csharpHeritageShapes } from './heritage-extractors/configs/csharp.js';
|
||||
import {
|
||||
typescriptExtendsShapes,
|
||||
typescriptInterfaceShapes,
|
||||
} from './heritage-extractors/configs/typescript.js';
|
||||
import { javascriptHeritageShapes } from './heritage-extractors/configs/javascript.js';
|
||||
import { pythonHeritageShapes } from './heritage-extractors/configs/python.js';
|
||||
import { rustHeritageShapes } from './heritage-extractors/configs/rust.js';
|
||||
import { goHeritageShapes } from './heritage-extractors/configs/go.js';
|
||||
import { kotlinHeritageShapes } from './heritage-extractors/configs/kotlin.js';
|
||||
import { cppHeritageShapes } from './heritage-extractors/configs/cpp.js';
|
||||
import { rubyHeritageShapes } from './heritage-extractors/configs/ruby.js';
|
||||
|
||||
// Pre-built heritage alternation fragments, one per (language, capture-tag).
|
||||
// These are plain strings interpolated into the *_QUERIES template literals.
|
||||
const JAVA_EXTENDS_ALT = buildSupertypeAlternation(javaHeritageShapes, 'heritage.extends');
|
||||
const JAVA_IMPLEMENTS_ALT = buildSupertypeAlternation(javaHeritageShapes, 'heritage.implements');
|
||||
const CSHARP_BASE_ALT = buildSupertypeAlternation(csharpHeritageShapes, 'heritage.extends');
|
||||
const TS_EXTENDS_ALT = buildSupertypeAlternation(typescriptExtendsShapes, 'heritage.extends');
|
||||
const TS_INTERFACE_IMPLEMENTS_ALT = buildSupertypeAlternation(
|
||||
typescriptInterfaceShapes,
|
||||
'heritage.implements',
|
||||
);
|
||||
const JS_EXTENDS_ALT = buildSupertypeAlternation(javascriptHeritageShapes, 'heritage.extends');
|
||||
const PYTHON_EXTENDS_ALT = buildSupertypeAlternation(pythonHeritageShapes, 'heritage.extends');
|
||||
const RUST_TRAIT_ALT = buildSupertypeAlternation(rustHeritageShapes, 'heritage.trait');
|
||||
const RUST_CLASS_ALT = buildSupertypeAlternation(rustHeritageShapes, 'heritage.class');
|
||||
const GO_EMBED_ALT = buildSupertypeAlternation(goHeritageShapes, 'heritage.extends');
|
||||
const KOTLIN_EXTENDS_ALT = buildSupertypeAlternation(kotlinHeritageShapes, 'heritage.extends');
|
||||
const CPP_BASE_ALT = buildSupertypeAlternation(cppHeritageShapes, 'heritage.extends');
|
||||
const RUBY_SUPERCLASS_ALT = buildSupertypeAlternation(rubyHeritageShapes, 'heritage.extends');
|
||||
const RUBY_CLASS_ALT = buildSupertypeAlternation(rubyHeritageShapes, 'heritage.class');
|
||||
import { ARRAY_METHOD_NOT_ANY_OF_PREDICATE } from './ts-js-hoc-utils.js';
|
||||
|
||||
// TypeScript queries - works with tree-sitter-typescript
|
||||
|
|
@ -355,30 +317,6 @@ export const TYPESCRIPT_QUERIES = `
|
|||
(accessibility_modifier)
|
||||
pattern: (identifier) @name) @definition.property
|
||||
|
||||
; Heritage queries - class extends (bare or qualified ns.Base; generics ride
|
||||
; a separate type_arguments field, captured by the extends_clause value).
|
||||
(class_declaration
|
||||
name: (type_identifier) @heritage.class
|
||||
(class_heritage
|
||||
(extends_clause
|
||||
value: ${TS_EXTENDS_ALT}))) @heritage
|
||||
|
||||
; Heritage queries - class implements interface (bare/generic/nested)
|
||||
(class_declaration
|
||||
name: (type_identifier) @heritage.class
|
||||
(class_heritage
|
||||
(implements_clause
|
||||
${TS_INTERFACE_IMPLEMENTS_ALT}))) @heritage.impl
|
||||
|
||||
; Heritage queries - interface extends interface(s): interface I extends A, B<T>
|
||||
; Without this, interface-to-interface chains are never captured. Tagged as
|
||||
; @heritage.implements (interface relationship), matching the Java interface
|
||||
; extends block.
|
||||
(interface_declaration
|
||||
name: (type_identifier) @heritage.class
|
||||
(extends_type_clause
|
||||
${TS_INTERFACE_IMPLEMENTS_ALT})) @heritage.impl
|
||||
|
||||
; Write access: obj.field = value
|
||||
(assignment_expression
|
||||
left: (member_expression
|
||||
|
|
@ -669,14 +607,6 @@ export const JAVASCRIPT_QUERIES = `
|
|||
(field_definition
|
||||
property: (property_identifier) @name) @definition.property
|
||||
|
||||
; Heritage queries - class extends (JavaScript uses different AST than TypeScript)
|
||||
; In tree-sitter-javascript, class_heritage directly contains the parent
|
||||
; expression: a bare identifier or a qualified member_expression (ns.Base).
|
||||
(class_declaration
|
||||
name: (identifier) @heritage.class
|
||||
(class_heritage
|
||||
${JS_EXTENDS_ALT})) @heritage
|
||||
|
||||
; Write access: obj.field = value
|
||||
(assignment_expression
|
||||
left: (member_expression
|
||||
|
|
@ -763,13 +693,6 @@ export const PYTHON_QUERIES = `
|
|||
(assignment
|
||||
left: (identifier) @name)) @definition.variable
|
||||
|
||||
; Heritage queries - Python class inheritance (bare, qualified attribute
|
||||
; models.Model, or subscript Generic[T]).
|
||||
(class_definition
|
||||
name: (identifier) @heritage.class
|
||||
superclasses: (argument_list
|
||||
${PYTHON_EXTENDS_ALT})) @heritage
|
||||
|
||||
; Write access: obj.field = value
|
||||
(assignment
|
||||
left: (attribute
|
||||
|
|
@ -834,19 +757,6 @@ export const JAVA_QUERIES = `
|
|||
declarator: (variable_declarator
|
||||
name: (identifier) @name)) @definition.variable
|
||||
|
||||
; Heritage - extends class (bare / generic Foo<T> / scoped pkg.Foo)
|
||||
(class_declaration name: (identifier) @heritage.class
|
||||
(superclass ${JAVA_EXTENDS_ALT})) @heritage
|
||||
|
||||
; Heritage - implements interfaces (bare / generic / scoped)
|
||||
(class_declaration name: (identifier) @heritage.class
|
||||
(super_interfaces (type_list ${JAVA_IMPLEMENTS_ALT}))) @heritage.impl
|
||||
|
||||
; Heritage - interface extends interface(s): interface IA extends IB, IC<T>
|
||||
; Without this, interface-to-interface relationships are never captured.
|
||||
(interface_declaration name: (identifier) @heritage.class
|
||||
(extends_interfaces (type_list ${JAVA_IMPLEMENTS_ALT}))) @heritage.impl
|
||||
|
||||
; Write access: obj.field = value
|
||||
(assignment_expression
|
||||
left: (field_access
|
||||
|
|
@ -920,25 +830,6 @@ export const GO_QUERIES = `
|
|||
(field_declaration
|
||||
name: (field_identifier) @name) @definition.property)
|
||||
|
||||
; Struct embedding (anonymous fields = inheritance). Named fields also match
|
||||
; the field_declaration pattern but are filtered by goHeritageConfig
|
||||
; .shouldSkipExtends. Embed type may be bare, qualified (pkg.Base) or generic.
|
||||
(type_declaration
|
||||
(type_spec
|
||||
name: (type_identifier) @heritage.class
|
||||
type: (struct_type
|
||||
(field_declaration_list
|
||||
(field_declaration
|
||||
type: ${GO_EMBED_ALT}))))) @definition.struct
|
||||
|
||||
; Interface embedding: an embedded interface inside an interface_type
|
||||
; (type I interface { io.Reader; Other }) — type_elem holds the embed.
|
||||
(type_declaration
|
||||
(type_spec
|
||||
name: (type_identifier) @heritage.class
|
||||
type: (interface_type
|
||||
(type_elem ${GO_EMBED_ALT})))) @definition.interface
|
||||
|
||||
; Calls
|
||||
(call_expression function: (identifier) @call.name) @call
|
||||
(call_expression function: (selector_expression field: (field_identifier) @call.name)) @call
|
||||
|
|
@ -1113,12 +1004,6 @@ export const CPP_QUERIES = `
|
|||
declarator: (init_declarator
|
||||
declarator: (identifier) @name)) @definition.variable
|
||||
|
||||
; Heritage (base class). Bracketed alternation matches the base node whether
|
||||
; or not it is preceded by an access_specifier (public/private/protected), and
|
||||
; covers bare / templated (Base<T>) / qualified (ns::Base) bases.
|
||||
(class_specifier name: (type_identifier) @heritage.class
|
||||
(base_class_clause ${CPP_BASE_ALT})) @heritage
|
||||
|
||||
; Write access: obj.field = value
|
||||
(assignment_expression
|
||||
left: (field_expression
|
||||
|
|
@ -1181,26 +1066,6 @@ export const CSHARP_QUERIES = `
|
|||
(variable_declarator
|
||||
(identifier) @name))) @definition.variable
|
||||
|
||||
; Heritage. Every base_list entry is captured as @heritage.extends regardless
|
||||
; of bare/generic/qualified/scoped/primary-ctor shape; EXTENDS-vs-IMPLEMENTS is
|
||||
; decided downstream by resolveExtendsType, so we do not pre-split here.
|
||||
(class_declaration name: (identifier) @heritage.class
|
||||
(base_list ${CSHARP_BASE_ALT})) @heritage
|
||||
|
||||
; record base_list: record R(...) : Base(args), IFoo
|
||||
(record_declaration name: (identifier) @heritage.class
|
||||
(base_list ${CSHARP_BASE_ALT})) @heritage
|
||||
|
||||
; struct base_list: struct S : IFoo, ns.IBar
|
||||
(struct_declaration name: (identifier) @heritage.class
|
||||
(base_list ${CSHARP_BASE_ALT})) @heritage
|
||||
|
||||
; Interface inheritance: interface IFoo : IBar / interface IFoo : IBar, IBaz
|
||||
; Without these patterns, interface-to-interface relationships are never
|
||||
; captured, so transitive "class X implements IBar" chains are broken.
|
||||
(interface_declaration name: (identifier) @heritage.class
|
||||
(base_list ${CSHARP_BASE_ALT})) @heritage
|
||||
|
||||
; Write access: obj.field = value
|
||||
(assignment_expression
|
||||
left: (member_access_expression
|
||||
|
|
@ -1253,13 +1118,6 @@ export const RUST_QUERIES = `
|
|||
(field_declaration
|
||||
name: (field_identifier) @name) @definition.property)
|
||||
|
||||
; Heritage (trait implementation). Both trait and type positions accept bare /
|
||||
; generic (Trait<T>) / scoped (ns::Trait) shapes; the normalizer reduces each
|
||||
; to the innermost simple name.
|
||||
(impl_item
|
||||
trait: ${RUST_TRAIT_ALT}
|
||||
type: ${RUST_CLASS_ALT}) @heritage
|
||||
|
||||
; Write access: obj.field = value
|
||||
(assignment_expression
|
||||
left: (field_expression
|
||||
|
|
@ -1349,35 +1207,6 @@ export const PHP_QUERIES = `
|
|||
(const_element
|
||||
(name) @name)) @definition.const
|
||||
|
||||
; ── Heritage: extends ────────────────────────────────────────────────────────
|
||||
(class_declaration
|
||||
name: (name) @heritage.class
|
||||
(base_clause
|
||||
[(name) (qualified_name)] @heritage.extends)) @heritage
|
||||
|
||||
; ── Heritage: implements ─────────────────────────────────────────────────────
|
||||
(class_declaration
|
||||
name: (name) @heritage.class
|
||||
(class_interface_clause
|
||||
[(name) (qualified_name)] @heritage.implements)) @heritage.impl
|
||||
|
||||
; ── Heritage: use trait (must capture enclosing class name) ──────────────────
|
||||
(class_declaration
|
||||
name: (name) @heritage.class
|
||||
body: (declaration_list
|
||||
(use_declaration
|
||||
[(name) (qualified_name)] @heritage.trait))) @heritage
|
||||
|
||||
; ── Heritage: trait uses another trait (transitive trait composition) ────────
|
||||
; PHP allows a trait body to contain "use OtherTrait;". The trait-uses-trait
|
||||
; IMPLEMENTS edge is required by buildPhpMro to compute the full transitive
|
||||
; trait closure (depth 3+ chains).
|
||||
(trait_declaration
|
||||
name: (name) @heritage.class
|
||||
body: (declaration_list
|
||||
(use_declaration
|
||||
[(name) (qualified_name)] @heritage.trait))) @heritage
|
||||
|
||||
; PHP HTTP consumers: file_get_contents('/path'), curl_init('/path')
|
||||
(function_call_expression
|
||||
function: (name) @_php_http (#match? @_php_http "^(file_get_contents|curl_init)$")
|
||||
|
|
@ -1452,15 +1281,6 @@ export const RUBY_QUERIES = `
|
|||
(body_statement
|
||||
(identifier) @call.name @call)
|
||||
|
||||
; ── Heritage: class < SuperClass ─────────────────────────────────────────────
|
||||
; Both the class name and the superclass accept a bare constant or a
|
||||
; scope_resolution (class Foo::Bar < Base::Sup); normalized to the trailing
|
||||
; constant downstream.
|
||||
(class
|
||||
name: ${RUBY_CLASS_ALT}
|
||||
superclass: (superclass
|
||||
${RUBY_SUPERCLASS_ALT})) @heritage
|
||||
|
||||
; Write access: obj.field = value (Ruby setter — syntactically a method call to field=)
|
||||
(assignment
|
||||
left: (call
|
||||
|
|
@ -1547,18 +1367,6 @@ export const KOTLIN_QUERIES = `
|
|||
(infix_expression
|
||||
(simple_identifier) @call.name) @call
|
||||
|
||||
; ── Heritage: extends / implements via delegation_specifier ──────────────
|
||||
; A delegation_specifier wraps one of:
|
||||
; user_type class Foo : Bar (interface impl / bare)
|
||||
; constructor_invocation class Foo : Bar() (superclass ctor call)
|
||||
; explicit_delegation class Foo : Bar by baz (interface delegation)
|
||||
; The normalizer descends into the wrapper to the inner user_type's name, so a
|
||||
; single alternation captures all three forms (including qualified pkg.Bar and
|
||||
; generic Gen<T>).
|
||||
(class_declaration
|
||||
(type_identifier) @heritage.class
|
||||
(delegation_specifier ${KOTLIN_EXTENDS_ALT})) @heritage
|
||||
|
||||
; Write access: obj.field = value
|
||||
(assignment
|
||||
(directly_assignable_expression
|
||||
|
|
@ -1616,19 +1424,6 @@ export const SWIFT_QUERIES = `
|
|||
; Calls - member/navigation calls (obj.method())
|
||||
(call_expression (navigation_expression (navigation_suffix (simple_identifier) @call.name))) @call
|
||||
|
||||
; Heritage - class/struct/enum inheritance and protocol conformance
|
||||
(class_declaration name: (type_identifier) @heritage.class
|
||||
(inheritance_specifier inherits_from: (user_type (type_identifier) @heritage.extends))) @heritage
|
||||
|
||||
; Heritage - protocol inheritance
|
||||
(protocol_declaration name: (type_identifier) @heritage.class
|
||||
(inheritance_specifier inherits_from: (user_type (type_identifier) @heritage.extends))) @heritage
|
||||
|
||||
; Heritage - extension protocol conformance (e.g. extension Foo: SomeProtocol)
|
||||
; Extensions wrap the name in user_type unlike class/struct/enum declarations
|
||||
(class_declaration "extension" name: (user_type (type_identifier) @heritage.class)
|
||||
(inheritance_specifier inherits_from: (user_type (type_identifier) @heritage.extends))) @heritage
|
||||
|
||||
; Write access: obj.field = value (tree-sitter-swift 0.7.1 uses named fields)
|
||||
(assignment
|
||||
target: (directly_assignable_expression
|
||||
|
|
@ -1830,25 +1625,6 @@ export const DART_QUERIES = `
|
|||
(unconditional_assignable_selector
|
||||
(identifier) @assignment.property))
|
||||
right: (_)) @assignment
|
||||
|
||||
; ── Heritage: extends ────────────────────────────────────────────────────────
|
||||
(class_definition
|
||||
name: (identifier) @heritage.class
|
||||
superclass: (superclass
|
||||
(type_identifier) @heritage.extends)) @heritage
|
||||
|
||||
; ── Heritage: implements ─────────────────────────────────────────────────────
|
||||
(class_definition
|
||||
name: (identifier) @heritage.class
|
||||
interfaces: (interfaces
|
||||
(type_identifier) @heritage.implements)) @heritage.impl
|
||||
|
||||
; ── Heritage: with (mixins) ──────────────────────────────────────────────────
|
||||
(class_definition
|
||||
name: (identifier) @heritage.class
|
||||
superclass: (superclass
|
||||
(mixins
|
||||
(type_identifier) @heritage.trait))) @heritage
|
||||
`;
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export type ConstructorBindingScanner = (
|
|||
|
||||
/** Infer the type name of a literal AST node for overload disambiguation.
|
||||
* Returns the canonical type name (e.g. 'int', 'String', 'boolean') or undefined
|
||||
* for non-literal nodes. Only used when resolveCallTarget has multiple candidates
|
||||
* for non-literal nodes. Only used when the call resolver has multiple candidates
|
||||
* with parameterTypes — ~1-3% of call sites. */
|
||||
export type LiteralTypeInferrer = (node: SyntaxNode) => string | undefined;
|
||||
|
||||
|
|
|
|||
|
|
@ -239,9 +239,9 @@ export const CONTAINER_TYPE_TO_LABEL: Record<string, string> = {
|
|||
extension_declaration: 'Extension',
|
||||
class: 'Class',
|
||||
// Ruby `module` declarations map to `Trait` so they participate in the
|
||||
// class-like type registry used by `lookupClassByName` / `buildHeritageMap`.
|
||||
// This lets `include` / `extend` / `prepend` mixin heritage resolve to
|
||||
// the providing module. Safe for non-Ruby languages: the only supported
|
||||
// class-like type registry used by `lookupClassByName` / inheritance
|
||||
// resolution. This lets `include` / `extend` / `prepend` mixin heritage
|
||||
// resolve to the providing module. Safe for non-Ruby languages: the only supported
|
||||
// grammar that uses the bare `module` AST node type as a container is
|
||||
// Ruby (Rust uses `mod_item`). Any new language adding a `module` node
|
||||
// type must explicitly reclassify here.
|
||||
|
|
|
|||
|
|
@ -1,101 +0,0 @@
|
|||
// gitnexus/src/core/ingestion/utils/ruby-self-call.ts
|
||||
|
||||
/**
|
||||
* Ruby bare-call self-inference helper.
|
||||
*
|
||||
* Ruby makes `self` implicit for method calls inside instance and class bodies:
|
||||
* `serialize` inside `Account#call_serialize` means `self.serialize`. Other
|
||||
* supported languages make the receiver explicit in source (`this.x`, `self.x`),
|
||||
* so tree-sitter produces a member call directly. Ruby's bare identifier
|
||||
* produces either `callForm === 'free'` or `callForm === undefined` (body_statement
|
||||
* identifier captures where the @call node IS the @call.name node), and
|
||||
* `resolveFreeCall` does a global tiered name lookup — no MRO walk.
|
||||
*
|
||||
* This helper is a pure decision function consumed by the Ruby language
|
||||
* provider's `inferImplicitReceiver` hook. Shared pipeline code never imports
|
||||
* it directly — only `languages/ruby.ts` does.
|
||||
*/
|
||||
|
||||
import type { SyntaxNode } from './ast-helpers.js';
|
||||
import type { LanguageProvider } from '../language-provider.js';
|
||||
|
||||
/**
|
||||
* Rewrite suggestion returned by `maybeRewriteRubyBareCallToSelf`.
|
||||
*
|
||||
* `callForm` is always `'member'`; `receiverName` is always `'self'`.
|
||||
* `dispatchKind` controls the stage-4 ancestry view:
|
||||
* - `'instance'` → prepend → direct → include (normal MRO)
|
||||
* - `'singleton'` → extend providers only, no file-scoped fallback
|
||||
*
|
||||
* Consumed by `languages/ruby.ts § inferImplicitReceiver` (wraps into
|
||||
* `ImplicitReceiverOverride`; `dispatchKind` becomes the `hint` field).
|
||||
*/
|
||||
export interface SelfCallRewrite {
|
||||
readonly callForm: 'member';
|
||||
readonly receiverName: 'self';
|
||||
readonly receiverTypeName: string;
|
||||
/** `'singleton'` when the enclosing method is `def self.foo` / inside a
|
||||
* `singleton_class` body; `'instance'` otherwise. Controls MRO ancestry
|
||||
* view selection in stage-4 dispatch. */
|
||||
readonly dispatchKind: 'instance' | 'singleton';
|
||||
}
|
||||
|
||||
/** Maximum parent-walk depth to prevent runaway traversal. */
|
||||
const MAX_PARENT_DEPTH = 50;
|
||||
|
||||
/**
|
||||
* Returns true if `callNode` is inside a `singleton_method` or `singleton_class`.
|
||||
* Stops at `class`/`module` boundary or MAX_PARENT_DEPTH (50) to bound traversal.
|
||||
*/
|
||||
function isInsideSingletonMethod(callNode: SyntaxNode): boolean {
|
||||
let current: SyntaxNode | null = callNode.parent;
|
||||
let depth = 0;
|
||||
while (current && depth++ < MAX_PARENT_DEPTH) {
|
||||
if (current.type === 'singleton_method') return true;
|
||||
if (current.type === 'singleton_class') return true;
|
||||
if (current.type === 'class' || current.type === 'module') return false;
|
||||
current = current.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision function: should a bare Ruby call be rewritten as `self.method`?
|
||||
*
|
||||
* Returns a `SelfCallRewrite` when all gates pass; null otherwise.
|
||||
* Gates (all required): `callForm` is `'free'` or `undefined`, strategy is
|
||||
* `'ruby-mixin'`, `enclosingClassName` is non-null, name is not `'super'`,
|
||||
* name is not a built-in.
|
||||
*
|
||||
* Note: Ruby body-statement identifiers produce `callForm === undefined` because
|
||||
* the @call node IS the @call.name node in tree-sitter-ruby.
|
||||
*
|
||||
* Example: `calledName='serialize'` in `Account` instance method →
|
||||
* `{callForm:'member', receiverName:'self', receiverTypeName:'Account', dispatchKind:'instance'}`
|
||||
*/
|
||||
export function maybeRewriteRubyBareCallToSelf(
|
||||
calledName: string,
|
||||
callForm: 'free' | 'member' | 'constructor' | undefined,
|
||||
callNode: SyntaxNode,
|
||||
enclosingClassName: string | null,
|
||||
provider: Pick<LanguageProvider, 'isBuiltInName' | 'mroStrategy'>,
|
||||
): SelfCallRewrite | null {
|
||||
// Body-statement bare identifiers produce `callForm === undefined` because
|
||||
// the @call node IS the @call.name node in tree-sitter-ruby. Treat both
|
||||
// undefined and 'free' as qualifying.
|
||||
if (callForm !== 'free' && callForm !== undefined) return null;
|
||||
if (provider.mroStrategy !== 'ruby-mixin') return null;
|
||||
if (!enclosingClassName) return null;
|
||||
if (calledName === 'super') return null;
|
||||
if (provider.isBuiltInName(calledName)) return null;
|
||||
|
||||
const dispatchKind: SelfCallRewrite['dispatchKind'] = isInsideSingletonMethod(callNode)
|
||||
? 'singleton'
|
||||
: 'instance';
|
||||
return {
|
||||
callForm: 'member',
|
||||
receiverName: 'self',
|
||||
receiverTypeName: enclosingClassName,
|
||||
dispatchKind,
|
||||
};
|
||||
}
|
||||
|
|
@ -27,7 +27,6 @@ import {
|
|||
} from '../ts-js-hoc-utils.js';
|
||||
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
|
||||
import type { SymbolTableReader } from '../model/symbol-table.js';
|
||||
import type { ExtractedHeritage } from '../model/heritage-map.js';
|
||||
import type {
|
||||
ExtractedRouterInclude,
|
||||
ExtractedRouterImport,
|
||||
|
|
@ -225,9 +224,6 @@ export interface ExtractedAssignment {
|
|||
line?: number;
|
||||
}
|
||||
|
||||
// `ExtractedHeritage` now lives in `../model/heritage-map.ts` and is
|
||||
// re-exported at the top of this file.
|
||||
|
||||
export interface ExtractedFetchCall {
|
||||
filePath: string;
|
||||
fetchURL: string;
|
||||
|
|
@ -319,7 +315,6 @@ export interface ParseWorkerResult {
|
|||
imports: ExtractedImport[];
|
||||
calls: ExtractedCall[];
|
||||
assignments: ExtractedAssignment[];
|
||||
heritage: ExtractedHeritage[];
|
||||
routes: ExtractedRoute[];
|
||||
fetchCalls: ExtractedFetchCall[];
|
||||
fetchWrapperDefs: FetchWrapperDef[];
|
||||
|
|
@ -802,7 +797,6 @@ const processBatch = (
|
|||
imports: [],
|
||||
calls: [],
|
||||
assignments: [],
|
||||
heritage: [],
|
||||
routes: [],
|
||||
fetchCalls: [],
|
||||
fetchWrapperDefs: [],
|
||||
|
|
@ -1180,39 +1174,13 @@ const processFileGroup = (
|
|||
);
|
||||
if (parsedFile !== undefined) result.parsedFiles.push(parsedFile);
|
||||
|
||||
// Pre-pass: extract heritage from query matches to build parentMap for buildTypeEnv.
|
||||
// Heritage edges (EXTENDS/IMPLEMENTS) are created by heritage-processor which runs
|
||||
// in PARALLEL with call-processor, so the graph edges don't exist when buildTypeEnv
|
||||
// runs. This pre-pass makes parent class information available for type resolution.
|
||||
const fileParentMap = new Map<string, string[]>();
|
||||
if (provider.heritageExtractor) {
|
||||
for (const match of matches) {
|
||||
const captureMap: Record<string, SyntaxNode> = {};
|
||||
for (const c of match.captures) {
|
||||
captureMap[c.name] = c.node;
|
||||
}
|
||||
if (captureMap['heritage.class']) {
|
||||
const heritageItems = provider.heritageExtractor.extract(captureMap, {
|
||||
filePath: file.path,
|
||||
language,
|
||||
});
|
||||
for (const item of heritageItems) {
|
||||
if (item.kind === 'extends') {
|
||||
let parents = fileParentMap.get(item.className);
|
||||
if (!parents) {
|
||||
parents = [];
|
||||
fileParentMap.set(item.className, parents);
|
||||
}
|
||||
if (!parents.includes(item.parentName)) parents.push(item.parentName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build per-file type environment + constructor bindings in a single AST walk.
|
||||
// Constructor bindings are verified against the SymbolTable in processCallsFromExtracted.
|
||||
const parentMap: ReadonlyMap<string, readonly string[]> = fileParentMap;
|
||||
// The legacy heritage pre-pass that seeded a file-local parentMap for
|
||||
// buildTypeEnv was removed in RING4-1 (#942) along with the rest of the
|
||||
// call-resolution DAG. Inheritance is now emitted by scope-resolution
|
||||
// (preEmitInheritanceEdges + @reference.inherits), so buildTypeEnv runs with
|
||||
// an empty parentMap — cross-file inheritance was never resolved here anyway.
|
||||
const parentMap: ReadonlyMap<string, readonly string[]> = new Map();
|
||||
const typeEnv = buildTypeEnv(tree, language, {
|
||||
filePath: file.path,
|
||||
parentMap,
|
||||
|
|
@ -1491,28 +1459,10 @@ const processFileGroup = (
|
|||
if (callNameNode) {
|
||||
const calledName = callNameNode.text;
|
||||
|
||||
// Check heritage extractor for call-based heritage (e.g., Ruby include/extend/prepend)
|
||||
if (provider.heritageExtractor?.extractFromCall) {
|
||||
const heritageItems = provider.heritageExtractor.extractFromCall(
|
||||
calledName,
|
||||
callNode,
|
||||
{ filePath: file.path, language },
|
||||
);
|
||||
if (heritageItems !== null) {
|
||||
for (const item of heritageItems) {
|
||||
result.heritage.push({
|
||||
filePath: file.path,
|
||||
className: item.className,
|
||||
parentName: item.parentName,
|
||||
kind: item.kind,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch: route language-specific calls (properties, imports)
|
||||
// Heritage routing is handled by heritageExtractor.extractFromCall above.
|
||||
// Dispatch: route language-specific calls (properties, imports).
|
||||
// Call-based heritage (Ruby include/extend/prepend) is no longer
|
||||
// routed here — those calls return 'skip' from the router and the
|
||||
// mixin edges are emitted by scope-resolution (emitHeritageEdges).
|
||||
const routed = callRouter?.(calledName, captureMap['call']);
|
||||
if (routed) {
|
||||
if (routed.kind === 'skip') continue;
|
||||
|
|
@ -1694,38 +1644,6 @@ const processFileGroup = (
|
|||
continue;
|
||||
}
|
||||
|
||||
// Extract heritage (extends/implements) via provider heritage extractor
|
||||
if (captureMap['heritage.class']) {
|
||||
if (provider.heritageExtractor) {
|
||||
const heritageItems = provider.heritageExtractor.extract(captureMap, {
|
||||
filePath: file.path,
|
||||
language,
|
||||
});
|
||||
for (const item of heritageItems) {
|
||||
result.heritage.push({
|
||||
filePath: file.path,
|
||||
className: item.className,
|
||||
parentName: item.parentName,
|
||||
kind: item.kind,
|
||||
});
|
||||
}
|
||||
// When the extractor consumes the match, skip symbol processing below.
|
||||
if (heritageItems.length > 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Fallback: the extractor returned [] (or is absent), but the match still
|
||||
// carries a heritage-specific capture. The match belongs to a heritage
|
||||
// clause and must not fall through to generic symbol processing.
|
||||
if (
|
||||
captureMap['heritage.extends'] ||
|
||||
captureMap['heritage.implements'] ||
|
||||
captureMap['heritage.trait']
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
|
||||
const defaultNodeLabel = getLabelFromCaptures(captureMap, provider);
|
||||
if (!defaultNodeLabel) continue;
|
||||
|
|
@ -2248,7 +2166,6 @@ let accumulated: ParseWorkerResult = {
|
|||
imports: [],
|
||||
calls: [],
|
||||
assignments: [],
|
||||
heritage: [],
|
||||
routes: [],
|
||||
fetchCalls: [],
|
||||
fetchWrapperDefs: [],
|
||||
|
|
@ -2280,7 +2197,6 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => {
|
|||
appendAll(target.imports, src.imports);
|
||||
appendAll(target.calls, src.calls);
|
||||
appendAll(target.assignments, src.assignments);
|
||||
appendAll(target.heritage, src.heritage);
|
||||
appendAll(target.routes, src.routes);
|
||||
appendAll(target.fetchCalls, src.fetchCalls);
|
||||
appendAll(target.fetchWrapperDefs, src.fetchWrapperDefs);
|
||||
|
|
@ -2384,7 +2300,6 @@ parentPort!.on('message', (msg: WorkerIncomingMessage) => {
|
|||
imports: [],
|
||||
calls: [],
|
||||
assignments: [],
|
||||
heritage: [],
|
||||
routes: [],
|
||||
fetchCalls: [],
|
||||
fetchWrapperDefs: [],
|
||||
|
|
|
|||
|
|
@ -44,7 +44,11 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
|
|||
* On version mismatch, `loadParseCache` returns an empty cache and the
|
||||
* next save overwrites the on-disk file with the new version baked in.
|
||||
*/
|
||||
const SCHEMA_BUMP = 2;
|
||||
// Bumped to 3 in RING4-1 (#942): ParseWorkerResult lost its `heritage` field
|
||||
// when the legacy heritage path was deleted. Invalidating stale on-disk caches
|
||||
// prevents cross-version replay (e.g. a rollback reading a heritage-less cache
|
||||
// into legacy code that expects `result.heritage`).
|
||||
const SCHEMA_BUMP = 3;
|
||||
const GITNEXUS_PKG_VERSION = (() => {
|
||||
try {
|
||||
// package.json sits at gitnexus/package.json — two levels up from
|
||||
|
|
|
|||
|
|
@ -489,7 +489,7 @@
|
|||
},
|
||||
"csharp-qualified-base/src/Shapes.cs": {
|
||||
"captureGroups": 38,
|
||||
"digest": "d0023367c412f37f030860022c859a58a249aff2d17ec92b109f47a7efe77cf3"
|
||||
"digest": "9f7730c2579cff867f929efc8ad9e314f847b7e020174282c7c104339ad5b610"
|
||||
},
|
||||
"csharp-qualified-types/Data/User.cs": {
|
||||
"captureGroups": 7,
|
||||
|
|
|
|||
|
|
@ -3,10 +3,9 @@ using DomainAlias = App.Domain;
|
|||
|
||||
namespace App
|
||||
{
|
||||
// Each declaration below exercises a base-list shape the registry-primary
|
||||
// inheritance synth DROPPED before #1951. The legacy @heritage leg already
|
||||
// covered them (tree-sitter-queries.ts record/struct base_list arms), so
|
||||
// both resolver legs must now agree.
|
||||
// Each declaration below exercises a base-list shape an earlier inheritance
|
||||
// synth DROPPED before #1951. Scope-resolution (the single path since #942)
|
||||
// now covers them all.
|
||||
|
||||
// record_declaration base_list, plain identifier bases (record traversal
|
||||
// was skipped — synth only walked class/interface declarations).
|
||||
|
|
@ -38,9 +37,9 @@ namespace App
|
|||
{
|
||||
}
|
||||
|
||||
// alias_qualified_name base (`DomainAlias::Base` → `Base`): the extractor
|
||||
// had no case for alias_qualified_name and returned null. Its `name` field
|
||||
// is the bare identifier; `normalizeSupertypeName` reduces it the same way.
|
||||
// alias_qualified_name base (`DomainAlias::Base` → `Base`): an earlier
|
||||
// extractor had no case for alias_qualified_name and returned null. Its
|
||||
// `name` field is the bare identifier; scope-resolution reduces it the same way.
|
||||
public class B : DomainAlias::Base
|
||||
{
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,11 @@ package app;
|
|||
|
||||
// Interface-to-interface EXTENDS (#1951). `interface IA extends IB, IC<String>`
|
||||
// lives under `interface_declaration > extends_interfaces > type_list`, which
|
||||
// the registry-primary synth previously NEVER walked (it visited
|
||||
// class_declaration only) — so production silently dropped these edges while the
|
||||
// legacy @heritage `interface_declaration` arm emitted them. Both bases resolve
|
||||
// to Interface symbols, so the edges are emitted as IMPLEMENTS at both legs.
|
||||
// IC<String> exercises the generic-base reduction (IC<String> -> IC), matching
|
||||
// normalizeSupertypeName.
|
||||
// an earlier synth NEVER walked (it visited class_declaration only) — so
|
||||
// production silently dropped these edges. Both bases resolve to Interface
|
||||
// symbols, so the edges are emitted as IMPLEMENTS. IC<String> exercises the
|
||||
// generic-base reduction (IC<String> -> IC). Scope-resolution owns these edges
|
||||
// since #942.
|
||||
public interface IA extends IB, IC<String> {
|
||||
void a();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ package app;
|
|||
|
||||
// 2-SEGMENT qualified non-generic bases (Outer.Inner shape): `extends base.Base`
|
||||
// and `implements base.IBar`. Both segments parse as direct type_identifier
|
||||
// children of the scoped_type_identifier (no nested prefix), so the legacy
|
||||
// @heritage query MUST end-anchor to the trailing segment or it double-matches
|
||||
// and emits a spurious prefix edge. Regression guard for the U2 anchor fix.
|
||||
// children of the scoped_type_identifier (no nested prefix), so resolution MUST
|
||||
// end-anchor to the trailing segment or it double-matches and emits a spurious
|
||||
// prefix edge. Regression guard for the U2 anchor fix.
|
||||
public class Plain extends base.Base implements base.IBar {
|
||||
public void bar() {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@ import { Base } from './base.js';
|
|||
|
||||
// Qualified base: `extends ns.Base` parses as a class_heritage holding a
|
||||
// member_expression (object: identifier `ns`, property: property_identifier
|
||||
// `Base`). The registry-primary synth resolves it by its trailing
|
||||
// property_identifier (`Base`), matching the legacy @heritage leg's
|
||||
// normalizeSupertypeName reduction (member_expression -> `Base`).
|
||||
// `Base`). Scope-resolution resolves it by its trailing property_identifier
|
||||
// (`Base`) — the documented member_expression -> `Base` reduction.
|
||||
export class Service extends ns.Base {
|
||||
base() {
|
||||
return 'service';
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ package models
|
|||
// Interface-delegation base: `: Iface by d` parses as
|
||||
// `(delegation_specifier (explicit_delegation (user_type (type_identifier)) <delegate>))`.
|
||||
// The supertype is the LEADING `user_type` (Iface); the trailing delegate
|
||||
// expression (`by d`) is NOT a supertype. The registry-primary synth previously
|
||||
// DROPPED this shape, so production emitted no IMPLEMENTS edge here (#1951).
|
||||
// Resolves by its simple name `Iface`, matching the legacy @heritage leg's
|
||||
// normalizeSupertypeName(explicit_delegation) reduction.
|
||||
// expression (`by d`) is NOT a supertype. An earlier synth DROPPED this shape,
|
||||
// so production emitted no IMPLEMENTS edge here (#1951). Scope-resolution now
|
||||
// resolves it by its simple name `Iface` — the documented explicit_delegation
|
||||
// reduction.
|
||||
class F(d: Iface) : Iface by d {
|
||||
fun extra() {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ a = auth.Admin()
|
|||
a.login()
|
||||
|
||||
# Same-name cross-module disambiguation: both models and auth export User.
|
||||
# moduleAliasMap maps receiverName='auth' → auth.py, enabling resolveCallTarget
|
||||
# moduleAliasMap maps receiverName='auth' → auth.py, enabling scope-resolution
|
||||
# to narrow candidates to the correct file.
|
||||
v = auth.User()
|
||||
v.verify()
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
require_relative 'outer'
|
||||
|
||||
# SCOPED superclass `class C < Outer::Super`: the superclass field holds a
|
||||
# `scope_resolution` (Outer::Super), not a direct `constant`. The registry-
|
||||
# primary synth previously dropped this (findChild(superclass,'constant') was
|
||||
# null) so production silently omitted the EXTENDS edge while the legacy
|
||||
# @heritage leg captured it (#1951). It must resolve to `Super` by the trailing
|
||||
# `name:` constant, at parity with normalizeSupertypeName. `include Mixin` flows
|
||||
# through the independent mixin lane (IMPLEMENTS, unchanged).
|
||||
# `scope_resolution` (Outer::Super), not a direct `constant`. An earlier synth
|
||||
# dropped this (findChild(superclass,'constant') was null) so production silently
|
||||
# omitted the EXTENDS edge (#1951). It must resolve to `Super` by the trailing
|
||||
# `name:` constant, per the documented scoped-base reduction. Scope-resolution
|
||||
# owns these edges since #942. `include Mixin` flows through the independent
|
||||
# mixin lane (IMPLEMENTS, unchanged).
|
||||
class C < Outer::Super
|
||||
include Mixin
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use crate::parent::Parent;
|
|||
pub struct Child;
|
||||
|
||||
impl Child {
|
||||
// Direct impl method — MUST resolve via resolveMemberCall owner-scoped path.
|
||||
// Direct impl method — MUST resolve via the owner-scoped resolution path.
|
||||
pub fn own_method(&self) -> &str {
|
||||
"child-own"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@
|
|||
"digest": "8662c17b0f21fcfa650065abd62f0c9b7e1c65bf8a1f7dce6f2a16bba9df759f"
|
||||
},
|
||||
"python-abstract-dispatch/base.py": {
|
||||
"captureGroups": 19,
|
||||
"digest": "892a2e6ad60f7fc6e206bcc42f9c8905dfbab27e84b4334a3a4ba353078a879a"
|
||||
"captureGroups": 18,
|
||||
"digest": "f555f01c16bd9696b9854eccb0225ed6df3d271eebcac19812914ad38004786f"
|
||||
},
|
||||
"python-abstract-dispatch/impl.py": {
|
||||
"captureGroups": 16,
|
||||
"digest": "5f785934a573d11499ccea29ae87daaaf817390884acd1328a803ec0ce828297"
|
||||
"captureGroups": 15,
|
||||
"digest": "6c27015f13d32024ce06c1515ab29ca0436df7a4864bbb62188d5f79d685cc31"
|
||||
},
|
||||
"python-alias-imports/app.py": {
|
||||
"captureGroups": 13,
|
||||
|
|
@ -40,8 +40,8 @@
|
|||
"digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"python-ambiguous/services/user_handler.py": {
|
||||
"captureGroups": 9,
|
||||
"digest": "b0ee065813f8d113ad5f6cc413a36bc645e05fe9b533c3a40cd0b62fe9eb77ca"
|
||||
"captureGroups": 8,
|
||||
"digest": "5a4bb82d0e6a6a53fe012f197e42c572ac1169ff38e84db9c6281399f6739021"
|
||||
},
|
||||
"python-ancestor-import/a/b/c/deep.py": {
|
||||
"captureGroups": 5,
|
||||
|
|
@ -128,8 +128,8 @@
|
|||
"digest": "0f60d5cd521b0073524b0993e82d5291f86badd5cbefb986cefdf7b0bed64157"
|
||||
},
|
||||
"python-child-extends-parent/child.py": {
|
||||
"captureGroups": 6,
|
||||
"digest": "48c1f798021986fbe074bb37fdde4763791bfa9a18d1172510a7f4fe4ed676d5"
|
||||
"captureGroups": 5,
|
||||
"digest": "d118691eb76c9432841743efee8556f1e7a1d136e9b403a12fd512f91d73ca61"
|
||||
},
|
||||
"python-child-extends-parent/parent.py": {
|
||||
"captureGroups": 7,
|
||||
|
|
@ -208,16 +208,16 @@
|
|||
"digest": "392b15be747e2b5cbd3ac5a9e61a7677ffa6ba52e49d3631681e43c557373b5f"
|
||||
},
|
||||
"python-django-app-imports/accounts/apps.py": {
|
||||
"captureGroups": 7,
|
||||
"digest": "d7ef23ddaa13aa398f642580fd19bfff9207e88d0015aeb23a04bbd3140c7bb4"
|
||||
"captureGroups": 6,
|
||||
"digest": "784cba903ad9534337ed820b085c8ecc352964e797bde1d4dda9e700960366a0"
|
||||
},
|
||||
"python-django-app-imports/accounts/migrations/__init__.py": {
|
||||
"captureGroups": 0,
|
||||
"digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"python-django-app-imports/accounts/models.py": {
|
||||
"captureGroups": 9,
|
||||
"digest": "a513607782f1a3d33af594aff2674ff3fa72e38588bf3590e9581e8ca0dcf47a"
|
||||
"captureGroups": 8,
|
||||
"digest": "b240f4ea2135824ee47fd5f2d9a4788ff0374cafb5070b295fc710a523f19873"
|
||||
},
|
||||
"python-django-app-imports/accounts/tests.py": {
|
||||
"captureGroups": 2,
|
||||
|
|
@ -236,16 +236,16 @@
|
|||
"digest": "392b15be747e2b5cbd3ac5a9e61a7677ffa6ba52e49d3631681e43c557373b5f"
|
||||
},
|
||||
"python-django-app-imports/billing/apps.py": {
|
||||
"captureGroups": 7,
|
||||
"digest": "64644bea82f36aef4f7f2784405a630b6b5f5b87f32f837372190642b8af3557"
|
||||
"captureGroups": 6,
|
||||
"digest": "0ff487476397cc85c2ce5ec0afe59eb82d83f97bcb3d4518b5040f52790e1833"
|
||||
},
|
||||
"python-django-app-imports/billing/migrations/__init__.py": {
|
||||
"captureGroups": 0,
|
||||
"digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"python-django-app-imports/billing/models.py": {
|
||||
"captureGroups": 13,
|
||||
"digest": "94396c0755b2fab7ef73c06f92a237893097b57bf83637b83d39f6d0e0f6e0d9"
|
||||
"captureGroups": 12,
|
||||
"digest": "eedb2e1992f85a6743555947118784db9c78f3c113d2d598e53dd54cb80a0629"
|
||||
},
|
||||
"python-django-app-imports/billing/tests.py": {
|
||||
"captureGroups": 2,
|
||||
|
|
@ -348,12 +348,12 @@
|
|||
"digest": "5879a42d6655248623c9aee193fedcae51daa1887d8e597fdd944ad93af053a4"
|
||||
},
|
||||
"python-grandparent-resolution/models/b.py": {
|
||||
"captureGroups": 6,
|
||||
"digest": "58660d2049990cebed4ea917b45eb32d21120ee88633c97fa19d91277b25cd6a"
|
||||
"captureGroups": 5,
|
||||
"digest": "be5c28ebfb06cd90c7cd453d612f0cacdffdac3e57d7ce794c0a353a9b057597"
|
||||
},
|
||||
"python-grandparent-resolution/models/c.py": {
|
||||
"captureGroups": 6,
|
||||
"digest": "a04777b65ef018c652b96aa8978a67d14ee94905a5d3328c00a4c83ed1fb486a"
|
||||
"captureGroups": 5,
|
||||
"digest": "2ad9124422ca018e59855c55a054d5c37d15b448a90027842fc56dd6f61e4593"
|
||||
},
|
||||
"python-grandparent-resolution/models/greeting.py": {
|
||||
"captureGroups": 7,
|
||||
|
|
@ -472,8 +472,8 @@
|
|||
"digest": "88dfd417951b8083184f83da8c1e0c2b19700cfbf1f1ff203a904ebc00f50b30"
|
||||
},
|
||||
"python-method-enrichment/models.py": {
|
||||
"captureGroups": 29,
|
||||
"digest": "83ebd37c527396004fdb9175beeeb09ed03645a04bc6a4eaa25b1260ac123ad1"
|
||||
"captureGroups": 27,
|
||||
"digest": "c9f6e1678d8976665d7c7c3b1c649ff60ea5a4f397cf2fef2a559ccf4b5f08e3"
|
||||
},
|
||||
"python-module-export-vs-method-collision/app.py": {
|
||||
"captureGroups": 14,
|
||||
|
|
@ -485,7 +485,7 @@
|
|||
},
|
||||
"python-module-import/app.py": {
|
||||
"captureGroups": 15,
|
||||
"digest": "1cda3d492b732bd620b2e158a6967b719433d52b045f9066305ec3ef99973b18"
|
||||
"digest": "29560a379fc78162c760c142019d480ddbdd56e807da5831bc39d2c559136f3e"
|
||||
},
|
||||
"python-module-import/auth.py": {
|
||||
"captureGroups": 11,
|
||||
|
|
@ -500,16 +500,16 @@
|
|||
"digest": "98bcec072e85a50303be141212b835322f5f9e53f7fe5d77b23d8ee524623e84"
|
||||
},
|
||||
"python-multi-level-mro/child.py": {
|
||||
"captureGroups": 6,
|
||||
"digest": "48c1f798021986fbe074bb37fdde4763791bfa9a18d1172510a7f4fe4ed676d5"
|
||||
"captureGroups": 5,
|
||||
"digest": "d118691eb76c9432841743efee8556f1e7a1d136e9b403a12fd512f91d73ca61"
|
||||
},
|
||||
"python-multi-level-mro/grandparent.py": {
|
||||
"captureGroups": 7,
|
||||
"digest": "f9f81d3a37c55b3e23bec3774405920afa29c5793c46860a98a06c5d0c7f0980"
|
||||
},
|
||||
"python-multi-level-mro/parent.py": {
|
||||
"captureGroups": 6,
|
||||
"digest": "884be03e3640693bc087e297d018ce37d92cfd7cc5742cbca61471cd4a3c9e8c"
|
||||
"captureGroups": 5,
|
||||
"digest": "b68bfb8fdedb8f725c609264e604ccb674a5a775c0d87c008a9990234c70bde3"
|
||||
},
|
||||
"python-multi-segment-ancestor-import/backend/auth_utils.py": {
|
||||
"captureGroups": 6,
|
||||
|
|
@ -592,20 +592,20 @@
|
|||
"digest": "f0384bd6ecb7d1a9ad2306358917b7295c71ea8f1bcea818fd39c56d2c28c7e9"
|
||||
},
|
||||
"python-parent-resolution/models/user.py": {
|
||||
"captureGroups": 10,
|
||||
"digest": "897b68f06f59fd0a488d0ac5d694aa655a82c09f8846202697a61def1f32c488"
|
||||
"captureGroups": 9,
|
||||
"digest": "b06a66a108097eec9427a028dec38bbab284918ac39344e86e58bba89533c530"
|
||||
},
|
||||
"python-parsing-coverage/heritage.py": {
|
||||
"captureGroups": 26,
|
||||
"digest": "3ceaf5293361ca76d52aa6941bd82674a16d1bd002a1fa8bf0b2f84cd00120c2"
|
||||
"captureGroups": 21,
|
||||
"digest": "dc7df1006ed20c6f7fd17799eb090ce2ebdd4d064384949d612835f5f05a9158"
|
||||
},
|
||||
"python-pkg/models/base.py": {
|
||||
"captureGroups": 9,
|
||||
"digest": "4984ee01b7a9fefe622195f0e4925823c0e62a1714ca2dda5cd8250e5e45fa7c"
|
||||
},
|
||||
"python-pkg/models/user.py": {
|
||||
"captureGroups": 9,
|
||||
"digest": "7b6984be334f50334e6ebe4a895cf61e2d3773edd1c03ed41440b76abcd44f01"
|
||||
"captureGroups": 8,
|
||||
"digest": "9ee707b36f42a635fdb867ce20359b5f51ac4e13550cde801359dc8314e01a77"
|
||||
},
|
||||
"python-pkg/services/auth.py": {
|
||||
"captureGroups": 9,
|
||||
|
|
@ -640,8 +640,8 @@
|
|||
"digest": "7fc34dae23f54cdee030a2d4a1d80c0bf226ea3b37335ecf48551c4707d32e20"
|
||||
},
|
||||
"python-qualified-base/service.py": {
|
||||
"captureGroups": 20,
|
||||
"digest": "d1f66f8587c7c0e8284e877d2846685acb8bb165f1cfc96deea4688a4cf26c23"
|
||||
"captureGroups": 16,
|
||||
"digest": "adecbd613fe97656cd5797c4dbef9f3c32bcd1b9faa5e765227af5d2001da427"
|
||||
},
|
||||
"python-qualified-constructor/main.py": {
|
||||
"captureGroups": 9,
|
||||
|
|
@ -732,8 +732,8 @@
|
|||
"digest": "1d6eb1cdc661f2463d8e1a499eaa5bfcd9367324f6090c44fe4d59ed02151215"
|
||||
},
|
||||
"python-super-resolution/models/user.py": {
|
||||
"captureGroups": 12,
|
||||
"digest": "8faf7b3a238f654f3ae2231cd5a7d968b2604481581bc2e9cb07897e5027011f"
|
||||
"captureGroups": 11,
|
||||
"digest": "8a66f8962fcf960b66c1106d1d67da21f7f6bac323b961e5fc0623b3c93dbc38"
|
||||
},
|
||||
"python-variadic-resolution/app.py": {
|
||||
"captureGroups": 5,
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@
|
|||
},
|
||||
"ruby-qualified-base/lib/derived.rb": {
|
||||
"captureGroups": 18,
|
||||
"digest": "8825a54a774c8c77f96315413f632fda626f35d705d8fe697cd362f7acf77a8a"
|
||||
"digest": "a53ab5401694f26da04b02f5be6bc1ba6a292ca93eb16e2df6089e15c60dfba5"
|
||||
},
|
||||
"ruby-qualified-base/lib/outer.rb": {
|
||||
"captureGroups": 18,
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@
|
|||
},
|
||||
"rust-child-extends-parent/src/child.rs": {
|
||||
"captureGroups": 13,
|
||||
"digest": "0d60bdf7a88460ee3eebdd48debee9ebb163cfe3f7a22f53323dcef535b6c6b7"
|
||||
"digest": "4af705aa98a718b54c1e8a2037989c747c3e4e6bb8ba833cdf6672a1f8983cec"
|
||||
},
|
||||
"rust-child-extends-parent/src/main.rs": {
|
||||
"captureGroups": 17,
|
||||
|
|
|
|||
|
|
@ -7,10 +7,9 @@
|
|||
*
|
||||
* Run: GITNEXUS_BENCH=1 npx vitest run test/integration/cobol-pipeline-benchmark.test.ts
|
||||
*
|
||||
* Results are identical under both REGISTRY_PRIMARY_COBOL modes because
|
||||
* cobolPhase runs in both modes. Under =1, scope-resolution is skipped for
|
||||
* COBOL (standalone guard at phase.ts:164), so node/edge counts come entirely
|
||||
* from the legacy cobolPhase.
|
||||
* COBOL is wired as a standalone provider, so the scope-resolution phase is
|
||||
* skipped for it (standalone guard in phase.ts) and node/edge counts come
|
||||
* entirely from cobolPhase.
|
||||
*
|
||||
* IMPORTANT — this benchmark measures scaling in FILE COUNT, so per-file work
|
||||
* must stay constant as fileCount grows. Each program therefore COPYs a fixed
|
||||
|
|
|
|||
|
|
@ -1,186 +0,0 @@
|
|||
/**
|
||||
* Integration tests for heritage extractor wiring via real tree-sitter output.
|
||||
*
|
||||
* Complements test/unit/heritage-extraction.test.ts, which exercises the
|
||||
* configs and factory against mocked AST nodes. These tests drive the same
|
||||
* extractors against **real** tree-sitter parses so that a drift between
|
||||
* the per-language tree-sitter queries and the extractor configs would be
|
||||
* caught here even if mocked unit tests keep passing.
|
||||
*
|
||||
* Context: PR #890 review follow-up. See
|
||||
* docs/plans/2026-04-16-005-refactor-pr890-review-followups-plan.md Unit 3a.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import Parser from 'tree-sitter';
|
||||
import { loadParser, loadLanguage } from '../../src/core/tree-sitter/parser-loader.js';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { getProvider } from '../../src/core/ingestion/languages/index.js';
|
||||
import type { CaptureMap } from '../../src/core/ingestion/language-provider.js';
|
||||
import { rubyHeritageConfig } from '../../src/core/ingestion/heritage-extractors/configs/ruby.js';
|
||||
|
||||
let parser: Parser;
|
||||
|
||||
beforeAll(async () => {
|
||||
parser = await loadParser();
|
||||
});
|
||||
|
||||
/** Run the provider's tree-sitter queries over `code` and yield per-match capture maps. */
|
||||
function runQueries(code: string, lang: SupportedLanguages): CaptureMap[] {
|
||||
const tree = parser.parse(code);
|
||||
const provider = getProvider(lang);
|
||||
const query = new Parser.Query(parser.getLanguage(), provider.treeSitterQueries);
|
||||
const matches = query.matches(tree.rootNode);
|
||||
|
||||
return matches.map((match) => {
|
||||
const captureMap: Record<string, any> = {};
|
||||
for (const capture of match.captures) {
|
||||
captureMap[capture.name] = capture.node;
|
||||
}
|
||||
return captureMap as unknown as CaptureMap;
|
||||
});
|
||||
}
|
||||
|
||||
/** Parse `code` and return the first AST node whose type matches `nodeType`. */
|
||||
function findFirstNode(code: string, nodeType: string): any | null {
|
||||
const tree = parser.parse(code);
|
||||
const stack: any[] = [tree.rootNode];
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop();
|
||||
if (node.type === nodeType) return node;
|
||||
for (let i = node.childCount - 1; i >= 0; i--) {
|
||||
stack.push(node.child(i));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Ruby extractFromCall — real AST ─────────────────────────────────────────
|
||||
|
||||
describe('Ruby heritage extractFromCall (real tree-sitter AST)', () => {
|
||||
beforeAll(async () => {
|
||||
await loadLanguage(SupportedLanguages.Ruby);
|
||||
});
|
||||
|
||||
const extract = rubyHeritageConfig.callBasedHeritage!.extract;
|
||||
|
||||
it('3a-1: class Foo; include Bar; end → single include entry', () => {
|
||||
const code = `class Foo\n include Bar\nend\n`;
|
||||
const callNode = findFirstNode(code, 'call');
|
||||
expect(callNode).not.toBeNull();
|
||||
|
||||
const result = extract('include', callNode, 'foo.rb');
|
||||
expect(result).toEqual([{ className: 'Foo', parentName: 'Bar', kind: 'include' }]);
|
||||
});
|
||||
|
||||
it('3a-2: include A, B, C produces three entries, one per constant arg', () => {
|
||||
const code = `class Multi\n include A, B, C\nend\n`;
|
||||
const callNode = findFirstNode(code, 'call');
|
||||
expect(callNode).not.toBeNull();
|
||||
|
||||
const result = extract('include', callNode, 'multi.rb');
|
||||
expect(result).toEqual([
|
||||
{ className: 'Multi', parentName: 'A', kind: 'include' },
|
||||
{ className: 'Multi', parentName: 'B', kind: 'include' },
|
||||
{ className: 'Multi', parentName: 'C', kind: 'include' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('3a-3: extend ActiveSupport::Concern (scope_resolution arg)', () => {
|
||||
const code = `class Post\n extend ActiveSupport::Concern\nend\n`;
|
||||
const callNode = findFirstNode(code, 'call');
|
||||
expect(callNode).not.toBeNull();
|
||||
|
||||
const result = extract('extend', callNode, 'post.rb');
|
||||
expect(result).toEqual([
|
||||
{ className: 'Post', parentName: 'ActiveSupport::Concern', kind: 'extend' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('3a-4: nested module/class resolves to the nearest class, not the module', () => {
|
||||
const code = `module Outer\n class Inner\n include X\n end\nend\n`;
|
||||
const callNode = findFirstNode(code, 'call');
|
||||
expect(callNode).not.toBeNull();
|
||||
|
||||
const result = extract('include', callNode, 'nested.rb');
|
||||
expect(result).toEqual([{ className: 'Inner', parentName: 'X', kind: 'include' }]);
|
||||
});
|
||||
|
||||
it('3a-5: top-level include with no enclosing class returns []', () => {
|
||||
const code = `include Foo\n`;
|
||||
// NOTE: `include Foo` at top level may not produce a `call` node in tree-sitter-ruby;
|
||||
// it often lowers to an `identifier` body_statement. Construct a realistic top-level
|
||||
// call (`Kernel.include Foo`) to exercise the no-enclosing-class branch.
|
||||
const fallback = `Kernel.include(Foo)\n`;
|
||||
const callNode = findFirstNode(fallback, 'call');
|
||||
expect(callNode).not.toBeNull();
|
||||
|
||||
const result = extract('include', callNode, 'top.rb');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('prepend inside a module uses the module as enclosingClass', () => {
|
||||
const code = `module AppHelper\n prepend Logged\nend\n`;
|
||||
const callNode = findFirstNode(code, 'call');
|
||||
expect(callNode).not.toBeNull();
|
||||
|
||||
const result = extract('prepend', callNode, 'helper.rb');
|
||||
expect(result).toEqual([{ className: 'AppHelper', parentName: 'Logged', kind: 'prepend' }]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── TypeScript heritage.extract — real AST + real query captures ────────────
|
||||
|
||||
describe('TypeScript heritage extract (real tree-sitter captures)', () => {
|
||||
beforeAll(async () => {
|
||||
await loadLanguage(SupportedLanguages.TypeScript);
|
||||
});
|
||||
|
||||
it('3a-6: class Child extends Parent {} produces one extends entry', () => {
|
||||
const code = `class Child extends Parent {}\n`;
|
||||
const captureMaps = runQueries(code, SupportedLanguages.TypeScript);
|
||||
const heritageMatches = captureMaps.filter((m) => (m as any)['heritage.class']);
|
||||
expect(heritageMatches.length).toBeGreaterThan(0);
|
||||
|
||||
const provider = getProvider(SupportedLanguages.TypeScript);
|
||||
const extractor = provider.heritageExtractor!;
|
||||
const items = extractor.extract(heritageMatches[0], {
|
||||
filePath: 'child.ts',
|
||||
language: SupportedLanguages.TypeScript,
|
||||
});
|
||||
|
||||
expect(items).toEqual([{ className: 'Child', parentName: 'Parent', kind: 'extends' }]);
|
||||
});
|
||||
|
||||
it('3a-7: class Child extends Parent implements IFoo {} yields extends + implements', () => {
|
||||
const code = `interface IFoo {}\nclass Parent {}\nclass Child extends Parent implements IFoo {}\n`;
|
||||
const captureMaps = runQueries(code, SupportedLanguages.TypeScript);
|
||||
const heritageMatches = captureMaps.filter(
|
||||
(m) =>
|
||||
(m as any)['heritage.class'] &&
|
||||
((m as any)['heritage.extends'] || (m as any)['heritage.implements']),
|
||||
);
|
||||
expect(heritageMatches.length).toBeGreaterThan(0);
|
||||
|
||||
const provider = getProvider(SupportedLanguages.TypeScript);
|
||||
const extractor = provider.heritageExtractor!;
|
||||
|
||||
const kinds = new Set<string>();
|
||||
const parents = new Set<string>();
|
||||
for (const cm of heritageMatches) {
|
||||
const items = extractor.extract(cm, {
|
||||
filePath: 'child.ts',
|
||||
language: SupportedLanguages.TypeScript,
|
||||
});
|
||||
for (const item of items) {
|
||||
expect(item.className).toBe('Child');
|
||||
kinds.add(item.kind);
|
||||
parents.add(item.parentName);
|
||||
}
|
||||
}
|
||||
|
||||
expect(kinds).toContain('extends');
|
||||
expect(kinds).toContain('implements');
|
||||
expect(parents).toContain('Parent');
|
||||
expect(parents).toContain('IFoo');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,401 +0,0 @@
|
|||
/**
|
||||
* Integration tests for the heritage supertype-alternation fix.
|
||||
*
|
||||
* Each case parses a small real source snippet with the per-language grammar,
|
||||
* runs the provider's *live* treeSitterQueries (the same bank consumed by
|
||||
* heritage-processor.ts and parse-worker.ts), feeds the resulting capture maps
|
||||
* through provider.heritageExtractor.extract, and asserts the supertype name
|
||||
* the extractor would hand to resolution. This guards the qualified / generic /
|
||||
* scoped / interface supertype shapes that previously matched only the bare
|
||||
* (type_identifier) and were silently dropped.
|
||||
*
|
||||
* It also includes a query-compile guard: every supported language's full
|
||||
* treeSitterQueries MUST compile, because heritage-processor.ts catches a
|
||||
* query-compile error and skips the file, dropping ALL heritage for it.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import Parser from 'tree-sitter';
|
||||
import {
|
||||
createParserForLanguage,
|
||||
getLanguageGrammar,
|
||||
} from '../../src/core/tree-sitter/parser-loader.js';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { getProvider } from '../../src/core/ingestion/languages/index.js';
|
||||
import type { CaptureMap } from '../../src/core/ingestion/language-provider.js';
|
||||
import type { HeritageInfo } from '../../src/core/ingestion/heritage-types.js';
|
||||
|
||||
/**
|
||||
* Parse `code` with `lang`'s grammar, run the provider's live treeSitterQueries,
|
||||
* and return every heritage item the extractor emits across all matches.
|
||||
*/
|
||||
async function extractHeritage(
|
||||
code: string,
|
||||
lang: SupportedLanguages,
|
||||
filePath: string,
|
||||
): Promise<HeritageInfo[]> {
|
||||
const parser = await createParserForLanguage(lang, filePath);
|
||||
const provider = getProvider(lang);
|
||||
const tree = parser.parse(code);
|
||||
const query = new Parser.Query(parser.getLanguage(), provider.treeSitterQueries);
|
||||
const matches = query.matches(tree.rootNode);
|
||||
const extractor = provider.heritageExtractor!;
|
||||
|
||||
const out: HeritageInfo[] = [];
|
||||
for (const match of matches) {
|
||||
const captureMap: Record<string, any> = {};
|
||||
for (const capture of match.captures) captureMap[capture.name] = capture.node;
|
||||
if (!(captureMap as CaptureMap)['heritage.class']) continue;
|
||||
out.push(
|
||||
...extractor.extract(captureMap as unknown as CaptureMap, { filePath, language: lang }),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Set of `${className}->${parentName}:${kind}` keys for order-independent asserts. */
|
||||
function keys(items: HeritageInfo[]): Set<string> {
|
||||
return new Set(items.map((i) => `${i.className}->${i.parentName}:${i.kind}`));
|
||||
}
|
||||
|
||||
// ─── Query-compile guard ─────────────────────────────────────────────────────
|
||||
|
||||
describe('heritage query-compile guard', () => {
|
||||
// Every tree-sitter-backed language. A malformed heritage block would make the
|
||||
// whole bank fail to compile and silently drop heritage for the language.
|
||||
const languages: SupportedLanguages[] = [
|
||||
SupportedLanguages.TypeScript,
|
||||
SupportedLanguages.JavaScript,
|
||||
SupportedLanguages.Python,
|
||||
SupportedLanguages.Java,
|
||||
SupportedLanguages.Go,
|
||||
SupportedLanguages.Rust,
|
||||
SupportedLanguages.CSharp,
|
||||
SupportedLanguages.C,
|
||||
SupportedLanguages.CPlusPlus,
|
||||
SupportedLanguages.PHP,
|
||||
SupportedLanguages.Ruby,
|
||||
SupportedLanguages.Swift,
|
||||
SupportedLanguages.Dart,
|
||||
SupportedLanguages.Kotlin,
|
||||
];
|
||||
|
||||
for (const lang of languages) {
|
||||
it(`${lang}: provider.treeSitterQueries compiles`, () => {
|
||||
let grammar: unknown;
|
||||
try {
|
||||
grammar = getLanguageGrammar(lang);
|
||||
} catch {
|
||||
// Optional grammars (e.g. Kotlin) may be unavailable in some installs.
|
||||
return;
|
||||
}
|
||||
const provider = getProvider(lang);
|
||||
expect(() => new Parser.Query(grammar as any, provider.treeSitterQueries)).not.toThrow();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Java ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Java heritage shapes', () => {
|
||||
it('generic + qualified extends and qualified/bare implements', async () => {
|
||||
const code = 'class A extends pkg.Base<T> implements pkg.IFoo, Bar {}';
|
||||
const items = await extractHeritage(code, SupportedLanguages.Java, 'A.java');
|
||||
const k = keys(items);
|
||||
expect(k.has('A->Base:extends')).toBe(true);
|
||||
expect(k.has('A->IFoo:implements')).toBe(true);
|
||||
expect(k.has('A->Bar:implements')).toBe(true);
|
||||
});
|
||||
|
||||
it('interface extends interface(s)', async () => {
|
||||
const code = 'interface IA extends IB, pkg.IC<T> {}';
|
||||
const items = await extractHeritage(code, SupportedLanguages.Java, 'IA.java');
|
||||
const k = keys(items);
|
||||
expect(k.has('IA->IB:implements')).toBe(true);
|
||||
expect(k.has('IA->IC:implements')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── C# ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('C# heritage shapes', () => {
|
||||
it('class qualified + generic base entries', async () => {
|
||||
const code = 'class A : pkg.Base, IFoo<T>, ns.IBar {}';
|
||||
const items = await extractHeritage(code, SupportedLanguages.CSharp, 'A.cs');
|
||||
const k = keys(items);
|
||||
expect(k.has('A->Base:extends')).toBe(true);
|
||||
expect(k.has('A->IFoo:extends')).toBe(true);
|
||||
expect(k.has('A->IBar:extends')).toBe(true);
|
||||
});
|
||||
|
||||
it('record primary-constructor base', async () => {
|
||||
const code = 'record R(int X) : pkg.Base(X), IFoo {}';
|
||||
const items = await extractHeritage(code, SupportedLanguages.CSharp, 'R.cs');
|
||||
const k = keys(items);
|
||||
expect(k.has('R->Base:extends')).toBe(true);
|
||||
expect(k.has('R->IFoo:extends')).toBe(true);
|
||||
});
|
||||
|
||||
it('struct base list', async () => {
|
||||
const code = 'struct S : IFoo, ns.IBar {}';
|
||||
const items = await extractHeritage(code, SupportedLanguages.CSharp, 'S.cs');
|
||||
const k = keys(items);
|
||||
expect(k.has('S->IFoo:extends')).toBe(true);
|
||||
expect(k.has('S->IBar:extends')).toBe(true);
|
||||
});
|
||||
|
||||
// Alias-qualified bases. Verified against tree-sitter-c-sharp node-types.json
|
||||
// + a live parse of this exact source:
|
||||
// - `global::System.IDisposable` (dotted) parses as a `qualified_name`
|
||||
// whose qualifier is an `alias_qualified_name` (already covered).
|
||||
// - `MyAlias::Foo` (bare, no dotted suffix) parses as a bare
|
||||
// `alias_qualified_name` base_list entry — previously dropped because the
|
||||
// descriptor lacked that shape. Both collapse to the simple name.
|
||||
it('alias-qualified bases: global:: (dotted) and bare alias-qualified', async () => {
|
||||
const code =
|
||||
'extern alias MyAlias;\nclass A : System.Exception, global::System.IDisposable, MyAlias::Foo {}';
|
||||
const items = await extractHeritage(code, SupportedLanguages.CSharp, 'A.cs');
|
||||
const k = keys(items);
|
||||
expect(k.has('A->Exception:extends')).toBe(true);
|
||||
expect(k.has('A->IDisposable:extends')).toBe(true);
|
||||
expect(k.has('A->Foo:extends')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── TypeScript ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TypeScript heritage shapes', () => {
|
||||
it('qualified class extends + interface implements', async () => {
|
||||
const code = 'class C extends ns.Base implements IFoo, ns.IBar {}';
|
||||
const items = await extractHeritage(code, SupportedLanguages.TypeScript, 'c.ts');
|
||||
const k = keys(items);
|
||||
expect(k.has('C->Base:extends')).toBe(true);
|
||||
expect(k.has('C->IFoo:implements')).toBe(true);
|
||||
expect(k.has('C->IBar:implements')).toBe(true);
|
||||
});
|
||||
|
||||
it('interface extends interface(s)', async () => {
|
||||
const code = 'interface I extends A, ns.B<T> {}';
|
||||
const items = await extractHeritage(code, SupportedLanguages.TypeScript, 'i.ts');
|
||||
const k = keys(items);
|
||||
expect(k.has('I->A:implements')).toBe(true);
|
||||
expect(k.has('I->B:implements')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── JavaScript ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('JavaScript heritage shapes', () => {
|
||||
it('qualified member_expression extends', async () => {
|
||||
const code = 'class C extends ns.Base {}';
|
||||
const items = await extractHeritage(code, SupportedLanguages.JavaScript, 'c.js');
|
||||
expect(keys(items).has('C->Base:extends')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Python ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Python heritage shapes', () => {
|
||||
it('bare, attribute and subscript superclasses', async () => {
|
||||
const code = 'class C(Base, models.Model, Generic[T]):\n pass\n';
|
||||
const items = await extractHeritage(code, SupportedLanguages.Python, 'c.py');
|
||||
const k = keys(items);
|
||||
expect(k.has('C->Base:extends')).toBe(true);
|
||||
expect(k.has('C->Model:extends')).toBe(true);
|
||||
expect(k.has('C->Generic:extends')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Go ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Go heritage shapes', () => {
|
||||
it('qualified and generic struct embeds (named field skipped)', async () => {
|
||||
const code = 'type D struct {\n\tpkg.Base\n\tGen[T]\n\tAnimal\n\tName string\n}\n';
|
||||
const items = await extractHeritage(code, SupportedLanguages.Go, 'd.go');
|
||||
const k = keys(items);
|
||||
expect(k.has('D->Base:extends')).toBe(true);
|
||||
expect(k.has('D->Gen:extends')).toBe(true);
|
||||
expect(k.has('D->Animal:extends')).toBe(true);
|
||||
// Named field `Name string` must NOT become heritage.
|
||||
expect(k.has('D->string:extends')).toBe(false);
|
||||
});
|
||||
|
||||
it('interface-in-interface embed', async () => {
|
||||
const code = 'type I interface {\n\tio.Reader\n\tOther\n}\n';
|
||||
const items = await extractHeritage(code, SupportedLanguages.Go, 'i.go');
|
||||
const k = keys(items);
|
||||
expect(k.has('I->Reader:extends')).toBe(true);
|
||||
expect(k.has('I->Other:extends')).toBe(true);
|
||||
});
|
||||
|
||||
it('type-set union operands are NOT embeds (P3c)', async () => {
|
||||
// `int | float64` is a constraint type-set, not an embedded interface. A
|
||||
// multi-operand type_elem is skipped by goHeritageConfig.shouldSkipExtends.
|
||||
const code = 'type N interface {\n\tint | float64\n}\n';
|
||||
const items = await extractHeritage(code, SupportedLanguages.Go, 'n.go');
|
||||
const k = keys(items);
|
||||
expect(k.has('N->int:extends')).toBe(false);
|
||||
expect(k.has('N->float64:extends')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Rust ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Rust heritage shapes', () => {
|
||||
it('scoped + generic trait impl', async () => {
|
||||
const code = 'impl ns::Trait<T> for Foo {}';
|
||||
const items = await extractHeritage(code, SupportedLanguages.Rust, 'lib.rs');
|
||||
expect(keys(items).has('Foo->Trait:trait-impl')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Ruby ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Ruby heritage shapes', () => {
|
||||
it('scoped superclass and scoped class name', async () => {
|
||||
const code = 'class Foo::Bar < Base::Sup\nend\n';
|
||||
const items = await extractHeritage(code, SupportedLanguages.Ruby, 'foo.rb');
|
||||
expect(keys(items).has('Bar->Sup:extends')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── C++ ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('C++ heritage shapes', () => {
|
||||
it('templated and qualified bases', async () => {
|
||||
const code = 'class D : public ns::Base<T>, Other {};';
|
||||
const items = await extractHeritage(code, SupportedLanguages.CPlusPlus, 'd.cpp');
|
||||
const k = keys(items);
|
||||
expect(k.has('D->Base:extends')).toBe(true);
|
||||
expect(k.has('D->Other:extends')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Kotlin (optional grammar) ────────────────────────────────────────────────────
|
||||
|
||||
const KOTLIN_AVAILABLE = (() => {
|
||||
try {
|
||||
getLanguageGrammar(SupportedLanguages.Kotlin);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
// Visible skip (not a silent in-body `return`) so an absent optional grammar
|
||||
// shows as `skipped` rather than green-washing the by-delegation regression.
|
||||
(KOTLIN_AVAILABLE ? describe : describe.skip)('Kotlin heritage shapes', () => {
|
||||
// `explicit_delegation` (`Bar by <delegate>`) places the supertype user_type
|
||||
// FIRST and the delegate expression after `by`; the normalizer must pick the
|
||||
// leading user_type, never the trailing delegate. Every form resolves to Bar.
|
||||
it('bare-identifier delegate: `: Bar by baz`', async () => {
|
||||
const items = await extractHeritage(
|
||||
'class Foo : Bar by baz {}',
|
||||
SupportedLanguages.Kotlin,
|
||||
'Foo.kt',
|
||||
);
|
||||
const k = keys(items);
|
||||
expect(k.has('Foo->Bar:extends')).toBe(true);
|
||||
// The delegate property `baz` must NOT be recorded as the supertype (P2).
|
||||
expect(k.has('Foo->baz:extends')).toBe(false);
|
||||
});
|
||||
|
||||
it('navigation delegate: `: Bar by holder.value`', async () => {
|
||||
const items = await extractHeritage(
|
||||
'class Foo : Bar by holder.value {}',
|
||||
SupportedLanguages.Kotlin,
|
||||
'Foo.kt',
|
||||
);
|
||||
expect(keys(items).has('Foo->Bar:extends')).toBe(true);
|
||||
});
|
||||
|
||||
it('call delegate: `: Bar by makeBar()`', async () => {
|
||||
const items = await extractHeritage(
|
||||
'class Foo : Bar by makeBar() {}',
|
||||
SupportedLanguages.Kotlin,
|
||||
'Foo.kt',
|
||||
);
|
||||
expect(keys(items).has('Foo->Bar:extends')).toBe(true);
|
||||
});
|
||||
|
||||
it('constructor invocation: `: Bar()`', async () => {
|
||||
const items = await extractHeritage(
|
||||
'class Foo : Bar() {}',
|
||||
SupportedLanguages.Kotlin,
|
||||
'Foo.kt',
|
||||
);
|
||||
expect(keys(items).has('Foo->Bar:extends')).toBe(true);
|
||||
});
|
||||
|
||||
it('generic supertype with delegation: `: Bar<T> by baz`', async () => {
|
||||
const items = await extractHeritage(
|
||||
'class Foo : Bar<T> by baz {}',
|
||||
SupportedLanguages.Kotlin,
|
||||
'Foo.kt',
|
||||
);
|
||||
expect(keys(items).has('Foo->Bar:extends')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PHP ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PHP heritage shapes', () => {
|
||||
// PHP qualified names collapse to the simple name (the V1 ctx.resolve simple-
|
||||
// name contract): `Models\BaseModel` -> `BaseModel`. The php_only grammar
|
||||
// parses source already in PHP mode (no `<?php` opener).
|
||||
it('qualified extends/implements collapse to the simple name', async () => {
|
||||
const code =
|
||||
'namespace App;\nclass A extends Models\\BaseModel implements Contracts\\Jsonable {}\n';
|
||||
const items = await extractHeritage(code, SupportedLanguages.PHP, 'A.php');
|
||||
const k = keys(items);
|
||||
expect(k.has('A->BaseModel:extends')).toBe(true);
|
||||
expect(k.has('A->Jsonable:implements')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Swift / Dart (vendored, optional) ──────────────────────────────────────────────
|
||||
|
||||
const SWIFT_AVAILABLE = (() => {
|
||||
try {
|
||||
getLanguageGrammar(SupportedLanguages.Swift);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
(SWIFT_AVAILABLE ? describe : describe.skip)('Swift heritage shapes', () => {
|
||||
it('captures class supertype and protocol conformance', async () => {
|
||||
const items = await extractHeritage(
|
||||
'class A: BaseClass, SomeProtocol {}',
|
||||
SupportedLanguages.Swift,
|
||||
'A.swift',
|
||||
);
|
||||
const k = keys(items);
|
||||
expect(k.has('A->BaseClass:extends')).toBe(true);
|
||||
expect(k.has('A->SomeProtocol:extends')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const DART_AVAILABLE = (() => {
|
||||
try {
|
||||
getLanguageGrammar(SupportedLanguages.Dart);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
(DART_AVAILABLE ? describe : describe.skip)('Dart heritage shapes', () => {
|
||||
it('captures extends / implements / with', async () => {
|
||||
// Dart clause order is fixed: extends, then with, then implements.
|
||||
const items = await extractHeritage(
|
||||
'class A extends Base with MixinM implements Foo {}',
|
||||
SupportedLanguages.Dart,
|
||||
'a.dart',
|
||||
);
|
||||
const k = keys(items);
|
||||
expect(k.has('A->Base:extends')).toBe(true);
|
||||
expect(k.has('A->Foo:implements')).toBe(true);
|
||||
expect(k.has('A->MixinM:trait-impl')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,297 +0,0 @@
|
|||
/**
|
||||
* Worker-path inheritance edges for the registry-primary languages (issue #1951).
|
||||
*
|
||||
* Diagrams showed classes and interfaces with no EXTENDS / IMPLEMENTS edges
|
||||
* between them. Root cause: registry-primary languages have their legacy
|
||||
* `@heritage.*` edges dropped by the worker pipeline's `shouldAccumulate` gate
|
||||
* (parse-impl.ts) — while the scope-resolution path that DOES run in worker
|
||||
* mode emitted nothing for them (unlike C++, they synthesized no
|
||||
* `@reference.inherits` captures). Small fixtures stayed under the worker
|
||||
* threshold and ran sequentially (legacy heritage intact), so the bug hid.
|
||||
*
|
||||
* The migration routed every language's inheritance through scope-resolution.
|
||||
* These tests force the worker pool on small fixtures (production threshold is
|
||||
* 15 files / 512 KB) and assert the edges are present. They FAIL before the
|
||||
* fix (0 EXTENDS / 0 IMPLEMENTS in worker mode) and pass once each language
|
||||
* emits inheritance through scope-resolution. The `usedWorkerPool === true`
|
||||
* guard is mandatory: without the compiled worker (built by
|
||||
* `pretest:integration`) the pipeline silently falls back to sequential, which
|
||||
* would hide the regression.
|
||||
*
|
||||
* The C#/Java blocks below are the original (#1951) coverage; the
|
||||
* table-driven block at the end extends worker-forced coverage to the other
|
||||
* migrated languages (go, python, php, rust, kotlin, ruby, typescript,
|
||||
* javascript, swift) so a worker-only capture regression in ANY of them fails
|
||||
* here rather than slipping every sequential gate.
|
||||
*
|
||||
* Run under the default (registry-primary) flags — the bug only exists on the
|
||||
* registry-primary path, so we must NOT force REGISTRY_PRIMARY_*=0 here.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
runPipelineFromRepo,
|
||||
getRelationships,
|
||||
edgeSet,
|
||||
type PipelineResult,
|
||||
} from './resolvers/helpers.js';
|
||||
import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js';
|
||||
import { SupportedLanguages } from '../../src/config/supported-languages.js';
|
||||
|
||||
const FIXTURES = path.resolve(__dirname, '..', 'fixtures', 'lang-resolution');
|
||||
|
||||
const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift);
|
||||
|
||||
const runWorker = (fixture: string): Promise<PipelineResult> =>
|
||||
runPipelineFromRepo(path.join(FIXTURES, fixture), () => {}, {
|
||||
skipGraphPhases: true,
|
||||
// Force the worker-pool gate low so a 4-5 file fixture engages the pool.
|
||||
workerThresholdsForTest: { minFiles: 1, minBytes: 1 },
|
||||
workerPoolSize: 2,
|
||||
});
|
||||
|
||||
// Sequential counterpart (no worker pool): the legacy heritage path runs and
|
||||
// scope-resolution dedups against it. Used to pin worker/sequential parity.
|
||||
const runSequential = (fixture: string): Promise<PipelineResult> =>
|
||||
runPipelineFromRepo(path.join(FIXTURES, fixture), () => {}, {
|
||||
skipGraphPhases: true,
|
||||
skipWorkers: true,
|
||||
});
|
||||
|
||||
describe('C# inheritance edges on the worker path (#1951)', () => {
|
||||
let result: PipelineResult;
|
||||
beforeAll(async () => {
|
||||
result = await runWorker('csharp-proj');
|
||||
}, 120_000);
|
||||
|
||||
it('genuinely used the worker pool (guards against silent sequential fallback)', () => {
|
||||
expect(result.usedWorkerPool).toBe(true);
|
||||
});
|
||||
|
||||
it('emits class-extends-class EXTENDS: User → BaseEntity (class-owned, via scope-resolution)', () => {
|
||||
const extends_ = getRelationships(result, 'EXTENDS');
|
||||
expect(edgeSet(extends_)).toEqual(['User → BaseEntity']);
|
||||
// The edge must originate from scope-resolution (the worker-safe channel),
|
||||
// and be owned by the Class node — not a method/constructor.
|
||||
expect(extends_[0]?.sourceLabel).toBe('Class');
|
||||
expect(extends_[0]?.rel.reason).toBe('scope-resolution: inherits');
|
||||
});
|
||||
|
||||
it('emits class-implements-interface IMPLEMENTS: User → IRepository (class-owned, via scope-resolution)', () => {
|
||||
const implements_ = getRelationships(result, 'IMPLEMENTS');
|
||||
expect(edgeSet(implements_)).toEqual(['User → IRepository']);
|
||||
expect(implements_[0]?.sourceLabel).toBe('Class');
|
||||
expect(implements_[0]?.rel.reason).toBe('scope-resolution: inherits');
|
||||
});
|
||||
});
|
||||
|
||||
describe('C# interface heritage on the worker path (#1951)', () => {
|
||||
let result: PipelineResult;
|
||||
beforeAll(async () => {
|
||||
result = await runWorker('csharp-interface-heritage');
|
||||
}, 120_000);
|
||||
|
||||
it('genuinely used the worker pool', () => {
|
||||
expect(result.usedWorkerPool).toBe(true);
|
||||
});
|
||||
|
||||
it('models interface-extends-interface and multi-interface heritage as IMPLEMENTS', () => {
|
||||
// C# semantics (matching the legacy DAG): conforming to an interface is
|
||||
// IMPLEMENTS regardless of whether the child is a class or interface.
|
||||
const implements_ = getRelationships(result, 'IMPLEMENTS');
|
||||
expect(edgeSet(implements_)).toEqual([
|
||||
'IAuditableService → IBarService',
|
||||
'IAuditableService → IFooService',
|
||||
'IFooService → IBaseInterface',
|
||||
'MyService → IAuditableService',
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits no EXTENDS edges for pure interface heritage', () => {
|
||||
expect(getRelationships(result, 'EXTENDS').length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Java inheritance edges on the worker path (#1951)', () => {
|
||||
let result: PipelineResult;
|
||||
beforeAll(async () => {
|
||||
result = await runWorker('java-heritage');
|
||||
}, 120_000);
|
||||
|
||||
it('genuinely used the worker pool', () => {
|
||||
expect(result.usedWorkerPool).toBe(true);
|
||||
});
|
||||
|
||||
it('emits class-extends-class EXTENDS: User → BaseModel (and none to interfaces)', () => {
|
||||
const extends_ = getRelationships(result, 'EXTENDS');
|
||||
expect(edgeSet(extends_)).toEqual(['User → BaseModel']);
|
||||
});
|
||||
|
||||
it('emits multi-interface IMPLEMENTS: User → Serializable, User → Validatable', () => {
|
||||
const implements_ = getRelationships(result, 'IMPLEMENTS');
|
||||
expect(edgeSet(implements_)).toEqual(['User → Serializable', 'User → Validatable']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('C# primary-constructor + qualified-generic base on the worker path (#1951 regression)', () => {
|
||||
// A C# 12 primary constructor is synthesized into the class scope, so the
|
||||
// shared `resolveCallerGraphId` would degrade the inheritance edge source to
|
||||
// the constructor (breaking MRO). The edge must stay owned by the Class.
|
||||
// Also covers fully-qualified generic base-name normalization (App.Repo<int>).
|
||||
let result: PipelineResult;
|
||||
beforeAll(async () => {
|
||||
result = await runWorker('csharp-primary-ctor-heritage');
|
||||
}, 120_000);
|
||||
|
||||
it('genuinely used the worker pool', () => {
|
||||
expect(result.usedWorkerPool).toBe(true);
|
||||
});
|
||||
|
||||
it('emits EXTENDS owned by the Class, not the primary constructor', () => {
|
||||
const extends_ = getRelationships(result, 'EXTENDS');
|
||||
// User(int id) : BaseEntity and Service : App.Repo<int>
|
||||
expect(edgeSet(extends_)).toEqual(['Service → Repo', 'User → BaseEntity']);
|
||||
// The regression: every inheritance edge source is the Class node. Before
|
||||
// the fix, User's source degraded to Constructor:User.
|
||||
expect(extends_.every((e) => e.sourceLabel === 'Class')).toBe(true);
|
||||
});
|
||||
|
||||
it('emits IMPLEMENTS owned by the Class for a primary-constructor class', () => {
|
||||
const implements_ = getRelationships(result, 'IMPLEMENTS');
|
||||
expect(edgeSet(implements_)).toEqual(['User → IFoo']);
|
||||
expect(implements_.every((e) => e.sourceLabel === 'Class')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Worker/sequential inheritance-edge parity (#1951)', () => {
|
||||
// The dedup-key change (type-prefixed, graph-seeded) must keep the worker
|
||||
// path (scope-resolution emits) and the sequential path (legacy heritage
|
||||
// emits, scope-resolution dedups) producing the SAME single edges — no
|
||||
// double-emission, no dropped IMPLEMENTS.
|
||||
let worker: PipelineResult;
|
||||
let sequential: PipelineResult;
|
||||
beforeAll(async () => {
|
||||
worker = await runWorker('csharp-proj');
|
||||
sequential = await runSequential('csharp-proj');
|
||||
}, 120_000);
|
||||
|
||||
it('worker mode used the pool; sequential did not', () => {
|
||||
expect(worker.usedWorkerPool).toBe(true);
|
||||
expect(sequential.usedWorkerPool).toBe(false);
|
||||
});
|
||||
|
||||
it('produces identical EXTENDS and IMPLEMENTS edge sets in both modes', () => {
|
||||
expect(edgeSet(getRelationships(worker, 'EXTENDS'))).toEqual(
|
||||
edgeSet(getRelationships(sequential, 'EXTENDS')),
|
||||
);
|
||||
expect(edgeSet(getRelationships(worker, 'IMPLEMENTS'))).toEqual(
|
||||
edgeSet(getRelationships(sequential, 'IMPLEMENTS')),
|
||||
);
|
||||
});
|
||||
|
||||
it('emits exactly one EXTENDS and one IMPLEMENTS in each mode (no double-emission)', () => {
|
||||
expect(getRelationships(worker, 'EXTENDS').length).toBe(1);
|
||||
expect(getRelationships(worker, 'IMPLEMENTS').length).toBe(1);
|
||||
expect(getRelationships(sequential, 'EXTENDS').length).toBe(1);
|
||||
expect(getRelationships(sequential, 'IMPLEMENTS').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Worker-forced coverage for the remaining migrated languages (#1951 review).
|
||||
// Each language's inheritance now flows ONLY through its scope-resolution synth
|
||||
// in worker mode (legacy heritage gated off). Edge sets are sorted (edgeSet
|
||||
// sorts), so the expectations below are in sorted order.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface WorkerHeritageCase {
|
||||
readonly lang: string;
|
||||
readonly fixture: string;
|
||||
readonly extends: readonly string[];
|
||||
readonly implements: readonly string[];
|
||||
/** Optional gate for grammars that may not be installed (Swift). */
|
||||
readonly available?: boolean;
|
||||
}
|
||||
|
||||
const WORKER_HERITAGE_CASES: readonly WorkerHeritageCase[] = [
|
||||
// Go struct embedding → EXTENDS.
|
||||
{ lang: 'Go', fixture: 'go-child-extends-parent', extends: ['Child → Parent'], implements: [] },
|
||||
// Python single inheritance → EXTENDS.
|
||||
{
|
||||
lang: 'Python',
|
||||
fixture: 'python-child-extends-parent',
|
||||
extends: ['Child → Parent'],
|
||||
implements: [],
|
||||
},
|
||||
// PHP class extends + trait use → EXTENDS (Base) + IMPLEMENTS (trait Auditable).
|
||||
{
|
||||
lang: 'PHP',
|
||||
fixture: 'php-parent-vs-trait',
|
||||
extends: ['Child → Base'],
|
||||
implements: ['Child → Auditable'],
|
||||
},
|
||||
// Rust `impl T for S` → IMPLEMENTS (resolved scope-aware after #1951 review).
|
||||
{
|
||||
lang: 'Rust',
|
||||
fixture: 'rust-traits',
|
||||
extends: [],
|
||||
implements: ['Button → Clickable', 'Button → Drawable'],
|
||||
},
|
||||
// Kotlin class + interfaces → EXTENDS (BaseModel) + IMPLEMENTS (2 interfaces).
|
||||
{
|
||||
lang: 'Kotlin',
|
||||
fixture: 'kotlin-heritage',
|
||||
extends: ['User → BaseModel'],
|
||||
implements: ['User → Serializable', 'User → Validatable'],
|
||||
},
|
||||
// Ruby `class Child < Parent` → EXTENDS.
|
||||
{
|
||||
lang: 'Ruby',
|
||||
fixture: 'ruby-child-extends-parent',
|
||||
extends: ['Child → Parent'],
|
||||
implements: [],
|
||||
},
|
||||
// TypeScript generic base + generic interface → EXTENDS (Box) + IMPLEMENTS (IFoo).
|
||||
{
|
||||
lang: 'TypeScript',
|
||||
fixture: 'typescript-generic-base',
|
||||
extends: ['Service → Box'],
|
||||
implements: ['Service → IFoo'],
|
||||
},
|
||||
// JavaScript `class Child extends Parent` → EXTENDS.
|
||||
{
|
||||
lang: 'JavaScript',
|
||||
fixture: 'javascript-child-extends-parent',
|
||||
extends: ['Child → Parent'],
|
||||
implements: [],
|
||||
},
|
||||
// Swift class inheritance → EXTENDS (grammar is an optional dependency).
|
||||
{
|
||||
lang: 'Swift',
|
||||
fixture: 'swift-child-extends-parent',
|
||||
extends: ['Child → Parent'],
|
||||
implements: [],
|
||||
available: swiftAvailable,
|
||||
},
|
||||
];
|
||||
|
||||
for (const c of WORKER_HERITAGE_CASES) {
|
||||
describe.skipIf(c.available === false)(
|
||||
`${c.lang} inheritance edges on the worker path (#1951)`,
|
||||
() => {
|
||||
let result: PipelineResult;
|
||||
beforeAll(async () => {
|
||||
result = await runWorker(c.fixture);
|
||||
}, 120_000);
|
||||
|
||||
it('genuinely used the worker pool (guards against silent sequential fallback)', () => {
|
||||
expect(result.usedWorkerPool).toBe(true);
|
||||
});
|
||||
|
||||
it('emits the expected EXTENDS / IMPLEMENTS edge set via scope-resolution', () => {
|
||||
expect(edgeSet(getRelationships(result, 'EXTENDS'))).toEqual([...c.extends]);
|
||||
expect(edgeSet(getRelationships(result, 'IMPLEMENTS'))).toEqual([...c.implements]);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -12,70 +12,64 @@
|
|||
* binding is value-only and the inner call falls through to the File scope —
|
||||
* exactly the Zustand module-level-call behavior already pinned for TS.
|
||||
*
|
||||
* SCOPE: this asserts the registry-primary CALLS-edge ATTRIBUTION change only.
|
||||
* The duplicate *graph node* (`Function:exportData`) is created by the legacy
|
||||
* SCOPE: this asserts the scope-resolution CALLS-edge ATTRIBUTION change only.
|
||||
* The duplicate *graph node* (`Function:exportData`) is created by the
|
||||
* parse-worker node path, which this change does not touch; collapsing it is
|
||||
* the deferred node-creation migration. Accordingly this file makes NO node-
|
||||
* count assertion.
|
||||
*
|
||||
* Registry-primary-only correctness win: under the forced-legacy parity flag
|
||||
* (`REGISTRY_PRIMARY_JAVASCRIPT=0`) the legacy DAG still emits the phantom
|
||||
* attribution, so the suite is skipped there (mirrors the per-language
|
||||
* expected-failure handling in `resolvers/helpers.ts`).
|
||||
* The scope-resolution path attributes the inner call to the File scope rather
|
||||
* than emitting the phantom attribution.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
FIXTURES,
|
||||
getRelationships,
|
||||
isLegacyResolverParityRun,
|
||||
runPipelineFromRepo,
|
||||
type PipelineResult,
|
||||
} from './resolvers/helpers.js';
|
||||
|
||||
describe.skipIf(isLegacyResolverParityRun('javascript'))(
|
||||
'JavaScript array-method-callback CALLS attribution (#1876)',
|
||||
() => {
|
||||
let result: PipelineResult;
|
||||
describe('JavaScript array-method-callback CALLS attribution (#1876)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'javascript-array-method-callback'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'javascript-array-method-callback'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('control: run() body calls transform directly (resolver is wired)', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'transform');
|
||||
expect(calls.map((c) => `${c.source} → ${c.target}`)).toContain('run → transform');
|
||||
});
|
||||
it('control: run() body calls transform directly (resolver is wired)', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'transform');
|
||||
expect(calls.map((c) => `${c.source} → ${c.target}`)).toContain('run → transform');
|
||||
});
|
||||
|
||||
it('call inside .map callback attributes to File, not a phantom Function:exportData', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'transform');
|
||||
const fromExportData = calls.filter((c) => c.source === 'exportData');
|
||||
expect(
|
||||
fromExportData,
|
||||
'transform must NOT be attributed to exportData (phantom Function)',
|
||||
).toEqual([]);
|
||||
const fromFile = calls.filter((c) => c.sourceLabel === 'File');
|
||||
expect(
|
||||
fromFile,
|
||||
'the .map callback call to transform must source from the File node (exactly once)',
|
||||
).toHaveLength(1);
|
||||
});
|
||||
it('call inside .map callback attributes to File, not a phantom Function:exportData', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'transform');
|
||||
const fromExportData = calls.filter((c) => c.source === 'exportData');
|
||||
expect(
|
||||
fromExportData,
|
||||
'transform must NOT be attributed to exportData (phantom Function)',
|
||||
).toEqual([]);
|
||||
const fromFile = calls.filter((c) => c.sourceLabel === 'File');
|
||||
expect(
|
||||
fromFile,
|
||||
'the .map callback call to transform must source from the File node (exactly once)',
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('call inside .find callback attributes to File, not a phantom Function:firstActive', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'predicate');
|
||||
const fromFirstActive = calls.filter((c) => c.source === 'firstActive');
|
||||
expect(
|
||||
fromFirstActive,
|
||||
'predicate must NOT be attributed to firstActive (phantom Function)',
|
||||
).toEqual([]);
|
||||
const fromFile = calls.filter((c) => c.sourceLabel === 'File');
|
||||
expect(
|
||||
fromFile,
|
||||
'the .find callback call to predicate must source from the File node (exactly once)',
|
||||
).toHaveLength(1);
|
||||
});
|
||||
},
|
||||
);
|
||||
it('call inside .find callback attributes to File, not a phantom Function:firstActive', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'predicate');
|
||||
const fromFirstActive = calls.filter((c) => c.source === 'firstActive');
|
||||
expect(
|
||||
fromFirstActive,
|
||||
'predicate must NOT be attributed to firstActive (phantom Function)',
|
||||
).toEqual([]);
|
||||
const fromFile = calls.filter((c) => c.sourceLabel === 'File');
|
||||
expect(
|
||||
fromFile,
|
||||
'the .find callback call to predicate must source from the File node (exactly once)',
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
/**
|
||||
* C: struct + include-based imports + function calls across files
|
||||
*/
|
||||
import { describe, expect, beforeAll } from 'vitest';
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
FIXTURES,
|
||||
createResolverParityIt,
|
||||
getRelationships,
|
||||
getNodesByLabel,
|
||||
edgeSet,
|
||||
|
|
@ -13,8 +12,6 @@ import {
|
|||
type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
const it = createResolverParityIt('c');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C structs + include-based imports + cross-file function calls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue