From a857f4c5a6f7899772a49c2a264c1bec1fa4f69a Mon Sep 17 00:00:00 2001 From: Parafee41 Date: Wed, 5 Aug 2026 16:15:32 +0800 Subject: [PATCH 01/27] docs(taint): document per-language model files (#2809) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(taint): document per-language model files * docs(taint): link language-specific model tests --------- Co-authored-by: Gergő Magyar --- .../gitnexus/gitnexus-taint-analysis/SKILL.md | 20 ++++++++++++------- .../skills/gitnexus-taint-analysis/SKILL.md | 20 ++++++++++++------- gitnexus/skills/gitnexus-taint-analysis.md | 20 ++++++++++++------- 3 files changed, 39 insertions(+), 21 deletions(-) diff --git a/.claude/skills/gitnexus/gitnexus-taint-analysis/SKILL.md b/.claude/skills/gitnexus/gitnexus-taint-analysis/SKILL.md index 9bffffdac..e4069f9a8 100644 --- a/.claude/skills/gitnexus/gitnexus-taint-analysis/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-taint-analysis/SKILL.md @@ -148,13 +148,19 @@ finding is NOT proof of safety. ## Adding a source / sink / sanitizer -Edit the language model in `taint/typescript-model.ts` (registered via the -explicit `registerBuiltinTaintModels` seam, keyed by `SupportedLanguages`). The -spec is hashable data (no functions). A sanitizer's `neutralizes` lists the -EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert the -finding (or its absence) in `test/unit/taint/` (real-source harness: -`test/helpers/ts-cfg-harness.ts`); the end-to-end proof is -`test/integration/cfg/`. +Taint models cover four `SupportedLanguages` ids across three files: +TypeScript and JavaScript use `taint/typescript-model.ts`, Python uses +`taint/python-model.ts`, and Java uses `taint/java-model.ts`. Edit the model +for the language you are targeting. The explicit +`registerBuiltinTaintModels` seam in `typescript-model.ts` registers all four; +it is not an import side effect. + +The spec is hashable data (no functions). A sanitizer's `neutralizes` lists +the EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert +the finding (or its absence) in `test/unit/taint/`. TypeScript and JavaScript +use the real-source harness `test/helpers/ts-cfg-harness.ts`; Python and Java +model matches are covered by `python-model-match.test.ts` and +`java-model-match.test.ts`. The end-to-end proof is `test/integration/cfg/`. ## Validation checklist for any `--pdg` change diff --git a/gitnexus-claude-plugin/skills/gitnexus-taint-analysis/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-taint-analysis/SKILL.md index 9bffffdac..e4069f9a8 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-taint-analysis/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-taint-analysis/SKILL.md @@ -148,13 +148,19 @@ finding is NOT proof of safety. ## Adding a source / sink / sanitizer -Edit the language model in `taint/typescript-model.ts` (registered via the -explicit `registerBuiltinTaintModels` seam, keyed by `SupportedLanguages`). The -spec is hashable data (no functions). A sanitizer's `neutralizes` lists the -EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert the -finding (or its absence) in `test/unit/taint/` (real-source harness: -`test/helpers/ts-cfg-harness.ts`); the end-to-end proof is -`test/integration/cfg/`. +Taint models cover four `SupportedLanguages` ids across three files: +TypeScript and JavaScript use `taint/typescript-model.ts`, Python uses +`taint/python-model.ts`, and Java uses `taint/java-model.ts`. Edit the model +for the language you are targeting. The explicit +`registerBuiltinTaintModels` seam in `typescript-model.ts` registers all four; +it is not an import side effect. + +The spec is hashable data (no functions). A sanitizer's `neutralizes` lists +the EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert +the finding (or its absence) in `test/unit/taint/`. TypeScript and JavaScript +use the real-source harness `test/helpers/ts-cfg-harness.ts`; Python and Java +model matches are covered by `python-model-match.test.ts` and +`java-model-match.test.ts`. The end-to-end proof is `test/integration/cfg/`. ## Validation checklist for any `--pdg` change diff --git a/gitnexus/skills/gitnexus-taint-analysis.md b/gitnexus/skills/gitnexus-taint-analysis.md index 9bffffdac..e4069f9a8 100644 --- a/gitnexus/skills/gitnexus-taint-analysis.md +++ b/gitnexus/skills/gitnexus-taint-analysis.md @@ -148,13 +148,19 @@ finding is NOT proof of safety. ## Adding a source / sink / sanitizer -Edit the language model in `taint/typescript-model.ts` (registered via the -explicit `registerBuiltinTaintModels` seam, keyed by `SupportedLanguages`). The -spec is hashable data (no functions). A sanitizer's `neutralizes` lists the -EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert the -finding (or its absence) in `test/unit/taint/` (real-source harness: -`test/helpers/ts-cfg-harness.ts`); the end-to-end proof is -`test/integration/cfg/`. +Taint models cover four `SupportedLanguages` ids across three files: +TypeScript and JavaScript use `taint/typescript-model.ts`, Python uses +`taint/python-model.ts`, and Java uses `taint/java-model.ts`. Edit the model +for the language you are targeting. The explicit +`registerBuiltinTaintModels` seam in `typescript-model.ts` registers all four; +it is not an import side effect. + +The spec is hashable data (no functions). A sanitizer's `neutralizes` lists +the EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert +the finding (or its absence) in `test/unit/taint/`. TypeScript and JavaScript +use the real-source harness `test/helpers/ts-cfg-harness.ts`; Python and Java +model matches are covered by `python-model-match.test.ts` and +`java-model-match.test.ts`. The end-to-end proof is `test/integration/cfg/`. ## Validation checklist for any `--pdg` change From 9372b17049cc968676d620382c5cae68bcb7e7c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Wed, 5 Aug 2026 11:35:27 +0100 Subject: [PATCH 02/27] fix(python): resolve calls through an unaliased dotted namespace import (#2826) (#2828) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(python): resolve calls through an unaliased dotted namespace import (#2826) `import pkg.db` followed by `pkg.db.session_scope()` emitted no CALLS edge, while all three sibling spellings resolved. In a codebase whose style guide mandates absolute imports this is close to the only cross-module call form used, so `impact()` reported `impactedCount: 0, risk: LOW, epistemic: exact` for functions with dozens of real callers — a dropped caller reading as a verified all-clear. The resolution path was never missing; one map was keyed on the wrong half of the import. `interpretPythonImport`'s plain arm splits `import pkg.db` into `localName: 'pkg'` (the name Python actually binds) and `importedName: 'pkg.db'`, and finalize carries both onto the edge as `localName` / `targetExportedName`. `collectNamespaceTargets` keyed only on `localName`, but the receiver text captured at the call site is the whole dotted path — Python's query binds the attribute's `object` field with a wildcard, so `pkg.db.session_scope()` yields the receiver `pkg.db`. Case 0 declines it (a module is not a class) and falls through, Case 1 looks up `pkg.db` and misses, and Case 1.5 needs `resolveQualifiedReceiverMember`, which only the C++ provider implements. The site drops silently. Key the map on the dotted import path as well — gated on a provider opt-in, not on the edge shape. The shape alone cannot decide it: Swift's `import Foo.Bar` produces the identical pair (`localName: 'Foo'`, `targetExportedName: 'Foo.Bar'`), but there the FIRST segment is the resolved target and `Foo.Bar` names a nested type. Minting a key for it would hand `resolveConstructionExpressionClass` an authoritative namespace — that branch deliberately does not fall through on a miss — and break `Foo.Bar(x)` construction that resolves correctly today. Hence `ScopeResolver.namespaceReceiverIncludesImportPath`, which only Python sets. The root-segment check on the added key does real work: `import pkg.db as pdb` binds only `pdb`, so writing `pkg.db.f()` there is a NameError, and its edge (localName `pdb`, path `pkg.db`) is correctly rejected. Two same-package imports stay separate — `import pkg.db` + `import pkg.cache` key `pkg.db` and `pkg.cache` independently, so neither call can land in the other's module; the shared `pkg` bucket keeps its existing ambiguity rather than gaining any. Tests: five integration rows (the issue's own repro, the three sibling spellings as controls, non-crossing two-package imports, a three-segment receiver, and dotted construction) plus a unit pin on the keying rule that asserts a Swift-shaped edge mints nothing. All five integration rows fail on the pre-fix tree; the controls pass on both, which is what makes them controls. Resolver integration suite 3024 passed / 1 skipped / 0 failed; scope-resolution unit suite 1446 passed. This changes what the resolver produces, not how it is stored — no schema or version constant applies, and an existing index needs a re-analyze to show the new edges. Co-Authored-By: Claude Opus 5 (1M context) * fix(resolution): shadow-test a dotted namespace key by its root segment (#2826) `isNamespaceNameShadowed` walks the scope chain looking for a binding, type binding, lexical name, or owned def named exactly `namespaceName`. Once a namespace key can be a dotted import path, that string never matches anything: `import pkg.db` binds `pkg`, so a local `pkg = Decoy()` shadows the import, but the guard was asked about `pkg.db` and answered "not shadowed". The consequence is not a missed edge but a wrong one. The caller treats a verified namespace as authoritative and deliberately does not fall through to the workspace-wide simple-name heuristics, so an unguarded shadowed receiver resolves construction against the imported module instead of the local value. Test the first dot-separated segment instead. Single-segment names are unaffected — their root is themselves — so every pre-existing row keeps its behaviour. This ships with the key that first routes a dotted name into the guard rather than after it: the previous commit is what makes the defect reachable. The new pin fails on the pre-fix guard (verified by reverting the four comparisons and re-running: 1 failed / 5 passed), so it discriminates rather than merely passing. Co-Authored-By: Claude Opus 5 (1M context) * test(python): pin the callee name on the dotted-construction row (#2826) The row asserted only that `builds` reached `pkg/db.py`. That module also exports `session_scope`, so a regression that resolved the construction to the wrong member of the right module would have kept the test green — it pinned the file, not the answer. Assert the exact edge set for the caller instead. Verified against the current tree with a scratch probe: `builds -> Model@pkg/db.py` is the only edge the file produces. Co-Authored-By: Claude Opus 5 (1M context) * docs(plans): include the #2826 engineering plan in the PR `.gitignore` keeps `docs/*` local because planning output is normally throwaway. Force-added here at the reviewer's request so the plan travels with the work it drove: it records the evidence chain behind the fix, the two places the plan turned out to be wrong, and the follow-ups deliberately left out of scope. Co-Authored-By: Claude Opus 5 (1M context) * refactor(resolution): make the namespace shadow guard shared (#2826) `isNamespaceNameShadowed` lived module-private in `compound-receiver.ts` with a single caller. The namespace map it guards has three consumers, and the next commit adds the guard to a second one, so it moves to `scope/walkers.ts` alongside the other scope-chain primitives rather than being duplicated. Behaviour is unchanged — this is a move plus documentation. Two notes were added because both are easy to get wrong later: - Fails closed on a missing scope or a parent cycle. For every caller, suppressing costs a missing edge while trusting a corrupt scope chain costs a wrong one, so the bias is deliberate. - It reads `scope.bindings` DIRECTLY rather than through `lookupBindingsAt`, which is the opposite of the fix #2745 applied to Rust's `headBoundLocally`. There the question was "is this name bound at all?", so missing finalize's import channels lost real bindings. Here the question is "does something LOCAL shadow the import?", and the import's own finalized binding is exactly what must not count — routing this through `lookupBindingsAt` would find every namespace import shadowing itself and suppress the lot. Verified against a target module carrying a self-named def, which still resolves. Co-Authored-By: Claude Opus 5 (1M context) * fix(python): close the three remaining namespace-receiver gaps (#2826) Three defects the first fix left behind. All three were confirmed by probe before being touched, and a fourth suspected gap was disproved the same way. ## 1. Case 1 resolved through an import a local had shadowed `namespaceTargets` is collected per FILE, but Case 1 in `receiver-bound-calls` consulted it with no lexical guard at all, so import pkg.db def f(pkg): # parameter shadows the package return pkg.db.session_scope() emitted an edge to pkg/db.py. That is a WRONG edge, and it predates the dotted key: the single-segment spelling (`import single` + `def f(single)`) failed identically. The compound-receiver construction path has applied this guard since #2770; Case 1 simply never did. Now both use the shared guard. ## 2 + 3. The root key named the leaf module, not the package These read as two gaps and are one. `import a.b.c` binds ONE name — `a` — but makes three attribute paths callable, naming three different files: a → a/__init__.py a.b → a/b/__init__.py a.b.c → a/b/c.py The map keyed only `a`, pointed at the LEAF. So `a.helper()` resolved into a/b/c.py whenever that module happened to export `helper` — silently preferring a decoy over the real definition in the package — and `a.b.mid()` resolved to nothing at all. One wrong edge and one missing edge from a single mis-keying. Fixing it needs per-language knowledge the shared collector cannot have: which prefixes are reachable, and which file each names. The `__init__.py` convention is Python's alone, and the edge shape is ambiguous across languages — Swift's `import Foo.Bar` produces an identical `localName`/`targetExportedName` pair that means the opposite thing. So the previous commit's boolean opt-in is replaced by `ScopeResolver.namespaceReceiverPaths`, which returns every spelling with the file it names; absent or declining, the shared default (bound name → own target) is unchanged for every other language. Prefix files are proposed, not asserted — `moduleFileExists` drops any the workspace never parsed, so a PEP-420 namespace package contributes no key rather than one pointing at a missing file. ## Disproved: C# was not a fourth gap The plan listed C# `using System.Collections.Generic` + `System.Collections.Generic.List` as the same class of bug. It is not: a probe shows `My.Deep.Space.Helpers.Work()` already resolves through the FQN namespace bindings in `walkers.ts`. No change made, and the claim is withdrawn rather than carried forward as a known gap. ## Testing Integration: the shadow block asserts the exact surviving edge set (an absence-only assertion would also pass if the guard over-suppressed and killed the clean rows); the prefix block asserts all three spellings land on their own file, with `helper` defined in BOTH package and leaf so a wrong edge is visible rather than merely possible. Unit: 16 rows on the keying contract, including that a Swift-shaped edge mints nothing and an alias import keys neither the path nor the root. Resolver integration 3024 passed / 1 skipped / 0 failed; scope-resolution unit 1452 passed; tsc clean in both packages. Co-Authored-By: Claude Opus 5 (1M context) * fix(python): probe both path separators when resolving a prefix package (#2826) Workspace file paths are not normalized to POSIX at ingestion — `import-target` already re-normalizes at five other comparison points, and `moduleScopeByFile` is keyed by the raw `ParsedFile.filePath`. The prefix probe built only the `/` spelling, so on Windows it would compare `a/b/__init__.py` against an `a\b\__init__.py` key, find nothing, and mint no prefix keys at all. That fails quietly, which is the worst shape for it: `a.b.mid()` simply goes back to unresolved on one platform, with no drop recorded and every test on POSIX still green. Probe both spellings and key whichever the workspace actually holds. The new row is mutation-tested — reverting to the `/`-only probe turns it red (1 failed / 10 passed), so it pins the behaviour rather than passing alongside it. Co-Authored-By: Claude Opus 5 (1M context) * fix(python): correct three defects a multi-lane review found in this PR (#2826) All three were introduced by this PR's own earlier commits, and none was found by re-reading the diff — each came from a lane attacking an angle the author had not. ## 1. The shadow guard ran BEFORE the map lookup it gates Case 1 evaluated `isNamespaceNameShadowed` unconditionally, then consulted `namespaceTargets`. So every call/read/write site with an explicit receiver, in every language, paid a scope-chain walk (a Set allocation, three Map lookups and a linear `ownedDefs` scan per level) ahead of an O(1) hash miss that was going to decline it anyway. The proof it was an oversight rather than a decision sits in this same PR: the sibling guard in `compound-receiver.ts` reads the map first and only guards on a hit. Two call sites of one shared function, opposite order. Semantics are identical either way — a miss yields `undefined` regardless — which is exactly why it survived several readings. ## 2. Prefix packages were anchored on the import spelling, not the resolved leaf `pythonNamespaceReceiverPaths` built `a/__init__.py` from the dotted path joined at the workspace root, never consulting the file the import actually resolved to. But `resolvePythonImportTarget` resolves off-root in two of its three tiers, so `import utils.db` can land on `libs/common/utils/db.py`. That produced a wrong edge where a same-named `utils/` package exists at the root, and produced NOTHING in a `src/` layout — the prefix feature was inert for the most common Python project shape, silently. Now the prefix directories are derived by walking back from the resolved leaf, which is exact for root, `src/` and off-root layouts alike. It also inherits the leaf's own separator, which subsumes the previous dual-separator probe: that probe was dead code anyway, because `filesystem-walker.ts` normalizes `\` to `/` before a path ever becomes a `ParsedFile.filePath`. Its test row is removed rather than left asserting an unreachable state. ## 3. Keying the root at `__init__.py` INSTEAD of the leaf lost re-exports `findExportedDef` accepts only a binding whose `origin === 'local'`. The canonical Python package re-exports from its submodules — `from .b.c import helper` in `__init__.py` — which is an IMPORT binding, so it is rejected. Keying the prefix solely at the package therefore turned `a.helper()` from a correct edge into no edge at all for the most common package shape. Every fixture in this PR defined its members locally in `__init__.py`, which is precisely the one layout where that mistake is invisible. The prefix now keys the package FIRST and the leaf behind it. A real definition in `__init__.py` still wins over a same-named decoy deeper in the package, and a name merely re-exported there still resolves through the leaf. Ordering is the contract, so the unit rows assert the exact arrays rather than membership. ## Testing New rows: off-root layout with a decoy `utils/` at the root, and a `src/` layout. Both mutation-tested — reverting to the spelling-anchored build turns them red. The re-export case was verified end-to-end with a scratch fixture whose `__init__.py` only re-exports (`uses -> helper@a/b/c.py`). Resolver integration 3131 passed / 1 skipped / 0 failed — unchanged from before these fixes, so they regress nothing. Scope-resolution unit 1459 passed. tsc clean in both packages. Co-Authored-By: Claude Opus 5 (1M context) * fix(resolution): stop the namespace shadow guard AT the module scope (#2826) CI caught a regression this PR introduced: `cjs-exports-assignment.test.ts` lost both of its cross-file rows — cross-file require() member call resolves expected [] to deeply equal [ 'handle' ] an `exports` parameter does not hijack the module (UMD factory) expected [] to deeply equal [ 'publicApi' ] — i.e. `const svc = require('./svc'); svc.handle()` stopped resolving in JavaScript. Cause: in CommonJS the namespace import IS a variable declaration. One statement produces both the ImportEdge and a module-scope `const` binding, so the guard, by inspecting the module scope, found the import's own name there and read it as a shadow of itself — suppressing exactly the receivers it exists to enable. The guard's own contract sentence already said the right thing: "a declaration BETWEEN the call site and its module scope". The module scope is the floor of that walk, not a rung on it. It now returns at Module without inspecting it. Nothing is lost on the suppression side: a genuine shadow is a parameter, a local, or a nested declaration, and all of those live in scopes strictly inside the module. The Python rows that pin suppression (`def f(pkg): pkg.db.f()` and its single-segment `import single` twin) still pass, because a parameter is an inner scope. Worth recording for the next reader: two independent review lanes examined this exact scenario and both REFUTED it, reasoning that `require()` yields an ImportEdge in `scope.imports` rather than a local binding. That is true for Python's `import x` and false for CommonJS, where one statement is both. My own probe used a Python fixture and so could not surface it either. Agreement between reviewers was not evidence; the test corpus was. Verified: cjs-exports-assignment 36/36, the #2826 integration rows 7/7, scope-resolution unit 126/126. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Gergo Magyar Co-authored-by: Claude Opus 5 (1M context) --- ...s-plan-python-dotted-namespace-receiver.md | 473 ++++++++++++++++++ .../languages/python/import-target.ts | 73 +++ .../core/ingestion/languages/python/index.ts | 1 + .../languages/python/scope-resolver.ts | 7 + .../contract/scope-resolver.ts | 38 ++ .../passes/compound-receiver.ts | 37 +- .../passes/receiver-bound-calls.ts | 26 +- .../scope/namespace-targets.ts | 54 +- .../scope-resolution/scope/walkers.ts | 71 +++ .../test/integration/resolvers/python.test.ts | 269 ++++++++++ .../namespace-targets-import-path.test.ts | 181 +++++++ ...thon-module-namespace-construction.test.ts | 58 ++- 12 files changed, 1242 insertions(+), 46 deletions(-) create mode 100644 docs/plans/2026-08-04-gitnexus-plan-python-dotted-namespace-receiver.md create mode 100644 gitnexus/test/unit/scope-resolution/namespace-targets-import-path.test.ts diff --git a/docs/plans/2026-08-04-gitnexus-plan-python-dotted-namespace-receiver.md b/docs/plans/2026-08-04-gitnexus-plan-python-dotted-namespace-receiver.md new file mode 100644 index 000000000..bf4c175c8 --- /dev/null +++ b/docs/plans/2026-08-04-gitnexus-plan-python-dotted-namespace-receiver.md @@ -0,0 +1,473 @@ +# GitNexus Engineering Plan + +> Task: Emit the missing `CALLS` edge for Python's unaliased multi-segment namespace import (`import pkg.db` + `pkg.db.session_scope()`), issue #2826. +> Evidence verified at commit b2cd1c2ad637657125248c0dd2046de71ceea965; GitNexus index 13 commits behind HEAD, refresh skipped: every cited path is byte-identical between the index commit (1ef6447e) and the pinned commit — verified by blob-id comparison, so no graph claim here rests on drifted content. PDG layer absent from this index (`MATCH ()-[r:CodeRelation {type:'CDG'}]->() RETURN count(r)` → 0); `--pdg` upgrade skipped, source reads substitute at higher evidence strength. +> Evidence provenance schema 2; global dirty digest 0912a3ee3219cb75c82aefbf9f010e8dbe313150d6553768fd55d22af87a135c; cited-path manifest 13 sorted entries; exact generated plan path excluded. + +## 1. Objective + +`import pkg.db` followed by `pkg.db.session_scope()` must emit a `CALLS` edge from the caller to `session_scope`, matching the three sibling import spellings that already resolve (`from pkg.db import session_scope`, `import pkg.db as pdb`, `from pkg import db`). Two same-package imports in one file (`import pkg.a` + `import pkg.b`) must not cross-resolve, and no shared file under `gitnexus/src/core/ingestion/` may name a language (AGENTS.md §42). + +## 2. Current Behaviour + +The failure is a **key/lookup mismatch inside one map**, not a missing resolution path. + +For `import pkg.db`, `splitImportStmt` emits one match with `@import.source` = the whole `dotted_name` text `"pkg.db"` `[verified]` (`gitnexus/src/core/ingestion/languages/python/import-decomposer.ts:46-54`). `interpretPythonImport`'s `'plain'` arm then splits it `[verified]` (`gitnexus/src/core/ingestion/languages/python/interpret.ts:33-42`): + +```ts + case 'plain': { + // `import numpy` + if (sourceCap === undefined) return null; + return { + kind: 'namespace', + localName: sourceCap.text.split('.')[0]!, // `import a.b.c` exposes `a` + importedName: sourceCap.text, + targetRaw: sourceCap.text, + }; + } +``` + +`finalizeImportEdges` carries both halves onto the edge: `localName` verbatim, and `targetExportedName = parsed.importedName` for `kind === 'namespace'` `[verified]` (`gitnexus-shared/src/scope-resolution/finalize-algorithm.ts:398-406, 434-447`). So the finalized `ImportEdge` is `{ localName: 'pkg', targetExportedName: 'pkg.db', targetFile: 'pkg/db.py', kind: 'namespace' }`. + +`collectNamespaceTargets` keys **only on `localName`** `[verified]` (`gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts:44-57`), producing `{'pkg' → ['pkg/db.py']}`. + +At the call site, Python's query binds the attribute's `object` field with a wildcard — `object: (_) @reference.receiver` `[verified]` (`gitnexus/src/core/ingestion/languages/python/query.ts:267-270`) — so for `pkg.db.session_scope()` the receiver node is the inner `attribute`, and `extractExplicitReceiver` takes its raw text `[verified]` (`gitnexus/src/core/ingestion/scope-extractor.ts:1235-1239`): `receiverName === 'pkg.db'`. + +`emitReceiverBoundCalls` then walks its cases `[verified]` (`gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts:404-421, 546-655, 831-848`): + +- **Case 0 (compound receiver)** fires because `receiverName.includes('.')` (line 563-567). It asks `resolveCompoundReceiverClass` for a **class**; `pkg.db` names a module, so it returns `undefined`, sets `compoundReceiverUnresolved = true`, and — critically — does **not** `handledSites.add`, so control falls through (lines 577, 622-655). +- **Case 1 (namespace receiver)** runs `namespaceTargets.get('pkg.db')` (line 832). The map holds `'pkg'`. Miss. +- **Case 1.5** needs `provider.resolveQualifiedReceiverMember`, implemented only by the C++ provider `[verified]` (`gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts:399-406`; `context` on that symbol shows one outgoing call to `resolveCppQualifiedNamespaceMember` and no other implementer). Python leaves it undefined, so the case is skipped. + +No later case types a module receiver, so the site drops. Reproduced on both `origin/main` and PR #2810's head; PR #2810 changes Python receiver *typing* (`languages/python/receiver-binding.ts`) and does not touch this path `[verified]` by running the repro against both trees. + +The three sibling spellings resolve because each binds a **single-segment** local name: `pdb` (alias arm), `session_scope` (named binding, not a receiver at all), and `db` (reclassified to `kind: 'namespace'` by #2770's `isNamespaceImport` hook, keying the map on `'db'`). + +## 3. Relevant Architecture + +`collectNamespaceTargets` is the shared, language-neutral bridge between finalized import edges and receiver resolution. Its contract note already states that `ImportEdge.kind === 'namespace'` is authoritative and that providers may reclassify into it — that reclassification hook (`isNamespaceImport`) is #2770's extension point `[verified]` (`gitnexus-shared/src/scope-resolution/finalize-algorithm.ts:99-107`). + +Its output feeds three consumers, all per-file (`fileCompoundOpts`, `receiver-bound-calls.ts:405-406`): + +1. `emitReceiverBoundCalls` Case 1 — namespace-receiver member calls (`receiver-bound-calls.ts:832`); +2. `resolveConstructionExpressionClass` — namespace-qualified construction `pkg.db.Model()` (`compound-receiver.ts:245-260`); +3. `resolveCompoundReceiverClass`'s namespace-qualified-constructor disambiguation `options.namespaceTargets?.has(objExpr)` (`compound-receiver.ts:759-766`). + +AGENTS.md line 42 is the binding constraint: *"Shared code in `gitnexus/src/core/ingestion/` must not name languages — plug language behavior in via `LanguageProvider` / `ScopeResolver` hooks."* `[verified]` + +## 4. GitNexus Findings + +- `context({name: 'collectNamespaceTargets', repo: 'GitNexus'})` — `epistemic: "exact"`; incoming calls are exactly two: `emitReceiverBoundCalls` (`.../passes/receiver-bound-calls.ts`) and a test-local `build` in `test/unit/scope-resolution/python/python-module-namespace-construction.test.ts`. `[graph]` These are the d=1 dependents; the two `compound-receiver.ts` consumers reach the map by parameter rather than by call, so they do not appear here and were found by source grep `[verified]`. +- `context({name: 'resolveQualifiedReceiverMember', repo: 'GitNexus'})` — resolves to a single definition at `languages/cpp/scope-resolver.ts:399`, `outgoing.calls: [resolveCppQualifiedNamespaceMember]`, no incoming. `[graph]` Confirms the Case-1.5 hook is C++-only, matching the issue reporter's read of the published bundle. +- `cypher({statement: "MATCH ()-[r:CodeRelation {type: 'CDG'}]->() RETURN count(r)"})` — `| cdg_rows | 0 |`. `[graph]` The index carries no PDG layer; §5 is therefore empty by fact, not by omission. +- Related tests located by directory listing `[verified]`: `test/fixtures/lang-resolution/` already holds `python-module-import`, `python-bare-import`, `python-plain-import-alias`, `python-multi-segment-ancestor-import`, `python-function-local-namespace-import`, `python-class-body-namespace-import`, and #2770's `python-from-module-alias`. `test/integration/resolvers/python.test.ts` is the convention-matching home for the new assertions (#2770 added its coverage there, +38 lines). + +## 5. Statement-Level PDG Findings + +Empty by fact: the current index has zero `CDG` rows, so no statement-level slice exists to build. A `--pdg` re-index was deliberately not run — it is the largest fixed cost available to this session, the analyzer holds no writer lock against a live MCP server (#2658), and every constraint the slice would supply (which case gates the namespace lookup, whether Case 0's failure falls through) was read directly from source at higher evidence strength in §2. + +## 6. Proposed Changes + +### 6.1 `collectNamespaceTargets` — also key on the dotted access path + +- **File:** `gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts` +- **Symbol:** `collectNamespaceTargets` (source-verified) +- **Responsibility:** map every receiver spelling that names an imported module to that module's file(s). +- **Change:** inside the existing edge loop, after recording `edge.localName`, also record `edge.targetExportedName` **when it contains a dot and its first dot-separated segment equals `edge.localName`**. Same array-dedupe as the existing key. +- **Why this is language-neutral (AGENTS.md §42):** the condition names no language. It encodes one structural fact — *a namespace binding whose exported module name is a dotted path rooted at the local name is also reachable under that whole path.* Verified against every other namespace-emitting provider at the pinned commit `[verified]`: + - TypeScript `import * as X from './y'` → `localName 'X'`, `importedName './y'`; first segment `''` ≠ `'X'` → no key (`languages/typescript/interpret.ts:77-81, 118-122`). + - C# `using System.Collections.Generic` → `localName 'Generic'` (last segment), `importedName 'System.Collections.Generic'`; first segment `'System'` ≠ `'Generic'` → no key (`languages/csharp/interpret.ts:33-37, 62-66`). + - Go / Rust / Ruby → `localName === importedName`, no dot → no key (`languages/{go,rust,ruby}/interpret.ts`). + - Python `import pkg.db` → `'pkg' === 'pkg.db'.split('.')[0]` → key `'pkg.db'` added. This is the only provider the predicate admits today. +- **Constraint:** additive only. The existing `localName` key must keep its current value and ordering so no currently-resolving site changes target. +- **Two-package safety:** `import pkg.a` + `import pkg.b` in one file yields `{'pkg' → ['pkg/a.py','pkg/b.py'], 'pkg.a' → ['pkg/a.py'], 'pkg.b' → ['pkg/b.py']}`. Receiver `pkg.a` hits exactly one file; the ambiguous `'pkg'` bucket is only reachable by a receiver literally spelled `pkg`, which is unchanged from today. `[inferred]` — pinned by a test in §8. + +### 6.2 `isNamespaceNameShadowed` — test the root segment, not the dotted path + +- **File:** `gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts` +- **Symbol:** `isNamespaceNameShadowed` (source-verified, lines 152-183) and its one call site at line 250. +- **Defect this fix activates:** the guard walks the scope chain looking for a binding, type binding, lexical name, or owned def **named exactly `namespaceName`**. With 6.1 in place, `namespaceName` can be `'pkg.db'`, but Python binds only `pkg` — so a local `pkg = something` that genuinely shadows the import would fail to suppress the namespace interpretation, and the "verified namespace is authoritative" branch (line 249-259) would return a wrong class instead of declining. +- **Change:** shadow-test the first dot-separated segment of `namespaceName` (identical behaviour for the single-segment names it sees today, since root === whole name). +- **Not scope creep:** 6.1 is what first routes a dotted name into this guard; shipping 6.1 without it introduces the false positive. + +### 6.3 No change required in `receiver-bound-calls.ts` + +Case 1's lookup already uses the full dotted `receiverName` and Case 0's failure already falls through to it (`receiver-bound-calls.ts:577, 622-655, 832`) `[verified]`. Recorded here so the executor does not "fix" a path that is already correct. + +## 7. Implementation Sequence + +1. **Add the failing fixture and assertions first.** Create `gitnexus/test/fixtures/lang-resolution/python-dotted-namespace-import/` (files in §8) and a `describe` block in `gitnexus/test/integration/resolvers/python.test.ts` following the file's existing `writeFixtureRepo` + `mkdtempSync` convention. Confirm the dotted row fails and all three control rows pass. Delete the scratch `gitnexus/test/integration/resolvers/repro-2826-python-dotted-import.test.ts` in this step — its content is superseded by the fixture-backed tests. +2. **Implement 6.1** in `namespace-targets.ts`, and update its header contract note to state that a namespace edge may be keyed both by its local name and by a dotted access path rooted at that name. Re-run the step-1 tests: the dotted row must flip to passing with the controls still green. +3. **Implement 6.2** in `compound-receiver.ts` with the shadowing test from §8 (a local `pkg = Decoy()` must suppress, not misresolve). +4. **Run the regression surface**: full resolver + scope-resolution integration suites, both packages' `tsc --noEmit`. +5. **Regenerate recorded baselines once, last.** Run each `--check` gate; regenerate only the baselines that actually moved (`bench/receiver-resolution/baseline.json` is the expected one — this change adds resolved edges). Per plan-template §7, this is deliberately the final step so intermediate commits do not churn and re-drift the artifacts. + +## 8. Test Strategy + +**New fixture** `gitnexus/test/fixtures/lang-resolution/python-dotted-namespace-import/`: + +| file | contents | +| --- | --- | +| `pkg/__init__.py` | empty | +| `pkg/db.py` | `def session_scope(): ...` | +| `pkg/cache.py` | `def session_scope(): ...` — the decoy that makes cross-resolution detectable | +| `caller_dotted.py` | `import pkg.db` + `def uses_dotted(): return pkg.db.session_scope()` | +| `caller_from.py`, `caller_alias.py`, `caller_frommod.py` | the three sibling controls from the issue | +| `caller_two_pkgs.py` | `import pkg.db` **and** `import pkg.cache`, one function calling each | +| `caller_deep.py` | `import pkg.sub.deep` + `pkg.sub.deep.f()` (3-segment) | +| `caller_shadowed.py` | module-level `import pkg.db`, then a function with a local `pkg = Decoy()` before `pkg.db.session_scope()` | + +**Scenarios** (input → action → expected): + +1. `caller_dotted.py` → run pipeline → `CALLS` edge `uses_dotted` → `pkg/db.py:session_scope`, `reason: 'import-resolved'`. **This is the issue's acceptance row.** +2. The three sibling callers → same run → all three still resolve to `pkg/db.py:session_scope`. Regression control: a run where the controls also broke would prove nothing about row 1. +3. `caller_two_pkgs.py` → `pkg.db.session_scope()` resolves **only** to `pkg/db.py` and `pkg.cache.session_scope()` **only** to `pkg/cache.py`; assert the absence of the crossed pair explicitly, not just the presence of the right one. +4. `caller_deep.py` → 3-segment receiver resolves — proves the predicate is not hard-coded to two segments. +5. `caller_shadowed.py` → **no** edge from the shadowed function to `pkg/db.py` (6.2's guard). Fails loudly if 6.2 regresses. +6. Cross-language non-regression: the existing TypeScript / C# / Go namespace-import resolver tests must stay green unchanged — that is the executable proof the new key is not minted for them. + +**Tests to update:** `gitnexus/test/integration/resolvers/python.test.ts` (add the describe block). `gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts` is a direct `collectNamespaceTargets` caller — re-run it; extend it only if its expectations enumerate map keys exhaustively. + +**Verification commands** (each verified to exist in `gitnexus/package.json` / `.github/workflows/ci-tests.yml` at the pinned commit): + +```bash +# from gitnexus/ — pretest:integration runs scripts/build.js, so the parse worker exists +GITNEXUS_WORKER_READY_TIMEOUT_MS=60000 npm run test:integration -- test/integration/resolvers/python.test.ts +GITNEXUS_WORKER_READY_TIMEOUT_MS=60000 npm run test:integration -- test/integration/resolvers +npm run test:unit -- test/unit/scope-resolution +npx tsc --noEmit # and the same in ../gitnexus-shared +node --import tsx bench/receiver-resolution/measure.mjs --check +node --import tsx bench/python-scope/measure.mjs --check +node --import tsx bench/python-scope/import-target-fingerprint.mjs --check +node --import tsx bench/scope-capture/measure.mjs --check +``` + +`GITNEXUS_WORKER_READY_TIMEOUT_MS=60000` is required on this host: the default 5000 ms worker-ready deadline fails as a crash-loop here (observed while reproducing the issue), which is environmental, not a code fault. + +## 9. Risk and Impact Analysis + +Accounting for every direct (d=1) dependent of the changed map: + +| d=1 dependent | risk | mitigation | +| --- | --- | --- | +| `emitReceiverBoundCalls` Case 1 (`receiver-bound-calls.ts:832`) | New keys make previously-dropped sites resolve. A wrong target would be a *new* false edge. | The predicate admits only Python's `import a.b` shape; each new key maps to exactly one file per import statement. §8 scenario 3 pins non-crossing. | +| `resolveConstructionExpressionClass` (`compound-receiver.ts:245-260`) | `pkg.db.Model()` now takes the "verified namespace is authoritative" branch, which deliberately does **not** fall through on a miss or ambiguity — so a wrong key would convert a working heuristic resolution into a silent decline. | The branch requires `namespaceFiles.length > 0`, i.e. the import genuinely resolved. Ambiguity still returns `undefined` (`namespaceMatches.length === 1` guard). Shadowing is fixed by 6.2. | +| `resolveCompoundReceiverClass` namespace-constructor disambiguation (`compound-receiver.ts:759-766`) | `namespaceTargets.has(objExpr)` now true for dotted namespaces, routing `pkg.db.Model(x).run()` into the construction interpretation. | Correct by intent — that branch exists precisely to make a namespace-qualified bare constructor safe. Behaviour change, so §8 should include a construction row if the fixture's cost is low. | +| `test/unit/scope-resolution/python/python-module-namespace-construction.test.ts:build` | May assert exact map contents. | Re-run in step 4; extend rather than weaken if it enumerates keys. | +| C++ provider | Case 1 is skipped entirely for C++ (`provider.resolveQualifiedReceiverMember !== undefined`), but the two `compound-receiver.ts` consumers are **not** provider-gated. | C++ `#include` does not produce a `kind: 'namespace'` edge with a dotted `targetExportedName` rooted at its local name; the predicate declines. Covered by the existing C++ suites plus `bench/cpp-qualified-ns/measure.mjs --check`. | + +**Recorded-artifact risk:** `bench/receiver-resolution/measure.mjs --check` gates both a shape matrix and a drop-count arm; new resolved edges are expected to move the count arm and the gate fails on drift. Regenerating in step 5 only (per §7) keeps intermediate commits clean. `bench/python-scope/*` and `bench/scope-capture/*` fingerprint captures and import-target resolution — neither is touched by this change, so a movement there is a signal to stop and investigate, not to regenerate. + +**Performance:** one extra `Map.set` per multi-segment namespace import per file; the loop is already O(module import edges). No new traversal. + +**No schema/version impact:** this changes what the resolver produces, not how it is stored. Existing indexes need a re-analyze to show the new edges — matching the note PR #2810 carried for the same reason. + +## 10. Files Expected to Change + +| File | Symbols | Reason | +| ---- | ------- | ------ | +| `gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts` | `collectNamespaceTargets` | Add the dotted-access-path key (§6.1) and update the contract note | +| `gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts` | `isNamespaceNameShadowed` | Shadow-test the root segment (§6.2) | +| `gitnexus/test/integration/resolvers/python.test.ts` | new `describe` block | Issue acceptance row + controls + regression rows | +| `gitnexus/test/fixtures/lang-resolution/python-dotted-namespace-import/**` | — | New fixture (§8) | +| `gitnexus/test/integration/resolvers/repro-2826-python-dotted-import.test.ts` | — | Delete; superseded by the fixture-backed tests | +| `gitnexus/bench/receiver-resolution/baseline.json` | — | Regenerate once, final step, only if `--check` moves | + +## 11. Reusable Implementation Context + +```yaml +implementation_context: + task_summary: > + Python `import pkg.db` + `pkg.db.session_scope()` emits no CALLS edge (#2826). + Root cause: collectNamespaceTargets keys its map only on ImportEdge.localName + ('pkg'), while the receiver text is the full dotted path ('pkg.db'). Fix by + additionally keying on ImportEdge.targetExportedName when it is dotted and + rooted at localName — a predicate no other provider satisfies — plus a + root-segment fix to the shadow guard the new key first exposes. + acceptance_criteria: + - 'CALLS edge uses_dotted -> pkg/db.py:session_scope with reason import-resolved' + - 'The three sibling spellings (from-import, alias, from-module-attr) still resolve' + - 'import pkg.a + import pkg.b in one file do not cross-resolve' + - 'A local binding shadowing the package root suppresses the namespace interpretation' + - 'No shared file under gitnexus/src/core/ingestion/ names a language (AGENTS.md §42)' + + evidence_provenance: + schema_version: 2 + head_commit: 'b2cd1c2ad637657125248c0dd2046de71ceea965' + generated_plan_path: 'docs/plans/2026-08-04-gitnexus-plan-python-dotted-namespace-receiver.md' + global_dirty_digest: + algorithm: 'sha256' + canonicalization: 'gitnexus-evidence-provenance-v2 NUL-framed UTF-8 records' + value: '0912a3ee3219cb75c82aefbf9f010e8dbe313150d6553768fd55d22af87a135c' + cited_path_manifest: + - path: '.github/workflows/ci-tests.yml' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:0f1fba71be1e2b026d1ca2d35934ffe197b26bd4d31d5e5025d1e797e89754ff' + index_digest: 'sha256:0f1fba71be1e2b026d1ca2d35934ffe197b26bd4d31d5e5025d1e797e89754ff' + worktree_digest: 'sha256:0f1fba71be1e2b026d1ca2d35934ffe197b26bd4d31d5e5025d1e797e89754ff' + untracked_digest: 'absent' + - path: 'AGENTS.md' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:797b9d58a9c3dbed5af048904b3d3ba55ba6a2256a442fd15d35eb8b568cd1dd' + index_digest: 'sha256:797b9d58a9c3dbed5af048904b3d3ba55ba6a2256a442fd15d35eb8b568cd1dd' + worktree_digest: 'sha256:797b9d58a9c3dbed5af048904b3d3ba55ba6a2256a442fd15d35eb8b568cd1dd' + untracked_digest: 'absent' + - path: 'gitnexus-shared/src/scope-resolution/finalize-algorithm.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:9c3656484d8b5bd49394918446ab91c73db722e3fe2314fc08c9c284541c415b' + index_digest: 'sha256:9c3656484d8b5bd49394918446ab91c73db722e3fe2314fc08c9c284541c415b' + worktree_digest: 'sha256:9c3656484d8b5bd49394918446ab91c73db722e3fe2314fc08c9c284541c415b' + untracked_digest: 'absent' + - path: 'gitnexus-shared/src/scope-resolution/types.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:d9b0e9e0d47c10a71392ad8d0de31b08327c6268915488f1153c04cdc39fbdfc' + index_digest: 'sha256:d9b0e9e0d47c10a71392ad8d0de31b08327c6268915488f1153c04cdc39fbdfc' + worktree_digest: 'sha256:d9b0e9e0d47c10a71392ad8d0de31b08327c6268915488f1153c04cdc39fbdfc' + untracked_digest: 'absent' + - path: 'gitnexus/src/core/ingestion/languages/python/import-decomposer.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:97e28381e7d3f6040e5368d043d086ab2d3df24aad5e2bcb0c3da866a455a23e' + index_digest: 'sha256:97e28381e7d3f6040e5368d043d086ab2d3df24aad5e2bcb0c3da866a455a23e' + worktree_digest: 'sha256:97e28381e7d3f6040e5368d043d086ab2d3df24aad5e2bcb0c3da866a455a23e' + untracked_digest: 'absent' + - path: 'gitnexus/src/core/ingestion/languages/python/interpret.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:65ca96b207b89a86f44772f8f8ff8030acf06774214ddee67ef031db3d770419' + index_digest: 'sha256:65ca96b207b89a86f44772f8f8ff8030acf06774214ddee67ef031db3d770419' + worktree_digest: 'sha256:65ca96b207b89a86f44772f8f8ff8030acf06774214ddee67ef031db3d770419' + untracked_digest: 'absent' + - path: 'gitnexus/src/core/ingestion/languages/python/query.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:f9e145114aba978e34525c1ccb553ba37feea4152f882dc0e105dc8b21230d78' + index_digest: 'sha256:f9e145114aba978e34525c1ccb553ba37feea4152f882dc0e105dc8b21230d78' + worktree_digest: 'sha256:f9e145114aba978e34525c1ccb553ba37feea4152f882dc0e105dc8b21230d78' + untracked_digest: 'absent' + - path: 'gitnexus/src/core/ingestion/scope-extractor.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:34089a212075f16d8c270240c64985b0a666864547ed414449a59747e4922d80' + index_digest: 'sha256:34089a212075f16d8c270240c64985b0a666864547ed414449a59747e4922d80' + worktree_digest: 'sha256:34089a212075f16d8c270240c64985b0a666864547ed414449a59747e4922d80' + untracked_digest: 'absent' + - path: 'gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:88a083a625449187fe770e992c580ec84d70ddb9f395f54949c1b85a29838f97' + index_digest: 'sha256:88a083a625449187fe770e992c580ec84d70ddb9f395f54949c1b85a29838f97' + worktree_digest: 'sha256:88a083a625449187fe770e992c580ec84d70ddb9f395f54949c1b85a29838f97' + untracked_digest: 'absent' + - path: 'gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:1873a19be4235b6882aab63422a0bc632192ac30407e60e7d5648aa70e5759c3' + index_digest: 'sha256:1873a19be4235b6882aab63422a0bc632192ac30407e60e7d5648aa70e5759c3' + worktree_digest: 'sha256:1873a19be4235b6882aab63422a0bc632192ac30407e60e7d5648aa70e5759c3' + untracked_digest: 'absent' + - path: 'gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:54062a70276ec1761a94b6548499d265c91fd3b648422a8567a21e06d800e09d' + index_digest: 'sha256:54062a70276ec1761a94b6548499d265c91fd3b648422a8567a21e06d800e09d' + worktree_digest: 'sha256:54062a70276ec1761a94b6548499d265c91fd3b648422a8567a21e06d800e09d' + untracked_digest: 'absent' + - path: 'gitnexus/test/integration/resolvers/python.test.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:4c0f55a923f51d736476b5bcb276d293637d90fecc10ab5e08624a4b541fe999' + index_digest: 'sha256:4c0f55a923f51d736476b5bcb276d293637d90fecc10ab5e08624a4b541fe999' + worktree_digest: 'sha256:4c0f55a923f51d736476b5bcb276d293637d90fecc10ab5e08624a4b541fe999' + untracked_digest: 'absent' + - path: 'gitnexus/test/integration/resolvers/repro-2826-python-dotted-import.test.ts' + object_kind: { head: absent, index: absent, worktree: absent, untracked: regular } + state: 'untracked' + rename_from: null + rename_to: null + head_digest: 'absent' + index_digest: 'absent' + worktree_digest: 'absent' + untracked_digest: 'sha256:6fe3a74a69db12a1a0aeceef2b32eb0c04d5e4880fc93b7b840348118e70078c' + + primary_symbols: + - symbol: 'collectNamespaceTargets' + file: 'gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts' + lines: '39-57' + role: 'The defect site — builds the receiver-name → target-file map keyed only on localName' + - symbol: 'interpretPythonImport' + file: 'gitnexus/src/core/ingestion/languages/python/interpret.ts' + lines: '33-42' + role: 'Splits `import a.b` into localName "a" / importedName "a.b"; source of both halves' + - symbol: 'emitReceiverBoundCalls' + file: 'gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts' + lines: '404-421, 546-655, 831-848' + role: 'Case 0 declines on a module receiver and falls through; Case 1 does the failing map lookup' + - symbol: 'isNamespaceNameShadowed' + file: 'gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts' + lines: '152-183' + role: 'Shadow guard that must test the root segment once dotted keys exist' + - symbol: 'finalizeImportEdges' + file: 'gitnexus-shared/src/scope-resolution/finalize-algorithm.ts' + lines: '398-406, 434-447' + role: 'Carries importedName onto ImportEdge.targetExportedName for namespace edges' + + related_symbols: + - symbol: 'resolveQualifiedReceiverMember' + relationship: 'ScopeResolver hook, C++-only implementer' + relevance: 'Case 1.5 — deliberately NOT the fix path; implementing it for Python would duplicate what Case 1 already does' + - symbol: 'resolveConstructionExpressionClass' + relationship: 'consumes namespaceTargets by parameter' + relevance: 'Second consumer of the map; gains correct pkg.db.Model() resolution' + - symbol: 'resolveCompoundReceiverClass' + relationship: 'consumes namespaceTargets by parameter (compound-receiver.ts:759-766)' + relevance: 'Third consumer; has() now true for dotted namespaces' + - symbol: 'isNamespaceImport' + relationship: 'finalize hook added by #2770' + relevance: 'Prior art — how the from-pkg-import-db sibling was made to resolve' + - symbol: 'build' + relationship: 'test-of collectNamespaceTargets' + relevance: 'test/unit/scope-resolution/python/python-module-namespace-construction.test.ts — re-run after the change' + + execution_path: + - 'splitImportStatement emits one match per imported name; @import.source = full dotted_name text' + - 'interpretPythonImport plain arm → ParsedImport{kind:namespace, localName:first-segment, importedName:full-dotted}' + - 'finalizeImportEdges → ImportEdge{localName, targetExportedName=importedName, targetFile, kind:namespace}' + - 'collectNamespaceTargets builds Map keyed on localName only ← DEFECT' + - 'scope-extractor extractExplicitReceiver takes raw text of the attribute object → "pkg.db"' + - 'emitReceiverBoundCalls Case 0 declines (module, not class), falls through without marking handled' + - 'Case 1 map lookup on "pkg.db" misses; Case 1.5 skipped (no Python hook); site drops silently' + + pdg_constraints: [] # index has zero CDG rows; no --pdg layer to slice + + architectural_patterns: + - pattern: 'Provider reclassification at finalize instead of shared-code special-casing' + example_location: 'gitnexus-shared/src/scope-resolution/finalize-algorithm.ts:99-107 (isNamespaceImport, #2770)' + usage_guidance: 'Considered and rejected here: the information needed is already on the finalized edge, so no new hook is warranted' + - pattern: 'Verified namespace is authoritative — do not fall through to workspace-wide simple-name heuristics' + example_location: 'gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts:245-259' + usage_guidance: 'Because that branch declines rather than guessing, a wrong key costs a lost edge, not a wrong one — but the shadow guard must be right' + - pattern: 'Fixture + assertions in test/integration/resolvers/python.test.ts' + example_location: 'gitnexus/test/integration/resolvers/python.test.ts:562-600 (vendored-django guard)' + usage_guidance: 'mkdtempSync + writeFixtureRepo + afterAll rmSync; assert both presence of the right edge and absence of the wrong one' + + files_to_modify: + - file: 'gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts' + symbols: ['collectNamespaceTargets'] + intended_change: 'Additionally key the map on edge.targetExportedName when it contains a dot and its first segment equals edge.localName; keep the existing localName key unchanged; update the header contract note' + - file: 'gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts' + symbols: ['isNamespaceNameShadowed'] + intended_change: 'Shadow-test the first dot-separated segment of namespaceName (no-op for single-segment names)' + - file: 'gitnexus/test/integration/resolvers/python.test.ts' + symbols: [] + intended_change: 'Add a describe block covering the six §8 scenarios' + - file: 'gitnexus/test/fixtures/lang-resolution/python-dotted-namespace-import/' + symbols: [] + intended_change: 'New fixture per the §8 table' + - file: 'gitnexus/test/integration/resolvers/repro-2826-python-dotted-import.test.ts' + symbols: [] + intended_change: 'Delete — superseded by the fixture-backed tests' + + tests: + - file: 'gitnexus/test/integration/resolvers/python.test.ts' + scenarios: + - 'import pkg.db + pkg.db.session_scope() → run pipeline → CALLS uses_dotted → pkg/db.py:session_scope, reason import-resolved' + - 'three sibling spellings in the same repo → run pipeline → all still resolve to pkg/db.py:session_scope (control)' + - 'import pkg.db AND import pkg.cache in one file, both defining session_scope → each call resolves only to its own module; assert the crossed pair is ABSENT' + - 'import pkg.sub.deep + pkg.sub.deep.f() → 3-segment receiver resolves' + - 'module-level import pkg.db shadowed by a function-local pkg = Decoy() → NO edge to pkg/db.py' + - 'existing TypeScript/C#/Go namespace-import resolver tests → unchanged green (no key minted for them)' + - file: 'gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts' + scenarios: + - 'Re-run unchanged; extend only if it enumerates map keys exhaustively' + + verification_commands: + - 'cd gitnexus && GITNEXUS_WORKER_READY_TIMEOUT_MS=60000 npm run test:integration -- test/integration/resolvers/python.test.ts' + - 'cd gitnexus && GITNEXUS_WORKER_READY_TIMEOUT_MS=60000 npm run test:integration -- test/integration/resolvers' + - 'cd gitnexus && npm run test:unit -- test/unit/scope-resolution' + - 'cd gitnexus && npx tsc --noEmit' + - 'cd gitnexus-shared && npx tsc --noEmit' + - 'cd gitnexus && node --import tsx bench/receiver-resolution/measure.mjs --check' + - 'cd gitnexus && node --import tsx bench/python-scope/measure.mjs --check' + - 'cd gitnexus && node --import tsx bench/python-scope/import-target-fingerprint.mjs --check' + - 'cd gitnexus && node --import tsx bench/scope-capture/measure.mjs --check' + + risks: + - 'New map keys reach three consumers, two of them by parameter rather than by call — the graph d=1 list alone under-reports them' + - 'compound-receiver treats a verified namespace as authoritative and declines instead of falling through, so a bad key loses edges silently' + - 'bench/receiver-resolution/baseline.json is expected to move; regenerate ONCE in the final step' + - 'Default 5000 ms worker-ready timeout crash-loops on this host; export GITNEXUS_WORKER_READY_TIMEOUT_MS=60000' + + assumptions: + - 'Every non-Python provider fails the dotted-rooted-at-localName predicate. CHECK: grep "kind: .namespace." across gitnexus/src/core/ingestion/languages/*/interpret.ts and confirm localName is never the first segment of a dotted importedName. Verified at b2cd1c2ad for typescript, csharp, go, rust, ruby.' + - 'python.test.ts is no longer gated behind REGISTRY_PRIMARY_PYTHON. CHECK: grep REGISTRY_PRIMARY in that file — zero hits at b2cd1c2ad, so it runs unconditionally.' + - 'The GitNexus index is 13 commits behind but byte-identical on every cited path. CHECK: git rev-parse 1ef6447e: vs b2cd1c2ad:.' + + open_questions: + - 'Should the misleading localName-only key for a dotted import (pkg → pkg/db.py) be removed? It can produce a false positive today: pkg.helper() resolves into pkg/db.py if db.py happens to define helper. Deferred — a separate behaviour change needing its own regression pass.' + - 'Python`s `import a.b.c` also makes `a.b` reachable. The proposed predicate keys only the exact imported path, so `a.b.f()` under `import a.b.c` alone stays unresolved. Deferred as a narrower follow-up.' + - 'C# `using System.Collections.Generic` + `System.Collections.Generic.List` is the same class of gap and is deliberately NOT addressed here (its localName is the last segment, so the predicate declines). Worth its own issue.' + + avoid: + - 'Do not repeat full repository discovery' + - 'Do not replace established patterns without evidence' + - 'Do not implement resolveQualifiedReceiverMember for Python — Case 1 already does this job; a second path would double-resolve' + - 'Do not change ImportEdge.localName for dotted imports (interpret.ts:38) — it is the deliberate `import a.b.c exposes a` semantics and other consumers depend on it' + - 'Do not name a language in gitnexus/src/core/ingestion/ shared code (AGENTS.md §42)' + - 'Do not regenerate bench baselines per step — only once, in the final step' + - 'Do not weaken an existing test to accommodate the new keys; extend it instead' +``` + +## 12. Assumptions and Open Questions + +**Assumptions** (each re-checkable cheaply by the executor): + +1. Every non-Python namespace-emitting provider fails the `dotted && first segment === localName` predicate. Verified at `b2cd1c2ad` for TypeScript, C#, Go, Rust and Ruby by reading each `interpret.ts`; JavaScript, Java and PHP emit no `kind: 'namespace'` import there. **Re-check:** grep `kind: 'namespace'` across `gitnexus/src/core/ingestion/languages/*/interpret.ts`. +2. `python.test.ts` runs unconditionally — no `REGISTRY_PRIMARY_PYTHON` gate remains at the pinned commit (zero grep hits). An older parity-leg convention no longer applies. +3. The index's 13-commit lag is harmless here because every cited path is byte-identical at the index commit and the pinned commit. + +**Open questions / explicitly deferred:** + +- **The bogus first-segment key.** For `import pkg.db`, the map still holds `'pkg' → ['pkg/db.py']`, so `pkg.helper()` would resolve into `pkg/db.py` if that file happens to define `helper` — a pre-existing false positive this plan does **not** fix. Removing it is a separate behaviour change with its own regression surface (`python-multi-segment-ancestor-import`, `python-bare-import`). Worth pinning the current behaviour in a test so it is visible rather than silent. +- **`import a.b.c` also binds `a.b`.** Python makes intermediate packages reachable; the proposed predicate keys only the exact imported path, so `a.b.f()` under `import a.b.c` alone stays unresolved. Narrower follow-up. +- **C# has the mirror-image gap.** `using System.Collections.Generic` + `System.Collections.Generic.List` fails the predicate because C# sets `localName` to the *last* segment. Deliberately out of scope; deserves its own issue. +- **Construction coverage.** §8 does not currently include a `pkg.db.Model()` row. Add one if the fixture cost is trivial — that path (`compound-receiver.ts:245-260`) changes behaviour and is otherwise untested by this plan. + +## 13. Definition of Done + +1. `CALLS` edge `uses_dotted` → `pkg/db.py:session_scope` (`reason: 'import-resolved'`) is emitted, asserted by a fixture-backed test in `python.test.ts`. +2. All three sibling control rows still resolve in the same run. +3. `import pkg.a` + `import pkg.b` in one file resolve only to their own modules; the crossed pair is asserted **absent**. +4. A 3-segment receiver resolves; a package root shadowed by a local binding does **not**. +5. The scratch `repro-2826-python-dotted-import.test.ts` is deleted. +6. No file under `gitnexus/src/core/ingestion/` names a language. +7. `npm run test:integration -- test/integration/resolvers` and `npm run test:unit -- test/unit/scope-resolution` pass; `tsc --noEmit` clean in both packages. +8. Every bench `--check` in §8 passes, with `bench/receiver-resolution/baseline.json` regenerated exactly once in the final commit if and only if it moved — and any movement in `python-scope`/`scope-capture` investigated rather than regenerated. diff --git a/gitnexus/src/core/ingestion/languages/python/import-target.ts b/gitnexus/src/core/ingestion/languages/python/import-target.ts index d30823b92..d06912cf5 100644 --- a/gitnexus/src/core/ingestion/languages/python/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/python/import-target.ts @@ -439,3 +439,76 @@ export function isPythonImportedModule( normalizedTarget.endsWith('/' + packageFile) ); } + +/** + * The receiver spellings `import a.b.c` makes callable, and the file each one + * names (#2826). + * + * `import a.b.c` binds ONE name — `a` — but makes three attribute paths + * reachable, and they name three different files: + * + * a → a/__init__.py + * a.b → a/b/__init__.py + * a.b.c → a/b/c.py (the edge's own target) + * + * The shared default keyed `a` to the LEAF, which is wrong in both directions: + * `a.helper()` resolved into `a/b/c.py` whenever that module happened to export + * `helper`, and `a.b.mid()` resolved to nothing. + * + * Returns `undefined` — meaning "use the shared default" — for every spelling + * where the bound name is not the path's root: + * - `import single` — no dotted path to expand; + * - `import a.b as x` — binds only `x`; writing `a.b.f()` there is a + * NameError, so `a.b` must NOT become a key; + * - `from pkg import db` — reclassified to a namespace edge whose + * importPath is the bare name `db`. + * + * Prefix files are proposed, not asserted: `moduleFileExists` drops any that + * the workspace did not parse, so a PEP-420 namespace package (no + * `__init__.py`) contributes no key rather than one pointing at a missing file. + */ +export function pythonNamespaceReceiverPaths( + edge: { readonly localName: string; readonly importPath: string; readonly targetFile: string }, + moduleFileExists: (filePath: string) => boolean, +): readonly (readonly [string, string])[] | undefined { + const segments = edge.importPath.split('.'); + if (segments.length < 2) return undefined; + if (segments[0] !== edge.localName) return undefined; + + const out: (readonly [string, string])[] = [[edge.importPath, edge.targetFile]]; + + // Anchor the prefix packages on the RESOLVED leaf, never on the import + // spelling. `resolvePythonImportTarget` resolves off-root in two of its three + // tiers (suffix match and ancestor-relative), so `import utils.db` can land on + // `libs/common/utils/db.py`. Building `utils/__init__.py` from the spelling + // would then name a DIFFERENT package that merely shares the root segment — + // a wrong edge — and in a `src/` layout it would match nothing at all, + // silently making prefix keying inert for the most common Python layout. + // + // Walking back from the leaf also inherits that path's own separator, so no + // POSIX-vs-Windows probing is needed: workspace paths are not normalized at + // ingestion, and `moduleScopeByFile` is keyed by the raw `ParsedFile.filePath`. + const dirs = edge.targetFile.split('/').slice(0, -1); + // The import's leading segments name the leaf's innermost directories. + const offset = dirs.length - (segments.length - 1); + if (offset < 0) return out; + + for (let i = 1; i < segments.length; i++) { + const spelling = segments.slice(0, i).join('.'); + const packageFile = dirs.slice(0, offset + i).join('/') + '/__init__.py'; + // Package FIRST, then the leaf as a fallback — order is the whole point. + // + // `findExportedDef` only accepts a binding whose `origin === 'local'`, and + // the canonical package re-exports (`from .b.c import helper` in + // `__init__.py`) produce an IMPORT binding. Keying the prefix at the + // package alone therefore loses `a.helper()` entirely for the most common + // package shape — the fixtures here all define members locally in + // `__init__.py`, which is precisely the one layout where that mistake is + // invisible. Keeping the leaf behind the package restores that resolution + // while still letting a real definition in `__init__.py` win over a + // same-named decoy deeper in the package. + if (moduleFileExists(packageFile)) out.push([spelling, packageFile]); + out.push([spelling, edge.targetFile]); + } + return out; +} diff --git a/gitnexus/src/core/ingestion/languages/python/index.ts b/gitnexus/src/core/ingestion/languages/python/index.ts index 5cb5bda4d..dafddf357 100644 --- a/gitnexus/src/core/ingestion/languages/python/index.ts +++ b/gitnexus/src/core/ingestion/languages/python/index.ts @@ -78,6 +78,7 @@ export { pythonMergeBindings } from './merge-bindings.js'; export { pythonArityCompatibility } from './arity.js'; export { isPythonImportedModule, + pythonNamespaceReceiverPaths, resolvePythonImportTarget, type PythonResolveContext, } from './import-target.js'; diff --git a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts index 12b20ded8..25bee8f96 100644 --- a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts @@ -21,6 +21,7 @@ import { indexOnlyElementType } from '../../type-extractors/shared.js'; import { pythonProvider } from '../python.js'; import { isPythonImportedModule, + pythonNamespaceReceiverPaths, pythonArityCompatibility, pythonMergeBindings, resolvePythonImportTarget, @@ -57,6 +58,12 @@ const pythonScopeResolver: ScopeResolver = { isNamespaceImport: (parsedImport, targetFile, fromFile) => isPythonImportedModule(parsedImport, targetFile, fromFile), + // `import a.b.c` binds only `a`, yet makes `a`, `a.b` and `a.b.c` all + // callable — each naming a different file. Without this the absolute-import + // style is invisible to the call graph, and the root key points at the leaf + // module instead of the package (#2826). + namespaceReceiverPaths: pythonNamespaceReceiverPaths, + // Python LEGB precedence: local > import/namespace/reexport > wildcard. // The per-scope id is unused by pythonMergeBindings (tier ordering // is computed purely from BindingRef.origin), so we don't need to diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 089d98364..a02f407c0 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -1084,6 +1084,44 @@ export interface ScopeResolver { callsite?: Callsite, ) => SymbolDefinition | 'ambiguous' | undefined; + /** + * Every receiver spelling under which a namespace import's target is + * reachable, and the file each spelling names (#2826). Returning + * `undefined` keeps the shared default: the local binding name alone, + * mapped to the edge's own target file. + * + * Python needs this because one `import a.b.c` statement binds THREE + * spellings at once — `a`, `a.b` and `a.b.c` — each naming a DIFFERENT + * file (`a/__init__.py`, `a/b/__init__.py`, `a/b/c.py`), while + * `ImportEdge` carries only the leaf. The default keyed `a` (its + * `localName`) to the LEAF, so `a.helper()` resolved into `a/b/c.py` + * whenever that module happened to export `helper` — a wrong edge — and + * `a.b.mid()` resolved to nothing at all. + * + * Shared code cannot derive this. Swift's `import Foo.Bar` produces an + * edge shape identical to Python's (`localName: 'Foo'`, + * `targetExportedName: 'Foo.Bar'`), yet there the FIRST segment is the + * resolved target and `Foo.Bar` names a nested TYPE; keying it would hand + * `resolveConstructionExpressionClass` an authoritative namespace — that + * branch deliberately does not fall through on a miss — and break + * `Foo.Bar(x)` construction that resolves correctly today. And the + * `__init__.py` convention that turns a dotted prefix into a file is + * Python's alone. + * + * `moduleFileExists` reports whether a path is a module the workspace + * actually parsed, so a provider can propose a prefix file and have it + * dropped when absent (a PEP-420 namespace package has no `__init__.py`) + * rather than minting a key to a file that is not there. + */ + readonly namespaceReceiverPaths?: ( + edge: { + readonly localName: string; + readonly importPath: string; + readonly targetFile: string; + }, + moduleFileExists: (filePath: string) => boolean, + ) => readonly (readonly [spelling: string, targetFile: string])[] | undefined; + /** * Optional language-specific member-lattice lookup. Runs for a resolved * simple receiver type before the generic flattened-MRO walk. Languages diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index 39f98b467..72f2dcab7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -35,6 +35,7 @@ import { findExportedDefByName, findReceiverTypeBinding, isClassLike, + isNamespaceNameShadowed, } from '../scope/walkers.js'; /** Max depth for compound-receiver chain resolution (`a().b().c().d()`). @@ -156,42 +157,6 @@ function escapeForRegExp(literal: string): string { return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -/** True when a local declaration between the call site and its module scope - * shadows a file-level namespace import with the same name. Namespace targets - * are collected per file, so callers must apply this lexical guard before - * trusting them at an inner scope. */ -function isNamespaceNameShadowed( - namespaceName: string, - inScope: ScopeId, - scopes: ScopeResolutionIndexes, -): boolean { - let currentId: ScopeId | null = inScope; - const visited = new Set(); - while (currentId !== null) { - if (visited.has(currentId)) return true; - visited.add(currentId); - const scope = scopes.scopeTree.getScope(currentId); - if (scope === undefined) return true; - if ( - scope.kind !== 'Object' && - (scope.bindings.has(namespaceName) || - scope.typeBindings.has(namespaceName) || - scope.lexicalNames?.has(namespaceName) === true || - scope.ownedDefs.some((def) => { - const qualifiedName = def.qualifiedName; - if (qualifiedName === undefined) return false; - const dot = qualifiedName.lastIndexOf('.'); - return (dot === -1 ? qualifiedName : qualifiedName.slice(dot + 1)) === namespaceName; - })) - ) { - return true; - } - if (scope.kind === 'Module') return false; - currentId = scope.parent; - } - return true; -} - /** * Type of a construction expression's callee — the class it constructs. * diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 1202687a4..ce7ae7902 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -65,6 +65,7 @@ import { findReceiverTypeBinding, findValueBindingInScope, isClassLike, + isNamespaceNameShadowed, type DecorationStripper, } from '../scope/walkers.js'; import { @@ -107,6 +108,7 @@ type ReceiverBoundProviderSubset = Pick< | 'constructionSyntax' | 'stripTypePreservingDecoration' | 'resolveQualifiedReceiverMember' + | 'namespaceReceiverPaths' | 'resolveReceiverMember' | 'resolveThisViaEnclosingClass' | 'conversionRankFn' @@ -524,7 +526,10 @@ export function emitReceiverBoundCalls( }; for (const parsed of parsedFiles) { - const namespaceTargets = collectNamespaceTargets(parsed, scopes); + const namespaceTargets = collectNamespaceTargets(parsed, scopes, { + receiverPaths: provider.namespaceReceiverPaths, + moduleFileExists: (filePath) => index.moduleScopeByFile.has(filePath), + }); const fileCompoundOpts = { ...compoundOpts, namespaceTargets }; // Per-file resolved-callee-id capture context (#2227 U2). Built once per // file; `undefined` when the sink is absent (pdg off) so the `tryEmitEdge` @@ -993,7 +998,24 @@ export function emitReceiverBoundCalls( } // ── Case 1: namespace receiver ─────────────────────────────── - const targetFiles = namespaceTargets.get(receiverName); + // `namespaceTargets` is collected per FILE, so a local declaration that + // shadows the import must suppress it — `def f(pkg): pkg.db.query()` + // calls a method on the PARAMETER, and resolving it through the import + // emits a wrong edge, not a missing one. The compound-receiver + // construction path has applied this guard since #2770; Case 1 never did, + // for dotted and single-segment receivers alike. + // Map lookup FIRST: it is an O(1) miss for almost every site, and the + // guard is a scope-chain walk (a Set allocation plus a linear `ownedDefs` + // scan per level). Guarding before looking up would charge that walk to + // every explicit-receiver site in every language, for a candidate set + // that is usually empty. Mirrors the order the compound-receiver + // construction path already uses. + const namespaceCandidates = namespaceTargets.get(receiverName); + const targetFiles = + namespaceCandidates !== undefined && + !isNamespaceNameShadowed(receiverName, site.inScope, scopes) + ? namespaceCandidates + : undefined; if (targetFiles !== undefined && provider.resolveQualifiedReceiverMember === undefined) { let found = false; for (const targetFile of targetFiles) { diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts index f82a01be2..e80b7f359 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts @@ -20,6 +20,15 @@ * syntax or reclassify a named import after target resolution proves it names * a module. * + * A namespace edge may be reachable under TWO receiver spellings: the name it + * binds locally, and — for a language that opts in via + * `ScopeResolver.namespaceReceiverIncludesImportPath` — the dotted module path + * it was imported under (#2826). Python's `import a.b` binds only `a` while + * the call site writes `a.b`, so both keys are needed. The opt-in exists + * because the edge shape alone cannot tell that case from Swift's + * `import Foo.Bar`, where the same pair means the opposite thing — see the + * hook's contract note. + * * Scope-chain concern (verified 2026-04-21): `pythonImportOwningScope` * documents that function-local and class-body imports bind to the * inner scope, which would make a module-only read incomplete. In @@ -36,23 +45,58 @@ import type { ParsedFile } from 'gitnexus-shared'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import type { ScopeResolver } from '../contract/scope-resolver.js'; + +export interface NamespaceTargetOptions { + /** `ScopeResolver.namespaceReceiverPaths` for the file's language. Absent + * (or returning `undefined` per edge) keeps the local-name-only default: + * the extra spellings are opt-in, never inferred from the edge shape. */ + readonly receiverPaths?: ScopeResolver['namespaceReceiverPaths']; + /** Whether a path is a module the workspace parsed. Lets a provider propose + * a prefix file and have it dropped when absent, instead of minting a key + * to a file that does not exist. Defaults to "nothing exists". */ + readonly moduleFileExists?: (filePath: string) => boolean; +} export function collectNamespaceTargets( parsed: ParsedFile, scopes: ScopeResolutionIndexes, + options?: NamespaceTargetOptions, ): Map { const out = new Map(); const moduleEdges = scopes.imports.get(parsed.moduleScope); if (moduleEdges === undefined) return out; - for (const edge of moduleEdges) { - if (edge.targetFile === null || edge.kind !== 'namespace') continue; - let targets = out.get(edge.localName); + const addTarget = (key: string, targetFile: string): void => { + let targets = out.get(key); if (targets === undefined) { targets = []; - out.set(edge.localName, targets); + out.set(key, targets); } - if (!targets.includes(edge.targetFile)) targets.push(edge.targetFile); + if (!targets.includes(targetFile)) targets.push(targetFile); + }; + + const moduleFileExists = options?.moduleFileExists ?? ((): boolean => false); + + for (const edge of moduleEdges) { + if (edge.targetFile === null || edge.kind !== 'namespace') continue; + + const spellings = options?.receiverPaths?.( + { + localName: edge.localName, + importPath: edge.targetExportedName, + targetFile: edge.targetFile, + }, + moduleFileExists, + ); + + // A provider that declines this edge — or has no hook — gets the default: + // the bound name alone, pointing at this edge's own target. + if (spellings === undefined) { + addTarget(edge.localName, edge.targetFile); + continue; + } + for (const [spelling, targetFile] of spellings) addTarget(spelling, targetFile); } return out; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index b4b8aeab9..cf9c3560e 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -227,6 +227,77 @@ export function isReceiverOwnedButUnbound( return false; } +/** + * True when a declaration between the call site and its module scope shadows a + * file-level namespace import of the same name. Namespace targets are collected + * per FILE, so every consumer of that map must apply this lexical guard before + * trusting it at an inner scope — otherwise `def f(pkg): pkg.db.query()` + * resolves through the import that the parameter shadows, producing a wrong + * edge rather than a missing one. + * + * A namespace key may itself be a dotted import path (`pkg.db`, #2826), but the + * name a declaration can shadow is always the ROOT identifier — `pkg = Decoy()` + * shadows `pkg.db` too. Testing the whole dotted string would never match a + * binding, so the guard would silently stop guarding for exactly the keys it + * was extended to cover. Single-segment names are unaffected: their root is + * themselves. + * + * Fails closed (returns `true`) on a missing scope or a parent cycle: for every + * caller, suppressing a resolution costs a missing edge, while trusting a + * corrupt scope chain costs a wrong one. + * + * Reads `scope.bindings` DIRECTLY rather than through `lookupBindingsAt`, and + * that is deliberate — the opposite of the fix #2745 applied to Rust's + * `headBoundLocally`. There the question was "is this name bound at all?", so + * missing the finalized/augmented import channels lost real bindings. Here the + * question is "does something LOCAL shadow the import?", and the import's own + * finalized binding is the one thing that must NOT count: routing this through + * `lookupBindingsAt` would find the namespace import shadowing itself and + * suppress every namespace receiver in the workspace. Locals, parameters and + * lexical names all live in the scope's own tables, which is exactly the set + * this walk wants. + */ +export function isNamespaceNameShadowed( + namespaceName: string, + inScope: ScopeId, + scopes: ScopeResolutionIndexes, +): boolean { + const firstDot = namespaceName.indexOf('.'); + const rootName = firstDot === -1 ? namespaceName : namespaceName.slice(0, firstDot); + let currentId: ScopeId | null = inScope; + const visited = new Set(); + while (currentId !== null) { + if (visited.has(currentId)) return true; + visited.add(currentId); + const scope = scopes.scopeTree.getScope(currentId); + if (scope === undefined) return true; + // Stop AT the module scope without inspecting it. In languages where a + // namespace import IS a variable declaration — CommonJS + // `const svc = require('./svc')` — the import puts its own name into the + // module scope's tables, so inspecting them reads the import as its own + // shadow and suppresses every receiver it was meant to enable (#2723). + // The contract is "a declaration BETWEEN the call site and its module + // scope", and the module scope is the floor, not a rung. + if (scope.kind === 'Module') return false; + if ( + scope.kind !== 'Object' && + (scope.bindings.has(rootName) || + scope.typeBindings.has(rootName) || + scope.lexicalNames?.has(rootName) === true || + scope.ownedDefs.some((def) => { + const qualifiedName = def.qualifiedName; + if (qualifiedName === undefined) return false; + const dot = qualifiedName.lastIndexOf('.'); + return (dot === -1 ? qualifiedName : qualifiedName.slice(dot + 1)) === rootName; + })) + ) { + return true; + } + currentId = scope.parent; + } + return true; +} + export function findReceiverTypeBinding( startScope: ScopeId, receiverName: string, diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index db2c2d155..add7b7bd0 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -3102,3 +3102,272 @@ describe('Python inline constructor receiver resolution', () => { ]); }); }); + +// --------------------------------------------------------------------------- +// #2826 — unaliased multi-segment namespace import. +// +// `import pkg.db` binds only `pkg`, but the receiver text at the call site is +// the whole dotted path `pkg.db`. Every sibling spelling binds a single-segment +// name and so already resolved; this one fell between the namespace-receiver +// case (keyed on the local binding) and the qualified-receiver hook (C++ only). +// +// The three sibling rows are controls, not decoration: a run where they also +// broke would say nothing about the row under test. +// --------------------------------------------------------------------------- + +describe('Python unaliased multi-segment namespace import (#2826)', () => { + let repoDir: string; + let result: PipelineResult; + + beforeAll(async () => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-dotted-ns-')); + writeFixtureRepo(repoDir, { + 'pkg/__init__.py': '', + 'pkg/db.py': `class Model: + pass + + +def session_scope(): + return "db" +`, + // Same member name in a sibling module: makes a cross-resolution visible + // instead of letting the right answer and a lucky answer look identical. + 'pkg/cache.py': `def session_scope(): + return "cache" +`, + 'pkg/sub/__init__.py': '', + 'pkg/sub/deep.py': `def deep_fn(): + return "deep" +`, + 'caller_dotted.py': `import pkg.db + +def uses_dotted(): + return pkg.db.session_scope() +`, + 'caller_from.py': `from pkg.db import session_scope + +def uses_from(): + return session_scope() +`, + 'caller_alias.py': `import pkg.db as pdb + +def uses_alias(): + return pdb.session_scope() +`, + 'caller_frommod.py': `from pkg import db + +def uses_from_module_attr(): + return db.session_scope() +`, + 'caller_two_pkgs.py': `import pkg.db +import pkg.cache + +def uses_db(): + return pkg.db.session_scope() + +def uses_cache(): + return pkg.cache.session_scope() +`, + 'caller_deep.py': `import pkg.sub.deep + +def uses_deep(): + return pkg.sub.deep.deep_fn() +`, + 'caller_construct.py': `import pkg.db + +def builds(): + return pkg.db.Model() +`, + }); + result = await runPipelineFromRepo(repoDir, () => {}); + }, 60000); + + afterAll(() => { + if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + const sessionScopeCallers = (targetFile: string): string[] => + getRelationships(result, 'CALLS') + .filter((c) => c.target === 'session_scope' && c.targetFilePath === targetFile) + .map((c) => c.source) + .sort(); + + it('resolves the unaliased dotted receiver to the imported module', () => { + const edge = getRelationships(result, 'CALLS').find( + (c) => c.source === 'uses_dotted' && c.target === 'session_scope', + ); + expect(edge).toMatchObject({ + source: 'uses_dotted', + target: 'session_scope', + targetFilePath: 'pkg/db.py', + sourceFilePath: 'caller_dotted.py', + }); + }); + + it('keeps the three sibling spellings resolving (control)', () => { + expect(sessionScopeCallers('pkg/db.py')).toEqual( + expect.arrayContaining(['uses_alias', 'uses_from', 'uses_from_module_attr']), + ); + }); + + it('does not cross-resolve two same-package imports in one file', () => { + // Both modules export `session_scope`, so a receiver-blind fallback would + // be invisible in a presence-only assertion. Pin the exact pairing. + const pairs = getRelationships(result, 'CALLS') + .filter((c) => c.sourceFilePath === 'caller_two_pkgs.py' && c.target === 'session_scope') + .map((c) => `${c.source}->${c.targetFilePath}`) + .sort(); + expect(pairs).toEqual(['uses_cache->pkg/cache.py', 'uses_db->pkg/db.py']); + }); + + it('resolves a three-segment dotted receiver', () => { + const edge = getRelationships(result, 'CALLS').find( + (c) => c.source === 'uses_deep' && c.target === 'deep_fn', + ); + expect(edge).toMatchObject({ target: 'deep_fn', targetFilePath: 'pkg/sub/deep.py' }); + }); + + it('resolves construction through a dotted namespace receiver', () => { + // Pin the callee NAME, not just the file: pkg/db.py also exports + // `session_scope`, so a file-only assertion would stay green if the + // construction resolved to the wrong member of the right module. + const edges = getRelationships(result, 'CALLS') + .filter((c) => c.sourceFilePath === 'caller_construct.py') + .map((c) => `${c.source}->${c.target}@${c.targetFilePath}`) + .sort(); + expect(edges).toEqual(['builds->Model@pkg/db.py']); + }); +}); + +// --------------------------------------------------------------------------- +// #2826 follow-up — a namespace receiver shadowed by a local declaration. +// +// Case 1 (namespace receiver) in `receiver-bound-calls.ts` consulted the +// per-file namespace map with no lexical guard at all, so a parameter or local +// named like the imported package still resolved through the import. That is a +// WRONG edge, not a missing one, and it predates the dotted-path key — the +// single-segment rows below fail the same way without the guard. +// --------------------------------------------------------------------------- + +describe('Python namespace receiver shadowed by a local binding (#2826)', () => { + let repoDir: string; + let result: PipelineResult; + + beforeAll(async () => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-ns-shadow-')); + writeFixtureRepo(repoDir, { + 'pkg/__init__.py': '', + 'pkg/db.py': `def session_scope(): + return "db" +`, + 'single.py': `def session_scope(): + return "single" +`, + 'caller_dotted.py': `import pkg.db + +def clean_dotted(): + return pkg.db.session_scope() + +def param_shadow_dotted(pkg): + return pkg.db.session_scope() + +def local_shadow_dotted(): + pkg = object() + return pkg.db.session_scope() +`, + 'caller_single.py': `import single + +def clean_single(): + return single.session_scope() + +def param_shadow_single(single): + return single.session_scope() + +def local_shadow_single(): + single = object() + return single.session_scope() +`, + }); + result = await runPipelineFromRepo(repoDir, () => {}); + }, 60000); + + afterAll(() => { + if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + it('emits an edge only from the unshadowed callers', () => { + // Exact edge set: an assertion on absence alone would also pass if the + // guard over-suppressed and killed the clean rows too. + const edges = getRelationships(result, 'CALLS') + .filter((c) => c.target === 'session_scope') + .map((c) => `${c.source}->${c.targetFilePath}`) + .sort(); + expect(edges).toEqual(['clean_dotted->pkg/db.py', 'clean_single->single.py']); + }); +}); + +// --------------------------------------------------------------------------- +// #2826 follow-up — `import a.b.c` binds THREE receiver spellings, not one. +// +// Python makes `a`, `a.b` and `a.b.c` all callable off a single import, and +// each names a DIFFERENT file. The namespace map originally keyed only the +// bound name `a`, pointed at the LEAF module — wrong in both directions: +// `a.helper()` resolved into the leaf whenever it happened to export `helper` +// (a wrong edge, preferring a decoy over the real definition), and `a.b.mid()` +// resolved to nothing. +// --------------------------------------------------------------------------- + +describe('Python dotted import binds every package prefix (#2826)', () => { + let repoDir: string; + let result: PipelineResult; + + beforeAll(async () => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-prefix-')); + writeFixtureRepo(repoDir, { + // `helper` exists in BOTH the package and the leaf. Without the fix the + // root key points at the leaf and the decoy wins, so this pair is what + // makes the wrong edge visible rather than merely plausible. + 'a/__init__.py': `def helper(): + return "package" +`, + 'a/b/__init__.py': `def mid_fn(): + return "mid" +`, + 'a/b/c.py': `def helper(): + return "leaf-decoy" + + +def leaf_fn(): + return "leaf" +`, + 'caller.py': `import a.b.c + +def uses_leaf(): + return a.b.c.leaf_fn() + +def uses_mid(): + return a.b.mid_fn() + +def uses_root(): + return a.helper() +`, + }); + result = await runPipelineFromRepo(repoDir, () => {}); + }, 60000); + + afterAll(() => { + if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + it('resolves each prefix to its own module', () => { + const edges = getRelationships(result, 'CALLS') + .filter((c) => c.sourceFilePath === 'caller.py') + .map((c) => `${c.source}->${c.target}@${c.targetFilePath}`) + .sort(); + expect(edges).toEqual([ + 'uses_leaf->leaf_fn@a/b/c.py', + 'uses_mid->mid_fn@a/b/__init__.py', + 'uses_root->helper@a/__init__.py', + ]); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/namespace-targets-import-path.test.ts b/gitnexus/test/unit/scope-resolution/namespace-targets-import-path.test.ts new file mode 100644 index 000000000..20ca10078 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/namespace-targets-import-path.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest'; +import type { ImportEdge, ParsedFile, ScopeId } from 'gitnexus-shared'; +import { collectNamespaceTargets } from '../../../src/core/ingestion/scope-resolution/scope/namespace-targets.js'; +import { pythonNamespaceReceiverPaths } from '../../../src/core/ingestion/languages/python/import-target.js'; +import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js'; + +// `collectNamespaceTargets` reads exactly two things: the file's module scope +// and `scopes.imports`. Hand-building those keeps this test about the keying +// rule itself rather than about any one language's parser — which matters, +// because the rule's whole job is to tell otherwise identical-looking edges +// from different languages apart. + +const MODULE_SCOPE = 'mod:caller' as ScopeId; + +function edge(partial: Partial): ImportEdge { + return { + localName: 'pkg', + targetFile: 'pkg/db.py', + targetExportedName: 'pkg.db', + kind: 'namespace', + ...partial, + } as ImportEdge; +} + +/** Collect with Python's hook, against a workspace containing `files`. */ +function collectPython(edges: readonly ImportEdge[], files: readonly string[] = []) { + const parsed = { filePath: 'caller.py', moduleScope: MODULE_SCOPE } as ParsedFile; + const scopes = { imports: new Map([[MODULE_SCOPE, edges]]) } as unknown as ScopeResolutionIndexes; + const present = new Set(files); + return collectNamespaceTargets(parsed, scopes, { + receiverPaths: pythonNamespaceReceiverPaths, + moduleFileExists: (filePath) => present.has(filePath), + }); +} + +/** Collect the way a provider with no hook does. */ +function collectDefault(edges: readonly ImportEdge[]) { + const parsed = { filePath: 'caller.ts', moduleScope: MODULE_SCOPE } as ParsedFile; + const scopes = { imports: new Map([[MODULE_SCOPE, edges]]) } as unknown as ScopeResolutionIndexes; + return collectNamespaceTargets(parsed, scopes); +} + +describe('collectNamespaceTargets — namespace receiver spellings (#2826)', () => { + it('keys only the bound name when no provider hook is supplied', () => { + const targets = collectDefault([edge({})]); + expect(targets.get('pkg')).toEqual(['pkg/db.py']); + expect(targets.has('pkg.db')).toBe(false); + }); + + it('keys the dotted import path for Python', () => { + expect(collectPython([edge({})]).get('pkg.db')).toEqual(['pkg/db.py']); + }); + + // The root key is the reason this is a hook and not a flag. `import pkg.db` + // binds `pkg`, but `pkg` names the PACKAGE, not the submodule — keying it to + // pkg/db.py made `pkg.helper()` resolve into the submodule whenever that file + // happened to export `helper`, silently preferring a decoy over the real one. + it('keys the package root at its own __init__, never at the leaf module', () => { + const targets = collectPython([edge({})], ['pkg/__init__.py']); + expect(targets.get('pkg')).toEqual(['pkg/__init__.py', 'pkg/db.py']); + expect(targets.get('pkg.db')).toEqual(['pkg/db.py']); + }); + + it('omits a prefix whose package file the workspace never parsed', () => { + // PEP-420 namespace package: no __init__.py. Better no key than one + // pointing at a file that does not exist — or at the wrong file. + const targets = collectPython([edge({})], []); + expect(targets.get('pkg')).toEqual(['pkg/db.py']); + expect(targets.get('pkg.db')).toEqual(['pkg/db.py']); + }); + + it('keys every intermediate package of a deep import', () => { + const deep = edge({ + localName: 'a', + targetExportedName: 'a.b.c', + targetFile: 'a/b/c.py', + }); + const targets = collectPython([deep], ['a/__init__.py', 'a/b/__init__.py']); + expect(targets.get('a')).toEqual(['a/__init__.py', 'a/b/c.py']); + expect(targets.get('a.b')).toEqual(['a/b/__init__.py', 'a/b/c.py']); + expect(targets.get('a.b.c')).toEqual(['a/b/c.py']); + }); + + // Swift's `import Foo.Bar` produces an edge structurally identical to + // Python's `import pkg.db` — localName 'Foo', targetExportedName 'Foo.Bar'. + // Swift resolves the FIRST segment as the SPM target, so 'Foo.Bar' names a + // nested type, not the imported file. Minting a key for it would hand + // `resolveConstructionExpressionClass` an authoritative-but-wrong namespace, + // and that function deliberately does not fall through on a miss — so a + // working `Foo.Bar(x)` would start resolving to nothing. Only the provider + // opt-in keeps the two apart; a structural predicate cannot. + it('mints nothing extra for a provider without the hook, on a Swift-shaped edge', () => { + const swiftShaped = edge({ + localName: 'Foo', + targetExportedName: 'Foo.Bar', + targetFile: 'Sources/Foo/Foo.swift', + }); + const targets = collectDefault([swiftShaped]); + expect(targets.get('Foo')).toEqual(['Sources/Foo/Foo.swift']); + expect(targets.has('Foo.Bar')).toBe(false); + }); + + it('does not key an alias import under the module path it does not bind', () => { + // `import pkg.db as pdb` binds ONLY `pdb`; `pkg.db.f()` is a NameError. + const aliased = edge({ localName: 'pdb', targetExportedName: 'pkg.db' }); + const targets = collectPython([aliased], ['pkg/__init__.py']); + expect(targets.get('pdb')).toEqual(['pkg/db.py']); + expect(targets.has('pkg.db')).toBe(false); + expect(targets.has('pkg')).toBe(false); + }); + + it('keeps two same-package imports on separate keys', () => { + const targets = collectPython( + [ + edge({ targetExportedName: 'pkg.db', targetFile: 'pkg/db.py' }), + edge({ targetExportedName: 'pkg.cache', targetFile: 'pkg/cache.py' }), + ], + ['pkg/__init__.py'], + ); + expect(targets.get('pkg.db')).toEqual(['pkg/db.py']); + expect(targets.get('pkg.cache')).toEqual(['pkg/cache.py']); + // The shared root LEADS with the package itself — not with whichever + // submodule happened to be imported first — and keeps both leaves behind it + // so a name merely re-exported by `__init__.py` still resolves. + expect(targets.get('pkg')).toEqual(['pkg/__init__.py', 'pkg/db.py', 'pkg/cache.py']); + }); + + it('ignores non-namespace and unresolved edges', () => { + const targets = collectPython( + [ + edge({ kind: 'named', localName: 'db', targetExportedName: 'pkg.db' }), + edge({ targetFile: null, targetExportedName: 'pkg.gone' }), + ], + ['pkg/__init__.py'], + ); + expect(targets.size).toBe(0); + }); + + // Prefix packages are anchored on the RESOLVED leaf, not on the import + // spelling: `resolvePythonImportTarget` resolves off-root in two of its three + // tiers, so an import can land outside the workspace root. + it('anchors prefix packages on the resolved leaf, not the workspace root', () => { + const offRoot = edge({ + localName: 'utils', + targetExportedName: 'utils.db', + targetFile: 'libs/common/utils/db.py', + }); + // A DIFFERENT `utils` package exists at the root. Anchoring on the spelling + // would key `utils` to it — a module this import never named. + const targets = collectPython( + [offRoot], + ['utils/__init__.py', 'libs/common/utils/__init__.py'], + ); + expect(targets.get('utils')).toEqual([ + 'libs/common/utils/__init__.py', + 'libs/common/utils/db.py', + ]); + expect(targets.get('utils.db')).toEqual(['libs/common/utils/db.py']); + }); + + it('keys prefixes in a src/-style layout', () => { + const srcLayout = edge({ + localName: 'a', + targetExportedName: 'a.b.c', + targetFile: 'src/a/b/c.py', + }); + const targets = collectPython([srcLayout], ['src/a/__init__.py', 'src/a/b/__init__.py']); + expect(targets.get('a')).toEqual(['src/a/__init__.py', 'src/a/b/c.py']); + expect(targets.get('a.b')).toEqual(['src/a/b/__init__.py', 'src/a/b/c.py']); + expect(targets.get('a.b.c')).toEqual(['src/a/b/c.py']); + }); + + it('falls back to the default for a bare single-segment import', () => { + const bare = edge({ + localName: 'single', + targetExportedName: 'single', + targetFile: 'single.py', + }); + expect(collectPython([bare]).get('single')).toEqual(['single.py']); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts b/gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts index 75b3303c4..5b7d3402b 100644 --- a/gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts +++ b/gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts @@ -25,6 +25,18 @@ def shadowed(models): def locally_shadowed(): models = object() return models.User() +`, + ], + [ + 'pkg/dotted.py', + `import pkg.models + +def dotted(): + return pkg.models.User() + +def dotted_root_shadowed(): + pkg = object() + return pkg.models.User() `, ], [ @@ -66,9 +78,16 @@ function build() { }, }); const index = buildWorkspaceResolutionIndex(parsedFiles); + const namespaceTargetsFor = (file: ParsedFile) => + collectNamespaceTargets(file, scopes, { + // Mirror the production wiring in receiver-bound-calls.ts: the extra + // receiver spellings come from the provider hook, not from a default. + receiverPaths: pythonScopeResolver.namespaceReceiverPaths, + moduleFileExists: (filePath) => index.moduleScopeByFile.has(filePath), + }); const app = parsedFiles.find((file) => file.filePath === 'pkg/app.py'); if (app === undefined) throw new Error('missing app fixture'); - const namespaceTargets = collectNamespaceTargets(app, scopes); + const namespaceTargets = namespaceTargetsFor(app); const resolveIn = (functionName: string, expression: string) => { const functionScope = app.scopes.find( @@ -83,11 +102,28 @@ function build() { }); }; - return { resolveIn }; + const dotted = parsedFiles.find((file) => file.filePath === 'pkg/dotted.py'); + if (dotted === undefined) throw new Error('missing dotted fixture'); + const dottedNamespaceTargets = namespaceTargetsFor(dotted); + + const resolveDottedIn = (functionName: string, expression: string) => { + const functionScope = dotted.scopes.find( + (scope) => + scope.kind === 'Function' && + scope.ownedDefs.some((def) => def.qualifiedName === functionName), + ); + if (functionScope === undefined) throw new Error(`missing scope for ${functionName}`); + return resolveCompoundReceiverClass(expression, functionScope.id, scopes, index, { + constructionSyntax: { bare: true }, + namespaceTargets: dottedNamespaceTargets, + }); + }; + + return { resolveIn, resolveDottedIn }; } describe('Python module namespace construction', () => { - const { resolveIn } = build(); + const { resolveIn, resolveDottedIn } = build(); it('resolves an exported class from the verified module target', () => { expect(resolveIn('valid', 'models.User()')).toMatchObject({ @@ -107,4 +143,20 @@ describe('Python module namespace construction', () => { it('does not reuse a file-level namespace when a local shadows it', () => { expect(resolveIn('locally_shadowed', 'models.User()')).toBeUndefined(); }); + + // #2826: `import pkg.models` binds only `pkg`, so the namespace key is the + // dotted path `pkg.models` while the shadowable name is the root `pkg`. + it('resolves construction through a dotted import-path namespace', () => { + expect(resolveDottedIn('dotted', 'pkg.models.User()')).toMatchObject({ + filePath: 'pkg/models.py', + qualifiedName: 'User', + }); + }); + + it('does not reuse a dotted namespace when a local shadows its ROOT segment', () => { + // Fails without the root-segment fix: testing the whole `pkg.models` + // string against scope bindings never matches, so the guard would pass a + // shadowed receiver straight through to the authoritative namespace branch. + expect(resolveDottedIn('dotted_root_shadowed', 'pkg.models.User()')).toBeUndefined(); + }); }); From 905a1e191aa27e02094a2822d470072891c8dfdd Mon Sep 17 00:00:00 2001 From: Parafee41 Date: Wed, 5 Aug 2026 19:17:44 +0800 Subject: [PATCH 03/27] fix(mcp): ignore CR-only line ending diffs (#2839) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gergő Magyar --- gitnexus/src/mcp/local/local-backend.ts | 37 ++++++------- gitnexus/test/unit/detect-changes-eol.test.ts | 53 +++++++++++++++++++ 2 files changed, 72 insertions(+), 18 deletions(-) create mode 100644 gitnexus/test/unit/detect-changes-eol.test.ts diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index ec4872cdd..d4b3ea063 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -799,6 +799,21 @@ export function resolveWorktreeCwd(repoPath: string, launchCwd: string): string return repoPath; } +export function buildDetectChangesDiffArgs(scope: string, baseRef?: string): string[] | null { + const args = ['diff', '--ignore-cr-at-eol']; + switch (scope) { + case 'staged': + return [...args, '--staged', '-U0']; + case 'all': + return [...args, 'HEAD', '-U0']; + case 'compare': + return baseRef ? [...args, baseRef, '-U0'] : null; + case 'unstaged': + default: + return [...args, '-U0']; + } +} + /** * Length of the path-derived suffix appended to a colliding repo id. * Exported so tests can pin the suffix shape without re-deriving the @@ -4917,24 +4932,10 @@ export class LocalBackend { const scope = params.scope || 'unstaged'; const { execFileSync } = await import('child_process'); - // Build git diff args based on scope (using execFileSync to avoid shell injection) - let diffArgs: string[]; - switch (scope) { - case 'staged': - diffArgs = ['diff', '--staged', '-U0']; - break; - case 'all': - diffArgs = ['diff', 'HEAD', '-U0']; - break; - case 'compare': - if (!params.base_ref) return { error: 'base_ref is required for "compare" scope' }; - diffArgs = ['diff', params.base_ref, '-U0']; - break; - case 'unstaged': - default: - diffArgs = ['diff', '-U0']; - break; - } + // Ignore CR-only EOL differences, while preserving meaningful whitespace changes. + // execFileSync receives an argv array, so refs never pass through a shell. + const diffArgs = buildDetectChangesDiffArgs(scope, params.base_ref); + if (!diffArgs) return { error: 'base_ref is required for "compare" scope' }; let diffOutput: string; try { diff --git a/gitnexus/test/unit/detect-changes-eol.test.ts b/gitnexus/test/unit/detect-changes-eol.test.ts new file mode 100644 index 000000000..52a56c35b --- /dev/null +++ b/gitnexus/test/unit/detect-changes-eol.test.ts @@ -0,0 +1,53 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { buildDetectChangesDiffArgs } from '../../src/mcp/local/local-backend.js'; + +describe('detect_changes EOL filtering', () => { + it.each([ + ['unstaged', undefined, ['diff', '--ignore-cr-at-eol', '-U0']], + ['staged', undefined, ['diff', '--ignore-cr-at-eol', '--staged', '-U0']], + ['all', undefined, ['diff', '--ignore-cr-at-eol', 'HEAD', '-U0']], + ['compare', 'main', ['diff', '--ignore-cr-at-eol', 'main', '-U0']], + ])('adds the EOL guard for %s scope', (scope, baseRef, expected) => { + expect(buildDetectChangesDiffArgs(scope, baseRef)).toEqual(expected); + }); + + it('requires a base ref for compare scope', () => { + expect(buildDetectChangesDiffArgs('compare')).toBeNull(); + }); + + it('suppresses CRLF-only changes but retains other whitespace changes', () => { + const repoDir = mkdtempSync(path.join(tmpdir(), 'gitnexus-detect-eol-')); + try { + execFileSync('git', ['init', '-q'], { cwd: repoDir }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoDir }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoDir }); + writeFileSync(path.join(repoDir, 'sample.ts'), 'const first = 1;\r\nconst second = 2;\r\n'); + execFileSync('git', ['add', 'sample.ts'], { cwd: repoDir }); + execFileSync('git', ['commit', '-q', '-m', 'initial'], { cwd: repoDir }); + + writeFileSync(path.join(repoDir, 'sample.ts'), 'const first = 1;\nconst second = 2;\n'); + const diffArgs = buildDetectChangesDiffArgs('unstaged'); + if (!diffArgs) throw new Error('unstaged scope must produce git diff arguments'); + expect( + execFileSync('git', diffArgs, { + cwd: repoDir, + encoding: 'utf8', + }), + ).toBe(''); + + writeFileSync(path.join(repoDir, 'sample.ts'), 'const first = 1;\n const second = 2;\n'); + expect( + execFileSync('git', diffArgs, { + cwd: repoDir, + encoding: 'utf8', + }), + ).toContain('+ const second = 2;'); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } + }); +}); From aaa78f959077f6980c2ad2303a552f8e93b06f59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Wed, 5 Aug 2026 15:50:08 +0100 Subject: [PATCH 04/27] fix(scope-resolution): fan out interface dispatch from Case 3b receivers (#2832) (#2842) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(scope-resolution): fan out interface dispatch from Case 3b receivers (#2832) Case 3b (chain-typebinding) folds a receiver through the same `resolveCompoundReceiverClass` call and the same `[owner, ...mroFor(owner)]` walk Case 0 uses, but emitted its edge without calling `emitInterfaceDispatchFor`. When that fold landed on an Interface the site got one edge to the interface's bodiless declaration and none to any implementation — the defect #2813 reported for field receivers, in the half #2829 did not cover. The gap was a property of how a receiver was SPELLED rather than of what it resolved to. `d.repo.save()` contains a dot, so it took Case 0 and fanned out; binding the identical field to a local first — `const r = d.repo; r.save()` — made the receiver a bare name with a dotted typeBinding, which is Case 3b, and lost every implementation edge. `ownerDef` is the receiver's own folded type, matching Case 0's `currentClass` and Case 4's `ownerDef`, not the owner of the member the MRO walk settled on: a receiver folding to a concrete class that merely inherits an interface method must not fan out, because its runtime type is that class. The closure self-gates on `ownerDef.type !== 'Interface'`, so the call is inert for every concrete receiver and needs no language check. Confidence is the 0.85 literal this case's own primary emits, so dispatch edges never outrank the edge they hang off; Case 4's site.kind-dependent value has no counterpart here because Case 3b's primary does not vary that way. The new fixture pins the route as well as the fix. `const r = d.repo` reaches Case 3b and nothing else can take the site: Case 0 needs a `.`/`(` in the receiver name or a minted receiver chain, and `encodeReceiverChain` returns undefined for the empty step list a bare identifier produces; Case 4 excludes itself on the dot. Before the fix the primary assertion passed while the fan-out came back empty — the exact "reached Case 3b and stopped at the declaration" signature. Resolution-side only: this changes what the resolver produces, not how it is stored, so no SCHEMA_BUMP applies. An existing index must be re-analyzed to show the new edges. Follow-up from #2829. Co-Authored-By: Claude Opus 5 (1M context) * docs(scope-resolution): record Case 3b's interface-dispatch fan-out in I4 (#2832) Invariant I4 documented the fan-out as something "Cases 0 and 4 both perform" and spelled out Case 0.5's exclusion, while saying nothing about Case 3b — which is what made 3b's missing fan-out an undocumented asymmetry rather than a deliberate exclusion someone could defend or point at. With the fan-out added, Case 0.5 is the only case that folds or walks to a receiver type without dispatching to implementations, and its exclusion is gated behind `resolveThisViaEnclosingClass`. Saying so explicitly keeps the next reader from having to re-derive which cases fan out by reading the pass. Comment-only; `detect-changes --scope staged` reports no graph change. Co-Authored-By: Claude Opus 5 (1M context) * test(scope-resolution): add the concrete-implementor control for Case 3b (#2832) The Case 3b fan-out shipped with one negative control — a chain folding to PlainCache, a class that implements nothing. That proves only the weak claim: no interface anywhere near the site, no fan-out. Add the stronger negative. SqlRepo implements Repo, so an interface IS in scope and `save` is a name Repo declares, yet the receiver's folded type is the concrete class and nothing may fan out. This is the control that fails if a later change fans out from the interface a member is DECLARED in rather than from the receiver's own folded type. The comment says what the control cannot do, too: it cannot catch "member owner passed instead of folded type" in TypeScript, because an implementing class always declares the member itself, so the MRO walk never settles on the interface's bodiless declaration. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NdZtWXQJUGB1ZGLH2YNw3o * docs(scope-resolution): correct four overclaims the review found (#2832) A multi-lane review of this PR reproduced, against the real pipeline, that several claims in the comments and one test name assert more than the code delivers. No behavior changes here — only the text, and one test rename. 1. The test comment gave the WRONG REASON why the concrete-implementor control cannot catch "member owner passed instead of folded type". It said an implementing class always declares the member itself; `class C extends Base implements I {}` is valid TypeScript and inherits it. The real reason is that TypeScript's MRO chain never contains an implemented interface, so the walk cannot settle on an interface declaration for a concrete receiver. The mutation IS expressible where a concrete class inherits a `default` interface method (Java, Kotlin) — reproduced during review — so this is language-scoped, not inherent, and a follow-up fixture is tracked. 2. "fans out to every implementation of the folded interface" certified a completeness that does not exist. TypeScript emits heritage edges for `class_declaration` only (languages/typescript/captures.ts:749, stated in its own docstring at :732-733), so `abstract class X implements I` and `interface B extends A` produce no heritage edge and still dead-end on the bodiless declaration. Renamed to name the shape actually covered, with a KNOWN GAP note. The gap is in the capture layer and predates this fan-out. 3. Invariant I4 said Case 0.5 is the ONLY case that resolves a receiver type without fanning out. Cases 3 and 5 do too, by direct lookup rather than a fold or MRO walk. The sentence now says which distinction it means and states the reachability argument (no known language reaches Case 3 with an Interface — every one that could strips the namespace qualifier first, sending it to Case 4) instead of implying a completed audit. 4. The gate's rationale claimed the `ownerDef.type !== 'Interface'` test is right for every non-Interface receiver. An abstract-class receiver also dead-ends on a declaration-only member and does not fan out. Noted, with why widening the gate belongs to Cases 0 and 4 across all languages rather than to #2832. Also completes the module-level case ladder, which still credited the fan-out to Case 0 alone and omitted it from the Case 3b entry. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NdZtWXQJUGB1ZGLH2YNw3o * docs(scope-resolution): name Case 2 in the I4 exclusion list too (#2832) The first pass at this correction listed Cases 3 and 5 as the other cases that resolve a receiver type without fanning out, and was itself incomplete: Case 2 also walks an MRO and its binding admits `Interface`. It is excluded for a different reason than 3 and 5 — its receiver IS the type name, so the site is static dispatch and a fan-out would be wrong, whereas 3 and 5 resolve by direct lookup rather than a fold or MRO walk. Say both rather than enumerate one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NdZtWXQJUGB1ZGLH2YNw3o * fix(scope-resolution): close the two gaps the #2842 review left open Both were pre-existing and reached by Cases 0 and 4 as well; the Case 3b fan-out only widened the population of sites that hit them. Researched against the real TypeScript compiler and the language service before choosing semantics, plus how comparable tools draw the same lines. 1. THE FAN-OUT COULD TARGET A STATIC MEMBER `class C implements I { static save() {} }` does not satisfy `I` — TypeScript rejects it as TS2420, "Property 'save' is missing" — so an edge from an `I`-typed receiver to a static member names a target dispatch can never produce. The closure picked targets with `pickOverload`, which applies no static filter, while the surrounding cases pick their own primary with `pickFirstNonStaticOnly`: the speculative edges were picked with weaker rules than the certain edge they hang off. A same-name static+instance pair also made `pickOverload` return OVERLOAD_AMBIGUOUS, suppressing the CORRECT edge too, so this was a false negative as well as a false positive. Every comparable tool draws this line: tsserver partitions static from instance results, clangd gates on `isVirtual()` (C++ forbids virtual statics), jdtls filters abstract-or-static, and class-hierarchy analysis expands only VIRTUAL call sites. The guard prefers `provider.isStaticOnly` where a language declares it and falls back to the graph node's `isStatic`. That order is load-bearing, not stylistic: the method extractor derives `isStatic` from the OWNER type as well as the member (`staticOwnerTypes`), and the JVM config lists `object_declaration` — so reading the flag first would delete Kotlin `object` implementations, which are singleton INSTANCES and genuinely reachable. Kotlin is the only hook implementor and marks exactly the companion-promoted set; Ruby's `singleton_class` (`def self.foo`) is correctly filtered by the fallback. 2. TYPESCRIPT HERITAGE WAS CLASS-ONLY `interface B extends A` and `abstract class X implements I` emitted no heritage edge at all, so the subtype closure had nothing to descend and both shapes dead-ended on a bodiless declaration — including the very example the closure's own docstring cites as the reason it exists. Since Case 3b's dotted-alias binding survives qualifier-stripping only in TS/JS, this was the language that actually reaches the new path. The two shapes reach their bases differently: an abstract class carries the same `class_heritage` child a concrete one does, while an interface's bases hang off `extends_type_clause` directly. That clause's `type` field is `multiple: true`, so `childForFieldName('type')` would silently drop `C` from `interface B extends A, C` — hence iterating named children. Deliberately NOT structural matching. TypeScript is structurally typed, so a class satisfies an interface without `implements`, but tsc's own navigation is declaration-only and says why: "users are typically only interested in explicit implementations... The type checker doesn't let us make the distinction between structurally compatible implementations and explicit implementations, so we must use the AST." scip-typescript reached the same design independently. gopls does match structurally, but only because Go has no `implements` keyword to prefer. Abstract declarations are still walked THROUGH rather than targeted — the rule everywhere is "does it have a body?", which is what `isDeclarationOnly` already tests. VERSIONING. The capture change is parse-time, so a v43 warm cache would serve entries missing the new matches: SCHEMA_BUMP 43 -> 44 with its pin test moved in the same commit, verified against origin/main at a857f4c5a (still 43). Rebaselined only the `typescript` scope-capture fingerprint, justified by a capture-name histogram diff over the same 145-file corpus: the only deltas are @reference.inherits 17 -> 20 and its paired @reference.name 245 -> 248, emitted together by `emitTsInheritanceBase`. Every other capture count is byte-identical and javascript is unchanged, the language having no interfaces. Tests: the fan-out now covers a static-shadowing subclass, a concrete class below an abstract intermediate, and an implementor of an extending interface. Resolvers 3176 passed; all five bench gates pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NdZtWXQJUGB1ZGLH2YNw3o --------- Co-authored-by: Gergo Magyar Co-authored-by: Claude Opus 5 (1M context) --- gitnexus/bench/scope-capture/baselines.json | 91 ++++++++--------- .../languages/typescript/captures.ts | 29 +++++- .../contract/scope-resolver.ts | 18 +++- .../passes/receiver-bound-calls.ts | 97 +++++++++++++++++- gitnexus/src/storage/parse-cache.ts | 8 +- .../ts-chain-interface-dispatch/app.ts | 28 ++++++ .../ts-chain-interface-dispatch/deps.ts | 15 +++ .../ts-chain-interface-dispatch/hierarchy.ts | 30 ++++++ .../ts-chain-interface-dispatch/mem-repo.ts | 7 ++ .../plain-cache.ts | 5 + .../ts-chain-interface-dispatch/repository.ts | 3 + .../shadow-repo.ts | 13 +++ .../ts-chain-interface-dispatch/sql-repo.ts | 7 ++ .../integration/resolvers/typescript.test.ts | 98 +++++++++++++++++++ .../test/unit/incremental-parse-cache.test.ts | 7 +- 15 files changed, 398 insertions(+), 58 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/app.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/deps.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/hierarchy.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/mem-repo.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/plain-cache.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/repository.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/shadow-repo.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/sql-repo.ts diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 58b449287..07bc49d17 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -6,11 +6,11 @@ "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a -> 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a; scaling 1.058 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: provider-owned callable assignment/copy/formal/argument/invoke facts with invocation/constructor-result suppression. Prior 09ecd94911b830f52fa8807560abcbd79f163d02a2072870c1a59297e9a326e1 -> 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a; scaling 1.039 < 1.5.", "_rebaselined": "#1976: F33 generic composite literal constructor inference adds generic_type captures in composite_literal patterns; fingerprint drift expected.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a -> 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb.", + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a -> 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb.", "_rebaselined_2766_go_pointer_receiver_fixture": "#2766: added test/fixtures/lang-resolution/go-pointer-receiver-field-chain/ (2 Go files) as the committed regression fixture for pointer-receiver base resolution. Go fixture_count 100 -> 102. Prior 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb -> 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fix is a resolution-time lookup fallback (stripTypePreservingDecoration) and cannot move capture output; go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e -> 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f.", - "_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 \u2014 the two whose fixture corpora contain such receivers. Prior 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f -> c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9.", - "_rebaselined_2766_phantom_callee_read_site": "#2766: Go's `@reference.read` pattern matches EVERY selector_expression, so a member call `h.dep.Work()` minted THREE sites \u2014 the call, the genuine `h.dep` field read, and a PHANTOM read on the callee `h.dep.Work`. The phantom resolved through findOwnedMember (which prefers methods over fields) and emitted an ACCESSES edge to the METHOD duplicating the CALLS edge at the same position; visible today on any receiver the text cascade can type (`RunFromValueReceiver -> DoWork`). The emitter now drops a read match whose selector is in FUNCTION position. FEWER capture matches for Go, no other language affected \u2014 go was the only fingerprint of 15 that moved. A method VALUE (`f := h.dep.Work`) is not in function position and is untouched. Prior c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9 -> 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e -> 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f.", + "_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 — the two whose fixture corpora contain such receivers. Prior 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f -> c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9.", + "_rebaselined_2766_phantom_callee_read_site": "#2766: Go's `@reference.read` pattern matches EVERY selector_expression, so a member call `h.dep.Work()` minted THREE sites — the call, the genuine `h.dep` field read, and a PHANTOM read on the callee `h.dep.Work`. The phantom resolved through findOwnedMember (which prefers methods over fields) and emitted an ACCESSES edge to the METHOD duplicating the CALLS edge at the same position; visible today on any receiver the text cascade can type (`RunFromValueReceiver -> DoWork`). The emitter now drops a read match whose selector is in FUNCTION position. FEWER capture matches for Go, no other language affected — go was the only fingerprint of 15 that moved. A method VALUE (`f := h.dep.Work`) is not in function position and is untouched. Prior c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9 -> 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3.", "_rebaselined_2766_callee_position_marker": "#2766 review fix: a call's callee selector is no longer DROPPED at capture. An earlier commit on this branch dropped it outright, which also deleted the genuine field read on a func-typed struct field (`h.dep.Work()` where `Work func() error`) - callback/hook/mock structs lost their only ACCESSES evidence. The match is now emitted carrying `@reference.callee-position`, and the phantom is suppressed at EMIT by the resolved target's kind instead. Go only: the other 14 languages' fingerprints are byte-identical, which is the check that this is not a cross-language capture change. Prior 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3 -> e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3; scaling 1.001 < 1.5; fixtures 102 (unchanged), capture_groups_fp 2103.", "_rebaselined_2813_interface_field_dispatch_fixture": "#2813: added test/fixtures/lang-resolution/go-interface-field-dispatch/ (8 Go files) as the committed regression fixture for calls through an interface-typed struct field. Go fixture_count 102 -> 110. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fixes are a detection-time method-set change (interface-impls.ts) and a resolution-time fan-out in the shared receiver pass, neither of which emits captures; go/query.ts and go/captures.ts are untouched. Go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run - the same check used for the #2766 fixture growth above. Prior e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3 -> cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765; scaling 1.074 < 1.5, capture_groups_fp 2303." }, @@ -20,7 +20,7 @@ "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: COBOL procedure-pointer callable flow facts; multi-topic extraction now consumes each grouped scope/declaration match once instead of requiring a duplicate declaration-only match. Prior 68ee0e95eb9f86f2d92ca35f730f4c2d4d83abc1b5241ae767ff3437780ec8d1 -> d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e; scaling 0.853 < 1.5.", "_note": "Updated for F17-F23 fixes (P2: TIMES guard, ADD GIVING, SQL AS alias). See PR #1959.", - "_rebaselined_2793_declaratives": "PR #2793: corpus-only re-baseline. `cobol-declaratives` was added to test/fixtures/lang-resolution to reproduce the `Namespace\u2192Record` analyze abort (DECLARATIVES / USE AFTER STANDARD ERROR ON ), and this bench globs `lang-resolution/cobol-*`, so the corpus grew 14 -> 15 files. Verified capture-neutral: with that one fixture moved aside the fingerprint is byte-identical to the prior d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e. No COBOL capture code changed in that PR. Scaling 0.677 < 1.5." + "_rebaselined_2793_declaratives": "PR #2793: corpus-only re-baseline. `cobol-declaratives` was added to test/fixtures/lang-resolution to reproduce the `Namespace→Record` analyze abort (DECLARATIVES / USE AFTER STANDARD ERROR ON ), and this bench globs `lang-resolution/cobol-*`, so the corpus grew 14 -> 15 files. Verified capture-neutral: with that one fixture moved aside the fingerprint is byte-identical to the prior d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e. No COBOL capture code changed in that PR. Scaling 0.677 < 1.5." }, "c": { "fingerprint": "3418cded9f7072152f68992f0a426f43ae7d9d553579a47075fc0cab185848a5", @@ -28,8 +28,8 @@ "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 57fee292147ae6d2db7062da1e07d17122cf355207c8967fa85fd2ec9ca398a4 -> 3418cded9f7072152f68992f0a426f43ae7d9d553579a47075fc0cab185848a5; scaling 1.073 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C function-pointer signatures plus direct-callee argument metadata and invocation-result suppression. Prior 75bcdbbf006bf9bd263c0f5857461b118f39b164e9f821cb0651ad0ec46ef6ae -> 57fee292147ae6d2db7062da1e07d17122cf355207c8967fa85fd2ec9ca398a4; scaling 1.035 < 1.5.", "_rebaselined_callable_flow": "Callable-value-flow facts for C function pointers, copies, pointer-to-pointer cells, arguments, and indirect invokes. Prior 12a196b2d6249c8d86a931b12ecebc2a0cdf8d6f47683acdd0d8e9d8bc7657f5 -> 75bcdbbf006bf9bd263c0f5857461b118f39b164e9f821cb0651ad0ec46ef6ae; measured scaling ratio 0.980 < 1.5.", - "_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance \u2014 flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96.", - "_note": "#1983: + c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c \u2014 worker-path static-linkage side-channel test). Pure fixture-corpus drift: no c/captures.ts or query change branch-vs-main, existing fixtures' captures byte-identical (c-captures.test.ts 45/45), scaling stays linear (~0.97). The baseline was missed when the fixture landed; regenerated here. fingerprint 0de009b->39f3a83.", + "_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance — flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96.", + "_note": "#1983: + c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c — worker-path static-linkage side-channel test). Pure fixture-corpus drift: no c/captures.ts or query change branch-vs-main, existing fixtures' captures byte-identical (c-captures.test.ts 45/45), scaling stays linear (~0.97). The baseline was missed when the fixture landed; regenerated here. fingerprint 0de009b->39f3a83.", "_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)." }, "cpp": { @@ -42,11 +42,11 @@ "_note_1899_followup": "#1899 follow-up: braced-init metadata now carries element count, intentionally changing C++ capture output; CI benchmark scaling remains linear (1.129 < 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": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression). #2094: deleted C++ declarations retain @declaration.is-deleted metadata; deleted operator and pointer-return shapes plus the expanded deleted-overload fixture are included. Intended capture drift; scaling remains linear (1.139 < 1.5).", - "_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. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5). #1899: braced-init call arguments emit a conservative parameter-type capture; fixture_count 277, scaling remains linear (1.141 < 1.5).", + "_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. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5). #1899: braced-init call arguments emit a conservative parameter-type capture; fixture_count 277, scaling remains linear (1.141 < 1.5).", "_rebaselined_2522_review_fixes": "PR #2522 review fixes: outermost-chain passing modes; ->* ERROR-recovery role order; member-store visibility. Prior 57860dd2a8d4b06c6d2dd0d854c08b781faee3da8f2b6c42ba0c68a9f70e5ccb -> f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65; scaling ratio re-verified within budget.", - "_rebaselined_2522_prototype_value_cells": "Plain function/method prototypes no longer index as callable value cells (only pointer/parenthesized variable declarators do) \u2014 removes the spurious indirect-invoke facts that leaked phantom CALLS past two-phase suppression. Prior f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65 -> a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1; scaling re-verified within budget.", + "_rebaselined_2522_prototype_value_cells": "Plain function/method prototypes no longer index as callable value cells (only pointer/parenthesized variable declarators do) — removes the spurious indirect-invoke facts that leaked phantom CALLS past two-phase suppression. Prior f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65 -> a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1; scaling re-verified within budget.", "_rebaselined_receiver_chain_2747": "#2747: additionally adds the `cpp-receiver-chain-arrow` fixture, the behavioural proof for a `->` BASE receiver (`svc->getUser()->save()`) that the rollout fixed and that `cpp-chain-call/` could never catch because it uses the value `.` form. Prior a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1 -> 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5 -> 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc." + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5 -> 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc." }, "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.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.", @@ -56,23 +56,23 @@ "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C# method-group/delegate callable flow facts with invocation-result suppression. Prior 2bb5bc8c19cb8eb08c9590545ad8a1968a7152951f7e12746e2d7901d542fed9 -> f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a; scaling 1.115 < 1.5.", "_note": "#2046: F35 qualified-constructor captures now emit @reference.qualified-name + a simple-name @reference.name on `new Ns.Foo()`/`new A.B.Foo()`; namespace_declaration/file_scoped_namespace_declaration now emit @declaration.namespace name captures (feeding the non-destructive namespacePrefix sidecar for `new B.Foo()` same-tail disambiguation). + csharp-interface-only-base and csharp-namespace-qualified-ctor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.11).", "_rebaselined_2563_instance_ownership": "#2563: csharp-using-static adds same-file ownership, local-function, overload, partial-class, and cross-namespace same-name coverage. Prior 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1 -> e05dc27456bde8175948586c9e7689033a378fa40e9ca4ce78cce41fbea0f2f8; scaling 1.058 < 1.5.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 05a85bae70cf9c94f42459c843cfc36e3e81c872e5dcc7d77bc42fbc390f4bfe -> 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855 -> 476d98a7cc659951c315d63319c8077bbcf0e5f3ec12d32ed773992a1f3a2adc." + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 05a85bae70cf9c94f42459c843cfc36e3e81c872e5dcc7d77bc42fbc390f4bfe -> 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855 -> 476d98a7cc659951c315d63319c8077bbcf0e5f3ec12d32ed773992a1f3a2adc." }, "rust": { "fingerprint": "6174889b8c98e0af430fa54c268dc781989ca9a8172d690eebae37a95f77e809", "scaling_budget": 1.5, - "_rebaselined_mod_node_identity_2745_review": "#2745 review: added rust-2742-mod-members, rust-2742-nested-mods and rust-2742-type-vs-module under lang-resolution for the container/owner-edge fix, nested inline modules, and the imported-type-vs-module precedence. emitRustScopeCaptures is unchanged \u2014 verified by removing ONLY those three fixture dirs and re-running, which reproduces the prior fingerprint exactly, so the shift is purely corpus growth (fixture_count 196 -> 202, capture_groups_fp 3432 -> 3556). Prior 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5 -> 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300; scaling 1.022 local / 1.057 CI < 1.5. NOTE for the next fixture author: a new rust-* fixture drifts BOTH this bench baseline and the rust-captures-golden snapshot. Updating only the golden is how this reached CI red.", + "_rebaselined_mod_node_identity_2745_review": "#2745 review: added rust-2742-mod-members, rust-2742-nested-mods and rust-2742-type-vs-module under lang-resolution for the container/owner-edge fix, nested inline modules, and the imported-type-vs-module precedence. emitRustScopeCaptures is unchanged — verified by removing ONLY those three fixture dirs and re-running, which reproduces the prior fingerprint exactly, so the shift is purely corpus growth (fixture_count 196 -> 202, capture_groups_fp 3432 -> 3556). Prior 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5 -> 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300; scaling 1.022 local / 1.057 CI < 1.5. NOTE for the next fixture author: a new rust-* fixture drifts BOTH this bench baseline and the rust-captures-golden snapshot. Updating only the golden is how this reached CI red.", "_rebaselined_dyn_trait_object_2604": "#2604: RUST_SCOPE_QUERY now captures function_signature_item (abstract trait methods, no body) as a scope + declaration, so a &dyn Trait receiver can dispatch a CALLS edge to the trait's own method. Additive capture shift across every bench fixture with a required trait method. Prior df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29 -> f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846; scaling 1.033 < 1.5.", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c -> df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29; scaling 1.065 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Rust fn-value callable flow facts with invocation/constructor-result suppression. Prior ac610bbe97666bf285923479dd7b43a2fe4c5354aae8df1bcbafdc04fb220f82 -> 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c; scaling 1.024 < 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) \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.", + "_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. | #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 — @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_import_disambiguation_2514": "#2514: added rust-import-* and rust-dup-* fixtures under lang-resolution for the range-binding ambiguity latch + import-disambiguated resolution (for-loops / struct destructuring across explicit/aliased/glob use imports). emitRustScopeCaptures is unchanged; the corpus fingerprint shifts purely because the fixture set grew (130 -> 174). Prior f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846 -> 655aed01cf1b6b84fa0c64d48dfb2526ecb67f47d90f0a91edabacd269a212db; scaling 1.06 < 1.5.", - "_rebaselined_self_type_binding_2714": "#2714: a Rust `Self` type binding now records the enclosing impl's type instead of the literal 'Self'. `let fresh = Self { .. }` inside `impl User` binds `fresh: User`; recorded verbatim it bound `fresh: Self`, which resolves to nothing. The type-env channel already substituted this (type-extractors/rust.ts findEnclosingImplType); the scope-resolution channel did not, so the two disagreed. The gap was invisible while lookupCore Step 1 still walked the lexical chain for NAMED receivers \u2014 the impl scope binds the method by name, so fresh.validate() resolved by accident \u2014 and became a lost CALLS edge when #2714 stopped that walk. Only the rust fingerprint moves; the other 14 languages are byte-identical.", + "_rebaselined_self_type_binding_2714": "#2714: a Rust `Self` type binding now records the enclosing impl's type instead of the literal 'Self'. `let fresh = Self { .. }` inside `impl User` binds `fresh: User`; recorded verbatim it bound `fresh: Self`, which resolves to nothing. The type-env channel already substituted this (type-extractors/rust.ts findEnclosingImplType); the scope-resolution channel did not, so the two disagreed. The gap was invisible while lookupCore Step 1 still walked the lexical chain for NAMED receivers — the impl scope binds the method by name, so fresh.validate() resolved by accident — and became a lost CALLS edge when #2714 stopped that walk. Only the rust fingerprint moves; the other 14 languages are byte-identical.", "_rebaselined_module_tree_2730": "#2730 + #2741 review: RUST_SCOPE_QUERY captures mod_item as @declaration.namespace (a Rust module is an item, mirroring the C++ namespace_definition capture) and tags scoped call sites with @reference.qualified-name so the written path survives to resolution. Both are additive captures: every bench fixture holding a mod block or a Foo::bar() call gains groups, and the corpus also grew by the rust-2730-* fixtures added for the fix and its review (workspace-crates, type-qualified, gaps, samename-wrapper, crate-layout). Prior 7f1240b38457468f06b7931e0c2c578f218f922774d0dc7e2ee6ef3b08d4d689 -> 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5; scaling 1.061 < 1.5; fixture_count 196. Only the rust fingerprint moves; the other 14 languages are byte-identical. The earlier revision of this note cited 655aed01... as the prior value, which was two rebaselines stale (it predates #2604 and #2714); the CI gate compares live fingerprints, not this prose, so nothing caught it.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300 -> 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c -> 6174889b8c98e0af430fa54c268dc781989ca9a8172d690eebae37a95f77e809." + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300 -> 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c -> 6174889b8c98e0af430fa54c268dc781989ca9a8172d690eebae37a95f77e809." }, "php": { "fingerprint": "b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c", @@ -80,9 +80,9 @@ "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior df7b1565f9115d66b1ae32e4a408d651afb2521b14e5ca615f3be426c29af618 -> 4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd; scaling 1.078 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: PHP first-class callable and variable-invocation flow facts with invocation-result suppression. Prior 31c9e3f3cb7094a2bf9021cf9db859036e002f8b44605cd993b470fc600e97cb -> df7b1565f9115d66b1ae32e4a408d651afb2521b14e5ca615f3be426c29af618; scaling 1.074 < 1.5.", "_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04). | #2481/#2482: PHP imports carry a symbol-kind capture so function/constant imports resolve by declaring file; capture shape changes, scaling remains linear (~1.04).", - "_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class \u2014 fixture count 138\u2192140, fingerprint drift expected.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd -> 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28 -> b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c." + "_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class — fixture count 138→140, fingerprint drift expected.", + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd -> 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28 -> b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c." }, "ruby": { "fingerprint": "1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57", @@ -90,10 +90,10 @@ "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior cff273ae6cb7232c977d9241581834a2a2fa8bcf6369f7bd8f2471cd4419a6ef -> bf50ec6a53c8c91680dc6feac63a8956e78b1059249232dc25a0cfed25f31236; scaling 1.103 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Ruby Method/Proc callable flow facts with invocation/constructor-result suppression. Prior b5ea93bb3d0469c3821a8c70f5d5991c6f326e41097c119ad691154301dcc753 -> cff273ae6cb7232c977d9241581834a2a2fa8bcf6369f7bd8f2471cd4419a6ef; scaling 1.086 < 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.) | #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. #1991: + ruby-nested-mixin-tail-collision fixture (85\u219286). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb.", + "_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. #1991: + ruby-nested-mixin-tail-collision fixture (85→86). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb.", "_rebaselined_2522_review_fixes": "PR #2522 review fixes: bare identifiers are calls, not callable references (bareNamesAreCalls). Prior bf50ec6a53c8c91680dc6feac63a8956e78b1059249232dc25a0cfed25f31236 -> 070e4e11502442998ddf4048c2981cf1b2b735a87362ff854c5d14d71f98f4e2; scaling ratio re-verified within budget.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior fea3edf82f521995147874b7f6c5f9e2eb88efdebf6365668f3260e913f0b558 -> fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83 -> 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57." + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior fea3edf82f521995147874b7f6c5f9e2eb88efdebf6365668f3260e913f0b558 -> fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83 -> 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57." }, "swift": { "fingerprint": "adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9", @@ -102,8 +102,8 @@ "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Swift function-value callable flow facts with invocation-result suppression. Prior 180ac68e780bdf6f9089d53f51cbb9a66aed3e7774631cc3fcbaae5020213998 -> 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725; scaling 1.043 < 1.5.", "_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression).", "_rebaselined_2522_review_fixes": "PR #2522 review fixes: assignment target:/result: fields join the shared fallback. Prior 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d -> 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248; scaling ratio re-verified within budget.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248 -> a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b -> 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7.", + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248 -> a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b -> 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7.", "_rebaselined_inferred_field_receiver_2807": "#2807: optional property annotations (`var a: Outer?`) now emit a type binding. The prior pattern required the `user_type` to be a DIRECT child of the annotation, so an `optional_type` wrapper meant an optional field was never typed at all and its receiver could not resolve. ADDS @type-binding.annotation captures on the optional form only; no capture is removed. Prior 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7 -> adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9; scaling 1.023 < 1.5." }, "dart": { @@ -121,7 +121,7 @@ "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata; same-name lexical regions use an O(ancestor-depth) ID-set lookup. Prior d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a -> 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4; scaling 0.992 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Java method-reference/SAM callable flow facts with invocation-result suppression. Prior 062d754764aaa8a6772fb90875c710502a63e3e7a300e633942381ed914faada -> d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a; scaling 1.074 < 1.5.", - "_rebaselined": "#2357 (supersedes #2353): + java-cast-receiver, java-this-field-chain, java-this-dispatch fixtures (cast-wrapped receivers, this.field chains incl. initializer contexts, bare-this dispatch pinning). Drift is purely fixture-additive: with the three new dirs parked, the fingerprint reproduces the prior baseline byte-identically \u2014 no emit/capture change. #1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC), 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.", + "_rebaselined": "#2357 (supersedes #2353): + java-cast-receiver, java-this-field-chain, java-this-dispatch fixtures (cast-wrapped receivers, this.field chains incl. initializer contexts, bare-this dispatch pinning). Drift is purely fixture-additive: with the three new dirs parked, the fingerprint reproduces the prior baseline byte-identically — no emit/capture change. #1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC), 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.", "_note": "#1928 / #2045: F35 adds qualified + qualified-generic constructor query captures (`new pkg.Foo()`, `new a.b.Foo()`, `new pkg.Box()`); F38 synthesizes `@reference.call.constructor` on `super(...)`/`this(...)` explicit_constructor_invocation nodes; F41 generic-aware stripQualifier in interpret (type-binding normalization). + java-qualified-constructor and java-explicit-constructor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.06).", "_rebaselined_2522_review_fixes": "PR #2522 review fixes: get/test dropped from callableProtocolMethods. Prior 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4 -> f3b4f4b6610e07c3ac90deb1c53d3572b6ad55a36e5d7134984876d30031ff67; scaling ratio re-verified within budget.", "_rebaselined_2550_instance_model": "PR #2549 (#2550): anonymous class bodies emit synthesized @declaration.class/@declaration.name (Worker$N), an @reference.inherits to the constructed type, and receiver @type-binding.* captures; six new java-* fixtures joined the corpus. Prior f3b4f4b6610e07c3ac90deb1c53d3572b6ad55a36e5d7134984876d30031ff67 -> d79c3b92acfc866094981499b977388ca14f90839bca0c040342ab1cec00aa90; scaling 1.058 < 1.5.", @@ -129,30 +129,31 @@ "_rebaselined_2564_record_capture": "PR for #2564: JAVA_QUERIES gained a (record_declaration name: (identifier) @name) @definition.record capture, previously entirely missing (record_declaration had no structure-phase capture at all, unlike class/interface/enum) - a record's methods existed as ownerless Method nodes with no HAS_METHOD edge. Two new java-* fixtures (java-record-methods, java-new-expr-chain-call) joined the corpus. Prior 975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca -> 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537; scaling 1.059 < 1.5.", "_rebaselined_2561_enum_constant_receiver": "PR for #2561: synthesizeJavaAnonymousClassDeclarations now emits a class-scope @type-binding.annotation/name/type per enum constant (constant simple name -> its E$N synthesized class when bodied, else the host enum) so E.CONST.method() resolves through the existing compound-receiver chain walk. Two drivers of the drift, both in the java-enum-constant-body fixture (this bench's corpus IS test/fixtures/lang-resolution): (1) one extra type-binding match per enum_constant from the capture change; (2) review follow-up added a body-less Plain.java enum + EnumConst.dispatchToConstant/dispatchInherited methods (bodied-override, inherited-via-MRO, and body-less dispatch call sites). The review's fail-safe hardening (bodied constant binds ONLY to E$N, never the host enum, when name synthesis fails on a malformed tree) is output-neutral on this well-formed corpus (verified: fingerprint identical with and without it). Prior 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537 -> d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686; scaling < 1.5.", "_rebaselined_2562_local_classes": "#2562: Java block-local classes, enums, records, and interfaces use source-type-relative JLS 13.1 Host$NLocal identities with javac-compatible per-(host, simple-name) numbering; anonymous numbering remains separate. Lexical aliases begin at each declaration and end with its immediate block. Expanded java-local-class-naming fixtures cover declaration order, disjoint blocks, initializers, lambdas, local type kinds, and recursive local/member/anonymous host chains. Prior d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686 -> 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197; scaling 1.204 < 1.5.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197 -> 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee -> a9943355e945e03ddb87c800f4cc1f62b3d04feefb3ec64c258d8e0bb3b3fcd9." + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197 -> 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee -> a9943355e945e03ddb87c800f4cc1f62b3d04feefb3ec64c258d8e0bb3b3fcd9." }, "java-local-types": { "fingerprint": "8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633", "scaling_budget": 1.5, "_added": "#2562 performance follow-up: co-scales same-host, same-name local classes and anonymous classes to gate JLS binary-name ordinal allocation. Precomputed per-sequence ordinals reduce the focused 100->800 workload from 176->6655ms to 141->752ms; normalized 250->800 scaling is 1.054.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b -> 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236 -> 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633." + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b -> 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236 -> 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633." }, "typescript": { - "fingerprint": "248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965", + "fingerprint": "7a960908031331360ce582f5b55b7681e1cd7f8a2eabfd73c00982cb17f2a949", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78 -> e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63; scaling 0.983 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior db5933cc6760234ed7d495123410feba6de243646d583f20d43032b9459f81fd -> 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78; scaling 0.975 < 1.5.", "_rebaselined_callable_flow": "Callable assignment/copy/formal/argument/invoke facts (also consumed by Vue script blocks). Prior 25de86fd3377132c4e35d3d98f4f94a58e0cfeb7c22948a8ea3be4e793be74fd -> db5933cc6760234ed7d495123410feba6de243646d583f20d43032b9459f81fd; measured scaling ratio 0.951 < 1.5.", - "_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures \u2014 fingerprint drift expected.", - "_note": "#1968: F44, F85, F87 \u2014 fingerprint drift expected.", + "_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures — fingerprint drift expected.", + "_note": "#1968: F44, F85, F87 — fingerprint drift expected.", "_rebaselined_2522": "#2522 intentional @reference.value-ref/property-key capture additions. GitHub Actions run 29553361660 job 87800394279: prior 3f44a4a6892698df2d145c8ff2812c3b318807648983c88aca28fbd694f172f9 -> 25de86fd3377132c4e35d3d98f4f94a58e0cfeb7c22948a8ea3be4e793be74fd; scaling ratio 0.987 < 1.5.", "_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object (was unscoped, then @scope.block during development). Prior e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63 -> 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4; scaling 0.981 < 1.5.", - "_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4 -> 281e95484203b481094729ca249ef0423c41273eac35e424cdfd032a0dac7699.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior cad25be9f81d6e021ebae8dcb166bc0af3a1ba8021f1506f6ca93fd4c2649000 -> 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc -> cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff.", - "_rebaselined_inferred_field_receiver_2807": "#2807: inference-typed class fields now emit a type binding \u2014 `public_field_definition` with a `new_expression` value, and `this. = new ...` carrying a @type-binding.this-field marker. ADDS @type-binding.constructor captures only; no capture is removed, and the annotated form is unchanged because annotation outranks constructor-inferred in typeBindingStrength. Prior cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff -> 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965; scaling 0.994 < 1.5." + "_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) — every other capture count is byte-identical, so no existing capture moved. Prior 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4 -> 281e95484203b481094729ca249ef0423c41273eac35e424cdfd032a0dac7699.", + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior cad25be9f81d6e021ebae8dcb166bc0af3a1ba8021f1506f6ca93fd4c2649000 -> 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc -> cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff.", + "_rebaselined_inferred_field_receiver_2807": "#2807: inference-typed class fields now emit a type binding — `public_field_definition` with a `new_expression` value, and `this. = new ...` carrying a @type-binding.this-field marker. ADDS @type-binding.constructor captures only; no capture is removed, and the annotated form is unchanged because annotation outranks constructor-inferred in typeBindingStrength. Prior cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff -> 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965; scaling 0.994 < 1.5.", + "_rebaselined_ts_heritage_2842": "#2842 review: TypeScript heritage capture now emits `@reference.inherits` for `interface_declaration` (bases on `extends_type_clause`) and `abstract_class_declaration` (bases on `class_heritage`), which were both silently skipped — so `interface B extends A` and `abstract class X implements I` produced no edge and every interface-dispatch walk dead-ended on a bodiless declaration. Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus (145 files) with and without the change: the ONLY deltas are @reference.inherits 17 -> 20 (+3) and its paired @reference.name 245 -> 248 (+3), emitted together by emitTsInheritanceBase. Every other capture count is byte-identical, so no existing capture moved. The +3 is the three `interface X extends BasePayload` declarations in typescript-generic-calls/src/{auth,admin,guest}.ts. javascript is unchanged (no interfaces in the language). Prior 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965 -> 7a960908031331360ce582f5b55b7681e1cd7f8a2eabfd73c00982cb17f2a949." }, "javascript": { "fingerprint": "806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594", @@ -164,9 +165,9 @@ "_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.", "_rebaselined_2522": "#2522 intentional @reference.value-ref/property-key capture additions. GitHub Actions run 29553361660 job 87800394279: prior d72f03c6c502235d2d4b74d66baa5c7d361f040d7a1b72e84acad61210d05ae8 -> 5567dd47e7ba29821a518c4a9852adc3b774e25ef3e7a6e2b3ecb7b59ddab73c; scaling ratio 1.031 < 1.5.", "_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object. Prior 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b -> f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c; scaling 1.096 < 1.5.", - "_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c -> 90601494695b834d3a9af7ac4844eac603f4f432809a05554cc59de0674a4354.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 1c71ef628eb75a3b111afa8c2a7c351c16a7f5aab9fac2f098f82b2866312aa8 -> 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc -> 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594." + "_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) — every other capture count is byte-identical, so no existing capture moved. Prior f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c -> 90601494695b834d3a9af7ac4844eac603f4f432809a05554cc59de0674a4354.", + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 1c71ef628eb75a3b111afa8c2a7c351c16a7f5aab9fac2f098f82b2866312aa8 -> 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc -> 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594." }, "kotlin": { "fingerprint": "efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2", @@ -175,12 +176,12 @@ "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Kotlin callable-reference flow facts with invocation-result suppression. Prior 4900431791f2b9280009deb2b82659c26ead8aa6fb8731190a7c505dec5a9041 -> bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12; scaling 0.880 < 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": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0.", - "_rebaselined_2271": "PR #2271: re-vendored tree-sitter-kotlin 0.3.8 -> unreleased fwcd main c8ac3d26 for `fun interface` support + new kotlin-fun-interface fixture in the corpus. Drift is both corpus-additive (the fixture) and grammar-driven (the new grammar parses `fun interface` as a class_declaration, not an ERROR node). Baselined to the NEW grammar's fingerprint, so this --check passes only once the regenerated prebuilds land \u2014 until then CI loads the committed 0.3.8 binary and the bench is red, same as the kotlin fun-interface integration tests. scaling ~0.83 (linear).", + "_rebaselined_2271": "PR #2271: re-vendored tree-sitter-kotlin 0.3.8 -> unreleased fwcd main c8ac3d26 for `fun interface` support + new kotlin-fun-interface fixture in the corpus. Drift is both corpus-additive (the fixture) and grammar-driven (the new grammar parses `fun interface` as a class_declaration, not an ERROR node). Baselined to the NEW grammar's fingerprint, so this --check passes only once the regenerated prebuilds land — until then CI loads the committed 0.3.8 binary and the bench is red, same as the kotlin fun-interface integration tests. scaling ~0.83 (linear).", "_rebaselined_2522_review_fixes": "PR #2522 review fixes: fieldless assignment nodes decomposed positionally. Prior e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1 -> 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112; scaling ratio re-verified within budget.", "_rebaselined_2550_instance_model": "PR #2549 (#2545): anonymous object expressions (object_literal) emit @scope.class, and the kotlin-object-literal-scope fixture joined the corpus. Prior 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112 -> a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091; scaling 0.951 < 1.5.", "_rebaselined_2563_instance_ownership": "#2563: kotlin-instance-ownership adds unrelated, inherited, outer-instance, and anonymous-object coverage. Prior a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091 -> 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195; scaling 1.257 < 1.5.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195 -> d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1 -> c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b.", - "_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 \u2014 the two whose fixture corpora contain such receivers. Prior c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b -> efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2." + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195 -> d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1 -> c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b.", + "_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 — the two whose fixture corpora contain such receivers. Prior c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b -> efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2." } } diff --git a/gitnexus/src/core/ingestion/languages/typescript/captures.ts b/gitnexus/src/core/ingestion/languages/typescript/captures.ts index 88989d369..b3d9315ee 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/captures.ts @@ -729,8 +729,12 @@ export function emitTsScopeCaptures( * matching arms (member_expression for extends, nested_type_identifier plain + * generic-wrapped for implements). * - * `interface_declaration` / `abstract_class_declaration` heritage is NOT emitted - * by the synth. The EXTENDS-vs-IMPLEMENTS split is decided downstream from the + * `interface_declaration` and `abstract_class_declaration` heritage IS emitted + * (#2842 review; both were silently skipped before, so `interface B extends A` + * and `abstract class X implements I` produced no edge and every dispatch walk + * dead-ended on a bodiless declaration). They reach their bases by different + * shapes: an abstract class carries the same `class_heritage` child a concrete + * one does, while an interface's bases hang off `extends_type_clause` directly. 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 @@ -746,7 +750,26 @@ function synthesizeTsInheritanceReferences(root: SyntaxNode, out: CaptureMatch[] if (child !== null) stack.push(child); } - if (node.type !== 'class_declaration') continue; + // `interface B extends A, C` hangs its bases off an `extends_type_clause` + // DIRECTLY on the interface — there is no `class_heritage` wrapper, so the + // class path below cannot reach them (#2842 review). The clause's `type` + // field is `multiple: true`, so `childForFieldName('type')` would silently + // return only `A` and drop `C`; iterate the named children instead. + if (node.type === 'interface_declaration') { + for (const child of node.namedChildren) { + if (child === null || child.type !== 'extends_type_clause') continue; + for (const base of child.namedChildren) { + emitTsInheritanceBase(base, out); + } + } + continue; + } + + // `abstract class X implements I` carries an identical `class_heritage` + // child, so the existing body handles it once the node type is admitted. + // Omitting it severed the only link between an interface and the concrete + // classes below an abstract base — a whole subtree, not a leaf. + if (node.type !== 'class_declaration' && node.type !== 'abstract_class_declaration') continue; // Find the `class_heritage` child (holds extends / implements clauses). let heritage: SyntaxNode | null = null; diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index a02f407c0..d3eb90aa6 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -114,13 +114,23 @@ * languages that set `resolveThisViaEnclosingClass === true`; * it intercepts every bare-`this` call/read/write site ahead of * Case 4 and does NOT emit the interface-dispatch fan-out that - * Cases 0 and 4 both perform (Case 0 gained it in #2829), so - * enabling the toggle for a language changes that language's - * `this` dispatch semantics (see the toggle's doc below) + * Cases 0, 3b and 4 all perform (Case 0 gained it in #2829 and + * Case 3b in #2832, leaving 0.5 the only INSTANCE-receiver case + * that folds or walks to a receiver type without fanning out. + * Read that narrowly: Case 2 also walks an MRO and its binding + * admits `Interface`, but its receiver IS the type name, so the + * site is static dispatch and a fan-out would be wrong; Cases 3 + * and 5 resolve by direct lookup rather than a fold or MRO walk, + * and no language is known to reach Case 3 with an Interface — + * every one that could strips the namespace qualifier first, + * sending it to Case 4), so enabling the toggle + * for a language changes that language's `this` dispatch + * semantics (see the toggle's doc below) * 4. Case 1 namespace-receiver * 5. Case 2 class-name receiver * 6. Case 3 dotted typeBinding for namespace prefix - * 7. Case 3b chain-typebinding (compound resolver) + * 7. Case 3b chain-typebinding (compound resolver + interface-dispatch + * fan-out on an Interface fold, #2832) * 8. Case 4 simple typeBinding (MRO walk + findOwnedMember) * Reordering or merging cases changes resolution semantics. The * numbering is part of the contract — keep the comments. diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index ce7ae7902..cbe3112bb 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -18,8 +18,8 @@ * toggle unset skip this case entirely; their `this` sites fall * through to Case 4 via the synthesized `this` typeBinding (which * emits the interface-dispatch fan-out that this case does not — - * as does Case 0 since #2829; Case 0.5 remains the only resolving - * case without it). + * as do Cases 0 since #2829 and 3b since #2832; Case 0.5 remains + * the only fold-or-walk case without it). * 4. **Case 1 (namespace)** — receiver in `namespaceTargets` → exported def * 5. **Case 2 (class-name / static receiver)** — receiver resolves to a * class-like binding (Class/Interface/Struct/Record/Enum/Trait) → MRO @@ -29,7 +29,9 @@ * 6. **Case 3 (dotted typeBinding for namespace prefix)** — * `typeRef.rawName` like `models.User` * 7. **Case 3b (chain-typebinding)** — `typeRef.rawName` has a dot - * but not a namespace prefix → compound resolver + * but not a namespace prefix → compound resolver. Also emits the + * interface-dispatch fan-out when the folded receiver type is an + * Interface (#2832) — same call Cases 0 and 4 make. * 8. **Case 4 (simple typeBinding)** — `typeRef.rawName` has no dot → * MRO walk + `findOwnedMember` * 9. **Case 5 (value-receiver bridge)** — receiver is a `Const`/`Variable` @@ -430,6 +432,48 @@ export function emitReceiverBoundCalls( return graph.getNode(graphId)?.properties.isAbstract === true; }; + /** + * Can an INSTANCE-typed receiver reach this member? A static member cannot be, + * ever — `class C implements I { static save() {} }` does not satisfy `I` + * (TypeScript rejects it outright as TS2420, "Property 'save' is missing"), so + * an edge to it from an `I`-typed receiver names a target no dispatch can + * produce. Every comparable tool draws the same line: tsserver partitions + * static from instance results, clangd gates on `isVirtual()` (C++ forbids + * virtual statics), jdtls filters abstract-or-static, and class-hierarchy + * analysis expands only VIRTUAL call sites — a static call already has exactly + * one target and needs no fan-out. + * + * Two sources answer this, and the order matters: + * + * 1. `provider.isStaticOnly` when the language declares it. It is the + * precise answer, because a language that needs the distinction defines + * it exactly — Kotlin marks only COMPANION-promoted defs, so a Kotlin + * `object Impl : Iface { override fun handle() }` is correctly kept: an + * `object` is a singleton INSTANCE and its members really are reachable + * through an `Iface`-typed receiver. + * 2. Otherwise the graph node's `isStatic`. For every language that does not + * declare the hook, that flag comes from the member's own modifier (or, + * for Ruby, from `singleton_class` — `def self.foo`, which is likewise + * unreachable through an instance), so it means what we need here. + * + * Getting that order wrong is a live regression, not a hypothetical: the + * method extractor derives `isStatic` from the OWNER type as well as the + * member (`method-extractors/generic.ts`, `staticOwnerTypes`), and the JVM + * config lists `object_declaration`. Reading the flag first would delete + * Kotlin object implementations from the fan-out. A language that needs + * precision declares the hook; that is the upgrade path. + * + * Unresolvable defs fail open (treated as reachable), matching + * `isDeclarationOnly` and the rest of this pass. + */ + const isUnreachableByInstanceDispatch = (def: SymbolDefinition): boolean => { + const staticOnly = provider.isStaticOnly; + if (staticOnly !== undefined) return staticOnly(def) === true; + const graphId = resolveDefGraphId(def.filePath, def, nodeLookup); + if (graphId === undefined) return false; + return graph.getNode(graphId)?.properties.isStatic === true; + }; + /** * Emit secondary CALLS edges with reason='interface-dispatch' when the primary * receiver-typed edge targeted an Interface's method. @@ -486,6 +530,9 @@ export function emitReceiverBoundCalls( // A re-declared interface method or an `abstract` override is not an // implementation — keep descending past it rather than emitting to it. if (isDeclarationOnly(implMember)) continue; + // Nor is a static member: no instance-typed receiver can reach one, so + // an edge to it is a target dispatch cannot produce (#2842 review). + if (isUnreachableByInstanceDispatch(implMember)) continue; targets.push(implMember); } } @@ -1363,6 +1410,50 @@ export function emitReceiverBoundCalls( calleeCapture, ); if (ok) emitted++; + // Interface dispatch, exactly as Cases 0 and 4 do it (#2832). Case + // 3b folds a chain to a receiver type through the SAME + // `resolveCompoundReceiverClass` call and the same MRO walk Case 0 + // uses, so when that fold lands on an Interface the primary edge + // above names the interface's own bodiless DECLARATION and nothing + // reaches the implementations. + // + // Leaving 3b out made the fan-out a property of how the receiver + // was SPELLED rather than of what it resolved to: `d.repo.save()` + // took Case 0 and fanned out, while binding the identical field to + // a local first (`const r = d.repo; r.save()`) took Case 3b and + // did not. #2829 closed that gap for Case 0 and left this half of + // it open (#2832). + // + // `ownerDef` is the receiver's own folded type — matching Case 0's + // `currentClass` and Case 4's `ownerDef` — NOT the owner of the + // member the MRO walk settled on. That distinction matters: a + // receiver that folds to a concrete class merely INHERITING an + // interface method must not fan out, because its runtime type is + // that class. `emitInterfaceDispatchFor` self-gates on + // `ownerDef.type !== 'Interface'`, so this is inert for every + // concrete receiver and needs no language check of its own. + // + // That gate is deliberately narrower than "the primary landed on + // something bodiless": a chain folding to an ABSTRACT class also + // dead-ends on a declaration-only member and does NOT fan out + // here. Widening it to `|| isDeclarationOnly(memberDef)` would + // cover that, but it changes Cases 0 and 4 identically and for + // every language, so it is not #2832's to make. + // + // Confidence mirrors THIS case's own primary emit above — the 0.85 + // literal — so a site's dispatch edges never claim more certainty + // than the edge they hang off. Case 4 passes a site.kind-dependent + // value instead because ITS primary varies that way; Case 3b's + // primary, like Case 0's, does not, so there is no 1.0 arm here to + // mirror. + emitted += emitInterfaceDispatchFor( + ownerDef, + memberName, + memberDef, + site, + 0.85, + calleeCapture, + ); // Always mark handled when the site was resolved, even // if the edge was deduplicated (collapse mode), so // `emitReferencesViaLookup` doesn't re-emit from the diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index a5688b77f..16fab259f 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -222,7 +222,13 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // value is unchanged by the rebase — the reason it was chosen is simply now // visible in the history above. RE-CHECK AGAINST origin/main IMMEDIATELY // BEFORE MERGING; this file records eight prior collisions, two EXACT. -const SCHEMA_BUMP = 43; +// Moved 43 -> 44 for #2842's TypeScript heritage capture, which now emits +// `@reference.inherits` for `interface_declaration` and +// `abstract_class_declaration`. That is PARSE-TIME emission, so a v43 warm +// cache would serve entries that are missing those matches entirely — the +// exact failure a bump exists to prevent. Verified against origin/main at +// a857f4c5a, which is still on 43, so 44 is free. RE-CHECK BEFORE MERGE. +const SCHEMA_BUMP = 44; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/app.ts b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/app.ts new file mode 100644 index 000000000..602db29ea --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/app.ts @@ -0,0 +1,28 @@ +import { Deps } from './deps'; + +// `r` is a bare-name receiver whose type binding is the member expression +// `d.repo` — the chain-typebinding shape that reaches Case 3b. The fold lands +// on the Repo INTERFACE, so the primary edge targets Repo.save and the +// implementations are reachable only through the interface-dispatch fan-out. +export function runSave(d: Deps): void { + const r = d.repo; + r.save('row'); +} + +// Same shape, concrete owner: Case 3b resolves PlainCache, which is not an +// Interface, so the fan-out must stay inert. +export function runCache(d: Deps): void { + const c = d.cache; + c.run(); +} + +// Stronger negative than runCache: SqlRepo IMPLEMENTS Repo, so an interface is +// in scope at this site and `save` is a name Repo also declares. The fan-out +// must still stay inert, because the fold produced the concrete class — the +// receiver's runtime type is SqlRepo, not "any Repo". This is what fails if a +// later change fans out from the interface a member is DECLARED in rather than +// from the receiver's own folded type. +export function runConcrete(d: Deps): void { + const s = d.sql; + s.save('row'); +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/deps.ts b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/deps.ts new file mode 100644 index 000000000..4560a5a96 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/deps.ts @@ -0,0 +1,15 @@ +import { Repo } from './repository'; +import { PlainCache } from './plain-cache'; +import { SqlRepo } from './sql-repo'; +import { ShadowRepo } from './shadow-repo'; + +/** + * Declared field types only. No initializer and no constructor assignment, so + * the field's type comes from the annotation rather than from an inferred RHS. + */ +export class Deps { + repo!: Repo; + cache!: PlainCache; + sql!: SqlRepo; + shadow!: ShadowRepo; +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/hierarchy.ts b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/hierarchy.ts new file mode 100644 index 000000000..886ab6ecc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/hierarchy.ts @@ -0,0 +1,30 @@ +import { Repo } from './repository'; + +// The two shapes the dispatch closure exists to handle, and which TypeScript +// heritage capture silently dropped before #2842's review. + +// (1) Abstract intermediate: the interface reaches DiskRepo only through +// BaseRepo. The abstract declaration itself is bodiless and must be walked +// THROUGH, not emitted to — tsc's own Go-to-Implementation does the same. +export abstract class BaseRepo implements Repo { + abstract save(entity: string): boolean; +} + +export class DiskRepo extends BaseRepo { + save(entity: string): boolean { + return entity.length > 1; + } +} + +// (2) Interface extension: `Archiving extends Repo` means an implementor of +// Archiving is an implementor of Repo, two hops from the receiver's type. +export interface Archiving extends Repo { + archive(): void; +} + +export class ColdRepo implements Archiving { + save(entity: string): boolean { + return entity !== 'cold'; + } + archive(): void {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/mem-repo.ts b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/mem-repo.ts new file mode 100644 index 000000000..2eb0a98af --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/mem-repo.ts @@ -0,0 +1,7 @@ +import { Repo } from './repository'; + +export class MemRepo implements Repo { + save(entity: string): boolean { + return entity !== ''; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/plain-cache.ts b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/plain-cache.ts new file mode 100644 index 000000000..2847204e8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/plain-cache.ts @@ -0,0 +1,5 @@ +export class PlainCache { + run(): void { + // No interface in sight — the negative control for the fan-out gate. + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/repository.ts b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/repository.ts new file mode 100644 index 000000000..43edc8792 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/repository.ts @@ -0,0 +1,3 @@ +export interface Repo { + save(entity: string): boolean; +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/shadow-repo.ts b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/shadow-repo.ts new file mode 100644 index 000000000..8c79dd209 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/shadow-repo.ts @@ -0,0 +1,13 @@ +import { SqlRepo } from './sql-repo'; + +// A subclass adding a STATIC helper that shares its name with the instance +// method it inherits — an ordinary shape (`static create` / `static serialize` +// mirroring an instance method). A `Repo`-typed receiver can hold a ShadowRepo, +// but dispatches to the INHERITED SqlRepo.save; the static below is reachable +// only as `ShadowRepo.save(...)`, never through an instance. It must never +// appear in the interface-dispatch fan-out. +export class ShadowRepo extends SqlRepo { + static save(entity: string): boolean { + return entity === ''; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/sql-repo.ts b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/sql-repo.ts new file mode 100644 index 000000000..28fcf3d10 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/sql-repo.ts @@ -0,0 +1,7 @@ +import { Repo } from './repository'; + +export class SqlRepo implements Repo { + save(entity: string): boolean { + return entity.length > 0; + } +} diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 6fa9f3213..df9041fba 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -3562,3 +3562,101 @@ export function alreadyWorked(svc: Service): void { ).toHaveLength(1); }); }); + +// --------------------------------------------------------------------------- +// Case 3b (chain-typebinding) interface dispatch (#2832). `const r = d.repo` +// binds `r` to the member expression `d.repo`, so the receiver is a bare name +// with a DOTTED typeBinding rawName — Case 3b's entry condition. Case 0 cannot +// take the site (`r` has no `.`/`(`, and no receiver chain is minted for a +// bare identifier), and Case 4 excludes itself on the dot. The fold lands on +// an Interface, so the implementations are reachable only via the fan-out. +// --------------------------------------------------------------------------- + +describe('TypeScript chain-typed receiver folding to an interface (Case 3b, #2832)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ts-chain-interface-dispatch'), + () => {}, + ); + }, 60000); + + const saveCalls = () => + getRelationships(result, 'CALLS').filter((e) => e.source === 'runSave' && e.target === 'save'); + const fanout = () => saveCalls().filter((e) => e.rel.reason === 'interface-dispatch'); + const basename = (p: string) => p.slice(p.lastIndexOf('/') + 1); + + it('emits the primary edge to the interface declaration', () => { + const primary = saveCalls().filter((e) => e.rel.reason !== 'interface-dispatch'); + expect(primary.map((e) => basename(e.targetFilePath))).toEqual(['repository.ts']); + }); + + // A static member can never be reached through an instance-typed receiver — + // TypeScript rejects `class C implements I { static save() {} }` as TS2420, + // "Property 'save' is missing". ShadowRepo inherits the real SqlRepo.save and + // adds a same-named static; only the inherited instance method is a dispatch + // target. Before the guard this emitted an edge to the static one. + it('never fans out to a static member', () => { + const targets = fanout().map((e) => `${basename(e.targetFilePath)}`); + expect(targets).not.toContain('shadow-repo.ts'); + }); + + // Covers all three hierarchy shapes, now that TypeScript emits heritage for + // interfaces and abstract classes too (#2842 review): a direct implementor + // (sql-repo, mem-repo), a concrete class below an ABSTRACT intermediate + // (hierarchy.ts DiskRepo, reachable only through BaseRepo), and an + // implementor of an EXTENDING interface (hierarchy.ts ColdRepo, two hops from + // the receiver's type). Exact-set, so a target appearing OR vanishing fails. + // Two hierarchy.ts entries because that file holds two of the four targets. + it('fans out through abstract intermediates and interface extension', () => { + expect( + fanout() + .map((e) => basename(e.targetFilePath)) + .sort(), + ).toEqual(['hierarchy.ts', 'hierarchy.ts', 'mem-repo.ts', 'sql-repo.ts']); + }); + + // The abstract declaration is bodiless: it must be WALKED THROUGH to reach + // DiskRepo, never emitted to. tsc's own Go-to-Implementation behaves the same + // way — the rule everywhere is "does it have a body?", not "is it in a class?". + it('walks through the abstract declaration without targeting it', () => { + const names = fanout() + .filter((e) => basename(e.targetFilePath) === 'hierarchy.ts') + .map((e) => e.target); + expect(names).toEqual(['save', 'save']); + }); + + it('never targets the interface declaration in the fan-out', () => { + expect(fanout().map((e) => basename(e.targetFilePath))).not.toContain('repository.ts'); + }); + + // Stronger negative than the PlainCache case below: here an interface IS in + // scope (SqlRepo implements Repo) and `save` is a name Repo declares, yet the + // receiver's folded type is the concrete class, so nothing may fan out. + // + // This does NOT catch "member owner passed instead of folded type". The + // reason is a property of TypeScript, not of the control: TS's MRO chain + // never contains an implemented interface, so the walk cannot settle on an + // interface declaration for a concrete receiver and the two values coincide. + // (Not, as an earlier draft of this comment claimed, because an implementing + // class always declares the member itself — `class C extends Base implements + // I {}` is valid TS and inherits it.) The mutation IS expressible where a + // concrete class inherits a `default` interface method — Java or Kotlin — + // and is tracked for a follow-up fixture there. + it('emits no fan-out when the chain folds to a concrete implementor', () => { + const concrete = getRelationships(result, 'CALLS').filter( + (e) => e.source === 'runConcrete' && e.target === 'save', + ); + expect(concrete.map((e) => e.rel.reason).filter((r) => r === 'interface-dispatch')).toEqual([]); + expect(concrete.map((e) => basename(e.targetFilePath))).toEqual(['sql-repo.ts']); + }); + + it('emits no fan-out when the chain folds to a concrete class', () => { + const runCalls = getRelationships(result, 'CALLS').filter( + (e) => e.source === 'runCache' && e.target === 'run', + ); + expect(runCalls.map((e) => e.rel.reason).filter((r) => r === 'interface-dispatch')).toEqual([]); + expect(runCalls.map((e) => basename(e.targetFilePath))).toEqual(['plain-cache.ts']); + }); +}); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index ec4224b21..c253070fb 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -120,8 +120,11 @@ describe('PARSE_CACHE_VERSION', () => { // against origin/main immediately before merge, not at review time. // Moved 42 -> 43 for #2813's `@reference.embedded-pointer` capture, which is // parse-time emission and so cannot be served from a v42 warm cache. - it('pins SCHEMA_BUMP to 43 so concurrent bumps cannot silently collide (#2766)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(43); + // Moved 43 -> 44 for #2842's TypeScript heritage capture (interface and + // abstract-class `@reference.inherits`), which is parse-time emission and so + // cannot be served from a v43 warm cache. + it('pins SCHEMA_BUMP to 44 so concurrent bumps cannot silently collide (#2766)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(44); }); it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => { From a033b04c4675d1b2e1e1b428cff4315fefed6b9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Thu, 6 Aug 2026 00:55:48 +0100 Subject: [PATCH 05/27] fix(go): scope and define each type_spec, not the type_declaration (#2837) (#2843) --- gitnexus/bench/scope-capture/baselines.json | 5 +- .../ingestion/class-extractors/configs/go.ts | 15 +- gitnexus/src/core/ingestion/languages/go.ts | 9 + .../ingestion/languages/go/interface-impls.ts | 11 +- .../ingestion/languages/go/method-owners.ts | 43 +++-- .../ingestion/languages/go/package-clause.ts | 78 ++++++++ .../languages/go/package-siblings.ts | 20 +-- .../src/core/ingestion/languages/go/query.ts | 40 ++++- .../scope-resolution/unresolved-receivers.ts | 131 ++++++++++++-- .../src/core/ingestion/tree-sitter-queries.ts | 11 +- .../src/core/ingestion/utils/ast-helpers.ts | 34 ++-- gitnexus/src/core/run-analyze.ts | 12 +- gitnexus/src/storage/parse-cache.ts | 22 ++- .../go-captures-golden/expected-captures.json | 164 +++++++++-------- .../go-grouped-type-decl/go.mod | 3 + .../internal/repository/interfaces.go | 20 +++ .../internal/repository/order_repo.go | 9 + .../internal/repository/sinks.go | 13 ++ .../internal/services/pick_service.go | 35 ++++ .../internal/services/sort_service.go | 20 +++ .../internal/services/wave_service.go | 14 ++ .../test/integration/resolvers/go.test.ts | 129 ++++++++++++++ .../test/unit/incremental-parse-cache.test.ts | 12 +- ...ding-doc-description-all-languages.test.ts | 55 ++++++ .../go/go-package-clause.test.ts | 167 ++++++++++++++++++ .../go/go-workspace-owners-warning.test.ts | 94 ++++++++++ .../unresolved-receiver-files.test.ts | 119 +++++++++++++ 27 files changed, 1138 insertions(+), 147 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/go/package-clause.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/go.mod create mode 100644 gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/repository/interfaces.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/repository/order_repo.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/repository/sinks.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/services/pick_service.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/services/sort_service.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/services/wave_service.go create mode 100644 gitnexus/test/unit/scope-resolution/go/go-package-clause.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/go/go-workspace-owners-warning.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/unresolved-receiver-files.test.ts diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 07bc49d17..fe25691f5 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -1,7 +1,7 @@ { "_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/-* fixture corpus + a 20-entity synthetic source (correctness gate; re-baseline intentionally on a legitimate capture change). scaling_budget = max allowed (t800/t250)/(800/250); ~1.0 is linear, ~3.2 is quadratic. The synthetic source is now HERITAGE-BEARING for every language (each Entity extends/implements/embeds/uses-trait/conforms-to a shared base) so the #1951 @reference.inherits synth is gated at scale, not just the base capture loop. All languages thread the tree-sitter captured node instead of re-deriving it with findNodeAtRange(tree.rootNode,...) per match, so all are linear (go #1915, python #1918, ruby/php/rust/csharp #1951, java #1956).", "go": { - "fingerprint": "c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832", + "fingerprint": "e386598526e502d131e52a17d219635b3a4196d94f1ebdd25922a2582c985d18", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a -> 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a; scaling 1.058 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: provider-owned callable assignment/copy/formal/argument/invoke facts with invocation/constructor-result suppression. Prior 09ecd94911b830f52fa8807560abcbd79f163d02a2072870c1a59297e9a326e1 -> 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a; scaling 1.039 < 1.5.", @@ -12,7 +12,8 @@ "_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 — the two whose fixture corpora contain such receivers. Prior 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f -> c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9.", "_rebaselined_2766_phantom_callee_read_site": "#2766: Go's `@reference.read` pattern matches EVERY selector_expression, so a member call `h.dep.Work()` minted THREE sites — the call, the genuine `h.dep` field read, and a PHANTOM read on the callee `h.dep.Work`. The phantom resolved through findOwnedMember (which prefers methods over fields) and emitted an ACCESSES edge to the METHOD duplicating the CALLS edge at the same position; visible today on any receiver the text cascade can type (`RunFromValueReceiver -> DoWork`). The emitter now drops a read match whose selector is in FUNCTION position. FEWER capture matches for Go, no other language affected — go was the only fingerprint of 15 that moved. A method VALUE (`f := h.dep.Work`) is not in function position and is untouched. Prior c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9 -> 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3.", "_rebaselined_2766_callee_position_marker": "#2766 review fix: a call's callee selector is no longer DROPPED at capture. An earlier commit on this branch dropped it outright, which also deleted the genuine field read on a func-typed struct field (`h.dep.Work()` where `Work func() error`) - callback/hook/mock structs lost their only ACCESSES evidence. The match is now emitted carrying `@reference.callee-position`, and the phantom is suppressed at EMIT by the resolved target's kind instead. Go only: the other 14 languages' fingerprints are byte-identical, which is the check that this is not a cross-language capture change. Prior 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3 -> e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3; scaling 1.001 < 1.5; fixtures 102 (unchanged), capture_groups_fp 2103.", - "_rebaselined_2813_interface_field_dispatch_fixture": "#2813: added test/fixtures/lang-resolution/go-interface-field-dispatch/ (8 Go files) as the committed regression fixture for calls through an interface-typed struct field. Go fixture_count 102 -> 110. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fixes are a detection-time method-set change (interface-impls.ts) and a resolution-time fan-out in the shared receiver pass, neither of which emits captures; go/query.ts and go/captures.ts are untouched. Go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run - the same check used for the #2766 fixture growth above. Prior e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3 -> cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765; scaling 1.074 < 1.5, capture_groups_fp 2303." + "_rebaselined_2813_interface_field_dispatch_fixture": "#2813: added test/fixtures/lang-resolution/go-interface-field-dispatch/ (8 Go files) as the committed regression fixture for calls through an interface-typed struct field. Go fixture_count 102 -> 110. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fixes are a detection-time method-set change (interface-impls.ts) and a resolution-time fan-out in the shared receiver pass, neither of which emits captures; go/query.ts and go/captures.ts are untouched. Go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run - the same check used for the #2766 fixture growth above. Prior e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3 -> cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765; scaling 1.074 < 1.5, capture_groups_fp 2303.", + "_rebaselined_2837": "#2837: Go struct/interface captures re-anchored from the type_declaration onto the type_spec (@scope.class/@declaration.struct/@declaration.interface in languages/go/query.ts, @definition.struct/@definition.interface in GO_QUERIES). A grouped `type (...)` block used to yield ONE scope and ONE node for every type in it, so each type after the first lost its field typeBindings and every field-receiver call in the file emitted nothing. Capture COUNT is unchanged; only ranges moved, plus the new go-grouped-type-decl fixture. Prior c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832 -> e386598526e502d131e52a17d219635b3a4196d94f1ebdd25922a2582c985d18; scaling 1.054 < 1.5." }, "cobol": { "fingerprint": "c8c00b56a7da24e04080eb885714fbbf45e3903324f0cf9df0754f5b5a92e3aa", diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/go.ts b/gitnexus/src/core/ingestion/class-extractors/configs/go.ts index 58dade9fa..0a32de2f6 100644 --- a/gitnexus/src/core/ingestion/class-extractors/configs/go.ts +++ b/gitnexus/src/core/ingestion/class-extractors/configs/go.ts @@ -5,15 +5,20 @@ import type { ClassExtractionConfig } from '../../class-types.js'; export const goClassConfig: ClassExtractionConfig = { language: SupportedLanguages.Go, - typeDeclarationNodes: ['type_declaration'], + // `type_spec`, not the enclosing `type_declaration` (#2837): one node per + // DECLARED TYPE, so a grouped `type ( A struct{…}; B struct{…} )` yields one + // each instead of one for the whole block. Every Go capture that reaches this + // extractor is anchored the same way, so the wrapper is never handed over — + // and accepting it would be worse than rejecting it, because picking one spec + // out of several with no reference point silently returns the FIRST type's + // name. A `null` here is loud; the wrong name is not. + typeDeclarationNodes: ['type_spec'], fileScopeNodeTypes: ['package_clause'], extractName(node) { - const typeSpec = node.namedChildren.find((child) => child.type === 'type_spec'); - return typeSpec?.childForFieldName('name')?.text; + return node.childForFieldName('name')?.text; }, extractType(node) { - const typeSpec = node.namedChildren.find((child) => child.type === 'type_spec'); - const typeNode = typeSpec?.childForFieldName('type'); + const typeNode = node.childForFieldName('type'); if (typeNode?.type === 'struct_type') return 'Struct'; if (typeNode?.type === 'interface_type') return 'Interface'; return undefined; diff --git a/gitnexus/src/core/ingestion/languages/go.ts b/gitnexus/src/core/ingestion/languages/go.ts index e7a25e82f..915e241a3 100644 --- a/gitnexus/src/core/ingestion/languages/go.ts +++ b/gitnexus/src/core/ingestion/languages/go.ts @@ -144,6 +144,15 @@ export const goProvider = defineLanguage({ descriptionExtractor: createLeadingDocDescriptionExtractor({ lineCommentPrefixes: ['//'], lineDirectivePrefixes: ['//go:', '// +build', '//nolint', '//line'], + // #2837: Go type declarations anchor on the `type_spec`, whose + // `previousNamedSibling` is null for the ordinary `type T struct{…}` form — + // the godoc comment is a sibling of the enclosing `type_declaration`, one + // level up. Without this the doc silently vanished for EVERY Go struct and + // interface (measured: pre-#2837 `desc=YES`, post `none`), taking #2270's + // description out of both the embedding header and the FTS column. + // Functions/methods are unaffected — they anchor on their own declaration + // node, which does carry the comment as a previous sibling. + wrapperNodeTypes: ['type_declaration'], }), builtInNames: GO_BUILT_INS, diff --git a/gitnexus/src/core/ingestion/languages/go/interface-impls.ts b/gitnexus/src/core/ingestion/languages/go/interface-impls.ts index d684340d3..fad2ee8f1 100644 --- a/gitnexus/src/core/ingestion/languages/go/interface-impls.ts +++ b/gitnexus/src/core/ingestion/languages/go/interface-impls.ts @@ -3,6 +3,7 @@ import type { SemanticModel } from '../../model/semantic-model.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import { simpleQualifiedName } from '../../scope-resolution/graph-bridge/ids.js'; import { resolveInheritanceBaseInScope } from '../../scope-resolution/scope/walkers.js'; +import { goPackageDir } from './package-clause.js'; type MethodSet = ReadonlyMap; type MutableMethodSet = Map; @@ -752,11 +753,13 @@ function signatureContextForFile( }; } +/** The package directory, or `undefined` for a repo-root file. + * + * Shares `goPackageDir` with the package-clause resolver rather than repeating + * its normalize-and-slice (#2837): the two disagree only on how they spell "no + * directory", so the difference stays here, at the one call site that cares. */ function packageQualifierForFile(filePath: string): string | undefined { - const normalized = filePath.replace(/\\/g, '/'); - const slash = normalized.lastIndexOf('/'); - if (slash === -1) return undefined; - const packageDir = normalized.slice(0, slash); + const packageDir = goPackageDir(filePath); return packageDir.length === 0 ? undefined : packageDir; } diff --git a/gitnexus/src/core/ingestion/languages/go/method-owners.ts b/gitnexus/src/core/ingestion/languages/go/method-owners.ts index 53ea352b0..329c94447 100644 --- a/gitnexus/src/core/ingestion/languages/go/method-owners.ts +++ b/gitnexus/src/core/ingestion/languages/go/method-owners.ts @@ -1,6 +1,12 @@ import type { ParsedFile } from 'gitnexus-shared'; +import { logger } from '../../../logger.js'; import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; +import { goPackageDir, inferGoPackageName } from './package-clause.js'; + +/** Bound on the sample of no-package-clause paths named in the warning. */ +const SKIPPED_SAMPLE_CAP = 5; + /** * Populate `ownerId` on Go Method defs by matching receiver types * extracted from `@type-binding.self` captures against struct defs in @@ -27,14 +33,34 @@ export function populateGoWorkspaceOwners( ctx: { readonly fileContents: ReadonlyMap }, ): void { const filesByPackage = new Map(); + // A file with no resolvable package clause is dropped from ownership + // resolution entirely — its methods never attach to a struct declared in a + // sibling file. That used to be a bare `continue` with no trace, which is the + // same false-safe silence #2813 was filed about. Report it once, bounded + // (mirrors the fan-out-cap warning in scope-resolution/pipeline/run.ts). + let skippedCount = 0; + const skippedSample: string[] = []; for (const parsed of parsedFiles) { - const pkgName = inferPackageName(ctx.fileContents.get(parsed.filePath) ?? ''); - if (pkgName === null) continue; - const key = `${packageDir(parsed.filePath)}\0${pkgName}`; + const pkgName = inferGoPackageName(ctx.fileContents.get(parsed.filePath) ?? ''); + if (pkgName === null) { + // Count everything, retain only the sample — a misrouted vendored tree + // would otherwise accumulate one path reference per file to print five. + skippedCount += 1; + if (skippedSample.length < SKIPPED_SAMPLE_CAP) skippedSample.push(parsed.filePath); + continue; + } + const key = `${goPackageDir(parsed.filePath)}\0${pkgName}`; const bucket = filesByPackage.get(key) ?? []; bucket.push(parsed); filesByPackage.set(key, bucket); } + if (skippedCount > 0) { + logger.warn( + { skippedFiles: skippedCount, sample: skippedSample }, + 'go: files with no resolvable package clause were excluded from method-owner ' + + 'resolution (their methods cannot attach to structs declared in sibling files)', + ); + } for (const bucket of filesByPackage.values()) { populateGoOwnersInPackage(bucket); @@ -107,14 +133,3 @@ function populateGoOwnersInPackage(parsedFiles: readonly ParsedFile[]): void { } } } - -function inferPackageName(sourceText: string): string | null { - const match = sourceText.match(/^\s*package\s+([A-Za-z_][A-Za-z0-9_]*)/m); - return match?.[1] ?? null; -} - -function packageDir(filePath: string): string { - const normalized = filePath.replace(/\\/g, '/'); - const idx = normalized.lastIndexOf('/'); - return idx === -1 ? '' : normalized.slice(0, idx); -} diff --git a/gitnexus/src/core/ingestion/languages/go/package-clause.ts b/gitnexus/src/core/ingestion/languages/go/package-clause.ts new file mode 100644 index 000000000..657a40bad --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/package-clause.ts @@ -0,0 +1,78 @@ +/** + * Go package-clause resolution — the single derivation of a file's package + * identity (#2837). + * + * Both Go passes that bucket files by package (`populateGoWorkspaceOwners` and + * `populateGoPackageSiblings`) previously carried their own byte-identical copy + * of this, spelled as one unanchored multiline regex: + * + * sourceText.match(/^\s*package\s+([A-Za-z_][A-Za-z0-9_]*)/m) + * + * With the `m` flag that matches the first line ANYWHERE in the file starting + * with `package ` — comment bodies included. Measured against that exact + * expression: a header comment containing `package legacy_notes kept for + * history` yields `legacy_notes`, and an indented ` package helper old name` + * yields `helper`. A file that mis-infers its own package gets a bucket key no + * sibling shares, so it is isolated in BOTH passes: its methods never attach to + * structs declared in sibling files, and it exchanges no same-package bindings. + * Every field-receiver call in it then resolves to nothing, silently — the same + * per-file signature #2837 reported. + * + * The Go spec makes the correct rule exact rather than heuristic: a source + * file's first non-comment, non-blank token is `package`. So skip the leading + * run of whitespace and comments, then require the very next token to be the + * clause. Anything else is `null` — a truncated read, a misrouted non-Go file, + * an unparseable header — reported by the caller rather than guessed at. + * + * ONE rule governs every leniency below (the `\s+` separator, the shebang skip, + * CR-only line endings): a file this returns `null` for is dropped from BOTH + * passes, so refusing a shape the previous regex accepted is a silent + * regression, not a principled tightening. Be no stricter than the grammar. + */ + +/** Leading trivia: whitespace, `//` lines, block comments. Sticky. */ +const LEADING_TRIVIA = /(?:\s+|\/\/[^\n\r]*|\/\*[\s\S]*?\*\/)*/y; + +/** The clause itself, anchored at the first non-trivia byte. `\s+` (not + * `[ \t]+`) because Go separates tokens by any whitespace: `package\nmain` is + * legal and tree-sitter parses it without error. */ +const PACKAGE_CLAUSE = /package\s+([A-Za-z_][A-Za-z0-9_]*)/y; + +/** + * The package name declared by this Go source text, or `null` when its first + * real token is not a package clause. + * + * Only the leading run before the clause is skipped — deliberately NOT a + * whole-file comment strip, which would be O(file) on every Go file and would + * also have to model string literals to stay correct. + */ +export function inferGoPackageName(sourceText: string): string | null { + let i = 0; + // A leading `#!` line: `gorun`-style scripts carry one. Only a FIRST-line + // `#!` is skipped; a `#` anywhere else still ends the scan. + if (sourceText.startsWith('#!')) { + const eol = sourceText.search(/[\n\r]/); + if (eol === -1) return null; + i = eol + 1; + } + // Whitespace (`\s` covers the BOM and every line ending), `//` lines and block + // comments — the run a Go file may carry before its clause. Sticky, so the + // header is skipped in place without slicing a copy of the file. An + // unterminated `/*` or a `//` running to EOF simply leaves `lastIndex` where + // the clause cannot match, so neither needs its own early return. + LEADING_TRIVIA.lastIndex = i; + LEADING_TRIVIA.exec(sourceText); + PACKAGE_CLAUSE.lastIndex = LEADING_TRIVIA.lastIndex; + return PACKAGE_CLAUSE.exec(sourceText)?.[1] ?? null; +} + +/** + * The directory half of a Go package key. Go package identity is + * directory-scoped, so repeated `package main` directories must not see each + * other's unqualified names. + */ +export function goPackageDir(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/'); + const idx = normalized.lastIndexOf('/'); + return idx === -1 ? '' : normalized.slice(0, idx); +} diff --git a/gitnexus/src/core/ingestion/languages/go/package-siblings.ts b/gitnexus/src/core/ingestion/languages/go/package-siblings.ts index bcb0687dd..bb5c4cab8 100644 --- a/gitnexus/src/core/ingestion/languages/go/package-siblings.ts +++ b/gitnexus/src/core/ingestion/languages/go/package-siblings.ts @@ -2,6 +2,7 @@ import type { BindingRef, ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import { expandGoDotImports } from './expand-wildcards.js'; +import { goPackageDir, inferGoPackageName } from './package-clause.js'; /** * O(n²×d) where n = files per package, d = defs per file. @@ -27,9 +28,13 @@ export function populateGoPackageSiblings( // must not see each other's unqualified names. const packageByFile = new Map(); for (const parsed of nonTestFiles) { - const pkgName = inferPackageName(ctx.fileContents.get(parsed.filePath) ?? ''); + // Same derivation as `populateGoWorkspaceOwners` — one shared resolver, so + // the two passes cannot disagree about a file's package (#2837). The + // no-clause case is reported there; warning twice for one fact would be + // noise. + const pkgName = inferGoPackageName(ctx.fileContents.get(parsed.filePath) ?? ''); if (pkgName !== null) { - packageByFile.set(parsed.filePath, `${packageDir(parsed.filePath)}\0${pkgName}`); + packageByFile.set(parsed.filePath, `${goPackageDir(parsed.filePath)}\0${pkgName}`); } } @@ -71,17 +76,6 @@ export function populateGoPackageSiblings( } } -function inferPackageName(sourceText: string): string | null { - const match = sourceText.match(/^\s*package\s+([A-Za-z_][A-Za-z0-9_]*)/m); - return match?.[1] ?? null; -} - -function packageDir(filePath: string): string { - const normalized = filePath.replace(/\\/g, '/'); - const idx = normalized.lastIndexOf('/'); - return idx === -1 ? '' : normalized.slice(0, idx); -} - function getAugmentationBucket( augmentations: Map>, scopeId: ScopeId, diff --git a/gitnexus/src/core/ingestion/languages/go/query.ts b/gitnexus/src/core/ingestion/languages/go/query.ts index 885c19df7..b782a3503 100644 --- a/gitnexus/src/core/ingestion/languages/go/query.ts +++ b/gitnexus/src/core/ingestion/languages/go/query.ts @@ -4,9 +4,33 @@ import Go from 'tree-sitter-go'; const GO_SCOPE_QUERY = ` ;; Scopes (source_file) @scope.module +;; One Class scope per DECLARED TYPE, not per declaration (#2837). +;; +;; Capturing the type_declaration made a grouped declaration +;; type ( +;; Decoy struct { ... } +;; PickService struct { ... } +;; ) +;; a SINGLE Class scope owning every struct in the block. Downstream that scope +;; can name only one owner -- buildWorkspaceResolutionIndex keeps the first +;; class-like def it finds -- so the block's structs lost their field type +;; bindings, typeOfMemberOnClass found no scope, the compound-receiver fold +;; declined, and every s.field.Method() site in the file emitted nothing at +;; all. Silent, per-file, and independent of file size: exactly the split +;; reported in #2837 that #2829's global fixes could not explain. Measured on +;; the go-grouped-type-decl fixture: EVERY struct in a grouped block lost its +;; edges, the first one included, and grouped interface blocks produced no +;; IMPLEMENTS edges at all. +;; +;; type_spec is the node Go's own grammar gives one declared type, so one +;; capture per type_spec is the granularity the rest of the pipeline already +;; assumes. A plain single-type declaration is unaffected in count -- only its +;; scope range narrows, from the type keyword to the name. +;; +;; NOTE: this string is a JS template literal. Backticks are a syntax error. (type_declaration (type_spec - type: [(struct_type) (interface_type)])) @scope.class + type: [(struct_type) (interface_type)]) @scope.class) (function_declaration) @scope.function (method_declaration) @scope.function (func_literal) @scope.function @@ -22,14 +46,24 @@ const GO_SCOPE_QUERY = ` (communication_case) @scope.block ;; Declarations — struct +;; +;; Anchored on the type_spec, in lockstep with @scope.class above (#2837). Both +;; captures MUST name the same node: the def node and the class-scope node are +;; paired by range, so anchoring the def on the enclosing type_declaration while +;; the scope sits on the type_spec leaves the def strictly larger than its own +;; scope and NOTHING is owned -- measured as every Go field-receiver edge in the +;; fixture disappearing, plain declarations included. Keeping both on +;; type_declaration is the original bug: a grouped block gave both structs the +;; same capture node, so one silently displaced the other. (type_declaration (type_spec name: (type_identifier) @declaration.name - type: (struct_type))) @declaration.struct + type: (struct_type)) @declaration.struct) ;; Declarations — interface +;; Same lockstep requirement as @declaration.struct above (#2837). (type_declaration (type_spec name: (type_identifier) @declaration.name - type: (interface_type))) @declaration.interface + type: (interface_type)) @declaration.interface) ;; Declarations — function (function_declaration diff --git a/gitnexus/src/core/ingestion/scope-resolution/unresolved-receivers.ts b/gitnexus/src/core/ingestion/scope-resolution/unresolved-receivers.ts index 11e7d7f61..9c26a673d 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/unresolved-receivers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/unresolved-receivers.ts @@ -13,6 +13,9 @@ * symbol stays exact. */ +import { createLogger } from '../../logger.js'; +import { compareCodeUnits } from '../../../lib/utils.js'; + import type { ResolutionOutcome } from './resolution-outcome.js'; /** Cap on distinct member names persisted. Well above what a real repo @@ -74,17 +77,55 @@ export interface UnresolvedReceiverSummary { * `omitted` is the number of distinct names past the cap, so the caller can * report truncation rather than silently losing entries. */ -function rankAndCap(counts: Map): { +function rankAndCap( + counts: Map, + cap: number = MAX_UNRESOLVED_RECEIVER_MEMBERS, +): { kept: [string, number][]; omitted: number; } { const ranked = [...counts.entries()].sort( - ([aName, aCount], [bName, bCount]) => bCount - aCount || aName.localeCompare(bName), + // `compareCodeUnits`, not `localeCompare` (#2787). The tiebreak feeds the + // `.slice()` below, so locale-sensitive collation would decide WHICH + // entries survive the cap, not merely how they are listed — and ICU order + // varies by platform and ICU build, so two runs over one repo could persist + // different sets. Key-based lookup is unaffected either way. + ([aName, aCount], [bName, bCount]) => bCount - aCount || compareCodeUnits(aName, bName), ); - const kept = ranked.slice(0, MAX_UNRESOLVED_RECEIVER_MEMBERS); + const kept = ranked.slice(0, cap); return { kept, omitted: ranked.length - kept.length }; } +/** + * A `receiver-unresolved` drop at a CALL site. + * + * ONE predicate, shared by the persisted summary and the per-file diagnostic — + * the same contract `rankAndCap` states for its comparator. If the two ever + * disagreed, the diagnostic would rank a different population than the artifact + * it exists to explain. + * + * Call sites only: Case 0's recorder gates on the receiver's punctuation, not on + * what the reference IS, so property reads (`d.source.kind`) and writes + * (`x.argtypes = [...]`) arrive alongside lost method calls — measured at 25 of + * 124 drops on the fixture corpus. Counting them made the consumer's "N call + * sites invoking X were dropped" literally false. A missing `siteKind` counts as + * a call: the only emitter always sets it, and erring toward `lower-bound` is + * the safe direction for an epistemic signal. + * + * Receiver ORIGIN is deliberately NOT decided here — the summary routes external + * drops into their own bucket while the diagnostic excludes them, and collapsing + * that choice into this predicate would take it away from both callers. + */ +function isUnresolvedReceiverCall( + outcome: ResolutionOutcome, +): outcome is Extract { + return ( + outcome.kind === 'suppressed' && + outcome.reason === 'receiver-unresolved' && + (outcome.siteKind === undefined || outcome.siteKind === 'call') + ); +} + /** * Build the summary, or `undefined` when nothing was dropped — an index with * no unresolved receivers stores no key at all, so `epistemic` keeps its @@ -99,17 +140,10 @@ export function summarizeUnresolvedReceivers( let totalSites = 0; let externalSites = 0; for (const outcome of outcomes) { - if (outcome.kind !== 'suppressed' || outcome.reason !== 'receiver-unresolved') continue; + // CALL sites only — see `isUnresolvedReceiverCall`, which the per-file + // diagnostic shares so the two can never rank different populations. + if (!isUnresolvedReceiverCall(outcome)) continue; if (outcome.name.length === 0) continue; - // CALL sites only. Case 0's recorder gates on the receiver's punctuation, not - // on what the reference IS, so property reads (`d.source.kind`) and writes - // (`x.argtypes = [...]`) are recorded alongside lost method calls — measured at - // 25 of 124 drops on the fixture corpus. Counting them made the consumer's - // "N call sites invoking X were dropped" literally false, and flagged symbols - // whose CALL count was never short. `siteKind` exists to make this separable. - // A missing `siteKind` counts as a call: the only emitter always sets it, and - // erring toward `lower-bound` is the safe direction for an epistemic signal. - if (outcome.siteKind !== undefined && outcome.siteKind !== 'call') continue; // Routed, not discarded. External-rooted drops (`console.log(...)`, // `fetch(...)`) reach code this index does not contain, so there is no node // an edge could have pointed at and nothing was lost — they must not hedge. @@ -194,3 +228,74 @@ export function lookupExternalCallCount( if (typeof sites !== 'number' || !Number.isFinite(sites) || sites <= 0) return undefined; return sites; } + +/** + * Opt-in, following the repo's established diagnostic pattern + * (`createLogger(name, { debugEnvVar })`). Every repository in every language + * drops SOME receivers, so emitting this at `info` unconditionally would add a + * line to every analyze anyone ever runs — the noise ADV-6 flagged. #2837's + * reporter turns it on deliberately: + * + * GITNEXUS_DEBUG_RECEIVER_DROPS=1 gitnexus analyze + */ +// Built on first use, not at import. `createLogger` is eager where the `logger` +// singleton is a lazy Proxy — it resolves `pino-pretty`, constructs a SonicBoom +// destination and registers a `beforeExit` hook. This module is imported by +// `mcp/local/local-backend.ts` for its lookup helpers, so an eager call would +// put that on MCP server startup for a diagnostic that is off by default. +let receiverDropLog: ReturnType | undefined; +const dropLog = (): ReturnType => + (receiverDropLog ??= createLogger('receiver-drops', { + debugEnvVar: 'GITNEXUS_DEBUG_RECEIVER_DROPS', + })); + +/** Files named in the per-file receiver-drop line. Bounded: this is a signpost + * pointing at where to look, not an inventory. */ +const UNRESOLVED_RECEIVER_FILE_SAMPLE = 10; + +/** + * Report which FILES lost the most call sites to an untyped receiver (#2837). + * + * `summarizeUnresolvedReceivers` persists the same drops keyed by member NAME, + * capped at 500 distinct names, and discards `filePath` — deliberately, because + * its consumer (`impact()`'s exact-vs-lower-bound verdict) asks "is this + * symbol's caller count trustworthy", a question about names. + * + * That leaves "why does THIS file resolve nothing while its sibling resolves + * everything" unanswerable from any artifact, which is exactly what #2837 asked + * for and could not run: comparing the drop records of two files with identical + * declared shapes separates "receiver never typed" from "typed but no edge + * emitted", and narrows a per-file split in one run instead of a bisect. + * + * A log line rather than a persisted field because nothing queries it — a + * persisted `byFile` map would be an unread field carrying its own cap and + * truncation semantics. When a consumer appears, `UnresolvedReceiverSummary` is + * already a `RepoMeta` field and can carry it with no schema change. + */ +export function logUnresolvedReceiverFiles(outcomes: readonly ResolutionOutcome[]): void { + // Nothing below is observable unless the diagnostic is switched on, so skip + // the whole tally rather than building it and discarding it at the emit. + if (!dropLog().isLevelEnabled('debug')) return; + + const byFile = new Map(); + let totalSites = 0; + for (const outcome of outcomes) { + // External-rooted drops (`fmt.Println`) reach code this index does not + // contain, so no node existed for an edge to point at and nothing was lost. + // Counting them here led the ranking with files that call `fmt` the most. + if (!isUnresolvedReceiverCall(outcome) || outcome.receiverOrigin === 'external') continue; + totalSites += 1; + byFile.set(outcome.filePath, (byFile.get(outcome.filePath) ?? 0) + 1); + } + if (totalSites === 0) return; + const { kept } = rankAndCap(byFile, UNRESOLVED_RECEIVER_FILE_SAMPLE); + dropLog().debug( + { + totalSites, + filesAffected: byFile.size, + topFiles: kept.map(([filePath, sites]) => ({ filePath, sites })), + }, + 'receiver-unresolved call sites by file (top offenders; a file far above its ' + + 'siblings usually means its receivers were never typed, not that it has more calls)', + ); +} diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index d4ce4860f..765e8492b 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -1121,8 +1121,15 @@ export const GO_QUERIES = ` (method_elem name: (field_identifier) @name) @definition.method ; Types -(type_declaration (type_spec name: (type_identifier) @name type: (struct_type))) @definition.struct -(type_declaration (type_spec name: (type_identifier) @name type: (interface_type))) @definition.interface +; +; Anchored on the type_spec, NOT the enclosing type_declaration (#2837) — a +; grouped type ( A struct{}; B struct{} ) block otherwise gave every match the +; same capture node, and goClassConfig.extractName resolved all of them to the +; FIRST spec's name, collapsing the block to one node. Must stay in lockstep +; with @scope.class / @declaration.struct in languages/go/query.ts, which +; carries the full rationale. (No backticks here: this is a template literal.) +(type_declaration (type_spec name: (type_identifier) @name type: (struct_type)) @definition.struct) +(type_declaration (type_spec name: (type_identifier) @name type: (interface_type)) @definition.interface) ; Imports (import_declaration (import_spec path: (interpreted_string_literal) @import.source)) @import diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index a1832335e..64eab074f 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -881,20 +881,26 @@ export const findEnclosingClassInfo = ( } } } - // Go: type_declaration wrapping a struct_type (type User struct { ... }) - if (current.type === 'type_declaration') { - const typeSpec = current.children?.find((c: SyntaxNode) => c.type === 'type_spec'); - if (typeSpec) { - const typeBody = typeSpec.childForFieldName?.('type'); - if (typeBody?.type === 'struct_type' || typeBody?.type === 'interface_type') { - const nameNode = typeSpec.childForFieldName?.('name'); - if (nameNode) { - const label = typeBody.type === 'struct_type' ? 'Struct' : 'Interface'; - return { - classId: generateId(label, `${filePath}:${nameNode.text}`), - className: nameNode.text, - }; - } + // Go: the `type_spec` IS the declared type (`type User struct { ... }`, and + // one per member of a grouped `type ( A struct{…}; B struct{…} )` block). + // + // Matched here rather than on the enclosing `type_declaration` (#2837): this + // walk climbs `node.parent`, so it passes THROUGH the containing spec on its + // way up from any member, and the structure it already has is the answer. + // Keying on the wrapper instead meant picking one spec out of several with + // no reference point — which filed every member of a grouped block under its + // FIRST struct, so two same-named fields minted one id and first-write-wins + // dropped the second. + if (current.type === 'type_spec') { + const typeBody = current.childForFieldName?.('type'); + if (typeBody?.type === 'struct_type' || typeBody?.type === 'interface_type') { + const nameNode = current.childForFieldName?.('name'); + if (nameNode) { + const label = typeBody.type === 'struct_type' ? 'Struct' : 'Interface'; + return { + classId: generateId(label, `${filePath}:${nameNode.text}`), + className: nameNode.text, + }; } } } diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 5738a504d..b2fa92ab9 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -15,7 +15,10 @@ import { randomUUID } from 'node:crypto'; import { retryRename } from '../storage/fs-atomic.js'; import { acquireIndexLock } from '../storage/index-lock.js'; import { runPipelineFromRepo } from './ingestion/pipeline.js'; -import { summarizeUnresolvedReceivers } from './ingestion/scope-resolution/unresolved-receivers.js'; +import { + logUnresolvedReceiverFiles, + summarizeUnresolvedReceivers, +} from './ingestion/scope-resolution/unresolved-receivers.js'; import type { KnowledgeGraph } from './graph/types.js'; import { resetDegradedParseCounter } from './tree-sitter/safe-parse.js'; import { @@ -2899,6 +2902,9 @@ async function runFullAnalysisInner( const newFileHashesRecord: Record = {}; for (const [k, v] of newFileHashes) newFileHashesRecord[k] = v; + const resolutionOutcomes = pipelineResult.resolutionOutcomes ?? []; + logUnresolvedReceiverFiles(resolutionOutcomes); + // Annotated so the capabilities stamp below is compile-checked against // RepoMeta's status unions (tri-review 4669518496 P1/U3) — an unannotated // literal widens the vectorSearch.status ternary to `string` and the @@ -2968,9 +2974,7 @@ async function runFullAnalysisInner( // Derived digest of the DDL this run created the tables from (#2798). // Git-only: non-git repos never take the incremental path. schemaFingerprint: hasGitDir(repoPath) ? SCHEMA_FINGERPRINT : undefined, - unresolvedReceiverMembers: summarizeUnresolvedReceivers( - pipelineResult.resolutionOutcomes ?? [], - ), + unresolvedReceiverMembers: summarizeUnresolvedReceivers(resolutionOutcomes), analysisFeatures: currentAnalysisFeatures, // Always stamped with the live resolved mode (#2331/#2339) — unlike // `pdg` below, 'none' is a meaningful value to compare, not an diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 16fab259f..63654ffb8 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -228,7 +228,27 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // cache would serve entries that are missing those matches entirely — the // exact failure a bump exists to prevent. Verified against origin/main at // a857f4c5a, which is still on 43, so 44 is free. RE-CHECK BEFORE MERGE. -const SCHEMA_BUMP = 44; + +// 44 -> 45: #2837 re-anchors Go's struct/interface captures from the +// `type_declaration` onto the `type_spec` (`languages/go/query.ts` +// @scope.class/@declaration.struct/@declaration.interface, and GO_QUERIES +// @definition.struct/@definition.interface in `tree-sitter-queries.ts`). Every +// Go file declaring a type therefore emits DIFFERENT capture ranges — measured: +// 70 fixture digests moved with zero change in capture COUNT — and a grouped +// `type (...)` block emits nodes it previously did not emit at all. Parse-time, +// so a warm cache would replay pre-fix ParsedFiles and the fix would be a silent +// no-op on every incremental analyze while still passing every cold-run test. +// +// This branch originally took 44 and it COLLIDED: #2842 above merged first and +// claimed it. The ninth entry in this ledger, and the third EXACT clash. Worth +// recording HOW it was caught, because the pin test cannot catch it — both PRs +// asserted `toBe(44)`, which passes even when main is already 44, so the two +// capture schemas would have shared one PARSE_CACHE_VERSION and the durable +// ParsedFile store would have replayed pre-fix ParsedFiles verbatim for one of +// them. Only comparing against origin/main at MERGE time surfaces it. +// PR #2840 (Objective-C, draft) still claims 44 as well — it must move too. +// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. +const SCHEMA_BUMP = 45; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json index 3065bfa0d..22c8693b4 100644 --- a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json @@ -9,15 +9,15 @@ }, "go-ambiguous/internal/models/handler.go": { "captureGroups": 9, - "digest": "737d7ca26cf03aebcae6b5df6a13267d400b42cd37799d72eda92db3485a1add" + "digest": "db813c607a3ade67157d57e1f1223bb3bf7e2f7885ca3237bea0c23b40499864" }, "go-ambiguous/internal/other/handler.go": { "captureGroups": 9, - "digest": "645a526694111ba6066715515ef4055d84f5c328505f7fa92d00b0b253d0aeca" + "digest": "57dcd1dacf4a1e2caf9ba0a6d6c79fd8ede4ea1492ed3b7eff8c7702b1386ca2" }, "go-ambiguous/internal/services/user.go": { "captureGroups": 9, - "digest": "0a988c48838a34eb7a6f29b324191c39c19087e728914fffa06bbb9221841d23" + "digest": "58bed05b21968f435b9c9abaa71602fdc0544724ef43e71d9eb6bdece4d5bfe1" }, "go-assignment-chain/cmd/main.go": { "captureGroups": 54, @@ -25,11 +25,11 @@ }, "go-assignment-chain/models/repo.go": { "captureGroups": 8, - "digest": "63cb96d06478f4b2039d6eaeab1468a8af3c503ec45e5785ff3e4de5aabe9240" + "digest": "05d37c0347bc18023c551bbe805be28ceb742d0bc97798f6d80b8df5c02ce507" }, "go-assignment-chain/models/user.go": { "captureGroups": 8, - "digest": "fcee44ed373eda2ff00778ff951fc2fa1503c5187b9e7d43dea3ee8b63656907" + "digest": "5178178770dc01525d5fe29cfc88e5c59fe71f87f235855b1bb5d71dd479fa70" }, "go-call-result-binding/cmd/main.go": { "captureGroups": 17, @@ -37,7 +37,7 @@ }, "go-call-result-binding/models/user.go": { "captureGroups": 10, - "digest": "4193f6356f75505415e36d4b090dc9f5265937efe72b4251c33e5b5f06aaa99d" + "digest": "b63d9f87f84b4bdab8bab924fe116502dddaf871b2d1ffb00618a6799abb811b" }, "go-calls/cmd/main.go": { "captureGroups": 6, @@ -53,23 +53,23 @@ }, "go-chain-call/cmd/main.go": { "captureGroups": 20, - "digest": "90aed1ac18f7d992d47f24b7a432b9077cdc113e1b356ba9bd4e430be81c76f2" + "digest": "5ee61ed7f61f3a1c5d1b1ceaa2a08f51422e01def6e03ef3eb0be1d49343e396" }, "go-chain-call/models/repo.go": { "captureGroups": 10, - "digest": "94b6f138a062dfd96969dc7ec3d3eddecab591cb29d16fed4ea91da20ff0363b" + "digest": "28d5828d139c5a0415eb559628c70dc2a6f625b9b0b6271dcf0e89e4a52fc2ef" }, "go-chain-call/models/user.go": { "captureGroups": 10, - "digest": "4193f6356f75505415e36d4b090dc9f5265937efe72b4251c33e5b5f06aaa99d" + "digest": "b63d9f87f84b4bdab8bab924fe116502dddaf871b2d1ffb00618a6799abb811b" }, "go-child-extends-parent/models/child.go": { "captureGroups": 4, - "digest": "61dafb01f7616ab6d965bcf17197de315f57a606c7b39cb25a0f83b84508d86d" + "digest": "80db3a5331edffec08bfa8b73779a4fe361516f8bfdfa0b818e36e63bd0ee1ee" }, "go-child-extends-parent/models/parent.go": { "captureGroups": 8, - "digest": "19a2b1696d45ea66cd00df562b65ea0b2e9fdbe60233cf31587a334221420d6e" + "digest": "e81975670b9e19c132231b801ff1cce77526a5a5681b2b9a47b0e07c03bce162" }, "go-child-extends-parent/services/app.go": { "captureGroups": 10, @@ -89,11 +89,11 @@ }, "go-constructor-type-inference/models/repo.go": { "captureGroups": 8, - "digest": "63cb96d06478f4b2039d6eaeab1468a8af3c503ec45e5785ff3e4de5aabe9240" + "digest": "05d37c0347bc18023c551bbe805be28ceb742d0bc97798f6d80b8df5c02ce507" }, "go-constructor-type-inference/models/user.go": { "captureGroups": 12, - "digest": "bf814d4db5d2b0026caab6aa5ade9c173abd96fdf2c1e127b57a854d0203f9a5" + "digest": "11b64a43661a469adc9b070cba520618ce487bbbd58e331db3dce97930f6906a" }, "go-deep-field-chain/cmd/main.go": { "captureGroups": 14, @@ -101,7 +101,7 @@ }, "go-deep-field-chain/models/models.go": { "captureGroups": 33, - "digest": "3941943919b0066138d8a759cceadd95784c8a89978e7a345ea056ef1ca78c9c" + "digest": "9bbba66d384a47803aef95ef963224bbb80838340cdfb67560f2af5e81aa637a" }, "go-field-types/cmd/main.go": { "captureGroups": 10, @@ -109,7 +109,7 @@ }, "go-field-types/models/models.go": { "captureGroups": 22, - "digest": "78de0f686b33993bd361a1d5366982afbe5378f4a62464c4248e3c9b65aaa9c3" + "digest": "9125a5a04d25873b3628447f74302a577a391c12f67f433fc75dbdc702173a5c" }, "go-for-call-expr/cmd/main.go": { "captureGroups": 27, @@ -117,47 +117,71 @@ }, "go-for-call-expr/models/repo.go": { "captureGroups": 14, - "digest": "33889573461301951f51786e94a60a1d20c87bd3d2cad12f8619e951b94ad7ed" + "digest": "c1d3bd4514bfc74acbe3ce966d52c5456a1474df12367b32ed6efb0f82ff0ed6" }, "go-for-call-expr/models/user.go": { "captureGroups": 14, - "digest": "964cf1bd181cc7196726c3261a683884ac9c6fc3aee6170b47bd54427a6cb947" + "digest": "094432863997d5215dc91bc382fc52241f7d148dcab0f146ba8ff3551f40b709" + }, + "go-grouped-type-decl/internal/repository/interfaces.go": { + "captureGroups": 11, + "digest": "6653543ddae1c7f9d3cc216cc074035c73c7da561bf495f3c1b0a07a8c97745e" + }, + "go-grouped-type-decl/internal/repository/order_repo.go": { + "captureGroups": 19, + "digest": "fdfa1730082b98c793f83c0a0d454327316ef526f050c1cc4161e0675ea2320c" + }, + "go-grouped-type-decl/internal/repository/sinks.go": { + "captureGroups": 23, + "digest": "f21caf7ae1cecf25e112cc28596b0ec2c0af060a5c05e5fbd8005c84674f8cd7" + }, + "go-grouped-type-decl/internal/services/pick_service.go": { + "captureGroups": 32, + "digest": "1bb068b1ca6c3baa6ca6ef3c97f3a50777b05e5c6d80408be8084c13ea7dde0b" + }, + "go-grouped-type-decl/internal/services/sort_service.go": { + "captureGroups": 21, + "digest": "75f8e4b967d249d5d0bef69812ed1ec040a06db7359ec691aba9165340930c98" + }, + "go-grouped-type-decl/internal/services/wave_service.go": { + "captureGroups": 17, + "digest": "3bc5ef120e5c4648ac31d1e80ade828990521513b8380d2a7f45875ba990dc9c" }, "go-inc-dec-write-access/main.go": { "captureGroups": 23, - "digest": "fd35d917bc7a039ef4bcb0a6501c31ffbea0fe39bf3604938191597415aa33f7" + "digest": "a10c68642a9f4390765ea4679edeb38af54414e0d05d53f54161c3ff74976090" }, "go-interface-field-dispatch/internal/handlers/orders.go": { "captureGroups": 22, - "digest": "438b02c0990fa6c830767327d07de642f7aff6287e95217c3dfd7423a520942c" + "digest": "922a4f0220ba8e4363e455b2cf4185e3f83c0e5593b3296f575f5900a612f0f5" }, "go-interface-field-dispatch/internal/handlers/picking.go": { "captureGroups": 28, - "digest": "0ad401ba19e7cf7deace88ea7973572cb6ee2a63a6006aa5cc06f55bac56e14b" + "digest": "9ce10f70ed1e54e9fbc9affbbf36976046f432b70049807abe88189ad1d19f10" }, "go-interface-field-dispatch/internal/repository/audit_repo.go": { "captureGroups": 18, - "digest": "2710feb4bee288248e346d7ab0a32a8511b002d8c0fad4cd7db3588ee1926bc2" + "digest": "7c7dc2d8b41c59889d924c7e8289c77f946918efb9edf7e8a9663ccb3336705f" }, "go-interface-field-dispatch/internal/repository/interfaces.go": { "captureGroups": 10, - "digest": "ee5b78fc03ea3283b2e06b9b8d0cf4999c2de091262571966d1d4670036c479d" + "digest": "308b8afd93a78d33551c18e39ffd133d4aa1440251c3b5dca89ba26ccc1d980f" }, "go-interface-field-dispatch/internal/repository/mock_repo.go": { "captureGroups": 47, - "digest": "7b9219cc06e8c45fb08c304b71ebc58e1a9e052cb9c92fd01b9f22ab8328a91d" + "digest": "72d39d85e44d59fdbd69b9a3805403fad0781db0cd269af34f1e523cef78659c" }, "go-interface-field-dispatch/internal/repository/order_repo.go": { "captureGroups": 26, - "digest": "00e0f98586043ff84f96af92d9e6b3593018b6b37f92d183b5c05c6de745e2b0" + "digest": "50316b51850b2e8828b7a6910aa61b82375af4e94039a2caf6f292e858b4665d" }, "go-interface-field-dispatch/internal/services/pick_service.go": { "captureGroups": 39, - "digest": "036d1a4b7304bbbf963f640710fd286702ed34809df2940411c14da8f9de9a6f" + "digest": "907ecf7608704c44a2ef84ff10e2ada7d3c998d933c7af14e9c967b11ac4cb10" }, "go-interface-field-dispatch/internal/services/wave_service.go": { "captureGroups": 46, - "digest": "62a3830311106e4a349c5b604e914531249aacc47001a888ecf5d38e2bc4c821" + "digest": "99ce17d5757170222d98bde469d0ae0d1a41f386cdd4894d4f0d246debb90894" }, "go-local-shadow/cmd/main.go": { "captureGroups": 14, @@ -173,7 +197,7 @@ }, "go-make-builtin/models.go": { "captureGroups": 15, - "digest": "2af382fcd2e59cf01d9439bdb8709cba150bb239fae71b6f0d09c1fa7248fc5b" + "digest": "631ab2da86be2dbc2ef9aa61e834c30b3ad83b9a8af570c4cbcebe97841a607a" }, "go-map-range/main.go": { "captureGroups": 11, @@ -181,11 +205,11 @@ }, "go-map-range/models/repo.go": { "captureGroups": 9, - "digest": "6cbc4422fb287007735b4c58b5e9c84bbd6b8f6082e3b4d5fe48c306e6acc75b" + "digest": "5cd6835db0a273e1c651222b0fd3c1155ad94f39dbda8cd886fda6b8d8830962" }, "go-map-range/models/user.go": { "captureGroups": 9, - "digest": "7e4dbc05ad1de859cd3103c27cddb60566d95a8a8354c752e8c75f84d7ca055c" + "digest": "38bdb58e980de1327db28cb9110c2c20eff329decab4d62ac0ac2f57d0bf5d7c" }, "go-member-calls/cmd/main.go": { "captureGroups": 11, @@ -193,7 +217,7 @@ }, "go-member-calls/models/user.go": { "captureGroups": 8, - "digest": "fcee44ed373eda2ff00778ff951fc2fa1503c5187b9e7d43dea3ee8b63656907" + "digest": "5178178770dc01525d5fe29cfc88e5c59fe71f87f235855b1bb5d71dd479fa70" }, "go-method-chain-binding/cmd/main.go": { "captureGroups": 22, @@ -201,11 +225,11 @@ }, "go-method-chain-binding/models/user.go": { "captureGroups": 24, - "digest": "d72468ce0567ce35393b570e7e3e86f0c004e37a9614dc5ee698aff6d3a4348a" + "digest": "2b3759d03ee88c548be2bceec060d0cb385713f191b7a0d508eb2852a09028a2" }, "go-method-enrichment/animal.go": { "captureGroups": 17, - "digest": "090d1bba93a5271a8e8e75d2e9d9790e58e4c5a6679b7fb9290f318591eb619b" + "digest": "ad4cfabcd53397ab664ba4bb05df4e1a803455b5c255a19e2ed283c6523a1660" }, "go-method-enrichment/app.go": { "captureGroups": 15, @@ -217,7 +241,7 @@ }, "go-mixed-chain/models/models.go": { "captureGroups": 41, - "digest": "afb1eaf2869f534033c5cd844ca111a93b63df1c098cec9c9518c4480a47ddb0" + "digest": "6e467299d440a015b039470d82c33d5ab92ae92b131887607172ced38bccd761" }, "go-multi-assign/app.go": { "captureGroups": 18, @@ -225,7 +249,7 @@ }, "go-multi-assign/models.go": { "captureGroups": 19, - "digest": "e071ebdd59b5bbebb42785ebf850a9c0ee7c2e3a8cc13fb0b17233480efa26ed" + "digest": "28ef663decb5e16b8cdbf16216849dff53d1e898a2cbd1dd1e68c116ce426cd8" }, "go-multi-return-inference/cmd/main.go": { "captureGroups": 38, @@ -233,11 +257,11 @@ }, "go-multi-return-inference/models/repo.go": { "captureGroups": 10, - "digest": "94b6f138a062dfd96969dc7ec3d3eddecab591cb29d16fed4ea91da20ff0363b" + "digest": "28d5828d139c5a0415eb559628c70dc2a6f625b9b0b6271dcf0e89e4a52fc2ef" }, "go-multi-return-inference/models/user.go": { "captureGroups": 10, - "digest": "4193f6356f75505415e36d4b090dc9f5265937efe72b4251c33e5b5f06aaa99d" + "digest": "b63d9f87f84b4bdab8bab924fe116502dddaf871b2d1ffb00618a6799abb811b" }, "go-new-builtin/main.go": { "captureGroups": 12, @@ -245,7 +269,7 @@ }, "go-new-builtin/models.go": { "captureGroups": 17, - "digest": "d5ce8ba6ff38c6c78bb87e746a5382822745d0c9841a8092495ff386c7c4ee24" + "digest": "c00fe49983ed36e5af137f9c6b230256b957b84ff92c0590535a040edaa1dde0" }, "go-nullable-receiver/cmd/main.go": { "captureGroups": 27, @@ -253,19 +277,19 @@ }, "go-nullable-receiver/models/repo.go": { "captureGroups": 8, - "digest": "63cb96d06478f4b2039d6eaeab1468a8af3c503ec45e5785ff3e4de5aabe9240" + "digest": "05d37c0347bc18023c551bbe805be28ceb742d0bc97798f6d80b8df5c02ce507" }, "go-nullable-receiver/models/user.go": { "captureGroups": 8, - "digest": "fcee44ed373eda2ff00778ff951fc2fa1503c5187b9e7d43dea3ee8b63656907" + "digest": "5178178770dc01525d5fe29cfc88e5c59fe71f87f235855b1bb5d71dd479fa70" }, "go-parent-resolution/models/base.go": { "captureGroups": 8, - "digest": "a5138789f8e35111dced225841a4d0e209eabad582973a55f5d47f882cf777e8" + "digest": "63f491f7e7968f96591fce93df493c03d1aca05ec1c60853a0e967116e29be8e" }, "go-parent-resolution/models/user.go": { "captureGroups": 9, - "digest": "e1b57b0c5865d84b80518fe52e3807f813ae5589b4b558d0037e6ea136843f85" + "digest": "190a4879616347c89d97ded78d4eb0dd6aba9e0a03cfbd756197ca2f8c8118e7" }, "go-pkg/cmd/main.go": { "captureGroups": 15, @@ -277,15 +301,15 @@ }, "go-pkg/internal/models/admin.go": { "captureGroups": 17, - "digest": "087b8188a2e3d5d58f70d5a812b1a31a135dbd435de0ee7799d63199a38eb6cf" + "digest": "e238db8934192ba88102b0c78b63cd792c60480fd0a95a5c49ff6f79760c0640" }, "go-pkg/internal/models/repository.go": { "captureGroups": 5, - "digest": "73a5c9bfcb59011f36c47e1b806edba89bad1ed32f81d46acbefdb8abdba67c7" + "digest": "0020141e695e4ab262316a50bc7df86fdc6c4210217b4d502f4e7145110fba71" }, "go-pkg/internal/models/user.go": { "captureGroups": 14, - "digest": "04a3005d9f97325b79c0ac524a424c84c004aed5af24318389a413462dee2c4f" + "digest": "90ebac927e246e8f5da7c61beefdf3ab18f3dba2f487c5750b7c910d85615212" }, "go-pointer-constructor-inference/cmd/main.go": { "captureGroups": 15, @@ -293,35 +317,35 @@ }, "go-pointer-constructor-inference/models/repo.go": { "captureGroups": 10, - "digest": "94b6f138a062dfd96969dc7ec3d3eddecab591cb29d16fed4ea91da20ff0363b" + "digest": "28d5828d139c5a0415eb559628c70dc2a6f625b9b0b6271dcf0e89e4a52fc2ef" }, "go-pointer-constructor-inference/models/user.go": { "captureGroups": 10, - "digest": "4193f6356f75505415e36d4b090dc9f5265937efe72b4251c33e5b5f06aaa99d" + "digest": "b63d9f87f84b4bdab8bab924fe116502dddaf871b2d1ffb00618a6799abb811b" }, "go-pointer-receiver-field-chain/handlers/handler.go": { "captureGroups": 116, - "digest": "ba27c7e7ddc71de0a2696963a50a1afcd2b0faed9716c55c02ce66a4232cd7fa" + "digest": "5d691e53c06ee365863626aea70c968eabc8c6e71f1ae53e4f93be26a685538b" }, "go-pointer-receiver-field-chain/repository/repo.go": { "captureGroups": 20, - "digest": "9e1c600c201dd4f5fdcb15820aaa3ad7fa1a3722e4c29bdf8daadf2e2f9f4b9e" + "digest": "78d5a003c1d6a2e200789a266261278ac6a24dea3112af86ac8d0c73773671d3" }, "go-qualified-base/base/base.go": { "captureGroups": 17, - "digest": "c2f2241a4e31ad1b003c1649d7511437f3884815412aeac65754cf3cfb9b6dc1" + "digest": "9130b5df9871611f0f28e86b7a78219da44c08eb244abc4f45e2aa3b45bfa588" }, "go-qualified-base/consumers/local.go": { "captureGroups": 20, - "digest": "923a41030b83b854118c12cb6f99ca02a8347b1db72ec8304904d3a99284f5f4" + "digest": "7165b0d6e3808ab29ee30b311ed387b59016d6fc8399e854e58f2e4affe1d467" }, "go-qualified-base/consumers/qualified.go": { "captureGroups": 14, - "digest": "4680bb7a7c62a54a91d1d7b8ab3150dfb0c54b1683bee8cf7fc747afe99ca0f8" + "digest": "9b1882cd492a04c605a5e6d32f2e264970eb8d4f359511d76b0ede6d8bff60c9" }, "go-receiver-method-free-call/example.go": { "captureGroups": 8, - "digest": "0b773eaede711e43ed8b8ae6aacf76c398dc362f54429b30a4f5eb7c71f863da" + "digest": "ec13b52723d8078edbf36d3f85abd6d58fc6e2bfec781b978ff57659c7f0676a" }, "go-receiver-method-free-call/util.go": { "captureGroups": 4, @@ -333,11 +357,11 @@ }, "go-receiver-resolution/models/repo.go": { "captureGroups": 8, - "digest": "63cb96d06478f4b2039d6eaeab1468a8af3c503ec45e5785ff3e4de5aabe9240" + "digest": "05d37c0347bc18023c551bbe805be28ceb742d0bc97798f6d80b8df5c02ce507" }, "go-receiver-resolution/models/user.go": { "captureGroups": 8, - "digest": "fcee44ed373eda2ff00778ff951fc2fa1503c5187b9e7d43dea3ee8b63656907" + "digest": "5178178770dc01525d5fe29cfc88e5c59fe71f87f235855b1bb5d71dd479fa70" }, "go-return-type-inference/cmd/main.go": { "captureGroups": 41, @@ -345,11 +369,11 @@ }, "go-return-type-inference/models/repo.go": { "captureGroups": 17, - "digest": "1117b573967ac1c687fe336f476f3c4e94680925f9a3ff2a84a47a87bdf86208" + "digest": "082f55e0526f824d570e807da706be940abea9584041d87d18a329c9ce198ffb" }, "go-return-type-inference/models/user.go": { "captureGroups": 17, - "digest": "a83045c31b12d9dc45b07315b081bef26ec380479d39706ee8c5d79e4efe5d88" + "digest": "f208647af1057fe1819f68e62d4afe7dae891af386a4cc688d2d60840a0fbd00" }, "go-same-package-factory/main.go": { "captureGroups": 14, @@ -357,11 +381,11 @@ }, "go-same-package-factory/repo.go": { "captureGroups": 8, - "digest": "a5488a5ebe483c88d9f5bb2ccf0c283e397b3345798509c0e8e5ab65272be7fd" + "digest": "694a509ecf0f496fd72c89c0d2f9bb4ed8969b1b08cdf51a76b8c265e8f12158" }, "go-same-package-factory/user.go": { "captureGroups": 8, - "digest": "067c51c54c15b71f3ec45b8da94ea693e6b440d30fa26122d0a90a3b81f42bce" + "digest": "96208d52047f9e72853919baa3ba1aa9a9efa79e53b8aeca1085fe1d27dc48eb" }, "go-split-method-owner/main.go": { "captureGroups": 9, @@ -369,7 +393,7 @@ }, "go-split-method-owner/repo.go": { "captureGroups": 8, - "digest": "a5488a5ebe483c88d9f5bb2ccf0c283e397b3345798509c0e8e5ab65272be7fd" + "digest": "694a509ecf0f496fd72c89c0d2f9bb4ed8969b1b08cdf51a76b8c265e8f12158" }, "go-split-method-owner/save.go": { "captureGroups": 6, @@ -377,7 +401,7 @@ }, "go-split-method-owner/user.go": { "captureGroups": 3, - "digest": "827dc0208b47776976313a4560fbd250ab7fa521ae46581213a79fc15fd52ce8" + "digest": "831b367d3e84a054d94fe53e7093f7f09a9ba021c1d62c1cbd1283671a762c09" }, "go-struct-literals/app.go": { "captureGroups": 11, @@ -385,11 +409,11 @@ }, "go-struct-literals/user.go": { "captureGroups": 10, - "digest": "16f0d425b25bdc7eaaf331bd47ce1ece6d476ae0e24e72110ce96c1f315bd314" + "digest": "c86101a45757cb20669616e7de23a688bd41316c200baab7cd13a394c30647a5" }, "go-structural-interface-cross-package/api/repository.go": { "captureGroups": 12, - "digest": "b5ef588c11391fa93237260baae4c4bb67ee4dfc381651444a9925c3f52adec3" + "digest": "e9b0ef4fea2112f18dfcda7cfca5c3a936c6a1977fbfed33c0dbd86929635d34" }, "go-structural-interface-cross-package/cmd/main.go": { "captureGroups": 33, @@ -397,23 +421,23 @@ }, "go-structural-interface-cross-package/contracts/read_closer.go": { "captureGroups": 6, - "digest": "0813deb9e72bfb1e07d0e9399c4d4537dc40181f0c5c43cff5580b91d4ed3605" + "digest": "c76628ad75f2062808833697c4cc71852efa193a6505ed48e7855f0c7daec2c1" }, "go-structural-interface-cross-package/impl/file.go": { "captureGroups": 20, - "digest": "950002bd5dfc126d13355f317944a509253fe6dd81f12638af1d5f0fe37d04db" + "digest": "471dd17baa970f470cd7ee0f1df7b778876db9d73c2a39ad54aa30f7afda672b" }, "go-structural-interface-cross-package/other/user.go": { "captureGroups": 5, - "digest": "4d6298ea05addadfa17f6dd8a2eb0fa2ea63b73de69355015594504ae4e6c1b1" + "digest": "c70128808449498017a9633dd721254ad614ef5d10ce8b8113d235b785793b86" }, "go-structural-interface-cross-package/store/repository.go": { "captureGroups": 37, - "digest": "1b69c40092cd49588936e68bfb831d1ccfd5764bcb5bf42279bd66d9cbc57450" + "digest": "ad0a631017b863b9b0b8d47a34d1fa2e1029a0b9e7fd4b17e72607e7e2dc1724" }, "go-structural-interface-dispatch/repository.go": { "captureGroups": 142, - "digest": "5bb89d0695cd94082b0657abd3435726372fafa5c570d01b1440ac604217574b" + "digest": "a40aca0e28f3201d34d8f24aaf71f476144fdb5a613c0ffe2738f9901659dcb7" }, "go-type-assertion/main.go": { "captureGroups": 12, @@ -421,7 +445,7 @@ }, "go-type-assertion/models.go": { "captureGroups": 18, - "digest": "018658521c83d757464357f9963cd5952e040be01a02d9f1d41304f3748fd1a5" + "digest": "6e894e96dc0287118d6543bf7c7bcd6c0ae87ad5d1b812e612364c5e4465d340" }, "go-variadic-resolution/cmd/main.go": { "captureGroups": 6, @@ -433,10 +457,10 @@ }, "go-write-access/main.go": { "captureGroups": 21, - "digest": "20c312ccec297b19022c681a486656b0f2d5d3562c791588df170043f4e45b09" + "digest": "c4c86b2d32c5fbf075dbdbe66280b67fe01061748936e19f6c8551edb391ac07" }, "synthetic:dao-20": { "captureGroups": 521, - "digest": "cd4e6169abfe6016f000a884acf0604f7fea0ea00d9e1f8f52c7d5ad9b97a01a" + "digest": "6e79c31cdc169cb7ef7eff2282d8497c5c74fb81107837071dad9029f95bc9ac" } } diff --git a/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/go.mod b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/go.mod new file mode 100644 index 000000000..57cb24d59 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/go.mod @@ -0,0 +1,3 @@ +module github.com/example/grouped + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/repository/interfaces.go b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/repository/interfaces.go new file mode 100644 index 000000000..43aa842a5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/repository/interfaces.go @@ -0,0 +1,20 @@ +package repository + +// OrderRepository is the interface held as a struct FIELD by every service in +// this fixture. Pointer-receiver implementors only (#2813 shape). +type OrderRepository interface { + DeleteItem(id string) error + GetPickQueue(id string) ([]string, error) +} + +// Grouped INTERFACE declaration — the same `type (...)` collapse that hits +// structs also hits interfaces, so both are pinned here (#2837). +type ( + AuditSink interface { + LogAudit(msg string) error + } + + MetricSink interface { + Observe(name string) error + } +) diff --git a/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/repository/order_repo.go b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/repository/order_repo.go new file mode 100644 index 000000000..f53a4e9a0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/repository/order_repo.go @@ -0,0 +1,9 @@ +package repository + +type OrderRepo struct { + dsn string +} + +func (r *OrderRepo) DeleteItem(id string) error { return nil } + +func (r *OrderRepo) GetPickQueue(id string) ([]string, error) { return nil, nil } diff --git a/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/repository/sinks.go b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/repository/sinks.go new file mode 100644 index 000000000..e4672ff5a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/repository/sinks.go @@ -0,0 +1,13 @@ +package repository + +type AuditWriter struct { + path string +} + +func (w *AuditWriter) LogAudit(msg string) error { return nil } + +type MetricWriter struct { + ns string +} + +func (w *MetricWriter) Observe(name string) error { return nil } diff --git a/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/services/pick_service.go b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/services/pick_service.go new file mode 100644 index 000000000..66245a675 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/services/pick_service.go @@ -0,0 +1,35 @@ +package services + +import "github.com/example/grouped/internal/repository" + +// THE #2837 SHAPE. Identical field shape to WaveService, declared SECOND in a +// grouped `type (...)` block. +// +// `@scope.class` is captured on the `type_declaration`, so this whole block is +// ONE Class scope owning both structs; `buildWorkspaceResolutionIndex` keeps +// only the FIRST class-like def per scope, so `PickService` gets no +// `classScopeByDefId` entry and every `s.orderRepo` call site here resolves to +// nothing. +// +// `Decoy.orderRepo` deliberately shares the field NAME with a DIFFERENT type: +// both structs' field typeBindings live in one name-keyed map while the scope +// is shared, so this also pins that PickService is not typed by Decoy's field. +type ( + Decoy struct { + orderRepo *LocalThing + } + + PickService struct { + orderRepo repository.OrderRepository + } +) + +type LocalThing struct { + n int +} + +func (l *LocalThing) DeleteItem(id string) error { return nil } + +func (s *PickService) Release(id string) error { + return s.orderRepo.DeleteItem(id) +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/services/sort_service.go b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/services/sort_service.go new file mode 100644 index 000000000..4a3ecf98e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/services/sort_service.go @@ -0,0 +1,20 @@ +package services + +import "github.com/example/grouped/internal/repository" + +// ORDER CONTROL: same grouped shape as pick_service.go, but the service struct +// is declared FIRST. If only this one resolves, the fix is order-luck, not a +// fix (#2837). +type ( + SortService struct { + orderRepo repository.OrderRepository + } + + SortDecoy struct { + orderRepo *LocalThing + } +) + +func (s *SortService) Release(id string) error { + return s.orderRepo.DeleteItem(id) +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/services/wave_service.go b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/services/wave_service.go new file mode 100644 index 000000000..383c0946d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/services/wave_service.go @@ -0,0 +1,14 @@ +package services + +import "github.com/example/grouped/internal/repository" + +// CONTROL: plain declaration. This struct is the only class-like def in its +// `type_declaration`, so it gets its own Class scope under either capture +// granularity and resolves today. +type WaveService struct { + orderRepo repository.OrderRepository +} + +func (s *WaveService) Release(id string) error { + return s.orderRepo.DeleteItem(id) +} diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts index 3f92964ae..870b486fd 100644 --- a/gitnexus/test/integration/resolvers/go.test.ts +++ b/gitnexus/test/integration/resolvers/go.test.ts @@ -2027,3 +2027,132 @@ describe('Go interface-typed struct field dispatch (#2813)', () => { expect(callsFromFileToFile()).toContain('picking.go:Queue → GetPickQueue@order_repo.go'); }); }); + +// --------------------------------------------------------------------------- +// #2837 — grouped `type (...)` declarations collapse N types into ONE Class +// scope. +// +// `languages/go/query.ts` captures `@scope.class` on the `type_declaration`, +// not on the `type_spec`. An idiomatic grouped declaration is therefore a +// SINGLE capture owning every struct in the block, and +// `buildWorkspaceResolutionIndex` keeps only the FIRST class-like def per Class +// scope (`workspace-index.ts:156-164`). Every struct after the first has no +// `classScopeByDefId` entry, so `typeOfMemberOnClass` cannot find its fields, +// the Case 0 compound-receiver fold declines, and every field-receiver call +// site in that file emits ZERO edges — silently, and independently of file +// size. That is the per-file split #2837 reported and #2829 could not explain. +// +// Not caught before because ZERO of this repo's 115 Go fixture files used a +// grouped `type (...)` block, including #2829's own fixture. +describe('Go grouped type declaration scoping (#2837)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'go-grouped-type-decl'), () => {}); + }, 120000); + + /** Both ends file-qualified: three services declare a method named `Release`, + * so a target-only row cannot tell which file resolved. */ + const callsFromFileToFile = (): string[] => + getRelationships(result, 'CALLS').map( + (e) => + `${e.sourceFilePath.split('/').slice(-1)[0]}:${e.source} → ` + + `${e.target}@${e.targetFilePath.split('/').slice(-1)[0]}`, + ); + const implementsEdges = (): string[] => edgeSet(getRelationships(result, 'IMPLEMENTS')); + + // Control: the plain declaration resolves today and must keep resolving. + it('resolves a field receiver whose struct is declared plainly', () => { + expect(callsFromFileToFile()).toContain('wave_service.go:Release → DeleteItem@order_repo.go'); + }); + + // The headline defect: identical field shape, declared SECOND in a grouped + // block, currently emits nothing at all. + it('resolves a field receiver whose struct is declared second in a grouped block', () => { + expect(callsFromFileToFile()).toContain('pick_service.go:Release → DeleteItem@order_repo.go'); + }); + + // Order control. If only the first-declared struct resolves, the fix is + // order-luck rather than a fix. + it('resolves a field receiver whose struct is declared first in a grouped block', () => { + expect(callsFromFileToFile()).toContain('sort_service.go:Release → DeleteItem@order_repo.go'); + }); + + // Equality, not mere presence: a one-sided fix fails here. + it('emits the same field-receiver edges for plain and grouped declarations', () => { + const rows = callsFromFileToFile(); + const forFile = (f: string): string[] => + rows + .filter((r) => r.startsWith(`${f}:`)) + .map((r) => r.slice(f.length + 1)) + .sort(); + expect(forFile('pick_service.go')).toEqual(forFile('wave_service.go')); + expect(forFile('sort_service.go')).toEqual(forFile('wave_service.go')); + }); + + // The field-binding-collision half. While the grouped structs share ONE Class + // scope they also share one name-keyed `typeBindings` map, so `orderRepo` + // declared by `Decoy` as `*LocalThing` can type `PickService.orderRepo`. + // A fix that only made `workspace-index` map every class-like def would leave + // this map shared and would fail this row — which is why it is not the fix. + it('does not type a grouped struct field from a sibling struct of the same name', () => { + expect(callsFromFileToFile()).not.toContain( + 'pick_service.go:Release → DeleteItem@pick_service.go', + ); + }); + + // Grouped INTERFACE declarations collapse the same way. This row is also what + // discriminates the `tree-sitter-queries.ts` half of the fix: with only + // `languages/go/query.ts` re-anchored, MetricSink has a scope but still no + // graph NODE, so its implementor edge cannot exist. + it('detects implementors of both interfaces in a grouped interface block', () => { + expect(implementsEdges()).toContain('AuditWriter → AuditSink'); + expect(implementsEdges()).toContain('MetricWriter → MetricSink'); + }); + + // The node-level symptom, asserted directly for STRUCTS rather than only + // inferred from the interface row above. Before the fix the whole grouped + // block collapsed to one node and `PickService` was absent from the inventory + // entirely — `impact("PickService")` would have returned a clean, wrong zero. + it('emits a graph node for every struct in a grouped block', () => { + const structs = getNodesByLabel(result, 'Struct'); + expect(structs).toContain('WaveService'); // plain — control + expect(structs).toContain('Decoy'); // grouped, first + expect(structs).toContain('PickService'); // grouped, second + expect(structs).toContain('SortService'); // grouped, first (reverse-order file) + expect(structs).toContain('SortDecoy'); // grouped, second (reverse-order file) + }); + + // Anchoring the captures on `type_spec` moved the node the class extractor and + // the doc-comment extractor are handed. Both had to be taught the new shape, + // and NEITHER is covered by the edge assertions above — the first pass of this + // change silently dropped both properties from every Go type while all seven + // rows above stayed green (#2843 review). + const typeProps = (label: 'Struct' | 'Interface', name: string): Record => + getNodesByLabelFull(result, label).find((n) => n.name === name)?.properties ?? {}; + + it('keeps the package-qualified name on every Go type', () => { + expect(typeProps('Struct', 'WaveService').qualifiedName).toBe('services.WaveService'); // plain + expect(typeProps('Struct', 'PickService').qualifiedName).toBe('services.PickService'); // grouped, 2nd + expect(typeProps('Interface', 'MetricSink').qualifiedName).toBe('repository.MetricSink'); // grouped iface, 2nd + }); + + it('keeps the godoc description on every Go type', () => { + expect(typeProps('Struct', 'WaveService').description).toBeTruthy(); // plain + expect(typeProps('Struct', 'PickService').description).toBeTruthy(); // grouped, 2nd + expect(typeProps('Interface', 'OrderRepository').description).toBeTruthy(); // plain interface + }); + + // Members must attribute to the struct that actually declares them. The owner + // walk took the FIRST `type_spec` of the declaration, so before the fix every + // field and method of a grouped block was filed under its first struct — and + // the two same-named `orderRepo` fields minted one id, dropping the second. + it('attributes grouped-block members to their own struct', () => { + const props = getRelationships(result, 'HAS_PROPERTY').map((e) => `${e.source}.${e.target}`); + expect(props).toContain('PickService.orderRepo'); + expect(props).toContain('Decoy.orderRepo'); + const methods = getRelationships(result, 'HAS_METHOD').map((e) => `${e.source}.${e.target}`); + expect(methods).toContain('MetricSink.Observe'); + expect(methods).not.toContain('AuditSink.Observe'); + }); +}); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index c253070fb..124529999 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -123,8 +123,16 @@ describe('PARSE_CACHE_VERSION', () => { // Moved 43 -> 44 for #2842's TypeScript heritage capture (interface and // abstract-class `@reference.inherits`), which is parse-time emission and so // cannot be served from a v43 warm cache. - it('pins SCHEMA_BUMP to 44 so concurrent bumps cannot silently collide (#2766)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(44); + // Moved 44 -> 45 for #2837 (Go struct/interface captures re-anchored from + // `type_declaration` to `type_spec`). This branch first took 44 and COLLIDED + // with #2842 above, which merged first — the ninth entry in the ledger and the + // third EXACT clash. Note what this pin could and could not do: it cannot + // detect the tie (both branches asserted `toBe(44)`, which passes when main is + // already 44); only the merge-time diff against origin/main surfaced it. What + // the pin DOES do is fail loudly the moment the constant and this expectation + // drift apart, which is what forces the re-check to happen at all. + it('pins SCHEMA_BUMP to 45 so concurrent bumps cannot silently collide (#2766)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(45); }); it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => { diff --git a/gitnexus/test/unit/leading-doc-description-all-languages.test.ts b/gitnexus/test/unit/leading-doc-description-all-languages.test.ts index 8a2165350..6bc3290c6 100644 --- a/gitnexus/test/unit/leading-doc-description-all-languages.test.ts +++ b/gitnexus/test/unit/leading-doc-description-all-languages.test.ts @@ -104,6 +104,61 @@ describe('leading-doc descriptionExtractor — behavior per comment family', () expect(d).toContain('GOMARK'); }); + // Go TYPE declarations, not just functions (#2837). Every Go row above uses + // `function_declaration`/`definition.function`, which anchors on its own + // declaration node and therefore carries the doc comment as a previous + // sibling. Types do not: #2837 re-anchored their captures onto the `type_spec`, + // whose `previousNamedSibling` is null because the comment belongs to the + // enclosing `type_declaration`. That silently removed the description from + // every Go struct and interface, and this file — the one place that checks doc + // extraction across all languages — was structurally incapable of noticing. + it('Go godoc on a struct declaration', () => { + const d = describeFromProvider( + SupportedLanguages.Go, + Go, + `package p\n// Wave carries orders, marker GOSTRUCT.\ntype Wave struct { n int }`, + 'type_spec', + 'definition.struct', + 'Struct', + 'Wave', + ); + expect(d).toContain('GOSTRUCT'); + }); + + it('Go godoc on an interface declaration', () => { + const d = describeFromProvider( + SupportedLanguages.Go, + Go, + `package p\n// Sink accepts events, marker GOIFACE.\ntype Sink interface { Emit() }`, + 'type_spec', + 'definition.interface', + 'Interface', + 'Sink', + ); + expect(d).toContain('GOIFACE'); + }); + + // A type declared inside a grouped `type (...)` block with its OWN doc comment + // must get that comment. Here the doc IS a previous sibling of the `type_spec` + // (verified against the real grammar), so this exercises the direct path while + // the two rows above exercise the `wrapperNodeTypes` fallback. + // + // The documented type is deliberately the FIRST spec in the block: the helper + // resolves `descendantsOfType(nodeType)[0]`, so a doc on a later spec would be + // read off the wrong node and the row would assert nothing. + it('Go godoc on a type inside a grouped declaration', () => { + const d = describeFromProvider( + SupportedLanguages.Go, + Go, + `package p\ntype (\n\t// First holds state, marker GOGROUPED.\n\tFirst struct { a int }\n\n\tSecond struct { b int }\n)`, + 'type_spec', + 'definition.struct', + 'Struct', + 'First', + ); + expect(d).toContain('GOGROUPED'); + }); + it('Rust /// doc comment', () => { const d = describeFromProvider( SupportedLanguages.Rust, diff --git a/gitnexus/test/unit/scope-resolution/go/go-package-clause.test.ts b/gitnexus/test/unit/scope-resolution/go/go-package-clause.test.ts new file mode 100644 index 000000000..9c4f8db3d --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/go/go-package-clause.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest'; +import Parser from 'tree-sitter'; +import Go from 'tree-sitter-go'; +import { + goPackageDir, + inferGoPackageName, +} from '../../../../src/core/ingestion/languages/go/package-clause.js'; +import { getGoParser, getGoScopeQuery } from '../../../../src/core/ingestion/languages/go/query.js'; +import { GO_QUERIES } from '../../../../src/core/ingestion/tree-sitter-queries.js'; + +/** + * #2837. Both Go package-bucketing passes used to carry their own copy of + * + * sourceText.match(/^\s*package\s+([A-Za-z_][A-Za-z0-9_]*)/m) + * + * whose `m` flag matches the first `package ` line ANYWHERE in the file, + * comment bodies included. The two rows marked "regression" below were measured + * returning the decoy name against that expression; everything else is a + * behaviour-preservation control, because a stricter resolver that rejected a + * real-world header would be a worse bug than the one being fixed. + */ +describe('inferGoPackageName (#2837)', () => { + it('reads a plain package clause', () => { + expect(inferGoPackageName('package services\n\nfunc f() {}\n')).toBe('services'); + }); + + it('reads past a build constraint', () => { + expect(inferGoPackageName('//go:build linux\n\npackage services\n')).toBe('services'); + }); + + it('reads past a line-comment doc block', () => { + expect(inferGoPackageName('// Package services does things.\npackage services\n')).toBe( + 'services', + ); + }); + + // REGRESSION: measured as "legacy_notes" before the fix. + it('ignores a package line inside a block comment', () => { + const src = '/*\npackage legacy_notes kept for history\n*/\npackage services\n'; + expect(inferGoPackageName(src)).toBe('services'); + }); + + // REGRESSION: measured as "helper" before the fix. + it('ignores an indented package line inside a block comment', () => { + const src = '/*\n package helper old name\n*/\npackage services\n'; + expect(inferGoPackageName(src)).toBe('services'); + }); + + it('reads past several comments on one line', () => { + expect(inferGoPackageName('/* a */ /* b */ package services\n')).toBe('services'); + }); + + it('reads past a byte-order mark and CRLF line endings', () => { + expect(inferGoPackageName('//go:build linux\r\n\r\npackage services\r\n')).toBe('services'); + }); + + it('is not fooled by a package line inside a later raw string literal', () => { + const src = 'package services\n\nconst tmpl = `\npackage other\n`\n'; + expect(inferGoPackageName(src)).toBe('services'); + }); + + // Go separates tokens by any whitespace, so this is legal and tree-sitter + // parses it without error. A stricter matcher would drop the file from BOTH + // Go cross-file passes (#2843 review). + it('accepts a newline between the keyword and the package name', () => { + expect(inferGoPackageName('package\nmain\n\nfunc f() {}\n')).toBe('main'); + }); + + it('accepts CR-only line endings', () => { + expect(inferGoPackageName('//go:build linux\r\rpackage services\r')).toBe('services'); + }); + + // `gorun`-style scripts. Not legal Go, but the regex this replaced skipped it + // via `/m`, so rejecting it would be a silent regression rather than a + // principled tightening. + it('reads past a leading shebang line', () => { + expect(inferGoPackageName('#!/usr/bin/env gorun\n\npackage main\n')).toBe('main'); + }); + + it('still stops on a # that is not a first-line shebang', () => { + expect(inferGoPackageName('// doc\n#!/usr/bin/env gorun\npackage main\n')).toBeNull(); + }); + + it('returns null when the first real token is not a package clause', () => { + expect(inferGoPackageName('func main() {}\n')).toBeNull(); + }); + + it('returns null for empty input', () => { + expect(inferGoPackageName('')).toBeNull(); + }); + + it('returns null for an unterminated block comment', () => { + expect(inferGoPackageName('/* never closed\npackage services\n')).toBeNull(); + }); + + it('returns null for a line comment running to EOF', () => { + expect(inferGoPackageName('// only a comment')).toBeNull(); + }); +}); + +describe('goPackageDir', () => { + it('returns the containing directory', () => { + expect(goPackageDir('internal/services/pick_service.go')).toBe('internal/services'); + }); + + it('normalizes Windows separators', () => { + expect(goPackageDir('internal\\services\\pick_service.go')).toBe('internal/services'); + }); + + it('returns an empty string for a repo-root file', () => { + expect(goPackageDir('main.go')).toBe(''); + }); +}); + +/** + * The scope/def range contract, asserted directly (#2843 review). + * + * #2837 moved five Go captures from the `type_declaration` onto the `type_spec`. + * That contract was previously observable only as 70 changed digests in the + * captures golden, where a future edit that moved ranges again would look + * identical. These rows state it outright, and pin the LOCKSTEP requirement: + * the scope capture and the def capture must name the SAME node, or the def is + * larger than its own scope and nothing is owned. + */ +describe('Go type capture anchoring (#2837)', () => { + // The SHIPPED queries, not copies of them. Inlining the patterns here would + // make every row below assert against the test's own string, so reverting the + // real re-anchor would leave them green — the exact regression they exist to + // catch. `getGoScopeQuery()` is the memoized `GO_SCOPE_QUERY`; `GO_QUERIES` is + // what the parse worker runs. + const goQueries = new Parser.Query(Go as Parameters[0], GO_QUERIES); + const nodesNamed = (src: string, query: Parser.Query, name: string) => + query + .captures(getGoParser().parse(src).rootNode) + .filter((c) => c.name === name) + .map((c) => c.node); + const classScopes = (src: string) => nodesNamed(src, getGoScopeQuery(), 'scope.class'); + + it('anchors the class scope on the type_spec, one per declared type', () => { + const nodes = classScopes( + 'package p\ntype ( A struct{ x int }\n B struct{ y int } )\ntype C struct{}\n', + ); + expect(nodes.map((n) => n.type)).toEqual(['type_spec', 'type_spec', 'type_spec']); + expect(nodes.map((n) => n.childForFieldName('name')?.text)).toEqual(['A', 'B', 'C']); + }); + + it('starts the scope range at the type name, not the `type` keyword', () => { + const [scope] = classScopes('package p\ntype C struct{}\n'); + expect(scope!.startIndex).toBe(scope!.childForFieldName('name')!.startIndex); + }); + + // The lockstep invariant, checked ACROSS the two shipped queries: the scope + // capture in languages/go/query.ts and the definition capture in + // tree-sitter-queries.ts must name the same node. Moving one without the other + // made the def strictly larger than its own scope and deleted every Go + // field-receiver edge, plain declarations included. + it('anchors scope and definition on the same node', () => { + const src = 'package p\ntype ( A struct{ x int }\n B struct{ y int } )\n'; + const defs = nodesNamed(src, goQueries, 'definition.struct'); + expect(defs.map((d) => d.type)).toEqual(['type_spec', 'type_spec']); + expect(defs.map((d) => d.startIndex)).toEqual(classScopes(src).map((s) => s.startIndex)); + }); + + it('emits no class scope for an alias or a named non-struct type', () => { + expect(classScopes('package p\ntype Alias = Other\ntype Named int\n')).toHaveLength(0); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/go/go-workspace-owners-warning.test.ts b/gitnexus/test/unit/scope-resolution/go/go-workspace-owners-warning.test.ts new file mode 100644 index 000000000..e4b4713f7 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/go/go-workspace-owners-warning.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/core/logger.js', () => ({ + logger: { + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + }, +})); + +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import { logger } from '../../../../src/core/logger.js'; +import { populateGoWorkspaceOwners } from '../../../../src/core/ingestion/languages/go/method-owners.js'; + +/** + * #2837. A Go file whose package clause cannot be resolved is dropped from + * method-owner resolution entirely — its methods can never attach to a struct + * declared in a sibling file. That used to be a bare `continue` with no trace, + * the same false-safe silence #2813 was filed about. + * + * The warning is bounded on purpose: one line per analyze, naming at most five + * paths, never one line per file. + */ +describe('populateGoWorkspaceOwners no-package-clause reporting (#2837)', () => { + beforeEach(() => { + vi.mocked(logger.warn).mockClear(); + }); + + it('says nothing when every file has a package clause', () => { + populateGoWorkspaceOwners([parsed('a/svc.go')], { + fileContents: new Map([['a/svc.go', 'package a\n']]), + }); + expect(vi.mocked(logger.warn)).not.toHaveBeenCalled(); + }); + + it('emits exactly one warning naming the file it dropped', () => { + populateGoWorkspaceOwners([parsed('a/svc.go'), parsed('a/broken.go')], { + fileContents: new Map([ + ['a/svc.go', 'package a\n'], + ['a/broken.go', 'func Orphan() {}\n'], + ]), + }); + expect(vi.mocked(logger.warn)).toHaveBeenCalledTimes(1); + const [payload] = vi.mocked(logger.warn).mock.calls[0] as [ + { skippedFiles: number; sample: string[] }, + ]; + expect(payload.skippedFiles).toBe(1); + expect(payload.sample).toEqual(['a/broken.go']); + }); + + // A file whose header comment happens to contain a `package` line used to be + // bucketed under THAT name, silently isolating it. It must now resolve to its + // real package and therefore produce no warning at all. + it('does not warn for a file whose comment mentions another package', () => { + populateGoWorkspaceOwners([parsed('a/svc.go')], { + fileContents: new Map([ + ['a/svc.go', '/*\npackage legacy_notes kept for history\n*/\npackage a\n'], + ]), + }); + expect(vi.mocked(logger.warn)).not.toHaveBeenCalled(); + }); + + it('caps the sample at five paths while reporting the true total', () => { + const files = Array.from({ length: 9 }, (_, i) => parsed(`a/f${i}.go`)); + const contents = new Map(files.map((f) => [f.filePath, 'not go source\n'])); + populateGoWorkspaceOwners(files, { fileContents: contents }); + expect(vi.mocked(logger.warn)).toHaveBeenCalledTimes(1); + const [payload] = vi.mocked(logger.warn).mock.calls[0] as [ + { skippedFiles: number; sample: string[] }, + ]; + expect(payload.skippedFiles).toBe(9); + expect(payload.sample).toHaveLength(5); + }); +}); + +function parsed(filePath: string): ParsedFile { + const localDef: SymbolDefinition = { + nodeId: `def:${filePath}`, + filePath, + type: 'Function', + qualifiedName: 'Noop', + }; + return { + filePath, + moduleScope: `module:${filePath}`, + scopes: [], + parsedImports: [], + localDefs: [localDef], + referenceSites: [], + }; +} diff --git a/gitnexus/test/unit/scope-resolution/unresolved-receiver-files.test.ts b/gitnexus/test/unit/scope-resolution/unresolved-receiver-files.test.ts new file mode 100644 index 000000000..a13186f46 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/unresolved-receiver-files.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const emit = vi.hoisted(() => vi.fn()); + +// Only `createLogger` is mocked: the module under test imports nothing else +// from the logger. The diagnostic is opt-in via `debugEnvVar` and emits at +// `debug`, so the assertions drive that child logger. +vi.mock('../../../src/core/logger.js', () => ({ + createLogger: () => ({ + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + debug: emit, + trace: vi.fn(), + fatal: vi.fn(), + isLevelEnabled: () => true, + }), +})); + +import { logUnresolvedReceiverFiles } from '../../../src/core/ingestion/scope-resolution/unresolved-receivers.js'; +import type { ResolutionOutcome } from '../../../src/core/ingestion/scope-resolution/resolution-outcome.js'; + +/** + * #2837 / #2843 review. This diagnostic exists so a reporter can see WHICH file + * lost its receivers. It must therefore count the same drops + * `summarizeUnresolvedReceivers` counts — the first version omitted both of that + * function's guards and its top-offender list was led by files that lost + * nothing (measured on this repo: the top three were all test files). + */ +describe('logUnresolvedReceiverFiles (#2843 review)', () => { + beforeEach(() => emit.mockClear()); + + const drop = (filePath: string, over: Partial = {}): ResolutionOutcome => + ({ + kind: 'suppressed', + phase: 'receiver-bound-calls', + reason: 'receiver-unresolved', + filePath, + name: 'DoWork', + range: { startLine: 1, startCol: 0, endLine: 1, endCol: 1 }, + candidateIds: [], + ...over, + }) as ResolutionOutcome; + + const payload = (): { + totalSites: number; + filesAffected: number; + topFiles: { filePath: string; sites: number }[]; + } => emit.mock.calls[0]![0] as never; + + it('says nothing when there are no receiver-unresolved drops', () => { + logUnresolvedReceiverFiles([]); + expect(emit).not.toHaveBeenCalled(); + }); + + it('counts in-program call drops per file', () => { + logUnresolvedReceiverFiles([drop('a.go'), drop('a.go'), drop('b.go')]); + expect(payload().topFiles).toEqual([ + { filePath: 'a.go', sites: 2 }, + { filePath: 'b.go', sites: 1 }, + ]); + expect(payload().totalSites).toBe(3); + }); + + // Guard 1, mirroring unresolved-receivers.ts: property reads/writes are not + // call sites. Measured there at 25 of 124 drops on the fixture corpus. + it('excludes non-call sites', () => { + logUnresolvedReceiverFiles([ + drop('a.go'), + drop('a.go', { siteKind: 'read' }), + drop('a.go', { siteKind: 'write' }), + ]); + expect(payload().topFiles).toEqual([{ filePath: 'a.go', sites: 1 }]); + }); + + // Guard 2: an external-rooted receiver (`fmt.Println`) reaches code this index + // does not contain, so no edge was ever possible and nothing was lost. + it('excludes external-rooted drops', () => { + logUnresolvedReceiverFiles([drop('a.go'), drop('a.go', { receiverOrigin: 'external' })]); + expect(payload().topFiles).toEqual([{ filePath: 'a.go', sites: 1 }]); + }); + + // `unknown` origin counts WITH in-program: assuming a completeness we cannot + // demonstrate is the unsafe direction. + it('counts unknown-origin drops', () => { + logUnresolvedReceiverFiles([drop('a.go', { receiverOrigin: 'unknown' })]); + expect(payload().topFiles).toEqual([{ filePath: 'a.go', sites: 1 }]); + }); + + it('ranks by count then by code units, and caps the sample at ten files', () => { + const outcomes = [ + ...Array.from({ length: 12 }, (_, i) => drop(`f${String(i).padStart(2, '0')}.go`)), + drop('zz.go'), + drop('zz.go'), + ]; + logUnresolvedReceiverFiles(outcomes); + expect(payload().topFiles).toHaveLength(10); + expect(payload().topFiles[0]).toEqual({ filePath: 'zz.go', sites: 2 }); + // Ties break by code-unit order, so the survivors of the cap are stable. + expect( + payload() + .topFiles.slice(1) + .map((f) => f.filePath), + ).toEqual([ + 'f00.go', + 'f01.go', + 'f02.go', + 'f03.go', + 'f04.go', + 'f05.go', + 'f06.go', + 'f07.go', + 'f08.go', + ]); + // The total is the true count, not the capped sample. + expect(payload().totalSites).toBe(14); + expect(payload().filesAffected).toBe(13); + }); +}); From f2717c6a7c44ea61f1e0b0e5ce3404751ee1f6dc Mon Sep 17 00:00:00 2001 From: Shifra Williams Date: Wed, 5 Aug 2026 17:19:44 -0700 Subject: [PATCH 06/27] feat(render): add one-click deploy to render support (#2804) --- Dockerfile.cli | 6 +- README.md | 22 + SECURITY.md | 13 + docker-server.mjs | 446 +++++++++- docker-server.test.mjs | 823 ++++++++++++++++++ .../src/components/AccessTokenPrompt.tsx | 67 ++ gitnexus-web/src/components/DropZone.tsx | 16 +- gitnexus-web/src/components/SettingsPanel.tsx | 28 + .../src/components/settings/SecretInput.tsx | 56 ++ gitnexus-web/src/config/ui-constants.ts | 15 + gitnexus-web/src/hooks/useAppState.tsx | 4 +- gitnexus-web/src/hooks/useBackend.ts | 18 +- gitnexus-web/src/i18n/error-messages.ts | 2 + gitnexus-web/src/locales/en/errors.json | 1 + gitnexus-web/src/locales/en/settings.json | 11 + gitnexus-web/src/locales/zh-CN/errors.json | 1 + gitnexus-web/src/locales/zh-CN/settings.json | 11 + gitnexus-web/src/services/backend-client.ts | 245 ++++-- .../test/unit/access-token-prompt.test.tsx | 60 ++ .../test/unit/backend-client-auth.test.ts | 239 +++++ gitnexus-web/test/unit/heartbeat.test.ts | 327 ++++--- .../test/unit/settings-panel-token.test.tsx | 49 ++ gitnexus/test/unit/render-blueprint.test.ts | 144 +++ render.yaml | 81 ++ 24 files changed, 2482 insertions(+), 203 deletions(-) create mode 100644 gitnexus-web/src/components/AccessTokenPrompt.tsx create mode 100644 gitnexus-web/src/components/settings/SecretInput.tsx create mode 100644 gitnexus-web/test/unit/access-token-prompt.test.tsx create mode 100644 gitnexus-web/test/unit/backend-client-auth.test.ts create mode 100644 gitnexus-web/test/unit/settings-panel-token.test.tsx create mode 100644 gitnexus/test/unit/render-blueprint.test.ts create mode 100644 render.yaml diff --git a/Dockerfile.cli b/Dockerfile.cli index 633d23f5d..b42c22dad 100644 --- a/Dockerfile.cli +++ b/Dockerfile.cli @@ -125,5 +125,7 @@ ENV GITNEXUS_HOME=/data/gitnexus \ EXPOSE 4747 -# Bind to 0.0.0.0 so the server is reachable from the host's mapped port. -CMD ["node", "gitnexus/dist/cli/index.js", "serve", "--host", "0.0.0.0", "--port", "4747"] +# Bind 0.0.0.0 for the host's mapped port, honoring an injected $PORT (Render +# sets one). `sh -c` expands it; `exec` keeps the server PID 1 so SIGTERM still +# reaches it. Platforms can rely on this instead of a dockerCommand override. +CMD ["sh", "-c", "exec gitnexus serve --host 0.0.0.0 --port \"${PORT:-4747}\""] diff --git a/README.md b/README.md index 7158cbd66..d6534f658 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,28 @@ That's it. `analyze` indexes the codebase, installs agent skills, registers Clau +### Deploy to Render + +Deploy GitNexus in one click: + +[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/abhigyanpatwari/GitNexus) + +The Blueprint creates two services. `gitnexus-server` runs `gitnexus serve` as a private service: no public URL, reachable only over Render's private network, with a persistent disk for indexes and cloned repos. `gitnexus-web` is the public one. It serves the UI and reverse-proxies `/api/*` to the server, so the browser talks to a single origin. + +At the Blueprint's defaults this runs about **$35/month**: $25 for the server's `standard` instance, $7 for the web service's `starter` instance, and $2.50 for the 10 GB disk. See [Render's pricing](https://render.com/pricing) for other plans. + +The deploy generates an access token, and the UI asks for it on first use: + +1. Open the `gitnexus-web` service in your [Render dashboard](https://dashboard.render.com/). +2. Copy `GITNEXUS_SERVE_AUTH_TOKEN` from its **Environment** tab. +3. Load the site and paste the token into the prompt (or the settings panel). + +Every `/api/*` request carries that token as a header, and the proxy answers `401` without it. The browser keeps it in `sessionStorage`, so a new tab asks again. To rotate it, edit the environment variable and redeploy. + +The proxy strips `Origin` before forwarding, so the server's CSRF guard does nothing for proxied traffic; it passes `Origin`-less requests through by design. The token is the only control on this deploy, not a second layer behind the guard. Anyone holding it can read every indexed repo. See [SECURITY.md](SECURITY.md#hosted-deploys-on-render). + +Indexing is memory-bound. If `gitnexus-server` runs out of memory on a large repo, raise its `plan`, which sets available RAM: `standard` is 2 GB, `pro` is 4 GB. Raise `sizeGB` only if the disk fills with clones and indexes. + ## Two Ways to Use GitNexus | | **CLI + MCP** (recommended) | **Web UI** | diff --git a/SECURITY.md b/SECURITY.md index 79ef97f6b..d1fbcd051 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -51,6 +51,19 @@ If you fork GitNexus or self-host it, we recommend enabling the following in you - **Secret scanning** and **Push protection** — blocks pushes that introduce known secret patterns. Defense-in-depth on top of the in-CI Gitleaks scan documented below. - **Code scanning** — surfaces SARIF results from CodeQL, Trivy, Scorecard, and zizmor in one place. +### Hosted Deploys on Render + +The `render.yaml` Blueprint (see the README's **Deploy to Render**) puts `gitnexus serve` on a **private service** with no public URL, and a public web service in front of it that reverse-proxies `/api/*`. What that does and does not protect: + +- **The web service is public and its URL is discoverable.** `onrender.com` hostnames appear in certificate transparency logs. Treat the URL as known rather than secret. +- **The generated `GITNEXUS_SERVE_AUTH_TOKEN` is the only access control.** The proxy rejects any `/api/*` request without it with a `401` before forwarding. Rotate it by editing the environment variable on the `gitnexus-web` service and redeploying. +- **The CSRF guard is inert on this path.** The proxy strips `Origin` before forwarding, so the server's write-origin guard does nothing for proxied traffic — it passes `Origin`-less requests through by design. The token is not a second layer behind the guard. +- **Anyone holding the token can read every indexed repo's source.** These routes carry no origin guard, and the first three carry no rate limiter either: `GET /api/repos`, `GET /api/graph`, `POST /api/query`, `GET /api/file`, `GET /api/grep`. Whoever has the token can also index and delete repositories. +- **`POST /api/mcp` rides the same path.** `serve` mounts the MCP handler via `mountMCPEndpoints`, and `createStreamableHttpHandler` is called with no `authToken` — a **pre-existing** gap in `serve` itself, not something this deploy introduces. On Render it is closed only by the edge token and the private network. A `serve` bound directly to a public interface has no such cover. +- **Rate limits bound cost, not access.** They cap what a token holder can spend; they do not decide who gets in. + +Do not hand the URL out as a public demo. A token holder has read access to everything the deploy has indexed. + ## Automated Scans Running in CI This repository runs the following scans automatically. Findings appear under the repository's **Security → Code scanning** tab. diff --git a/docker-server.mjs b/docker-server.mjs index f6c9eb8f6..e3e9f68ee 100644 --- a/docker-server.mjs +++ b/docker-server.mjs @@ -1,5 +1,8 @@ +import { timingSafeEqual } from 'node:crypto'; +import { writeSync } from 'node:fs'; import { open } from 'node:fs/promises'; -import { createServer } from 'node:http'; +import { createServer, request as httpRequest } from 'node:http'; +import { request as httpsRequest } from 'node:https'; import { extname, isAbsolute, normalize, relative, resolve, sep } from 'node:path'; const host = '0.0.0.0'; @@ -22,18 +25,430 @@ function jsonForScriptTag(obj) { .replace(/&/g, '\\u0026'); } -const rawBackendUrl = process.env.GITNEXUS_BACKEND_URL ?? null; -if (rawBackendUrl && !isValidUrl(rawBackendUrl)) { - const safeRaw = rawBackendUrl.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 200); - console.warn( - `[gitnexus-web] GITNEXUS_BACKEND_URL "${safeRaw}" is not a valid http/https URL -- ignoring.`, +// Warnings echo operator input back, so strip control characters (log forging) +// and cap the length first. +function sanitizeForLog(value) { + return ( + String(value) + // The line-break strip is redundant with the range below, but CodeQL's + // js/log-injection recognizes only this shape as a sanitizer: a global + // replace of a literal \n with the empty string. + .replace(/\n/g, '') + .replace(/\r/g, '') + .replace(/[\x00-\x1f\x7f]/g, ' ') + .slice(0, 200) ); } -const backendUrl = rawBackendUrl && isValidUrl(rawBackendUrl) ? rawBackendUrl : null; + +// console.error is asynchronous when stderr is a pipe, so pairing it with +// process.exit can drop the one message explaining the refusal. writeSync isn't. +function exitWithRefusal(message) { + writeSync(2, `${message}\n`); + process.exit(1); +} + +// `value` if it's a usable http/https URL, else null + a warning naming `label`. +// `rawForLog` lets a caller that normalized first echo back the operator's input. +function validHttpUrl(label, value, rawForLog = value) { + if (!value) return null; + if (isValidUrl(value)) return value; + const safeRaw = sanitizeForLog(rawForLog); + console.warn(`[gitnexus-web] ${label} "${safeRaw}" is not a valid http/https URL -- ignoring.`); + return null; +} + +// Numeric env var. Every consumer below reads <= 0 as "disabled", so obeying a +// typo like -1 would switch a timeout off silently. Warn and use the default. +function numberFromEnv(label, fallback, min = 0) { + const raw = process.env[label]; + if (raw === undefined || raw === '') return fallback; + const n = Number(raw); + if (!Number.isFinite(n)) { + console.warn( + `[gitnexus-web] ${label} "${sanitizeForLog(raw)}" is not a number -- using ${fallback}.`, + ); + return fallback; + } + if (n < min) { + console.warn( + `[gitnexus-web] ${label} "${sanitizeForLog(raw)}" is below the minimum ${min} -- using ${fallback}.`, + ); + return fallback; + } + return n; +} + +// Falls back to RENDER_EXTERNAL_URL so a Render web service hands the browser +// its own public origin — same-origin API calls via the proxy below, no config. +const backendUrlVar = + process.env.GITNEXUS_BACKEND_URL !== undefined ? 'GITNEXUS_BACKEND_URL' : 'RENDER_EXTERNAL_URL'; +const rawBackendUrl = process.env.GITNEXUS_BACKEND_URL ?? process.env.RENDER_EXTERNAL_URL ?? null; +const backendUrl = validHttpUrl(backendUrlVar, rawBackendUrl); const configScript = backendUrl ? `` : ''; +// Optional same-origin reverse proxy for the API server. On a split deploy +// (public web service, private API) the browser must reach the API without a +// cross-origin request, since its CORS allowlist and write-route guard only +// admit same-host origins. So the browser targets THIS origin and we forward +// /api/* to GITNEXUS_UPSTREAM_URL. Unset → no proxy (docker-compose default). +// A scheme-less host:port — what Render's `fromService: hostport` yields — +// gets http:// prepended. +const rawUpstream = process.env.GITNEXUS_UPSTREAM_URL; +const rawUpstreamUrl = rawUpstream + ? /^https?:\/\//.test(rawUpstream) + ? rawUpstream + : `http://${rawUpstream}` + : null; +const upstreamBase = validHttpUrl('GITNEXUS_UPSTREAM_URL', rawUpstreamUrl, rawUpstream); +// The one origin this proxy will ever connect to (see proxyToUpstream). +const upstreamOrigin = upstreamBase ? new URL(upstreamBase).origin : null; + +// The Bearer token every /api/* request must carry. The private upstream has no +// auth of its own and loses its Origin guard one hop below (see +// proxyToUpstream), so the gate belongs here. The browser holds it — never +// inject it next to `backendUrl`. Blank-is-absent follows resolveAuthToken +// (gitnexus/src/mcp/http-transport.ts). +const authToken = process.env.GITNEXUS_SERVE_AUTH_TOKEN?.trim() || null; + +// Mirrors the non-loopback refusal in http-transport.ts (startMcpHttpServer), +// relocated because the trust boundary is here: an unguarded `serve` behind a +// private service is legitimate, an unguarded public proxy is not. +if (upstreamBase && !authToken) { + exitWithRefusal( + '[gitnexus-web] Refusing to start: GITNEXUS_UPSTREAM_URL is set without ' + + 'GITNEXUS_SERVE_AUTH_TOKEN. The proxy would expose every indexed repo — ' + + 'index, read source, and delete — to anyone with this URL. Set a token, ' + + 'or unset GITNEXUS_UPSTREAM_URL to serve static assets only.', + ); +} + +// Rejected requests never reach the upstream limiter, so guesses are free. A +// throttle would add per-address state to a stateless proxy and a lockout an +// attacker can aim at a real user; a length floor makes guessing hopeless and +// only ever rejects a hand-picked token. +const MIN_AUTH_TOKEN_LENGTH = 32; +if (authToken && authToken.length < MIN_AUTH_TOKEN_LENGTH) { + exitWithRefusal( + `[gitnexus-web] Refusing to start: GITNEXUS_SERVE_AUTH_TOKEN is shorter than ` + + `${MIN_AUTH_TOKEN_LENGTH} characters. It is the only thing standing between the ` + + 'public internet and every indexed repo, and a failed guess is not rate-limited. ' + + 'Use a generated random value.', + ); +} + +// Whether an inbound X-Forwarded-For may be believed (see clientAddressFor). +// Default off, so a wrong deployment fails toward over-restriction rather than +// toward an address the caller picks. `true` is rejected as it is server-side +// (resolveTrustProxy, which also takes hop counts and so rejects `yes`/`on` +// too): it reads as "trust the whole chain". +function resolveTrustXff(raw) { + const value = raw?.trim(); + if (!value) return false; + if (/^(1|yes|on)$/i.test(value)) return true; + if (/^(0|no|off|false)$/i.test(value)) return false; + console.warn( + `[gitnexus-web] GITNEXUS_PROXY_TRUST_XFF "${sanitizeForLog(value)}" is not a recognized ` + + 'boolean -- ignoring the inbound X-Forwarded-For chain. Set 1 only when a load balancer ' + + 'that appends the real peer sits in front of this service.', + ); + return false; +} +const trustInboundXff = resolveTrustXff(process.env.GITNEXUS_PROXY_TRUST_XFF); + +// Idle timeout for a proxied request → 504. Socket activity (SSE heartbeats) +// resets it, so long-lived streams are unaffected. 0 disables. +const proxyTimeoutMs = numberFromEnv('GITNEXUS_PROXY_TIMEOUT_MS', 120000); + +// nginx's client_body_timeout equivalent: how long to wait for a replayable +// client body before 400. Defaults to the idle timeout; 0 disables. +const proxyClientBodyTimeoutMs = numberFromEnv( + 'GITNEXUS_PROXY_CLIENT_BODY_TIMEOUT_MS', + proxyTimeoutMs, +); + +// Bounded connection-retry, to ride out the few-second window where a +// single-instance upstream (private server + disk ⇒ no zero-downtime deploy) +// is restarting. Attempts of 1 disables it, and body buffering with it. +const proxyRetryAttempts = numberFromEnv('GITNEXUS_PROXY_RETRY_ATTEMPTS', 3, 1); +const proxyRetryEnabled = proxyRetryAttempts > 1; +const proxyRetryMaxBodyBytes = numberFromEnv('GITNEXUS_PROXY_RETRY_MAX_BODY_BYTES', 256 * 1024); +// Never connected ⇒ the upstream got nothing ⇒ safe to replay any method. +const preConnectRetryCodes = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN']); +// Failed after connecting ⇒ the upstream may already be working on it, so +// replay only idempotent methods (RFC 7231 §4.2.2) to avoid double-execution. +const postConnectRetryCodes = new Set(['ECONNRESET', 'ETIMEDOUT']); +const idempotentMethods = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE']); + +// Buffer a request body, capped. Resolves null on overflow, client error, or +// timeout — one "unreadable body" contract, which the caller maps to 400. +// Listeners detach once settled so a later pipe of the same request is clean. +function readBodyCapped(req, cap, timeoutMs) { + return new Promise((resolvePromise) => { + const chunks = []; + let total = 0; + let settled = false; + let timer = null; + const cleanup = () => { + if (timer) clearTimeout(timer); + req.removeListener('data', onData); + req.removeListener('end', onEnd); + req.removeListener('error', onError); + }; + const finish = (value) => { + if (settled) return; + settled = true; + cleanup(); + resolvePromise(value); + }; + const onData = (chunk) => { + total += chunk.length; + if (total > cap) { + finish(null); + return; + } + chunks.push(chunk); + }; + const onEnd = () => finish(Buffer.concat(chunks)); + const onError = () => finish(null); + req.on('data', onData); + req.on('end', onEnd); + req.on('error', onError); + // Hard cap regardless of idle activity; Node's requestTimeout is the outer + // backstop. + if (timeoutMs > 0) { + timer = setTimeout(() => { + console.warn(`[gitnexus-web] client body read timed out after ${timeoutMs}ms`); + finish(null); + }, timeoutMs); + } + }); +} + +// Constant-time Bearer check, mirroring createAuthMiddleware in +// gitnexus/src/mcp/http-transport.ts — dummy comparison included, so an absent +// or wrong-length header costs the same and the timing can't leak the length. +// Duplicated because this file is plain ESM and can't import from gitnexus/src. +function authorized(req) { + if (!authToken) return true; // static-only: no proxy, nothing to gate + const header = req.headers['authorization']; + const expected = Buffer.from(`Bearer ${authToken}`); + if (typeof header !== 'string') { + timingSafeEqual(Buffer.alloc(expected.length), expected); + return false; + } + const provided = Buffer.from(header); + if (provided.length !== expected.length) { + timingSafeEqual(Buffer.alloc(expected.length), expected); + return false; + } + return timingSafeEqual(provided, expected); +} + +// WWW-Authenticate names the scheme; the stable `code` is what the web client +// dispatches on, not message text. The body must not distinguish "no token +// configured" from "wrong token". `Connection: close` because we answer before +// reading the body, which Node would otherwise drain (as with the 400 below). +function sendUnauthorized(res) { + const body = JSON.stringify({ error: 'unauthorized', code: 'unauthorized' }); + res.writeHead(401, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + 'WWW-Authenticate': 'Bearer', + Connection: 'close', + }); + res.end(body); +} + +// Fail a proxied request. Once headers are sent the body is partially written +// and can't be replaced, so the socket is all we can destroy. +function failGateway(res, status, message) { + if (res.headersSent) { + res.destroy(); + } else { + res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end(message); + } +} + +// Hop-by-hop headers (RFC 7230 §6.1) describe one connection, so a proxy must +// not forward them in either direction; Node sets its own per hop. +const hopByHopHeaders = [ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]; + +function stripHopByHopHeaders(headers) { + // §6.1 also lets `Connection` name additional single-hop headers, which the + // fixed list below can't cover. Node lowercases header keys on both the + // server and client side, so a lowercased name indexes `headers` directly. + for (const listed of String(headers.connection ?? '').split(',')) { + const name = listed.trim().toLowerCase(); + if (name) delete headers[name]; + } + for (const name of hopByHopHeaders) delete headers[name]; + return headers; +} + +// The client address this proxy vouches for upstream. The API keys its rate +// limiter off req.ip, so forwarding a client-supplied X-Forwarded-For would let +// anyone rotate a fake address per request. Which entry is real depends on a +// deployment fact this process can't observe (is anything in front appending the +// peer?), so the operator asserts it via GITNEXUS_PROXY_TRUST_XFF; until then we +// forward the socket peer. +function clientAddressFor(req) { + if (!trustInboundXff) return req.socket.remoteAddress || null; + const forwarded = String(req.headers['x-forwarded-for'] ?? '') + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + .pop(); + return forwarded || req.socket.remoteAddress || null; +} + +// Forward an `/api/*` request upstream, streaming both bodies (SSE / chunked +// graph streams) untouched. Retries connect failures when the body is replayable. +async function proxyToUpstream(req, res) { + let upstream; + try { + upstream = new URL(req.url, upstreamBase); + } catch { + res.writeHead(400); + res.end('Bad request'); + return; + } + // The `/api/` route guard keeps req.url host-relative, so resolution can't + // leave upstreamBase. Asserting it here means the SSRF boundary doesn't rest + // on that two-step argument: one legitimate destination, checked locally. + if (upstream.origin !== upstreamOrigin) { + console.error(`[gitnexus-web] refusing to proxy off-origin target ${upstream.origin}`); + res.writeHead(400); + res.end('Bad request'); + return; + } + const isHttps = upstream.protocol === 'https:'; + const requestFn = isHttps ? httpsRequest : httpRequest; + const headers = stripHopByHopHeaders({ ...req.headers }); + // Terminate the browser origin: the API admits Origin-less requests as + // trusted server-to-server calls. Nothing is lost — the browser only ever + // talks to this same-origin web service. + delete headers.origin; + delete headers.referer; + // The edge token is spent here. `serve` reads no Authorization header + // (gitnexus/src/server/mcp-http.ts mounts /api/mcp unguarded), so forwarding + // it would only copy a live credential into another service's logs. Pinned by + // test. + delete headers.authorization; + headers.host = upstream.host; + // Replace, never forward, the inbound chain (see clientAddressFor). + const clientAddress = clientAddressFor(req); + if (clientAddress) headers['x-forwarded-for'] = clientAddress; + else delete headers['x-forwarded-for']; + + // A retry replays the body, so buffer it up front — but only when small and + // of known length. Larger/unknown bodies (multipart uploads) stream once with + // no retry; an upload is never buffered. + const method = (req.method || 'GET').toUpperCase(); + const isIdempotentMethod = idempotentMethods.has(method); + // A request has a body iff it frames one (RFC 7230 §3.3.3). Keying off the + // method sends a bodyless DELETE down the stream-once path and gives up a + // replay that costs nothing. + const hasBody = + req.headers['content-length'] !== undefined || req.headers['transfer-encoding'] !== undefined; + const len = Number(req.headers['content-length']); + const bufferable = + proxyRetryEnabled && Number.isFinite(len) && len >= 0 && len <= proxyRetryMaxBodyBytes; + let bodyBuf = hasBody ? null : Buffer.alloc(0); + if (hasBody && bufferable) { + bodyBuf = await readBodyCapped(req, proxyRetryMaxBodyBytes, proxyClientBodyTimeoutMs); + if (bodyBuf === null) { + // Overflow, client error, and timeout all collapse to 400 (not 413/408). + // `Connection: close` lets Node drop the socket after the 400 flushes, + // rather than half-open draining a stalled upload until requestTimeout. + if (!res.headersSent) { + res.writeHead(400, { + 'Content-Type': 'text/plain; charset=utf-8', + Connection: 'close', + }); + res.end('Bad request'); + } + return; + } + } + // bodyBuf === null means "stream the live request once, no retry". + const retryEligible = bodyBuf !== null; + + const attempt = (n) => { + let timedOut = false; + const upstreamReq = requestFn( + { + protocol: upstream.protocol, + hostname: upstream.hostname, + port: upstream.port || (isHttps ? 443 : 80), + method: req.method, + path: upstream.pathname + upstream.search, + headers, + }, + (upstreamRes) => { + // Pipe rather than buffer, so SSE / chunked streams reach the browser + // incrementally. Node re-derives Transfer-Encoding for this hop. + const responseHeaders = stripHopByHopHeaders({ ...upstreamRes.headers }); + res.writeHead(upstreamRes.statusCode || 502, responseHeaders); + upstreamRes.on('error', () => res.destroy()); + upstreamRes.pipe(res); + }, + ); + upstreamReq.on('error', (err) => { + if (timedOut) return; // 504 already sent by the timeout handler below + // Only before any response byte reaches the browser — once headers are + // sent the body is partially written and can't be replayed. + const retryableError = + preConnectRetryCodes.has(err.code) || + (isIdempotentMethod && postConnectRetryCodes.has(err.code)); + if (retryEligible && !res.headersSent && n < proxyRetryAttempts && retryableError) { + const delay = 250 * 2 ** (n - 1); // 250ms, 500ms, ... + console.warn( + `[gitnexus-web] upstream ${sanitizeForLog(err.code)}; retry ${n}/${proxyRetryAttempts - 1} in ${delay}ms`, + ); + setTimeout(() => { + // The client may have aborted during the backoff window; don't fire a + // fresh upstream request nobody is waiting for anymore. + if (res.writableEnded || res.destroyed) return; + attempt(n + 1); + }, delay); + return; + } + console.error('[gitnexus-web] upstream proxy error:', sanitizeForLog(err.message)); + failGateway(res, 502, 'Bad gateway'); + }); + if (proxyTimeoutMs > 0) { + upstreamReq.setTimeout(proxyTimeoutMs, () => { + timedOut = true; + console.error(`[gitnexus-web] upstream proxy timeout after ${proxyTimeoutMs}ms`); + failGateway(res, 504, 'Gateway timeout'); + upstreamReq.destroy(); + }); + } + if (bodyBuf !== null) { + // Replayable body already buffered; write it fresh on each attempt. + if (bodyBuf.length) upstreamReq.write(bodyBuf); + upstreamReq.end(); + } else { + // Non-retryable: stream the live request once. + req.on('error', () => upstreamReq.destroy()); + req.pipe(upstreamReq); + } + }; + attempt(1); +} + const contentTypes = { '.css': 'text/css; charset=utf-8', '.html': 'text/html; charset=utf-8', @@ -68,6 +483,23 @@ const spaFallback = resolve(root, 'index.html'); const server = createServer(async (req, res) => { const urlPath = req.url?.split('?')[0] || '/'; + // Same-origin API proxy; everything else falls through to the SPA below. + if (upstreamBase && (urlPath === '/api' || urlPath.startsWith('/api/'))) { + // Before body buffering and the upstream socket, so an unauthenticated + // request costs nothing upstream. Static assets are never gated: the UI has + // to load in order to prompt for the token. + if (!authorized(req)) { + sendUnauthorized(res); + return; + } + // Fire-and-forget, so guard the boundary against unhandledRejection. + proxyToUpstream(req, res).catch((err) => { + console.error('[gitnexus-web] proxy handler crashed:', sanitizeForLog(err?.message ?? err)); + failGateway(res, 502, 'Bad gateway'); + }); + return; + } + let decoded; try { decoded = decodeURIComponent(urlPath); diff --git a/docker-server.test.mjs b/docker-server.test.mjs index ee3a4301a..80e742f7e 100644 --- a/docker-server.test.mjs +++ b/docker-server.test.mjs @@ -1,4 +1,5 @@ import { mkdir, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'; +import { connect } from 'node:net'; import http, { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -263,3 +264,825 @@ it('does not inject config into static assets', async () => { assert.equal(res.body, 'body{}'); }); }); +// -- API reverse proxy (GITNEXUS_UPSTREAM_URL) ----------------------------- + +// Every proxy fixture below runs the server with this token: the proxy refuses +// to start without one, and refuses one under 32 characters. +const TEST_AUTH_TOKEN = 'proxy-test-token-0123456789abcdefghij'; +const TEST_BEARER = `Bearer ${TEST_AUTH_TOKEN}`; + +// rawRequest never sends credentials; apiRequest does. In a file whose subject +// is who gets let through, no test should pass because a helper quietly +// authenticated for it. +function rawRequest(port, path, { method = 'GET', headers = {}, body } = {}) { + // Send an explicit Content-Length like a browser fetch() does — the proxy + // only buffers (and so only retries) bodies of known length. + const outHeaders = { ...headers }; + if ( + body !== undefined && + !Object.keys(outHeaders).some((h) => h.toLowerCase() === 'content-length') + ) { + outHeaders['content-length'] = String(Buffer.byteLength(body)); + } + return new Promise((resolve, reject) => { + const req = http.request( + { host: '127.0.0.1', port, path, method, headers: outHeaders }, + (res) => { + let respBody = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { + respBody += chunk; + }); + res.on('end', () => + resolve({ status: res.statusCode, headers: res.headers, body: respBody }), + ); + }, + ); + req.on('error', reject); + if (body !== undefined) req.write(body); + req.end(); + }); +} + +// An authenticated /api/* call. An explicit `authorization` header wins, so the +// auth tests can send a wrong one. +function apiRequest(port, path, { headers = {}, ...rest } = {}) { + const hasAuth = Object.keys(headers).some((h) => h.toLowerCase() === 'authorization'); + return rawRequest(port, path, { + ...rest, + headers: hasAuth ? headers : { ...headers, authorization: TEST_BEARER }, + }); +} + +const respondOk = (_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end('{"ok":true}'); +}; + +// Every proxy test needs the same four parts: a dist/ to serve, a fake upstream, +// a docker-server pointed at it, and teardown that leaks neither a process nor a +// temp dir. They differ only in how the upstream misbehaves. +// +// upstream request handler, replaceable mid-test via `ctx.handler`; +// null points the proxy at a port nothing ever listens on +// listenAfterMs bind the upstream this late, so the first attempt(s) hit +// ECONNREFUSED (a single-instance restart window) +// schemeless drop http:// from GITNEXUS_UPSTREAM_URL, the way Render's +// `fromService: { property: hostport }` yields it +// env extra environment for docker-server.mjs +// +// `ctx` collects what the upstream saw (calls, last request, last body) plus the +// proxy's stderr, so assertions read off one object. +async function withProxy( + { upstream = respondOk, listenAfterMs = 0, schemeless = false, env = {} } = {}, + fn, +) { + const dir = await mkdtemp(join(tmpdir(), 'gitnexus-proxy-')); + await mkdir(join(dir, 'dist'), { recursive: true }); + await writeFile(join(dir, 'dist', 'index.html'), 'spa'); + + const ctx = { calls: 0, received: null, body: null, stderr: '', handler: upstream }; + // Read the forwarded request to completion before handing it to the handler, + // so no test has to repeat that plumbing to assert on headers or body. + const server = upstream + ? createServer((req, res) => { + let body = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + ctx.calls += 1; + ctx.body = body; + ctx.received = { method: req.method, url: req.url, headers: req.headers, body }; + ctx.handler(req, res); + }); + }) + : null; + + // A late (or never) bind needs its port reserved up front; otherwise let the + // OS assign one at listen time. + const upstreamPort = + server && listenAfterMs === 0 + ? await new Promise((r) => server.listen(0, '127.0.0.1', () => r(server.address().port))) + : await getFreePort(); + const bindTimer = + server && listenAfterMs > 0 + ? setTimeout(() => server.listen(upstreamPort, '127.0.0.1'), listenAfterMs) + : null; + + const port = await getFreePort(); + const target = `127.0.0.1:${upstreamPort}`; + const proc = spawnServerWithEnv(dir, port, { + GITNEXUS_UPSTREAM_URL: schemeless ? target : `http://${target}`, + GITNEXUS_SERVE_AUTH_TOKEN: TEST_AUTH_TOKEN, + ...env, + }); + proc.stderr.setEncoding('utf8'); + proc.stderr.on('data', (chunk) => { + ctx.stderr += chunk; + }); + try { + await waitForServer(port); + await fn(port, ctx); + } finally { + if (bindTimer) clearTimeout(bindTimer); + await killAndWait(proc); + if (server?.listening) { + server.closeAllConnections?.(); + await new Promise((r) => server.close(r)); + } + await rm(dir, { recursive: true, force: true }); + } +} + +it('proxies /api/* requests to the upstream server', async () => { + await withProxy({}, async (port, ctx) => { + const res = await apiRequest(port, '/api/info?x=1'); + assert.equal(res.status, 200); + assert.match(res.body, /"ok":true/); + assert.equal(ctx.received.url, '/api/info?x=1', 'path + query forwarded verbatim'); + }); +}); + +it('forwards the request method and body to the upstream', async () => { + await withProxy({}, async (port, ctx) => { + await apiRequest(port, '/api/query', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{"q":"hello"}', + }); + assert.equal(ctx.received.method, 'POST'); + assert.equal(ctx.received.body, '{"q":"hello"}'); + }); +}); + +it('strips the browser Origin and Referer before forwarding to the API', async () => { + await withProxy({}, async (port, ctx) => { + await apiRequest(port, '/api/info', { + headers: { origin: 'https://gitnexus-web.onrender.com', referer: 'https://x/y' }, + }); + assert.equal( + ctx.received.headers.origin, + undefined, + 'Origin must be stripped so the API treats it as a trusted server-to-server call', + ); + assert.equal(ctx.received.headers.referer, undefined, 'Referer must be stripped'); + }); +}); + +it('strips hop-by-hop headers before forwarding to the API', async () => { + await withProxy({}, async (port, ctx) => { + await apiRequest(port, '/api/info', { + headers: { + 'keep-alive': 'timeout=5', + upgrade: 'h2c', + 'proxy-authorization': 'Basic abc', + te: 'trailers', + }, + }); + assert.equal(ctx.received.headers['keep-alive'], undefined); + assert.equal(ctx.received.headers.upgrade, undefined); + assert.equal(ctx.received.headers['proxy-authorization'], undefined); + assert.equal(ctx.received.headers.te, undefined); + }); +}); + +it('strips request headers that Connection names as single-hop', async () => { + await withProxy({}, async (port, ctx) => { + // RFC 7230 §6.1 lets Connection name hop-by-hop headers beyond the + // well-known eight, and those must not be forwarded either. Against a fixed + // list alone, x-custom-hop reaches the upstream. + await apiRequest(port, '/api/info', { + headers: { connection: 'x-custom-hop', 'x-custom-hop': 'private' }, + }); + assert.equal(ctx.received.headers['x-custom-hop'], undefined); + // Connection itself is always re-derived by Node for the upstream hop, so + // assert the client's value didn't survive rather than that it's absent. + assert.notEqual(ctx.received.headers.connection, 'x-custom-hop'); + }); +}); + +it('collapses a spoofed X-Forwarded-For chain to the load balancer entry when XFF is trusted', async () => { + const env = { GITNEXUS_PROXY_TRUST_XFF: '1' }; + await withProxy({ env }, async (port, ctx) => { + // With a load balancer in front, only the last entry is the LB's; the rest + // is client-supplied and would otherwise let a caller fake req.ip and evade + // the API's rate limits. + await apiRequest(port, '/api/info', { + headers: { 'x-forwarded-for': '10.0.0.1, 1.2.3.4, 203.0.113.9' }, + }); + assert.equal(ctx.received.headers['x-forwarded-for'], '203.0.113.9'); + }); +}); + +it('ignores an inbound X-Forwarded-For chain when GITNEXUS_PROXY_TRUST_XFF is unset', async () => { + await withProxy({}, async (port, ctx) => { + // With nothing in front of the proxy, the whole chain is the caller's to + // write, so popping it would forward an address they chose. + await apiRequest(port, '/api/info', { + headers: { 'x-forwarded-for': '10.0.0.1, 1.2.3.4, 203.0.113.9' }, + }); + assert.match(ctx.received.headers['x-forwarded-for'], /127\.0\.0\.1$/); + }); +}); + +it('ignores an inbound X-Forwarded-For chain when GITNEXUS_PROXY_TRUST_XFF is off', async () => { + const env = { GITNEXUS_PROXY_TRUST_XFF: 'off' }; + await withProxy({ env }, async (port, ctx) => { + await apiRequest(port, '/api/info', { + headers: { 'x-forwarded-for': '203.0.113.9' }, + }); + assert.match(ctx.received.headers['x-forwarded-for'], /127\.0\.0\.1$/); + }); +}); + +it('warns and falls back to ignoring XFF when GITNEXUS_PROXY_TRUST_XFF is "true"', async () => { + // Rejected for the same reason resolveTrustProxy rejects it server-side: it + // reads as "trust everything", the configuration this knob exists to make + // deliberate. + const env = { GITNEXUS_PROXY_TRUST_XFF: 'true' }; + await withProxy({ env }, async (port, ctx) => { + await apiRequest(port, '/api/info', { + headers: { 'x-forwarded-for': '203.0.113.9' }, + }); + assert.match(ctx.received.headers['x-forwarded-for'], /127\.0\.0\.1$/); + assert.match( + ctx.stderr, + /GITNEXUS_PROXY_TRUST_XFF "true" is not a recognized boolean/, + 'an unrecognized value must warn rather than fail silently', + ); + }); +}); + +it('forwards the socket peer, not the rotating header, on every authenticated request', async () => { + // A caller rotating X-Forwarded-For per request earns a fresh limiter key + // upstream unless this proxy overwrites it. Hitting the API server directly + // would test its own trust-proxy handling instead of this hop. + await withProxy({}, async (port, ctx) => { + const forwarded = []; + for (const spoofed of ['1.1.1.1', '2.2.2.2', '3.3.3.3', '4.4.4.4']) { + await apiRequest(port, '/api/query', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-forwarded-for': spoofed }, + body: '{"q":"hi"}', + }); + forwarded.push(ctx.received.headers['x-forwarded-for']); + } + assert.equal(ctx.calls, 4); + for (const address of forwarded) { + assert.match( + address, + /127\.0\.0\.1$/, + 'every request must key off the socket peer, not the value the client rotated', + ); + } + }); +}); + +it('sets X-Forwarded-For from the socket peer when the client sends none', async () => { + await withProxy({}, async (port, ctx) => { + await apiRequest(port, '/api/info'); + assert.match( + ctx.received.headers['x-forwarded-for'], + /127\.0\.0\.1$/, + 'the API must always see a proxy-derived client address', + ); + }); +}); + +it('strips hop-by-hop headers from the upstream response', async () => { + const upstream = (_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain', Trailer: 'X-Late' }); + res.end('ok'); + }; + await withProxy({ upstream }, async (port) => { + const res = await apiRequest(port, '/api/info'); + assert.equal(res.status, 200); + assert.equal(res.headers.trailer, undefined, 'Trailer describes the upstream hop only'); + assert.equal(res.body, 'ok'); + }); +}); + +it('strips response headers that Connection names as single-hop', async () => { + const upstream = (_req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/plain', + Connection: 'x-upstream-hop', + 'x-upstream-hop': 'internal', + }); + res.end('ok'); + }; + await withProxy({ upstream }, async (port) => { + const res = await apiRequest(port, '/api/info'); + assert.equal(res.status, 200); + assert.equal(res.headers['x-upstream-hop'], undefined, 'named on the upstream hop only'); + }); +}); + +it('does NOT proxy non-/api routes (still serves the SPA)', async () => { + await withProxy({}, async (port, ctx) => { + const res = await rawRequest(port, '/some/app/route'); + assert.equal(res.status, 200); + assert.match(res.body, /spa/); + assert.equal(ctx.calls, 0, 'non-/api requests must not reach the upstream'); + }); +}); + +it('streams a chunked upstream response through to the client', async () => { + const upstream = (_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.write('data: one\n\n'); + setTimeout(() => { + res.write('data: two\n\n'); + res.end(); + }, 20); + }; + await withProxy({ upstream }, async (port) => { + const res = await apiRequest(port, '/api/stream'); + assert.equal(res.status, 200); + assert.equal(res.headers['content-type'], 'text/event-stream'); + assert.match(res.body, /data: one/); + assert.match(res.body, /data: two/); + }); +}); + +it('accepts a scheme-less host:port upstream (Render fromService hostport)', async () => { + await withProxy({ schemeless: true }, async (port, ctx) => { + const res = await apiRequest(port, '/api/info'); + assert.equal(res.status, 200); + assert.equal(ctx.received.url, '/api/info', 'scheme-less upstream should still be proxied'); + }); +}); + +it('serves RENDER_EXTERNAL_URL as the backend origin when GITNEXUS_BACKEND_URL is unset', async () => { + await withInjectionServer( + { RENDER_EXTERNAL_URL: 'https://gitnexus-web.onrender.com' }, + async (port) => { + const res = await rawGet(port, '/'); + assert.equal(res.status, 200); + // Assert on the parsed value, not a substring of the page: a bare + // includes() would also pass if the URL appeared in a comment. + const injected = /window\.__GITNEXUS_CONFIG__=(\{.*?\});/.exec(res.body)?.[1]; + assert.ok(injected, 'Expected __GITNEXUS_CONFIG__ in response body'); + assert.equal(JSON.parse(injected).backendUrl, 'https://gitnexus-web.onrender.com'); + }, + ); +}); + +it('returns 504 when the upstream does not respond within the timeout', async () => { + // Upstream accepts the connection but never responds — an idle hang. + const env = { GITNEXUS_PROXY_TIMEOUT_MS: '300' }; + await withProxy({ upstream: () => {}, env }, async (port) => { + const res = await apiRequest(port, '/api/info'); + assert.equal(res.status, 504); + }); +}); + +it('returns 502 when the upstream is unreachable', async () => { + // Retry disabled so this fails fast (the unreachable-upstream contract). + const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '1' }; + await withProxy({ upstream: null, env }, async (port) => { + const res = await apiRequest(port, '/api/info'); + assert.equal(res.status, 502); + }); +}); + +// -- Connection-retry across an upstream restart window --------------------- +// +// `listenAfterMs: 400` binds the upstream late, so the first attempt hits +// ECONNREFUSED and must be retried — a single-instance restart. The default 3 +// attempts (backoff 250ms, 500ms) span ~750ms, so a retry lands after the bind. + +it('retries a connection-refused POST and succeeds once the upstream is up', async () => { + await withProxy({ listenAfterMs: 400 }, async (port, ctx) => { + const res = await apiRequest(port, '/api/analyze', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{"repo":"x"}', + }); + assert.equal(res.status, 200, 'first attempt should ride out the restart gap'); + assert.match(res.body, /"ok":true/); + assert.equal(ctx.calls, 1, 'upstream must run the job exactly once (no double-execute)'); + assert.equal(ctx.body, '{"repo":"x"}', 'buffered body replayed intact'); + }); +}); + +it('retries a bodyless DELETE, which frames no body to replay', async () => { + // Retry eligibility follows RFC 7230 §3.3.3 framing. A DELETE with neither + // Content-Length nor Transfer-Encoding has nothing to buffer, so it replays + // safely even though it isn't a GET. + await withProxy({ listenAfterMs: 400 }, async (port, ctx) => { + const res = await apiRequest(port, '/api/repo', { method: 'DELETE' }); + assert.equal(res.status, 200, 'a bodyless DELETE must ride out the restart gap'); + assert.equal(ctx.calls, 1); + }); +}); + +it('falls back to the default retry budget when the knob is out of range', async () => { + // A negative attempt count is a typo. Obeying it would turn every restart + // window into a 502, silently. + const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '-1' }; + await withProxy({ listenAfterMs: 400, env }, async (port, ctx) => { + const res = await apiRequest(port, '/api/info'); + assert.equal(res.status, 200); + assert.equal(ctx.calls, 1); + }); +}); + +it('warns and keeps the default when a timeout knob is negative', async () => { + const env = { GITNEXUS_PROXY_TIMEOUT_MS: '-1' }; + await withProxy({ upstream: null, env }, async (_port, ctx) => { + // Every consumer reads <= 0 as "disabled", so an unvalidated -1 removes the + // idle timeout and lets a proxied request hang forever. + assert.match( + ctx.stderr, + /GITNEXUS_PROXY_TIMEOUT_MS "-1" is below the minimum 0 -- using 120000/, + ); + }); +}); + +it('does NOT retry after the client aborts during the backoff window', async () => { + // The client aborts (~100ms) while a retry is pending, before the upstream + // binds (~400ms). The backoff guard must cancel it — otherwise the retry + // lands after the bind and runs a job nobody is waiting on. + await withProxy({ listenAfterMs: 400 }, async (port, ctx) => { + await new Promise((resolve) => { + const req = http.request({ + host: '127.0.0.1', + port, + path: '/api/analyze', + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': '12', + authorization: TEST_BEARER, + }, + }); + req.on('error', () => {}); // aborting surfaces a local socket error; ignore + req.write('{"repo":"x"}'); + req.end(); + // Abort after the first attempt has failed-and-scheduled (ECONNREFUSED is + // near-instant) but well before the upstream binds at ~400ms. + setTimeout(() => { + req.destroy(); + resolve(); + }, 100); + }); + // Wait past the upstream bind + full retry budget (~750ms) so a leaked retry + // would already have landed. + await new Promise((r) => setTimeout(r, 900)); + assert.equal(ctx.calls, 0, 'aborted request must not be retried against the upstream'); + }); +}); + +it('returns 502 after exhausting the retry budget when the upstream stays down', async () => { + const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '3' }; + await withProxy({ upstream: null, env }, async (port) => { + const res = await apiRequest(port, '/api/analyze', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{"repo":"x"}', + }); + assert.equal(res.status, 502, 'genuinely-down upstream still returns 502 after the budget'); + }); +}); + +it('does NOT retry a POST that connects then resets before responding', async () => { + // The upstream accepts the connection, reads the whole request, then dies + // before sending any response byte — an instance that received the job and + // crashed/restarted mid-flight. Because the reset arrives AFTER connecting and + // POST is non-idempotent, replaying could run the job twice, so the proxy must + // NOT retry: the upstream sees exactly one call and the browser gets 502. + const upstream = (_req, res) => res.socket.destroy(); + const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '3' }; + await withProxy({ upstream, env }, async (port, ctx) => { + const res = await apiRequest(port, '/api/analyze', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{"repo":"x"}', + }); + assert.equal(res.status, 502, 'post-connection reset on a POST fails fast, no retry'); + // Give any (erroneous) retry a chance to fire before asserting. + await new Promise((r) => setTimeout(r, 300)); + assert.equal( + ctx.calls, + 1, + 'non-idempotent POST must not be replayed after the upstream got it', + ); + }); +}); + +it('does NOT retry after the upstream starts streaming, then drops mid-body', async () => { + // Send headers + a partial body, then abruptly destroy the socket. + const upstream = (_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.write('{"partial":'); + setTimeout(() => res.socket.destroy(), 20); + }; + const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '3' }; + await withProxy({ upstream, env }, async (port, ctx) => { + // Settle on end OR on the mid-body abort/error, so the dropped connection + // can't hang the test. What matters is that the proxy did NOT replay the + // request (no duplicate job): the upstream must see exactly 1 call. + await new Promise((resolve) => { + const req = http.request( + { + host: '127.0.0.1', + port, + path: '/api/analyze', + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': '12', + authorization: TEST_BEARER, + }, + }, + (res) => { + res.on('data', () => {}); + res.on('end', resolve); + res.on('aborted', resolve); + res.on('error', resolve); + }, + ); + req.on('error', resolve); + req.write('{"repo":"x"}'); + req.end(); + }); + // Give any (erroneous) retry a chance to fire before asserting. + await new Promise((r) => setTimeout(r, 300)); + assert.equal(ctx.calls, 1, 'must not replay once the response body has started'); + }); +}); + +it('does NOT buffer or retry a body larger than the retry cap', async () => { + // Tiny cap so a modest body exceeds it and is streamed, not buffered. + const env = { GITNEXUS_PROXY_RETRY_MAX_BODY_BYTES: '16' }; + const bigBody = 'x'.repeat(1024); + await withProxy({ env }, async (port, ctx) => { + const res = await apiRequest(port, '/api/analyze/upload', { + method: 'POST', + headers: { 'content-type': 'application/octet-stream' }, + body: bigBody, + }); + assert.equal(res.status, 200, 'over-cap body is streamed straight through'); + assert.equal(ctx.body.length, bigBody.length, 'full body reaches upstream (not capped)'); + }); +}); + +it('returns 400 when the client declares a body but never finishes sending it', async () => { + // A live upstream, so a failure to reach it can't be mistaken for the body + // timeout. It must see zero requests: the proxy never connects because the + // buffering read times out first. The dedicated knob is set (leaving the + // upstream idle timeout at its default) to prove the two tune independently. + const env = { GITNEXUS_PROXY_CLIENT_BODY_TIMEOUT_MS: '300' }; + await withProxy({ env }, async (port, ctx) => { + // Raw socket (not http.request, which would auto-finish the body): send a + // Content-Length: 100 request but only 10 bytes, then hold the socket open. + // We never close our side — the proxy must close it for us once the body + // read times out (via `Connection: close`), rather than holding the + // half-open connection until the server requestTimeout reaps it. + const { status, serverClosed, raw } = await new Promise((resolve) => { + const sock = connect(port, '127.0.0.1', () => { + sock.write( + 'POST /api/analyze HTTP/1.1\r\n' + + 'Host: 127.0.0.1\r\n' + + 'Content-Type: application/json\r\n' + + `Authorization: ${TEST_BEARER}\r\n` + + 'Content-Length: 100\r\n' + + '\r\n' + + 'x'.repeat(10), // fewer than 100 bytes, then stall + ); + }); + let buf = ''; + let status = null; + // Fail-safe: if the proxy never closes on its own, report serverClosed + // false (so the assertion fails cleanly) instead of hanging the test. + const guard = setTimeout(() => { + sock.destroy(); + resolve({ status, serverClosed: false, raw: buf }); + }, 2000); + sock.setEncoding('utf8'); + sock.on('data', (chunk) => { + buf += chunk; + if (status === null) { + const m = buf.split('\r\n', 1)[0].match(/^HTTP\/\d\.\d (\d{3})/); + if (m) status = Number(m[1]); + } + }); + // The server closing its side (Connection: close) ends our socket; treat + // any teardown initiated by the server as "closed promptly". + sock.on('error', () => {}); // a reset may precede 'close'; swallow it + sock.on('close', () => { + clearTimeout(guard); + resolve({ status, serverClosed: true, raw: buf }); + }); + }); + assert.equal(status, 400, 'stalled body read must be bounded and return 400, not hang'); + assert.ok( + serverClosed, + 'proxy must close the half-open connection promptly, not hold it until requestTimeout', + ); + assert.match( + raw.toLowerCase(), + /connection: close/, + 'the 400 for a stalled body must advertise Connection: close', + ); + assert.equal(ctx.calls, 0, 'proxy must not connect upstream when the body never arrives'); + }); +}); + +// -- Token gate at the public edge (GITNEXUS_SERVE_AUTH_TOKEN) -------------- +// +// The proxy terminates the browser Origin, so the API's own write guard can't +// see a cross-site request coming. The token replaces it, checked on the way in. + +it('answers an /api/* request with no Authorization header with a well-formed 401', async () => { + await withProxy({}, async (port, ctx) => { + const res = await rawRequest(port, '/api/health'); + assert.equal(res.status, 401); + assert.equal(res.headers['www-authenticate'], 'Bearer'); + assert.match(res.headers['content-type'], /application\/json/); + // The UI dispatches on the stable code, not on message text. + assert.deepEqual(JSON.parse(res.body), { error: 'unauthorized', code: 'unauthorized' }); + assert.equal(ctx.calls, 0, 'an unauthenticated request must cost nothing upstream'); + }); +}); + +it('closes the connection on a rejected request rather than draining its body', async () => { + // The 401 is answered before the body is read, so without Connection: close + // Node drains up to 64KB of an unauthenticated upload to keep the socket + // reusable. Same reasoning as the stalled-body 400 above. + await withProxy({}, async (port, ctx) => { + const res = await rawRequest(port, '/api/analyze', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path: '/etc' }), + }); + assert.equal(res.status, 401); + assert.equal(res.headers.connection, 'close'); + assert.equal(ctx.calls, 0); + }); +}); + +it('rejects a wrong token of the same length', async () => { + await withProxy({}, async (port, ctx) => { + const wrong = 'x'.repeat(TEST_AUTH_TOKEN.length); + const res = await apiRequest(port, '/api/health', { + headers: { authorization: `Bearer ${wrong}` }, + }); + assert.equal(res.status, 401); + assert.equal(ctx.calls, 0); + }); +}); + +it('rejects a wrong token of a different length', async () => { + // The unequal-length branch takes a different path through the comparison + // (dummy compare, no timingSafeEqual on the real buffers) and still must 401. + await withProxy({}, async (port, ctx) => { + const res = await apiRequest(port, '/api/health', { + headers: { authorization: 'Bearer short' }, + }); + assert.equal(res.status, 401); + assert.equal(ctx.calls, 0); + }); +}); + +it('rejects the raw token without the Bearer prefix', async () => { + await withProxy({}, async (port, ctx) => { + const res = await apiRequest(port, '/api/health', { + headers: { authorization: TEST_AUTH_TOKEN }, + }); + assert.equal(res.status, 401); + assert.equal(ctx.calls, 0); + }); +}); + +it('forwards an /api/* request that carries the correct token', async () => { + await withProxy({}, async (port, ctx) => { + const res = await apiRequest(port, '/api/health', { + headers: { authorization: TEST_BEARER }, + }); + assert.equal(res.status, 200); + assert.equal(ctx.calls, 1); + }); +}); + +it('strips the Authorization header instead of forwarding the edge token', async () => { + // The token is spent at this hop. `serve` reads no Authorization header, so + // forwarding would only copy a live credential into another service's logs. + await withProxy({}, async (port, ctx) => { + const res = await apiRequest(port, '/api/mcp', { method: 'POST', body: '{}' }); + assert.equal(res.status, 200, 'the request itself must still be proxied'); + assert.equal(ctx.received.headers.authorization, undefined); + }); +}); + +it('never gates static assets behind the token', async () => { + // The UI has to load before it can prompt for a token. + await withProxy({}, async (port, ctx) => { + for (const path of ['/', '/index.html', '/some/app/route']) { + const res = await rawRequest(port, path); + assert.equal(res.status, 200, `${path} must be served without a token`); + assert.match(res.body, /spa/); + } + assert.equal(ctx.calls, 0); + }); +}); + +// Run docker-server.mjs to completion and report how it exited. Used for the +// boot-time refusal, which never reaches a listening state. +function runUntilExit(cwd, env) { + return new Promise((resolve, reject) => { + const proc = spawn(process.execPath, [serverScript], { + cwd, + env: { ...process.env, ...env }, + stdio: 'pipe', + }); + let stderr = ''; + proc.stderr.setEncoding('utf8'); + proc.stderr.on('data', (chunk) => { + stderr += chunk; + }); + proc.on('error', reject); + proc.on('exit', (code) => resolve({ code, stderr })); + // A server that starts instead of refusing never exits, so name that failure + // here rather than letting it surface as a timeout or a null exit code. + setTimeout(() => { + proc.kill(); + reject(new Error('docker-server.mjs kept running; it was expected to refuse and exit')); + }, 5000).unref(); + }); +} + +async function withDistDir(fn) { + const dir = await mkdtemp(join(tmpdir(), 'gitnexus-boot-')); + await mkdir(join(dir, 'dist'), { recursive: true }); + await writeFile(join(dir, 'dist', 'index.html'), 'spa'); + try { + await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +it('refuses to start when the proxy is enabled without a token', async () => { + await withDistDir(async (dir) => { + const port = await getFreePort(); + const { code, stderr } = await runUntilExit(dir, { + PORT: String(port), + GITNEXUS_UPSTREAM_URL: '127.0.0.1:4747', + GITNEXUS_SERVE_AUTH_TOKEN: undefined, + }); + assert.equal(code, 1, 'an unauthenticated public proxy must fail closed at boot'); + assert.match(stderr, /Refusing to start/); + assert.match(stderr, /GITNEXUS_SERVE_AUTH_TOKEN/); + }); +}); + +it('refuses to start when the token is short enough to guess', async () => { + // Nothing rate-limits a failed token, so a weak one is guessable at network + // speed. The floor is what makes the missing limiter safe. + await withDistDir(async (dir) => { + const port = await getFreePort(); + const { code, stderr } = await runUntilExit(dir, { + PORT: String(port), + GITNEXUS_UPSTREAM_URL: '127.0.0.1:4747', + GITNEXUS_SERVE_AUTH_TOKEN: 'hunter2', + }); + assert.equal(code, 1); + assert.match(stderr, /shorter than 32 characters/); + assert.ok(!stderr.includes('hunter2'), 'the refusal must never echo the token'); + }); +}); + +it('treats a whitespace-only token as absent rather than as a short one', async () => { + // ' ' trims to empty, so this must hit the missing-token refusal, not the + // length one. + await withDistDir(async (dir) => { + const port = await getFreePort(); + const { code, stderr } = await runUntilExit(dir, { + PORT: String(port), + GITNEXUS_UPSTREAM_URL: '127.0.0.1:4747', + GITNEXUS_SERVE_AUTH_TOKEN: ' ', + }); + assert.equal(code, 1); + assert.match(stderr, /is set without GITNEXUS_SERVE_AUTH_TOKEN/); + }); +}); + +it('starts normally with neither the proxy nor a token configured', async () => { + // docker-compose's default: static assets only, nothing to gate, no refusal. + await withDistDir(async (dir) => { + const port = await getFreePort(); + const proc = spawnServerWithEnv(dir, port, { GITNEXUS_SERVE_AUTH_TOKEN: undefined }); + try { + await waitForServer(port); + const res = await rawRequest(port, '/'); + assert.equal(res.status, 200); + assert.match(res.body, /spa/); + } finally { + await killAndWait(proc); + } + }); +}); diff --git a/gitnexus-web/src/components/AccessTokenPrompt.tsx b/gitnexus-web/src/components/AccessTokenPrompt.tsx new file mode 100644 index 000000000..a903e9efc --- /dev/null +++ b/gitnexus-web/src/components/AccessTokenPrompt.tsx @@ -0,0 +1,67 @@ +import { useState } from 'react'; +import { Key } from '@/lib/lucide-icons'; +import { useTranslation } from 'react-i18next'; +import { getAuthToken, setAuthToken } from '../services/backend-client'; +import { SecretInput } from './settings/SecretInput'; + +interface AccessTokenPromptProps { + /** Called after the token is stored, so the caller can re-probe immediately. */ + onSubmit?: () => void; +} + +/** + * Shown instead of the "start a server" guide when the backend answers 401: + * the deploy is up, it just needs the access token its operator generated. + * + * The token is held in sessionStorage for this browser session only — see + * AUTH_TOKEN_STORAGE_KEY. Nothing here logs it or puts it in a URL. + */ +export const AccessTokenPrompt = ({ onSubmit }: AccessTokenPromptProps) => { + const { t } = useTranslation('settings'); + const [token, setToken] = useState(getAuthToken); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + setAuthToken(token); + onSubmit?.(); + }; + + return ( +
+
+
+ +
+

+ {t('accessToken.title')} +

+

+ {t('accessToken.promptHint')} +

+
+ + + + + +

+ {t('accessToken.sessionNote')} +

+ + ); +}; diff --git a/gitnexus-web/src/components/DropZone.tsx b/gitnexus-web/src/components/DropZone.tsx index 389f99869..8fd8b6621 100644 --- a/gitnexus-web/src/components/DropZone.tsx +++ b/gitnexus-web/src/components/DropZone.tsx @@ -7,6 +7,7 @@ import { type BackendRepo, } from '../services/backend-client'; import { useBackend } from '../hooks/useBackend'; +import { AccessTokenPrompt } from './AccessTokenPrompt'; import { OnboardingGuide } from './OnboardingGuide'; import { AnalyzeOnboarding } from './AnalyzeOnboarding'; import { RepoLanding } from './RepoLanding'; @@ -147,6 +148,7 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => { const { isConnected, isProbing, + isUnauthorized, startPolling, stopPolling, isPolling, @@ -310,8 +312,20 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => { )} + {/* The backend is up but gated — asking for a token is the only useful + thing to show. The "run gitnexus serve" guide would be wrong advice. */} + {isUnauthorized && !isConnected && ( + { + // The polling chain is already running while disconnected; it + // picks up the new token on its next tick and auto-connects. + if (!isPolling) startPolling(); + }} + /> + )} + {/* Crossfade between phases */} - {displayPhase && ( + {!isUnauthorized && displayPhase && ( {displayPhase === 'onboarding' && } {displayPhase === 'analyze' && } diff --git a/gitnexus-web/src/components/SettingsPanel.tsx b/gitnexus-web/src/components/SettingsPanel.tsx index e6b933e49..0c3a22aec 100644 --- a/gitnexus-web/src/components/SettingsPanel.tsx +++ b/gitnexus-web/src/components/SettingsPanel.tsx @@ -20,9 +20,11 @@ import { getAvailableModels, fetchOpenRouterModels, } from '../core/llm/settings-service'; +import { getAuthToken, setAuthToken } from '../services/backend-client'; import type { LLMSettings, LLMProvider } from '../core/llm/types'; import { DEFAULT_OLLAMA_BASE_URL } from '../config/ui-constants'; import { ProviderConfigCard } from './settings/ProviderConfigCard'; +import { SecretInput } from './settings/SecretInput'; import { useTranslation } from 'react-i18next'; interface SettingsPanelProps { @@ -253,6 +255,8 @@ export const SettingsPanel = ({ const { t } = useTranslation(['common', 'settings']); const [settings, setSettings] = useState(loadSettings); const [showApiKey, setShowApiKey] = useState>({}); + /** Deploy access token. Stored outside LLM settings, persisted on Save. */ + const [authToken, setAuthTokenState] = useState(getAuthToken); const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle'); const saveTimerRef = useRef>(undefined); // Ollama connection state @@ -275,6 +279,7 @@ export const SettingsPanel = ({ useEffect(() => { if (isOpen) { setSettings(loadSettings()); + setAuthTokenState(getAuthToken()); setSaveStatus('idle'); setOllamaError(null); } @@ -315,6 +320,10 @@ export const SettingsPanel = ({ const handleSave = () => { try { saveSettings(settings); + // The token persists on Save with everything else, not per keystroke: it + // is the only affordance this panel gives for "committed", and a + // half-typed token would otherwise ride the next probe. + setAuthToken(authToken); setSaveStatus('saved'); onSettingsSaved?.(); if (saveTimerRef.current) { @@ -372,6 +381,25 @@ export const SettingsPanel = ({ {/* Content */}
+ {/* Deploy access token. Rendered unconditionally, unlike the Local + Server block below, which only appears when a caller passes the + backend-URL props. An empty token is a valid state — a local + `gitnexus serve` or `docker compose` deploy has no gate. */} +
+ + +

{t('settings:accessToken.hint')}

+
+ {/* Local Server */} {backendUrl !== undefined && onBackendUrlChange && (
diff --git a/gitnexus-web/src/components/settings/SecretInput.tsx b/gitnexus-web/src/components/settings/SecretInput.tsx new file mode 100644 index 000000000..67cb3f9ce --- /dev/null +++ b/gitnexus-web/src/components/settings/SecretInput.tsx @@ -0,0 +1,56 @@ +import { useState } from 'react'; +import { Eye, EyeOff } from '@/lib/lucide-icons'; + +interface SecretInputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + /** Accessible name for the field — the visible `