Merge branch 'main' into octo/20260801-parameter-refresh-recvqf5VePTg5G

This commit is contained in:
Gergő Magyar 2026-08-05 22:17:07 +01:00 • committed by GitHub
commit 4d9422330d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
94 changed files with 8646 additions and 737 deletions

View file

@ -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

View file

@ -73,6 +73,7 @@ Commits within a PR may use any style — only the **merged PR title** shows up
- [ ] Typecheck passes: `npx tsc --noEmit` in `gitnexus/` and `npx tsc -b --noEmit` in `gitnexus-web/`.
- [ ] No secrets, tokens, or machine-specific paths committed.
- [ ] Documentation updated if behavior or public CLI/MCP contract changes.
- [ ] Every new `GITNEXUS_*` environment variable has a row in the **Environment variables** table in [README.md](README.md) — variable, default, effect, and when to tune it.
- [ ] Pre-commit hook runs clean (`.husky/pre-commit` — formatting via lint-staged + typecheck for staged packages; tests run in CI only).
## Code review

View file

@ -508,6 +508,8 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
| `GITNEXUS_MCP_ALLOWED_REPOS` | unset | Comma-separated allowlist of canonical indexed repository names or absolute paths. Invalid, ambiguous, or blank entries fail startup. | One MCP process must expose only a bounded subset of the repositories in the global registry. |
| `GITNEXUS_MCP_DEFAULT_REPO` | unset | Canonical indexed repository name or absolute path used when a tool or resource omits its repository. Must belong to the allowlist when one is set. | Several repositories are available but unqualified MCP calls should resolve deterministically. |
| `GITNEXUS_MCP_DEFAULT_MAX_TOKENS` | unset | Default positive-integer response budget for MCP `query`, `context`, and `impact`, estimated at four UTF-8 bytes per token. Explicit `maxTokens` wins. | Long MCP responses consume too much model context and callers cannot reliably add a per-request budget. |
| `GITNEXUS_PUBLIC_ORIGIN` | unset | The single browser origin `serve` is reached through, added to the CORS allowlist and to the write-route origin guard. A wildcard bind (`0.0.0.0`) has no host identity, so without this the server's own UI is refused. **Setting it currently refuses to start:** `serve` has no authentication, requests carrying no `Origin` header already reach `POST /api/analyze` and `DELETE /api/repo`, and this is the setting that would admit browser writes on top of that. Matching rules for when the gate lifts: the hostname must match exactly, and so must the scheme. A value with no scheme (`app.example.com`) means `https`, since a bare host comes from platform service discovery and those terminate TLS; spell out `http://app.example.com` for plain HTTP. An explicit port must match; with no port, any port on that hostname is accepted. Anything that is not one reachable host (a list, `*`, a bare port number, a `:0` port, a trailing dot) warns at startup and allows nothing. | `gitnexus serve` runs behind a reverse proxy or on a wildcard bind, and the UI's index/delete requests return `origin_not_allowed`. |
| `GITNEXUS_TRUST_PROXY` | `loopback, linklocal, uniquelocal` | Express `trust proxy` value — which upstream hops may set `X-Forwarded-*`, and so what the per-IP rate limiter reads as the client IP. Set it to the exact number of proxies you control. Every hop past that is one more entry of the chain the caller gets to write. `false`/`no`/`off` (and a `0` hop count) trust no hop; a proxy list Express can compile (`loopback`, `10.0.0.0/8, 127.0.0.1`) names them instead. `true`/`yes`/`on` is **rejected**: it reads the client-controlled leftmost `X-Forwarded-For` entry, so a spoofed chain earns a fresh rate-limit key per request, and express-rate-limit rejects it too (`ERR_ERL_PERMISSIVE_TRUST_PROXY`). Counts above `16` are rejected as well, as a sanity ceiling rather than a safety boundary. Any invalid value warns and falls back to the default. Bind non-loopback with this unset and `serve` warns: a load balancer outside the private ranges is untrusted, so every request keys to the balancer and the per-IP limit becomes one shared limit. | `serve` sits behind a load balancer outside the private ranges (AWS ALB, Cloudflare, CGNAT), where every request otherwise collapses to the proxy hop and rate limiting goes global. |
</details>

View file

@ -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:<path> vs b2cd1c2ad:<path>.'
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.

204
eval/uv.lock generated
View file

@ -21,7 +21,7 @@ wheels = [
[[package]]
name = "aiohttp"
version = "3.14.1"
version = "3.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@ -33,108 +33,108 @@ dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" }
sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" },
{ url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" },
{ url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" },
{ url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" },
{ url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" },
{ url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" },
{ url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" },
{ url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" },
{ url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" },
{ url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" },
{ url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" },
{ url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" },
{ url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" },
{ url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" },
{ url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" },
{ url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" },
{ url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" },
{ url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" },
{ url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" },
{ url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" },
{ url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" },
{ url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" },
{ url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" },
{ url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" },
{ url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" },
{ url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" },
{ url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" },
{ url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" },
{ url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" },
{ url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" },
{ url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" },
{ url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" },
{ url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" },
{ url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" },
{ url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" },
{ url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" },
{ url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" },
{ url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" },
{ url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" },
{ url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" },
{ url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" },
{ url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" },
{ url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" },
{ url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" },
{ url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" },
{ url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" },
{ url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" },
{ url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" },
{ url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" },
{ url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" },
{ url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" },
{ url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" },
{ url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" },
{ url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" },
{ url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" },
{ url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" },
{ url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" },
{ url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" },
{ url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" },
{ url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" },
{ url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" },
{ url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" },
{ url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" },
{ url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" },
{ url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" },
{ url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" },
{ url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" },
{ url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" },
{ url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" },
{ url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" },
{ url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" },
{ url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" },
{ url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" },
{ url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" },
{ url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" },
{ url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" },
{ url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" },
{ url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" },
{ url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" },
{ url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" },
{ url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" },
{ url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" },
{ url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" },
{ url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" },
{ url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" },
{ url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" },
{ url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" },
{ url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" },
{ url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" },
{ url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" },
{ url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" },
{ url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" },
{ url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" },
{ url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" },
{ url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" },
{ url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" },
{ url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" },
{ url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" },
{ url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" },
{ url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" },
{ url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" },
{ url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" },
{ url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" },
{ url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" },
{ url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" },
{ url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" },
{ url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" },
{ url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" },
{ url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" },
{ url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" },
{ url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" },
{ url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" },
{ url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" },
{ url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" },
{ url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" },
{ url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" },
{ url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" },
{ url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" },
{ url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" },
{ url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" },
{ url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" },
{ url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" },
{ url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" },
{ url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" },
{ url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" },
{ url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" },
{ url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" },
{ url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" },
{ url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" },
{ url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" },
{ url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" },
{ url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" },
{ url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" },
{ url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" },
{ url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" },
{ url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" },
{ url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" },
{ url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" },
{ url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" },
{ url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" },
{ url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" },
{ url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" },
{ url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" },
{ url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" },
{ url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" },
{ url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" },
{ url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" },
{ url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" },
{ url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" },
{ url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" },
{ url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" },
{ url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" },
{ url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" },
{ url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" },
{ url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" },
{ url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" },
{ url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" },
{ url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" },
{ url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" },
{ url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" },
{ url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" },
{ url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" },
{ url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" },
{ url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" },
{ url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" },
{ url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" },
{ url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" },
{ url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" },
{ url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" },
{ url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" },
{ url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" },
{ url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" },
{ url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" },
{ url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" },
{ url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" },
{ url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" },
{ url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" },
{ url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" },
{ url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" },
{ url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" },
{ url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" },
{ url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" },
{ url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" },
]
[[package]]

View file

@ -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

View file

@ -162,6 +162,30 @@ export interface ReferenceSite {
* in callee position, so nothing changes for languages that never set it.
*/
readonly inCalleePosition?: boolean;
/**
* This `inherits` site describes an embedded field written as a POINTER
* (`struct S { *T }`) rather than as a value (`struct S { T }`).
*
* Go's method-set rules make the two forms genuinely different, so the
* distinction cannot be normalized away without producing wrong answers
* (go.dev/ref/spec#Struct_types):
*
* - `S` embeds `T` → `MS(S)` and `MS(*S)` get promoted methods with
* receiver `T`; only `MS(*S)` also gets those with
* receiver `*T`.
* - `S` embeds `*T` → `MS(S)` AND `MS(*S)` get promoted methods with
* receiver `T` **or** `*T`.
*
* So with `func (t *T) Ping()`, `S{T}` does not implement a `Ping` interface
* by value while `S{*T}` does. Collapsing the forms makes both answers the
* same, and one of them is then wrong.
*
* A POSITION FACT, like `inCalleePosition`: the capture layer records how the
* field was spelled and resolution decides what it means. Set only by
* languages with pointer-embedding semantics (Go today); absent everywhere
* else, so every other language's sites stay byte-identical.
*/
readonly embeddedAsPointer?: boolean;
}
/**

View file

@ -2180,9 +2180,9 @@
"license": "MIT"
},
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3228,16 +3228,16 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/braces": {
@ -4391,9 +4391,9 @@
"license": "Unlicense"
},
"node_modules/fast-uri": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"dev": true,
"funding": [
{
@ -7274,9 +7274,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.22",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz",
"integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==",
"version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"funding": [
{
"type": "opencollective",

View file

@ -45,3 +45,39 @@
# integer only — non-integer or < 1 values fall back to 32.
# See README § "Scope-resolution dispatch-target cap".
# GITNEXUS_MAX_CALLABLE_VALUE_TARGETS=64
# `serve` behind a reverse proxy
# Only needed when `gitnexus serve` is reached through a proxy, or bound to a
# wildcard address. Leave both unset for a local install.
# The single browser origin this server is reached through. A wildcard bind has
# no host identity, so without this the write-route guard rejects the UI's
# writes.
#
# NOT USABLE YET: `serve` has no authentication, and requests with no Origin
# header (curl, any script) already reach POST /api/analyze and DELETE /api/repo,
# so setting this makes `serve` refuse to start rather than open a public bind
# with nothing behind it.
#
# Matching rules for when the gate lifts: the hostname must match exactly, and so
# must the scheme. A value with no scheme means https, so plain HTTP needs the
# explicit http:// form. An explicit port must match; with no port, any port on
# that hostname is accepted. Anything that is not one reachable host (a list, a
# wildcard, a bare port number, a :0 port, a trailing dot) warns and allows
# nothing.
# GITNEXUS_PUBLIC_ORIGIN=https://gitnexus.example.com
# Which upstream hops may set X-Forwarded-*, so the per-IP rate limiter keys on
# the client IP and not the proxy's. Set it to the exact number of proxies you
# control. Every hop past that is one more entry of the chain the caller gets to
# write. false/no/off (and a 0 hop count) trust no hop; a proxy list Express can
# compile ("loopback", "10.0.0.0/8, 127.0.0.1") names them instead.
# true/yes/on is REJECTED: it reads the client-controlled leftmost
# X-Forwarded-For entry, so a spoofed chain earns a fresh rate-limit key per
# request, and express-rate-limit rejects it too. Counts above 16 are rejected as
# well, as a sanity ceiling rather than a safety boundary. Any invalid value warns
# and falls back to "loopback, linklocal, uniquelocal".
# Bind non-loopback with this unset and `serve` warns: a load balancer outside the
# private ranges is untrusted, so every request keys to the balancer and the
# per-IP limit becomes one shared limit.
# GITNEXUS_TRUST_PROXY=1

View file

@ -133,7 +133,7 @@
"awaitParen": "N/A",
"explicitTypeArgs": "N/A",
"indexElement": "INVISIBLE-GAP",
"fieldReceiverCall": "INVISIBLE-GAP",
"fieldReceiverCall": "RESOLVES",
"decoratedReceiverBase": "N/A",
"decoratedFieldType": "N/A"
},
@ -165,7 +165,7 @@
"plainChain": "RESOLVES",
"plainDeepChain": "RESOLVES",
"optionalChain": "RESOLVES",
"nonNullAssert": "VISIBLE-GAP",
"nonNullAssert": "RESOLVES",
"awaitParen": "RESOLVES",
"explicitTypeArgs": "RESOLVES",
"indexElement": "RESOLVES",
@ -183,7 +183,7 @@
"indexElement": "INVISIBLE-GAP",
"fieldReceiverCall": "RESOLVES",
"decoratedReceiverBase": "N/A",
"decoratedFieldType": "INVISIBLE-GAP"
"decoratedFieldType": "RESOLVES"
},
"dart": {
"plainChain": "VISIBLE-GAP",

View file

@ -1,24 +1,26 @@
{
"_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/<lang>-* 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": "e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3",
"fingerprint": "c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832",
"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.",
"_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_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_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."
},
"cobol": {
"fingerprint": "c8c00b56a7da24e04080eb885714fbbf45e3903324f0cf9df0754f5b5a92e3aa",
"_rebaselined_2813_exact_method_sets": "#2813: Go embedded fields now emit `@reference.embedded-pointer` when spelled `*T` rather than `T`. A CAPTURE-EMISSION CHANGE, not fixture growth: fixture_count is unchanged at 110 and capture_groups_fp moves 2303 -> 2339 (+36), which is the new marker plus the WrongSigRepo/Recount rows added to two existing fixture files. The marker is required for exactness — Go gives `struct{ Base }` and `struct{ *Base }` different method sets, so structural interface satisfaction cannot be correct without knowing which was written (go.dev/ref/spec#Struct_types). Go was the ONLY language of 15 whose fingerprint moved, which is the check that this is a Go capture change and not a cross-language regression. Accompanied by SCHEMA_BUMP 39 -> 43 (skipping 40/41/42, taken by origin/main during review) so a warm cache cannot replay the pre-marker capture set. Prior cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765 -> c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832; scaling 0.987 < 1.5.",
"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 <file>), 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 <file>), 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",
@ -26,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": {
@ -40,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.",
@ -54,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",
@ -78,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",
@ -88,20 +90,21 @@
"_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": "2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7",
"fingerprint": "adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9",
"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 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725 -> 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d; scaling 1.042 < 1.5.",
"_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": {
"fingerprint": "ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73",
@ -118,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<T>), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
"_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<T>), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
"_note": "#1928 / #2045: F35 adds qualified + qualified-generic constructor query captures (`new pkg.Foo()`, `new a.b.Foo()`, `new pkg.Box<T>()`); 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.",
@ -126,29 +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": "cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff",
"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_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.<field> = 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",
@ -160,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",
@ -171,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."
}
}

View file

@ -33,6 +33,7 @@
"pandemonium": "^2.4.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"proxy-addr": "^2.0.7",
"tree-sitter": "0.21.1",
"tree-sitter-c-sharp": "0.23.1",
"tree-sitter-cpp": "0.23.2",
@ -59,6 +60,7 @@
"@types/cors": "^2.8.17",
"@types/express": "^5.0.6",
"@types/node": "^26.0.0",
"@types/proxy-addr": "^2.0.3",
"@vitest/coverage-v8": "^4.0.18",
"gitnexus-shared": "file:../gitnexus-shared",
"tsx": "^4.0.0",
@ -692,12 +694,12 @@
}
},
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz",
"integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
"node": ">=20"
},
"peerDependencies": {
"hono": "^4"
@ -1342,12 +1344,12 @@
"license": "MIT"
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
"integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1.19.9",
"@hono/node-server": "^1.19.9 || ^2.0.5",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
@ -1946,6 +1948,16 @@
"undici-types": "~8.3.0"
}
},
"node_modules/@types/proxy-addr": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/proxy-addr/-/proxy-addr-2.0.3.tgz",
"integrity": "sha512-TgAHHO4tNG3HgLTUhB+hM4iwW6JUNeQHCLnF1DjaDA9c69PN+IasoFu2MYDhubFc+ZIw5c5t9DMtjvrD6R3Egg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
@ -3043,9 +3055,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"funding": [
{
"type": "github",
@ -3404,9 +3416,9 @@
"license": "MIT"
},
"node_modules/hono": {
"version": "4.12.31",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz",
"integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==",
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz",
"integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@ -3477,9 +3489,9 @@
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
"license": "MIT",
"engines": {
"node": ">= 12"
@ -3583,9 +3595,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.2.tgz",
"integrity": "sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==",
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.3.tgz",
"integrity": "sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==",
"funding": [
{
"type": "github",
@ -4198,9 +4210,9 @@
}
},
"node_modules/node-addon-api": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
"integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==",
"version": "8.9.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.1.tgz",
"integrity": "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"

View file

@ -79,6 +79,7 @@
"pandemonium": "^2.4.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"proxy-addr": "^2.0.7",
"tree-sitter": "0.21.1",
"tree-sitter-c-sharp": "0.23.1",
"tree-sitter-cpp": "0.23.2",
@ -106,6 +107,7 @@
"@types/cors": "^2.8.17",
"@types/express": "^5.0.6",
"@types/node": "^26.0.0",
"@types/proxy-addr": "^2.0.3",
"@vitest/coverage-v8": "^4.0.18",
"gitnexus-shared": "file:../gitnexus-shared",
"tsx": "^4.0.0",

View file

@ -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

View file

@ -36,6 +36,7 @@ import { createCallExtractor } from '../call-extractors/generic.js';
import { dartCallConfig } from '../call-extractors/configs/dart.js';
import {
emitDartScopeCaptures,
dartScopeOwnsReceivers,
interpretDartImport,
interpretDartTypeBinding,
dartBindingScopeFor,
@ -139,6 +140,11 @@ export const dartProvider = defineLanguage({
// emit-side `ScopeResolver` lives in `dart/scope-resolver.ts`; the same
// function references flow through both interfaces.
emitScopeCaptures: emitDartScopeCaptures,
// Dart's `ownsReceivers` masks FIELD NAMES a member body rebinds, not `this`
// (which Dart binds lexically in every closure form, so there is nothing to
// own). See `dartShadowedFieldsCapture` in `dart/captures.ts` for why the
// read side needs the mask and what it deliberately does not cover.
scopeOwnsReceivers: dartScopeOwnsReceivers,
cfgVisitor: createDartCfgVisitor(),
interpretImport: interpretDartImport,
interpretTypeBinding: interpretDartTypeBinding,

View file

@ -47,6 +47,32 @@ import { encodeMarker } from '../../utils/heritage-marker.js';
import { DART_BUILT_INS } from './built-ins.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
import { hasKeyword } from '../../field-extractors/configs/helpers.js';
/**
* `LanguageProvider.scopeOwnsReceivers` for Dart — the read side of
* `dartShadowedFieldsCapture`, which is where the full rationale lives.
*
* Reads the marker rather than re-deriving anything: the names were computed at
* capture time, where the AST is, and a `CaptureMatch` carries only
* name/range/text. Kept beside the emitter so the tag string has exactly one
* producer and one consumer, both in this file's line of sight.
*/
export function dartScopeOwnsReceivers(match: CaptureMatch): ReadonlySet<string> | undefined {
const raw = match['@receiver-owner.shadowed-fields']?.text;
if (raw === undefined) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return undefined;
}
if (!Array.isArray(parsed)) return undefined;
const names = parsed.filter(
(name): name is string => typeof name === 'string' && name.length > 0,
);
return names.length > 0 ? new Set(names) : undefined;
}
const FUNCTION_DECL_TAGS = [
'@declaration.function',
@ -139,6 +165,13 @@ export function emitDartScopeCaptures(
// declarations by their statement node so each is emitted exactly once.
const seenFnDeclNodes = new Set<string>();
// Shared, per-file. Pass A (the receiver mask below) and Pass B
// (`emitDartFieldAssignmentBindings`) ask the SAME two questions of the same
// nodes — "what does this class declare as a field" and "what does this
// member body bind" — so the memo makes each answer cost one walk per node
// for the whole file instead of one per consumer.
const memo: DartClassMemo = { fieldsByClassBody: new Map(), shadowsByBody: new Map() };
// ── Pass A: query-driven scopes / declarations / imports ────────────────
for (const match of getDartScopeQuery().matches(root)) {
const grouped: Record<string, Capture> = {};
@ -168,7 +201,15 @@ export function emitDartScopeCaptures(
out.push(grouped);
if (bodyNode !== null) {
out.push({ '@scope.function': spanCapture('@scope.function', declNode, bodyNode) });
// The READ-side half of the bare-name field discipline (#2807 review).
// Rides the SAME synthesized match as `@scope.function` so the mask
// lands on this member's Function scope; outside the `@scope.`
// namespace so `anchorCaptureFor` cannot mistake it for the anchor.
const mask = dartShadowedFieldsCapture(bodyNode, memo);
out.push({
'@scope.function': spanCapture('@scope.function', declNode, bodyNode),
...(mask === undefined ? {} : { '@receiver-owner.shadowed-fields': mask }),
});
for (const cm of synthesizeDartReceiverBinding(declNode, bodyNode)) out.push(cm);
}
for (const cm of synthesizeDartSignatureBindings(declNode, bodyNode)) out.push(cm);
@ -204,6 +245,19 @@ export function emitDartScopeCaptures(
'@type-binding.name': syntheticCapture('@type-binding.name', propNode, fieldName),
'@type-binding.type': syntheticCapture('@type-binding.type', propNode, fieldType),
});
} else {
// No written type, so the field's type comes from the constructor its
// initializer calls (#2807). `constructor-inferred` is the weakest
// source, and the annotated branch above already returned, so an
// annotated field is untouched either way.
const callee = dartFieldConstructorCallee(nodeMap['@declaration.name']!);
if (callee !== null) {
out.push({
'@type-binding.constructor': nodeToCapture('@type-binding.constructor', propNode),
'@type-binding.name': syntheticCapture('@type-binding.name', propNode, fieldName),
'@type-binding.type': syntheticCapture('@type-binding.type', propNode, callee.text),
});
}
}
continue;
}
@ -234,6 +288,7 @@ export function emitDartScopeCaptures(
}
if (node.type === 'class_definition') {
emitHeritage(node, out);
emitDartFieldAssignmentBindings(node, out, memo);
return;
}
if (node.type === 'extension_declaration') {
@ -497,6 +552,22 @@ function emitCascadeReference(cascade: SyntaxNode, out: CaptureMatch[]): void {
// ─── Local-variable constructor / call-result type inference ────────────────
/**
* Is `node` the callee of a construction / free call written directly at this
* position — a bare identifier whose next named sibling is a `selector`
* carrying an `argument_part` (`Outer()`)?
*
* Dart has no `new` keyword, so a constructor call and a free call are the same
* shape; the resolver decides which by looking the name up. Anything else — a
* literal, a member call, an index — is NOT this shape and is left alone rather
* than guessed at.
*/
function isDirectConstruction(node: SyntaxNode | null): node is SyntaxNode {
if (node === null || node.type !== 'identifier') return false;
const next = node.nextNamedSibling;
return next !== null && next.type === 'selector' && next.namedChild(0)?.type === 'argument_part';
}
/** Find the callee identifier of a `var x = Callee(…)` / `await Callee(…)`
* initializer (a direct free-call / constructor); returns null for member
* calls or non-call values. */
@ -505,11 +576,7 @@ function findDirectCallValue(initVarDef: SyntaxNode): SyntaxNode | null {
if (firstValue === null) return null;
if (firstValue.type === 'identifier') {
const next = firstValue.nextNamedSibling;
if (next !== null && next.type === 'selector' && next.namedChild(0)?.type === 'argument_part') {
return firstValue;
}
return null;
return isDirectConstruction(firstValue) ? firstValue : null;
}
if (firstValue.type === 'unary_expression' || firstValue.type === 'await_expression') {
let aw = firstValue;
@ -519,22 +586,38 @@ function findDirectCallValue(initVarDef: SyntaxNode): SyntaxNode | null {
aw = inner;
}
if (aw.type === 'await_expression') {
// `namedChild(0)` is the awaited callee; its next named sibling is
// `namedChild(1)`, so `isDirectConstruction` asks exactly the same
// question this branch used to spell out.
const id = aw.namedChild(0);
const sel = aw.namedChild(1);
if (
id !== null &&
id.type === 'identifier' &&
sel !== null &&
sel.type === 'selector' &&
sel.namedChild(0)?.type === 'argument_part'
) {
return id;
}
if (isDirectConstruction(id)) return id;
}
}
return null;
}
/**
* Callee identifier of a class field initialized by a direct constructor call —
* `var b = Outer();` / `final b = Outer();` — or `null` for anything else.
*
* Dart spells a class field as `declaration(<keyword>, initialized_identifier_list(
* initialized_identifier))`, NOT the `initialized_variable_definition` that
* `emitVarTypeBinding` handles — that is the LOCAL form. So an unannotated field
* had no type binding and could not act as a call receiver (#2807), even though
* its annotated twin resolved fine.
*
* Takes the field's own `@declaration.name` node, whose next named sibling IS
* the initializer — `initialized_identifier(<name> <value> …)`. Deliberately NOT
* a search down from the `@declaration.property` node: one `declaration` can
* hold SEVERAL declarators (`var a = X(), b = Y();`), which the query matches
* once each with the same property node, so a first-descendant search hands
* every declarator the FIRST one's initializer and types `b` as `X`.
*/
function dartFieldConstructorCallee(nameNode: SyntaxNode): SyntaxNode | null {
const value = nameNode.nextNamedSibling;
return isDirectConstruction(value) ? value : null;
}
function emitVarTypeBinding(initVarDef: SyntaxNode, out: CaptureMatch[]): void {
const nameNode = initVarDef.childForFieldName('name');
if (nameNode === null) return;
@ -550,6 +633,429 @@ function emitVarTypeBinding(initVarDef: SyntaxNode, out: CaptureMatch[]): void {
// ─── Heritage ───────────────────────────────────────────────────────────────
/**
* Type an inference-typed field from a constructor call ASSIGNED to it —
* `var r; C() { r = Outer(); }` and `this.r = Outer();` (#2807).
*
* Dart is the one language here that writes a field with NO receiver prefix, so
* `r = Outer()` is syntactically identical to assigning a constructor-local. The
* discriminator is the class's own declared field set: a bare name binds only
* when the enclosing class declares it AND the enclosing member binds no name of
* its own that would shadow it (`collectDartBodyShadows` — parameters, locals,
* closure parameters, catch bindings, loop variables), which is exactly when
* Dart itself resolves `r` to the field. A `this.`-prefixed write is unambiguous
* and needs neither test.
*
* Emitted as `constructor-inferred`, the weakest source, so a field that also
* carries an annotation keeps it. The narrow `@type-binding.dart-field` marker
* rides the name node for `dartBindingScopeFor` to hoist on — the binding has to
* land on the Class scope, since the assignment sits inside a constructor's own
* Function scope where `typeOfMemberOnClass` never looks.
*/
function emitDartFieldAssignmentBindings(
classNode: SyntaxNode,
out: CaptureMatch[],
memo: DartClassMemo,
): void {
const body = findChild(classNode, 'class_body');
if (body === null) return;
// `namedChildren` allocates a fresh wrapper array on every access
// (node-tree-sitter), and the loop below walks the same list — read it once.
const members = body.namedChildren;
const fields = dartClassFieldNames(body, memo);
if (fields.size === 0) return;
for (const member of members) {
if (member === null || member.type !== 'function_body') continue;
// A STATIC member's body can never write this class's INSTANCE fields.
// Dart's static scope holds only the class's static members, so a bare
// `z = Outer()` inside `static void make()` binds a LIBRARY-level `z` (or is
// a compile error) — never the same-named instance field. Binding it anyway
// did not merely add an edge: the write landed on the Class scope at the
// same `constructor-inferred` strength as the constructor's own, and the
// `>=` tie-break in `scope-extractor` let it DISPLACE the correct type, so
// `z.inner()` resolved to the wrong class (#2807 review). The instance
// static-vs-instance collision TypeScript and JavaScript allow cannot arise
// in Dart — one class may not declare a static and an instance member of the
// same name — so this is the only shape the defect takes here.
//
// Grammar: every class-member body is a `function_body` whose PREVIOUS named
// sibling is a `method_signature` (method, constructor, factory, getter,
// setter, operator, `async`), and `static` is an anonymous direct child of
// that signature, ahead of the inner `*_signature` node. Detection is the
// shared `hasKeyword` on child TEXT, never `child.type === 'static'`, which
// a grammar bump silently breaks — the same rule TypeScript's
// `isStaticMethodThis` follows.
//
// A body whose signature cannot be read at all (a null or unexpected
// previous sibling — parse recovery) DECLINES to bind: staticness is
// undecidable there, and a missed field type costs an edge while a wrong one
// destroys a correct binding.
const signature = member.previousNamedSibling;
if (signature === null || signature.type !== 'method_signature') continue;
if (hasKeyword(signature, 'static')) continue;
// Still lazy per assignment, but the memo is now FILE-wide and shared with
// Pass A's read-side mask, which asks the same question of the same body.
// The laziness that used to matter here (87-100% of eagerly built sets were
// discarded, ~15% of total Dart emission) no longer buys much for a class
// that declares fields — Pass A has already forced those bodies — so this
// reads the memo rather than paying a second walk. It still short-circuits
// for a body whose class declares no fields, since `fields.size === 0`
// returned above before either pass touched it.
//
// Not visible to `bench/scope-capture`, which is a RATIO gate — this work is
// linear, so a constant factor leaves the ratio at 1.0.
const shadowsOf = (): ReadonlySet<string> => dartBodyShadows(member, memo);
walkNamedTree(member, (node) => {
if (node.type !== 'assignment_expression') return;
const target = node.namedChild(0);
if (target === null || target.type !== 'assignable_expression') return;
const first = target.namedChild(0);
if (first === null) return;
let fieldNameNode: SyntaxNode | null = null;
if (first.type === 'identifier' && target.namedChildCount === 1) {
// Bare `r = …`: a field only when declared here and not shadowed. The
// `fields` test runs FIRST so the shadow set is only ever built for a
// name the class actually declares.
if (!fields.has(first.text) || shadowsOf().has(first.text)) return;
fieldNameNode = first;
} else if (first.type === 'this') {
const selector = target.namedChild(1);
if (selector === null || selector.type !== 'unconditional_assignable_selector') return;
const nameNode = selector.namedChild(0);
if (nameNode === null || nameNode.type !== 'identifier') return;
fieldNameNode = nameNode;
} else {
return;
}
if (fieldNameNode === null) return;
// RHS must be a direct construction; anything else is left alone rather
// than guessed at.
const callee = node.namedChild(1);
if (!isDirectConstruction(callee)) return;
out.push({
'@type-binding.constructor': nodeToCapture('@type-binding.constructor', node),
'@type-binding.dart-field': syntheticCapture(
'@type-binding.dart-field',
fieldNameNode,
'1',
),
'@type-binding.name': syntheticCapture(
'@type-binding.name',
fieldNameNode,
fieldNameNode.text,
),
'@type-binding.type': syntheticCapture('@type-binding.type', callee, callee.text),
});
});
}
}
/**
* Per-file memo for the two class-shaped questions the passes share, keyed by
* node span. `emitDartScopeCaptures` owns one and threads it; nothing survives
* the call, so a re-parse cannot serve a stale answer.
*/
interface DartClassMemo {
readonly fieldsByClassBody: Map<string, ReadonlySet<string>>;
readonly shadowsByBody: Map<string, ReadonlySet<string>>;
}
const nodeSpanKey = (node: SyntaxNode): string => `${node.startIndex}:${node.endIndex}`;
/**
* The names a `class_body` declares as INSTANCE-or-static fields, from
* `declaration(… initialized_identifier_list … initialized_identifier)` — the
* shape every stored Dart field takes, annotated or not.
*/
function dartClassFieldNames(classBody: SyntaxNode, memo: DartClassMemo): ReadonlySet<string> {
const key = nodeSpanKey(classBody);
const cached = memo.fieldsByClassBody.get(key);
if (cached !== undefined) return cached;
const fields = new Set<string>();
for (const member of classBody.namedChildren) {
if (member === null || member.type !== 'declaration') continue;
const list = findChild(member, 'initialized_identifier_list');
if (list === null) continue;
for (const init of list.namedChildren) {
if (init === null || init.type !== 'initialized_identifier') continue;
const nameNode = init.namedChild(0);
if (nameNode !== null && nameNode.type === 'identifier') fields.add(nameNode.text);
}
}
memo.fieldsByClassBody.set(key, fields);
return fields;
}
function dartBodyShadows(bodyNode: SyntaxNode, memo: DartClassMemo): ReadonlySet<string> {
const key = nodeSpanKey(bodyNode);
const cached = memo.shadowsByBody.get(key);
if (cached !== undefined) return cached;
const shadows = collectDartBodyShadows(bodyNode);
memo.shadowsByBody.set(key, shadows);
return shadows;
}
/**
* The READ half of the bare-name field discipline: the field names a member
* body REBINDS, published on that member's Function scope as
* `Scope.ownsReceivers` (#2701) so the receiver walk stops there instead of
* reaching the Class scope (#2807 review).
*
* `emitDartFieldAssignmentBindings` above declines to WRITE a binding for a name
* the body shadows, but the shadow set gated writes only. A bare-name READ of a
* shadowing binder the resolver cannot type — `for (final conn in xs) {
* conn.inner(); }`, where the element type of `xs` is not modelled — therefore
* walked straight past the local and hit the class field binding this same
* feature mints, resolving `conn.inner()` to the CONSTRUCTOR's type. That turns
* "no edge" into a WRONG edge, the one failure mode
* `scope-resolution/passes/compound-receiver.ts` says must never happen, and it
* was introduced by the write side rather than pre-existing: delete the
* constructor and the same read emits nothing.
*
* `ownsReceivers` is the right primitive because the walk consults
* `typeBindings` FIRST at every scope (`scope/walkers.ts`) and only then honours
* the mask. A shadow the resolver CAN type still wins — an annotated parameter
* `void probe(Beta conn)` keeps `Beta`, because
* `synthesizeDartSignatureBindings` anchors parameter bindings on this same
* body node, so they land on this same Function scope. The mask only fires
* where the alternative was a fabricated type.
*
* ── SCOPE OF THE MASK, AND ITS ACCEPTED COSTS ────────────────────────────────
*
* `shadows ∩ fields`, and nothing wider. Three consequences are taken knowingly
* rather than hidden:
*
* 1. NOT every locally bound name is masked — only ones the enclosing class
* also declares as a field. A library-level `var logger = Logger();`
* shadowed by a loop variable of the same name still resolves against the
* library binding and can still produce the wrong edge. That is the general
* form of the same defect and arguably the more correct fix, but it changes
* resolution for code this feature never touched; it is recorded here as a
* known limitation rather than implemented.
* 2. The mask is BODY-WIDE, exactly as `collectDartBodyShadows` is on the write
* side. A member that binds `conn` anywhere — a nested closure, one `case`
* arm — masks `conn` for the whole member, so a read of the genuine field
* elsewhere in that member loses its edge. Deliberate symmetry: the write
* side already declines body-wide, and losing an edge is the error this
* whole line of work chooses over inventing one.
* 3. `fields` is EVERY field the class declares, not only the ones the
* constructor-write feature types. An ANNOTATED field shadowed by a binder
* is masked too, so this reaches resolution that predates #2807 — and it is
* meant to: which name a body's bare read refers to is a fact about Dart, not
* about how the field acquired its type. `mixin` bodies are reached for the
* same reason (the grammar gives a mixin a `class_body` as well), even though
* `emitDartFieldAssignmentBindings` mints nothing for them. `extension`
* bodies are a different node (`extension_body`) and are left alone.
*
* Returns `undefined` — not an empty marker — when nothing is masked, so the
* emitted capture set is unchanged for every body that does not shadow a field.
* Names are sorted so the capture text (and every fingerprint over it) is
* order-stable.
*/
function dartShadowedFieldsCapture(bodyNode: SyntaxNode, memo: DartClassMemo): Capture | undefined {
// Only a CLASS-MEMBER body can shadow a field: `function_body` whose parent is
// the `class_body`. A closure's `function_expression_body` and a top-level
// function are both excluded, and neither needs the mask — a closure's binders
// are already in its enclosing member's body-wide shadow set, and the walk
// passes through the enclosing member's Function scope on its way out.
if (bodyNode.type !== 'function_body') return undefined;
const classBody = bodyNode.parent;
if (classBody === null || classBody.type !== 'class_body') return undefined;
const fields = dartClassFieldNames(classBody, memo);
if (fields.size === 0) return undefined;
const shadows = dartBodyShadows(bodyNode, memo);
if (shadows.size === 0) return undefined;
const masked: string[] = [];
for (const name of fields) {
if (shadows.has(name)) masked.push(name);
}
if (masked.length === 0) return undefined;
masked.sort();
return syntheticCapture('@receiver-owner.shadowed-fields', bodyNode, JSON.stringify(masked));
}
/**
* Every name BOUND by one class-member body — the shadow set the bare-name
* branch of `emitDartFieldAssignmentBindings` tests against.
*
* A local `var` is only ONE of Dart's binders, and a bare `r = Outer()` writes
* whichever binder wins, so a set built from local declarations alone made a
* write to any OTHER binder look like a field write. `void reset(Alpha r) { r =
* Alpha(); }` in a class with a field `r` retyped the FIELD to `Alpha` —
* fabricating an edge and displacing the type the constructor had correctly
* given it. Formal parameters are the sharpest case because they are not even
* inside the body: `function_body` is a SIBLING of the `method_signature` that
* carries them, so no walk of the body can ever see one.
*
* Dart 3 patterns are the SAME defect a second time: `var (r, n) = …;`,
* `if (o case Beta r)`, `case Beta r:`, `for (var (r, _) in xs)`, list / map /
* object / rest / cast / null-check / null-assert patterns and pattern
* assignments all bind `r` through node types no earlier list named, so each of
* them let a write retype the field. `addDartBinderName` now enumerates the
* pattern family from the grammar rather than from reported shapes.
*
* Deliberately over-approximate in the shadow direction. The set is body-wide
* (a binder in a nested closure, a `case` arm or a collection-literal element
* shadows for the whole body), a parameter shape whose name cannot be read
* contributes nothing rather than being guessed at, and a bare name inside a
* pattern shadows whether it binds or merely references a constant. All err
* toward DECLINING to bind, which is the right error: a missed field type costs
* an edge, a wrong one produces an edge to the wrong class and destroys a
* correct binding — the failure mode this whole line of work exists to avoid
* (see `scope-resolution/passes/compound-receiver.ts`).
*/
function collectDartBodyShadows(bodyNode: SyntaxNode): Set<string> {
const shadows = new Set<string>();
// The enclosing function's own formal parameters live OUTSIDE the body, on
// the `method_signature` sibling that precedes it — the shape every class
// member takes (method, constructor, factory, static, getter, setter,
// operator, `async`/`async*`).
const signature = bodyNode.previousNamedSibling;
if (signature !== null && signature.type === 'method_signature') {
walkNamedTree(signature, (n) => addDartBinderName(n, shadows));
}
walkNamedTree(bodyNode, (n) => addDartBinderName(n, shadows));
return shadows;
}
/**
* Record the name `node` binds, if it binds one.
*
* The `case` list is the whole point of this function and the reason it is
* separate: an INCOMPLETE list of binder forms is the exact defect this file has
* now shipped twice (formal parameters, then every Dart 3 pattern). It is
* enumerated against `vendor/tree-sitter-dart/grammar.js`, not against the
* shapes a bug report happened to carry.
*/
function addDartBinderName(node: SyntaxNode, out: Set<string>): void {
switch (node.type) {
// `var s;`, `final r = 1;`, and the FIRST declarator of `var a = 1, b = 2;`.
// `for_loop_parts` carries the for-IN variable on the same `name` field
// (`for (var r in xs)`); the C-style form instead nests a
// `local_variable_declaration` the walk reaches on its own.
case 'initialized_variable_definition':
case 'for_loop_parts': {
const nameNode = node.childForFieldName('name');
if (nameNode !== null) out.add(nameNode.text);
return;
}
// Formal parameters — of the enclosing member, of a closure
// (`function_expression`), and of a nested `local_function_declaration`.
case 'formal_parameter': {
const name = dartParameterName(node);
if (name !== null) out.add(name);
return;
}
// `on E catch (e, stack)` — every identifier in the list is a binding.
case 'catch_parameters': {
for (const child of node.namedChildren) {
if (child !== null && child.type === 'identifier') out.add(child.text);
}
return;
}
// Second and later declarators of `var a = 1, b = 2;` — fieldless, so the
// name is the first named child. (A class field is this node type too, but
// only ever under `initialized_identifier_list`, which no body contains.)
case 'initialized_identifier': {
const first = node.namedChild(0);
if (first !== null && first.type === 'identifier') out.add(first.text);
return;
}
// ── Dart 3 patterns ──────────────────────────────────────────────────────
//
// EVERY pattern node the grammar emits, and every one of them takes its
// DIRECT `identifier` children. The grammar rules that would carry a binder
// are `_pattern_field`, `_map_pattern_entry`, `_list_pattern_element`,
// `_parenthesized_pattern`, `_outer_pattern`, `_guarded_pattern` and the
// logical/relational tiers — ALL hidden (`_`-prefixed), so they emit no node
// of their own and inline their children onto whichever visible pattern node
// encloses them. Reading direct children is therefore what actually sees a
// binder; there is no field to ask for (`variable_pattern`,
// `pattern_variable_declaration` and `constant_pattern` declare none).
//
// The first two are where a binder truly lands, and are alone sufficient:
// `variable_pattern` — `Beta s` / `final s` / `var s`.
// `constant_pattern` — a BARE name (`(s, n)`, `[s, t]`, `{'k': s}`,
// `Point(:s)`, `...s`, `(s)`). Dart itself decides bare-name-binds-vs-
// references-a-constant from the enclosing `final`/`var`, and
// tree-sitter gives both the same node, so `case kLimit:` shadows too.
// Over-shadowing a constant reference costs an edge; the other
// direction fabricates one.
// The containers after them are DEFENCE, not routing — the walk reaches
// nested patterns on its own. They matter because the hidden `_pattern_field`
// already drops a NON-binder label identifier straight onto `record_pattern`
// / `object_pattern` (and a key onto `map_pattern`), which is proof that this
// grammar inlines identifiers onto containers. If a grammar revision ever
// inlines a real binder the same way, it is shadowed here on arrival instead
// of becoming the third instance of this bug.
//
// DELIBERATELY EXCLUDED — `pattern_variable_declaration`, `pattern_assignment`
// and `for_loop_parts` hold the pattern and the `=`/`in` RHS at the SAME
// child level (`var [s, t] = xs` → `identifier xs` is a direct child), so
// taking their direct identifiers would shadow the SOURCE expression's name,
// which binds nothing. Their pattern child is a case below. Also excluded:
// `type_identifier` (never an `identifier`, so `Beta` in `Beta s` and `Point`
// in `Point(…)` cannot be mistaken for binders), `qualified` inside a
// `constant_pattern` (`Colors.red` nests one level deeper — a qualified name
// is always a constant reference), and relational/equality operands
// (`case > kLimit`), which the hidden tier drops onto the enclosing
// STATEMENT rather than any pattern node.
case 'variable_pattern':
case 'constant_pattern':
case 'record_pattern':
case 'list_pattern':
case 'map_pattern':
case 'object_pattern':
case 'rest_pattern':
case 'cast_pattern':
case 'null_check_pattern':
case 'null_assert_pattern': {
for (const child of node.namedChildren) {
if (child !== null && child.type === 'identifier') out.add(child.text);
}
return;
}
default:
return;
}
}
/** The name a `formal_parameter` binds, across every shape the grammar gives it. */
function dartParameterName(param: SyntaxNode): string | null {
// `Alpha r`, `final Alpha r`, `void Function(int) r`, `{required Beta r}`,
// `[Delta r]` — all carry an explicit `name` field.
const named = param.childForFieldName('name');
if (named !== null) return named.text;
const only = param.namedChild(0);
if (only === null) return null;
// An untyped closure parameter (`(r) { … }`) is a bare identifier with no
// field to read it from.
if (only.type === 'identifier') return only.text;
// `this.r` / `super.r` bind a parameter NAMED `r` that is initialized from
// the field — a later bare `r = …` writes that parameter, not the field, so
// these shadow exactly like any other.
if (only.type === 'constructor_param' || only.type === 'super_formal_parameter') {
for (let i = only.namedChildCount - 1; i >= 0; i--) {
const child = only.namedChild(i);
if (child !== null && child.type === 'identifier') return child.text;
}
}
return null;
}
function emitHeritage(classNode: SyntaxNode, out: CaptureMatch[]): void {
const nameNode = classNode.childForFieldName('name');
if (nameNode === null) return;

View file

@ -20,7 +20,7 @@
* - `cache-stats.ts` — PROF_SCOPE_RESOLUTION cache hit/miss counters
*/
export { emitDartScopeCaptures } from './captures.js';
export { emitDartScopeCaptures, dartScopeOwnsReceivers } from './captures.js';
export { getDartCaptureCacheStats, resetDartCaptureCacheStats } from './cache-stats.js';
export {
interpretDartImport,

View file

@ -163,6 +163,27 @@ const DART_SCOPE_QUERY = `
(initialized_identifier
. (identifier) @declaration.name))) @declaration.property
; Inference-typed fields — \`var b = Outer();\`, \`final b = Outer();\`,
; \`late final b = Outer();\`, \`static var b = Outer();\` (#2807). The two
; patterns above require a written type, so a field whose type comes from its
; initializer produced NO property declaration at all — no Property node, and
; nothing for captures.ts to hang a type binding on, so \`b.inner()\` could not
; resolve its receiver while the annotated twin resolved fine.
;
; Dart spells the keyword as \`inferred_type\` for \`var\` and \`final_builtin\`
; for \`final\` / \`late final\`; both are class fields and both are idiomatic,
; so covering only one would leave the more common Dart style broken.
(declaration
(inferred_type)
(initialized_identifier_list
(initialized_identifier
. (identifier) @declaration.name))) @declaration.property
(declaration
(final_builtin)
(initialized_identifier_list
(initialized_identifier
. (identifier) @declaration.name))) @declaration.property
; ── Declarations — closure bindings (#2693) ──────────────────────────────────
; \`var f = (x) => x;\` binds a callable. Without a declaration the binding has
; no SymbolDefinition, so callable-value-flow has nothing to attach its seed to

View file

@ -23,6 +23,7 @@ import type {
TypeRef,
CaptureMatch,
} from 'gitnexus-shared';
import { walkToScope } from '../../utils/scope-tree-walk.js';
export function dartBindingScopeFor(
decl: CaptureMatch,
@ -41,6 +42,15 @@ export function dartBindingScopeFor(
return null;
}
// (1b) A field typed from a constructor assigned to it (`r = Outer();` in a
// constructor body) must live on the CLASS scope — the assignment sits inside
// the constructor's own Function scope, where `typeOfMemberOnClass` never
// looks (#2807). Gated on the dedicated marker, never on
// `@type-binding.constructor` at large, which also fires for genuine locals.
if (decl['@type-binding.dart-field'] !== undefined) {
return walkToScope(innermost, tree, 'Class');
}
// (2) Function/method/constructor names are visible in the enclosing scope.
if (
decl['@declaration.function'] !== undefined ||

View file

@ -41,6 +41,21 @@ const CALLEE_POSITION_MARKER: Capture = Object.freeze({
text: '',
});
/**
* Presence-only marker for an embedded field written as `*T` rather than `T`.
*
* Go's method-set rules treat the two forms differently — `S{T}` promotes only
* the value-receiver methods of `T` into `MS(S)`, while `S{*T}` promotes both
* value- and pointer-receiver ones (go.dev/ref/spec#Struct_types). Structural
* interface detection cannot give an exact answer without knowing which was
* written, so the spelling is recorded here and interpreted downstream.
*/
const EMBEDDED_POINTER_MARKER: Capture = Object.freeze({
name: '@reference.embedded-pointer',
range: ZERO_RANGE,
text: '',
});
const GO_CALLABLE_CAPTURE_OPTIONS = {
functionNodeTypes: new Set(['function_declaration', 'method_declaration', 'func_literal']),
callNodeTypes: new Set(['call_expression']),
@ -358,7 +373,12 @@ function synthesizeGoInheritanceReferences(root: SyntaxNode): CaptureMatch[] {
if (field.childForFieldName('name') !== null) continue;
// `field.type` is the embedded base — bare/qualified/generic, with any
// `*` pointer marker as an unnamed sibling token (already unwrapped).
emitGoEmbedInheritance(field.childForFieldName('type'), out);
// That token is the ONLY record of `*T` versus `T`, and the two have
// different method sets, so read it off the field text before it is
// lost. An embedded field has no name, so a leading `*` can only be
// the pointer marker.
const embeddedAsPointer = field.text.trimStart().startsWith('*');
emitGoEmbedInheritance(field.childForFieldName('type'), out, embeddedAsPointer);
}
} else if (typeNode.type === 'interface_type') {
for (const elem of typeNode.namedChildren) {
@ -367,7 +387,8 @@ function synthesizeGoInheritanceReferences(root: SyntaxNode): CaptureMatch[] {
// `type_elem` with >1 named child — skip them (legacy
// `shouldSkipExtends`); a single-element `type_elem` is the embed.
if (elem.type !== 'type_elem' || elem.namedChildCount !== 1) continue;
emitGoEmbedInheritance(elem.namedChild(0), out);
// An interface embed is never a pointer form.
emitGoEmbedInheritance(elem.namedChild(0), out, false);
}
}
}
@ -380,13 +401,20 @@ function synthesizeGoInheritanceReferences(root: SyntaxNode): CaptureMatch[] {
* node, reducing the name to its bare simple identifier. No-ops when `baseNode`
* is null or not one of the embed shapes.
*/
function emitGoEmbedInheritance(baseNode: SyntaxNode | null, out: CaptureMatch[]): void {
function emitGoEmbedInheritance(
baseNode: SyntaxNode | null,
out: CaptureMatch[],
embeddedAsPointer: boolean,
): void {
if (baseNode === null) return;
const nameNode = goEmbedBaseNameNode(baseNode);
if (nameNode === null) return;
out.push({
'@reference.inherits': nodeToCapture('@reference.inherits', baseNode),
'@reference.name': nodeToCapture('@reference.name', nameNode),
// Added ONLY for the pointer form, so a value embed's capture set is
// byte-identical to what it was before this marker existed.
...(embeddedAsPointer ? { '@reference.embedded-pointer': EMBEDDED_POINTER_MARKER } : {}),
});
}

View file

@ -12,7 +12,22 @@ type MethodSetEntry = {
readonly ambiguous: boolean;
};
type MutableMethodSetEntries = Map<string, MethodSetEntry>;
type GoMethodDefinition = SymbolDefinition & { readonly goReceiverKind?: 'value' | 'pointer' };
/** A struct embedded in another, plus HOW it was embedded (`T` vs `*T`). */
type EmbeddedParent = { readonly structId: string; readonly asPointer: boolean };
/**
* The two method sets Go defines for a defined type, kept separately because
* they answer different questions and only one of them is assignability.
* `pointer` = MS(*T) — every method callable on a *T value.
* `value` = MS(T) — the subset callable on a T value.
*/
type DualMethodSet = { readonly value: MutableMethodSet; readonly pointer: MutableMethodSet };
/** Which method set satisfied an interface. `value` implies pointer too. */
export type GoReceiverForm = 'value' | 'pointer';
/** One structural implementor plus the form in which it implements. */
export type GoStructuralImplementor = {
readonly structDefId: string;
readonly receiverForm: GoReceiverForm;
};
type SignatureContext = {
readonly packageQualifier: string | undefined;
readonly importQualifiers: ReadonlyMap<string, string>;
@ -25,7 +40,8 @@ type DetectionIndexes = {
readonly interfaceById: ReadonlyMap<string, SymbolDefinition>;
readonly interfaceOwnMethodsById: ReadonlyMap<string, MethodSet>;
readonly embeddedSitesByInterfaceId: ReadonlyMap<string, readonly ReferenceSite[]>;
readonly parentStructIdsByStructId: ReadonlyMap<string, readonly string[]>;
readonly parentStructIdsByStructId: ReadonlyMap<string, readonly EmbeddedParent[]>;
readonly valueMethodsByStructId: ReadonlyMap<string, MethodSet>;
readonly structIdsByMethodName: ReadonlyMap<string, ReadonlySet<string>>;
readonly signatureContextByDefId: ReadonlyMap<string, SignatureContext>;
readonly scopeIndexes: ScopeResolutionIndexes;
@ -35,7 +51,7 @@ export function detectGoInterfaceImplementations(
parsedFiles: readonly ParsedFile[],
_indexes: ScopeResolutionIndexes,
_model: SemanticModel,
): Map<string, string[]> {
): Map<string, GoStructuralImplementor[]> {
return detectGoInterfaceImplementationsFromIndexes(buildDetectionIndexes(parsedFiles, _indexes));
}
@ -50,7 +66,8 @@ function buildDetectionIndexes(
const interfaceById = new Map<string, SymbolDefinition>();
const interfaceOwnMethodsById = new Map<string, MethodSet>();
const embeddedSitesByInterfaceId = new Map<string, ReferenceSite[]>();
const parentStructIdsByStructId = new Map<string, string[]>();
const parentStructIdsByStructId = new Map<string, EmbeddedParent[]>();
const valueMethodsByStructId = new Map<string, MethodSet>();
const structIdsByMethodName = new Map<string, Set<string>>();
const signatureContextByDefId = new Map<string, SignatureContext>();
const interfaceIdByScopeId = new Map<string, string>();
@ -71,11 +88,44 @@ function buildDetectionIndexes(
}
if (def.type !== 'Method' && def.type !== 'Function') continue;
if (def.ownerId === undefined) continue;
if (isPointerReceiverMethod(def)) continue;
const methodName = simpleQualifiedName(def);
if (methodName === undefined || methodName.length === 0) continue;
// POINTER-receiver methods count toward the method set (#2813).
//
// Go's rule is per-type, and there are two types here: the method set of
// `T` holds only its value-receiver methods, while the method set of `*T`
// holds BOTH. #1966 kept only the value-receiver half, which makes the
// `T` answer exactly right — and the `*T` answer permanently empty, so a
// struct whose methods all read `func (r *T)` satisfied nothing and got
// no IMPLEMENTS edge at all.
//
// That is the shape idiomatic Go actually writes: methods take pointer
// receivers so they can mutate, and `*T` is what gets stored in an
// interface-typed field. Excluding it did not make the graph
// conservative, it made it silent — every call through such a field
// resolved to the interface DECLARATION and `impact()` on the
// implementation reported zero callers, indistinguishable from a symbol
// that genuinely has none.
//
// GitNexus models one Struct node per type with no separate `*T` node, so
// the two method sets cannot both be represented. This picks the `*T`
// reading: the graph now answers "which types provide this interface's
// behaviour", and no longer proves `var x I = T{}` invalid. That trade is
// deliberate — blast radius is what every consumer of IMPLEMENTS asks for
// (verified: MRO/METHOD_IMPLEMENTS derivation, community clustering, the
// receiver-dispatch fan-out index, and the epistemic heritage probe; none
// performs value-assignability checking).
//
// `goReceiverKind` is still stamped in method-owners.ts and is the hook a
// future value/pointer-aware model would read; it is deliberately no
// longer a filter here.
const simpleName = simpleQualifiedName(def);
if (simpleName === undefined || simpleName.length === 0) continue;
addMethod(methodsByOwner, def.ownerId, methodName, def);
addMethod(
methodsByOwner,
def.ownerId,
methodSetKey(simpleName, def, signatureContextByDefId),
def,
);
}
}
@ -104,9 +154,11 @@ function buildDetectionIndexes(
for (const childScope of childScopesByParent.get(scope.id) ?? []) {
for (const def of childScope.ownedDefs) {
if (def.type !== 'Method' && def.type !== 'Function') continue;
const methodName = simpleQualifiedName(def);
if (methodName === undefined || methodName.length === 0) continue;
addMethodOverload(methods, methodName, def);
const simpleName = simpleQualifiedName(def);
if (simpleName === undefined || simpleName.length === 0) continue;
// Same key function as the struct side — an interface requiring an
// unexported `seal()` must only be satisfied from its own package.
addMethodOverload(methods, methodSetKey(simpleName, def, signatureContextByDefId), def);
}
}
interfaceOwnMethodsById.set(ifaceId, methods);
@ -126,13 +178,20 @@ function buildDetectionIndexes(
if (structId === undefined) continue;
const parent = resolveInheritanceBaseInScope(site.inScope, site.name, indexes);
if (parent === undefined || parent.type !== 'Struct') continue;
addParentStruct(parentStructIdsByStructId, structId, parent.nodeId);
// `embeddedAsPointer` is the capture-layer record of `*T` vs `T`; the two
// promote different method sets (go.dev/ref/spec#Struct_types).
addParentStruct(
parentStructIdsByStructId,
structId,
parent.nodeId,
site.embeddedAsPointer === true,
);
}
}
const structMethodSetCache = new Map<string, MutableMethodSetEntries>();
const structMethodSetCache = new Map<string, DualEntries>();
for (const structId of structsById.keys()) {
const effective = collectStructMethodSet(
const dual = collectStructMethodSet(
structId,
{
parentStructIdsByStructId,
@ -141,9 +200,12 @@ function buildDetectionIndexes(
new Set(),
structMethodSetCache,
);
if (effective === undefined) continue;
effectiveMethodsByStructId.set(structId, effective);
for (const methodName of effective.keys()) {
if (dual === undefined) continue;
// `pointer` is MS(*T) and is the superset, so it drives candidate lookup:
// a type that implements only in pointer form is still an implementor.
effectiveMethodsByStructId.set(structId, dual.pointer);
valueMethodsByStructId.set(structId, dual.value);
for (const methodName of dual.pointer.keys()) {
addStructMethodCandidate(structIdsByMethodName, methodName, structId);
}
}
@ -158,6 +220,7 @@ function buildDetectionIndexes(
embeddedSitesByInterfaceId,
parentStructIdsByStructId,
structIdsByMethodName,
valueMethodsByStructId,
signatureContextByDefId,
scopeIndexes: indexes,
};
@ -165,21 +228,31 @@ function buildDetectionIndexes(
function detectGoInterfaceImplementationsFromIndexes(
indexes: DetectionIndexes,
): Map<string, string[]> {
const implementations = new Map<string, string[]>();
): Map<string, GoStructuralImplementor[]> {
const implementations = new Map<string, GoStructuralImplementor[]>();
const methodSetCache = new Map<string, MutableMethodSet>();
for (const iface of indexes.interfaces) {
const required = collectInterfaceMethodSet(iface, indexes, new Set(), methodSetCache);
if (required === undefined || required.size === 0) continue;
if (!methodSetHasVerifiableSignatures(required)) continue;
const implementors: string[] = [];
const implementors: GoStructuralImplementor[] = [];
for (const structId of candidateStructIdsFor(required, indexes)) {
const actual = indexes.effectiveMethodsByStructId.get(structId);
if (actual === undefined) continue;
if (methodSetSatisfies(actual, required, indexes.signatureContextByDefId)) {
implementors.push(structId);
}
const pointerSet = indexes.effectiveMethodsByStructId.get(structId);
if (pointerSet === undefined) continue;
// MS(*T) is the superset: if it does not satisfy, neither does MS(T).
if (!methodSetSatisfies(pointerSet, required, indexes.signatureContextByDefId)) continue;
// Then ask the narrower question separately — does the VALUE type satisfy?
// This is the distinction `var x I = T{}` turns on, and it is a fact about
// the program, not a heuristic.
const valueSet = indexes.valueMethodsByStructId.get(structId);
const satisfiesByValue =
valueSet !== undefined &&
methodSetSatisfies(valueSet, required, indexes.signatureContextByDefId);
implementors.push({
structDefId: structId,
receiverForm: satisfiesByValue ? 'value' : 'pointer',
});
}
if (implementors.length > 0) implementations.set(iface.nodeId, implementors);
}
@ -187,6 +260,46 @@ function detectGoInterfaceImplementationsFromIndexes(
return implementations;
}
/**
* The key a method occupies in a method set.
*
* Go spec, Uniqueness of identifiers: "Two identifiers are different if they are
* spelled differently, **or if they appear in different packages and are not
* exported**." So an UNEXPORTED method name is scoped to its declaring package —
* `seal` in package `sealed` is a different identifier from `seal` in package
* `foreign`, and a type outside `sealed` can never satisfy `interface { seal() }`.
* That is the whole basis of the sealed-interface idiom.
*
* Matching on the bare name made those types satisfy each other, which is a
* FALSE implementation rather than an over-approximation. Qualifying the key
* with the declaring package for unexported names makes the comparison exact.
*
* Exported names are deliberately left unqualified: the spec makes them the same
* identifier across packages, which is what allows cross-package interface
* satisfaction to work at all.
*/
function methodSetKey(
simpleName: string,
def: SymbolDefinition,
signatureContextByDefId: ReadonlyMap<string, SignatureContext>,
): string {
if (isExportedGoIdentifier(simpleName)) return simpleName;
const pkg = signatureContextByDefId.get(def.nodeId)?.packageQualifier;
return pkg === undefined ? simpleName : `${pkg}\u0000${simpleName}`;
}
/**
* Go spec, Exported identifiers: exported iff the first character is a Unicode
* uppercase letter (category Lu). Method names always satisfy the second clause
* (they are method names), so the first character is the whole test.
*/
function isExportedGoIdentifier(name: string): boolean {
const first = name.codePointAt(0);
if (first === undefined) return false;
const ch = String.fromCodePoint(first);
return ch !== ch.toLowerCase() && ch === ch.toUpperCase();
}
function addMethod(
methodsByOwner: Map<string, Map<string, SymbolDefinition[]>>,
ownerId: string,
@ -222,59 +335,115 @@ function addStructMethodCandidate(
}
function addParentStruct(
parentStructIdsByStructId: Map<string, string[]>,
parentStructIdsByStructId: Map<string, EmbeddedParent[]>,
structId: string,
parentStructId: string,
asPointer: boolean,
): void {
const parents = parentStructIdsByStructId.get(structId) ?? [];
parents.push(parentStructId);
parents.push({ structId: parentStructId, asPointer });
parentStructIdsByStructId.set(structId, parents);
}
/** The two entry maps built in parallel: MS(T) and MS(*T). */
type DualEntries = {
readonly value: MutableMethodSetEntries;
readonly pointer: MutableMethodSetEntries;
};
function collectStructMethodSet(
structId: string,
indexes: Pick<DetectionIndexes, 'methodsByOwner' | 'parentStructIdsByStructId'>,
visiting: Set<string>,
cache: Map<string, MutableMethodSetEntries>,
): MutableMethodSet | undefined {
cache: Map<string, DualEntries>,
): DualMethodSet | undefined {
const entries = collectStructMethodEntries(structId, indexes, visiting, cache);
return entries === undefined ? undefined : methodEntriesToMethodSet(entries);
if (entries === undefined) return undefined;
return {
value: methodEntriesToMethodSet(entries.value),
pointer: methodEntriesToMethodSet(entries.pointer),
};
}
/**
* Build MS(T) and MS(*T) together, applying the spec's promotion table exactly
* (go.dev/ref/spec#Method_sets, #Struct_types):
*
* declared receiver T -> in MS(T) and MS(*T)
* declared receiver *T -> in MS(*T) only
* S embeds T (value) -> MS(S) gets P's MS(T); MS(*S) gets P's MS(*T)
* S embeds *T (pointer) -> MS(S) AND MS(*S) both get P's MS(*T)
*
* The last row is the one that makes the embed FORM load-bearing: with
* `func (b *Base) Ping()`, `struct{ Base }` does not implement a `Ping`
* interface by value while `struct{ *Base }` does. Collapsing the forms gives
* both the same answer and one of them is then wrong.
*/
function collectStructMethodEntries(
structId: string,
indexes: Pick<DetectionIndexes, 'methodsByOwner' | 'parentStructIdsByStructId'>,
visiting: Set<string>,
cache: Map<string, MutableMethodSetEntries>,
): MutableMethodSetEntries | undefined {
cache: Map<string, DualEntries>,
): DualEntries | undefined {
const cached = cache.get(structId);
if (cached !== undefined) return cloneMethodEntries(cached);
if (cached !== undefined) {
return { value: cloneMethodEntries(cached.value), pointer: cloneMethodEntries(cached.pointer) };
}
if (visiting.has(structId)) return undefined;
visiting.add(structId);
const merged = directMethodEntries(indexes.methodsByOwner.get(structId));
const own = indexes.methodsByOwner.get(structId);
// MS(*T) holds every declared method; MS(T) drops the pointer-receiver ones.
const pointer = directMethodEntries(own);
const value = directMethodEntries(filterValueReceiverMethods(own));
for (const parentStructId of indexes.parentStructIdsByStructId.get(structId) ?? []) {
const parentEntries = collectStructMethodEntries(parentStructId, indexes, visiting, cache);
for (const parent of indexes.parentStructIdsByStructId.get(structId) ?? []) {
const parentEntries = collectStructMethodEntries(parent.structId, indexes, visiting, cache);
if (parentEntries === undefined) {
visiting.delete(structId);
return undefined;
}
for (const [methodName, entry] of parentEntries) {
if (entry.ambiguous) continue;
mergePromotedMethodEntry(merged, methodName, {
overloads: entry.overloads,
depth: entry.depth + 1,
ambiguous: false,
});
}
// Embedding by POINTER lifts the parent's pointer-receiver methods into the
// embedder's VALUE method set; embedding by value does not.
const promotedIntoValue = parent.asPointer ? parentEntries.pointer : parentEntries.value;
promoteEntries(value, promotedIntoValue);
promoteEntries(pointer, parentEntries.pointer);
}
visiting.delete(structId);
cache.set(structId, cloneMethodEntries(merged));
return merged;
cache.set(structId, { value: cloneMethodEntries(value), pointer: cloneMethodEntries(pointer) });
return { value, pointer };
}
/** Merge one depth-level of promoted entries, preserving the shallowest-depth
* and ambiguity rules the selector spec defines. */
function promoteEntries(target: MutableMethodSetEntries, source: MutableMethodSetEntries): void {
for (const [methodName, entry] of source) {
if (entry.ambiguous) continue;
mergePromotedMethodEntry(target, methodName, {
overloads: entry.overloads,
depth: entry.depth + 1,
ambiguous: false,
});
}
}
/** MS(T) excludes methods declared with a `*T` receiver. */
function filterValueReceiverMethods(methods: MethodSet | undefined): MutableMethodSet | undefined {
if (methods === undefined) return undefined;
const out = new Map<string, SymbolDefinition[]>();
for (const [name, overloads] of methods) {
const valueOnly = overloads.filter(
(def) => (def as GoMethodDefinition).goReceiverKind !== 'pointer',
);
if (valueOnly.length > 0) out.set(name, valueOnly);
}
return out;
}
/** Receiver-kind sidecar stamped by `populateGoOwners` (method-owners.ts). */
type GoMethodDefinition = SymbolDefinition & { readonly goReceiverKind?: 'value' | 'pointer' };
function collectInterfaceMethodSet(
iface: SymbolDefinition,
indexes: DetectionIndexes,
@ -475,10 +644,6 @@ function methodSetHasVerifiableSignatures(methods: MethodSet): boolean {
return true;
}
function isPointerReceiverMethod(def: SymbolDefinition): boolean {
return (def as GoMethodDefinition).goReceiverKind === 'pointer';
}
function hasVerifiableSignature(def: SymbolDefinition): boolean {
return (
def.parameterCount !== undefined ||

View file

@ -29,8 +29,12 @@ export function interpretGoTypeBinding(captures: CaptureMatch): ParsedTypeBindin
if (captures['@type-binding.self'] !== undefined) {
source = 'self';
// Preserve pointer shape on receiver self-bindings (`*T` vs `T`).
// Method-owner enrichment consumes that raw shape to model Go value and
// pointer receiver method sets conservatively.
// Method-owner enrichment consumes that raw shape to stamp `goReceiverKind`
// on each method. Since #2813 that stamp is metadata, NOT a filter:
// structural interface satisfaction counts pointer-receiver methods, because
// `*T`'s method set is what an interface-typed field actually holds. The
// preserved distinction is the hook a future value/pointer-aware model would
// read — do not reintroduce it as an exclusion.
normalizedType = type.trim();
} else if (captures['@type-binding.constructor'] !== undefined) {
source = 'constructor-inferred';

View file

@ -39,6 +39,14 @@ import { getJsParser, getJsScopeQuery, jsCachedTreeMatchesGrammar } from './quer
import { computeTsArityMetadata } from '../typescript/arity-metadata.js';
import { synthesizeTsReceiverBinding } from '../typescript/receiver-binding.js';
import { isArrayMethodCallbackArrow } from '../typescript/array-callback.js';
import { isStaticClassFieldBinding } from '../typescript/captures.js';
/** JavaScript's spelling of a class-field declaration — the TypeScript grammar
* calls the same construct `public_field_definition`. Named here, not in the
* shared predicate, so each literal is checked against the grammar of the file
* it lives in (`grammar-literal-validation`). */
const JS_CLASS_FIELD_DEFINITION_TYPES: ReadonlySet<string> = new Set(['field_definition']);
import { hasKeyword } from '../../field-extractors/configs/helpers.js';
import { synthesizeCjsModuleExports } from '../typescript/cjs-module-exports.js';
import {
isShadowedCjsExportAssignment,
@ -638,8 +646,23 @@ function synthesizeConstructorFieldBindings(root: SyntaxNode, out: CaptureMatch[
}
// Only process constructor method definitions
if (node.type !== 'method_definition') continue;
// …and only a CLASS's. An object literal's members are `method_definition`
// too, so `{ constructor() { this.p = new Alien(); } }` reached here and
// typed a field on whatever class the hoist walked up to — an object
// literal's `this` is the literal, never that class's instance (#2807).
// TypeScript's sibling pattern carries the same constraint in its query
// nesting; the two must not read one source differently.
if (node.parent?.type !== 'class_body') continue;
const nameNode = node.childForFieldName('name');
if (nameNode?.text !== 'constructor') continue;
// `static constructor() {}` is legal JavaScript — the reserved-name rule
// applies to instance methods only — and its `this` is the CLASS object, so
// `this.p = new Alien()` there writes a static property and must not type
// the instance field `p`. TypeScript's sibling guard (`isStaticMethodThis`)
// already drops this shape; without the same test here `.js` and `.ts` read
// one source differently. `hasKeyword` skips the `name` field, so a method
// named `static` cannot false-positive.
if (hasKeyword(node, 'static')) continue;
const body = node.childForFieldName('body');
if (body === null) continue;
@ -868,6 +891,24 @@ export function emitJsScopeCaptures(
}
}
// A `static` class field is a member of the class object, not of instances,
// so `static p = new Wrong()` must not retype the `p = new Right()` beside
// it — the two land on one Class scope and the later match wins the `>=`
// tie-break. Shared with TypeScript (`isStaticClassFieldBinding`), which is
// where the reasoning and the grammar evidence live; the two languages read
// the same source and must not read it differently. JavaScript has no type
// annotations, so `field_definition` initializers are the only class-field
// type binding its query emits and `@type-binding.constructor` is the only
// anchor that can carry the modifier.
if (
isStaticClassFieldBinding(
groupedNodes['@type-binding.constructor'],
JS_CLASS_FIELD_DEFINITION_TYPES,
)
) {
continue;
}
// #1876: drop @declaration.function for array higher-order-method
// callbacks (`const x = arr.map(a => …)`). The HOC-wrapped-arrow
// pattern matches them, but the binding holds a value, not a callable.

View file

@ -430,6 +430,34 @@ export const JAVASCRIPT_SCOPE_QUERY = `
value: (new_expression
constructor: (member_expression) @type-binding.type)) @type-binding.constructor
;; Class field initializer: \`class C { p = new Outer(); }\` (#2807).
;; JavaScript has no field annotations at all, so a class field's type can only
;; ever come from its initializer — without this pattern \`this.p.inner()\` had
;; nothing to type the receiver with and the receiver fold declined the whole
;; chain. \`synthesizeConstructorFieldBindings\` in captures.ts already covers the
;; sibling shape (\`this.p = new Outer()\`), but only inside a \`constructor\`
;; body, so a field initialized at its declaration matched nothing.
;;
;; Anchored on \`field_definition\` so the binding lands in the class body scope,
;; where \`typeOfMemberOnClass\` reads it — the same anchoring TypeScript uses for
;; \`public_field_definition\`. Note the JS grammar names the field \`property:\`,
;; not \`name:\`.
(field_definition
property: (property_identifier) @type-binding.name
value: (new_expression
constructor: (identifier) @type-binding.type)) @type-binding.constructor
(field_definition
property: (property_identifier) @type-binding.name
value: (new_expression
constructor: (member_expression) @type-binding.type)) @type-binding.constructor
;; Private-name field: \`#p = new Outer()\`.
(field_definition
property: (private_property_identifier) @type-binding.name
value: (new_expression
constructor: (identifier) @type-binding.type)) @type-binding.constructor
;; Call-result alias: const u = getUser()
(variable_declarator
name: (identifier) @type-binding.name

View file

@ -18,7 +18,8 @@
*/
import type { CaptureMatch, Scope, ScopeId, ScopeTree } from 'gitnexus-shared';
import { tsBindingScopeFor, walkToScope } from '../typescript/simple-hooks.js';
import { walkToScope } from '../../utils/scope-tree-walk.js';
import { tsBindingScopeFor } from '../typescript/simple-hooks.js';
export {
tsImportOwningScope as jsImportOwningScope,

View file

@ -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;
}

View file

@ -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';

View file

@ -119,10 +119,17 @@ export function interpretPythonTypeBinding(captures: CaptureMatch): ParsedTypeBi
// `cls` is a self-like receiver; share the source label so downstream
// `Registry.lookup` Step 2 treats them identically.
else if (captures['@type-binding.cls'] !== undefined) source = 'self';
else if (captures['@type-binding.instance-field'] !== undefined) {
// Before the instance-field arm, not after it: `self.x = Outer()` (#2807)
// carries BOTH markers, and its type is inferred from the CONSTRUCTOR CALL —
// the weakest of the three tiers — so a class that also annotates the field
// keeps the annotation. Testing constructor first is what lets the
// instance-field arm below stay flat; the two orders agree on every input,
// since they differ only where both markers are present and both then say
// 'constructor-inferred'.
else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
else if (captures['@type-binding.instance-field'] !== undefined)
source =
captures['@type-binding.parameter'] !== undefined ? 'parameter-annotation' : 'annotation';
} else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation';
else if (captures['@type-binding.alias'] !== undefined) source = 'assignment-inferred';
else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';

View file

@ -114,17 +114,78 @@ export function synthesizeReceiverTypeBinding(fnNode: SyntaxNode): CaptureMatch
};
}
/**
* The class name of a direct constructor call — `Outer()` — or `undefined` for
* anything else. Python has no `new`, so a call is the only syntactic
* construction form, and a call to a plain name is the shape every other
* language spells `= new X()`.
*
* BARE NAMES ONLY. A dotted callee (`models.User()`, `f.Alpha()`) is refused.
* The first cut of #2807 returned the callee's full dotted text, on the theory
* that a dotted name resolves through `QualifiedNameIndex` the way the
* module-level `u = models.User()` capture in `query.ts` does. Measured on a
* fixture carrying both shapes, that arm produced no correct edge and did
* produce wrong ones:
*
* - `self.svc = f.Alpha()`, where `Alpha` is a METHOD on an unrelated object
* and `Alpha` is also a class elsewhere: the dotted lookup matches the
* TRAILING segment against a same-named class, so `self.svc.ping()` emitted
* a FABRICATED edge to `Alpha.ping`. The root does not have to be a
* parameter — a module-level `shared_factory.Alpha()` fabricates the same
* way — so no test on the root's binding form contains this.
* - `self.u = models.User()`, the shape the arm was written FOR, emitted no
* edge with the arm or without it. An instance field's binding lands in
* CLASS scope, which never reaches the namespace split that makes the
* module-level local form resolve.
*
* Accepted cost, stated plainly: `self.u = models.User()` still does not type
* its field. It did not before this narrowing either, so nothing that worked is
* given up — and declining to bind is the doctrine `compound-receiver.ts`
* states, that a confidently WRONG owner is strictly worse than a missing edge.
* Re-enabling dotted callees is a RESOLUTION-side change (teach the field path
* the namespace split that #2826/#2828 is reworking); redoing it here could
* only re-open the trailing-segment fabrication.
*
* This is also why the tier tie-break below can stay last-write-wins: the
* displacement it used to suffer — `self.conn = Outer()` then
* `self.conn = Registry.get()`, where the second, dotted, non-type candidate
* overwrote the real constructor binding at the shared weakest tier and left
* the field untyped — cannot arise once a dotted callee yields no candidate at
* all. The weakest tier now ranks only REAL constructions against each other,
* where the last write in `__init__` genuinely is the live one.
*/
function constructorCallTypeName(
right: SyntaxNode | null,
receiverName: string,
): string | undefined {
if (right === null || right.type !== 'call') return undefined;
const callee = right.childForFieldName('function');
if (callee === null) return undefined;
// An `attribute` callee is the dotted path refused above; `self.p =
// self.build()` is one instance of it, and a method call is not a
// construction.
if (callee.type !== 'identifier') return undefined;
const text = callee.text.trim();
if (text.length === 0) return undefined;
// `self.p = self()` invokes the instance's own `__call__`. It is a call to a
// bare name, but that name is the receiver, not a type.
if (text === receiverName) return undefined;
return text;
}
/**
* Synthesize class-scope field bindings for the common Python constructor
* injection pattern:
* injection patterns:
*
* def __init__(self, service: Service):
* self.service = service
* self.service = service # from the PARAMETER's annotation
* self.cache: Cache = build() # from the FIELD's own annotation
* self.outer = Outer() # from the CONSTRUCTOR called (#2807)
*
* An explicit field annotation (`self.service: Service = ...`) is also
* accepted and takes precedence over a parameter annotation. Deliberately do
* not infer from arbitrary unannotated RHS expressions: the receiver resolver
* needs a declared type, not a name-only guess.
* The three tiers rank in that order — an explicit field annotation beats a
* parameter annotation, which beats a construction. Anything else is still
* refused: the receiver resolver needs positive evidence, not a name-only
* guess from an arbitrary RHS.
*/
export function synthesizeConstructorFieldTypeBindings(fnNode: SyntaxNode): CaptureMatch[] {
if (fnNode.childForFieldName('name')?.text !== '__init__') return [];
@ -148,7 +209,18 @@ export function synthesizeConstructorFieldTypeBindings(fnNode: SyntaxNode): Capt
if (name !== null && annotation !== null) parameterTypes.set(name, annotation.text);
}
type Candidate = { readonly match: CaptureMatch; readonly explicit: boolean };
// Three evidence tiers for one field, strongest first. A later assignment of
// the SAME tier still wins (last write in `__init__` is the live one), but a
// weaker one never displaces a stronger: `self.x: Outer = make()` keeps its
// annotation even if a later branch does `self.x = Other()`.
//
// Same-tier last-write-wins is only sound because `constructorCallTypeName`
// admits nothing but real constructions into the weakest tier — see the note
// there on the `self.conn = Registry.get()` displacement it used to allow.
const TIER_EXPLICIT = 2;
const TIER_PARAMETER = 1;
const TIER_CONSTRUCTOR = 0;
type Candidate = { readonly match: CaptureMatch; readonly tier: number };
const candidates = new Map<string, Candidate>();
const stack: SyntaxNode[] = [body];
@ -178,13 +250,30 @@ export function synthesizeConstructorFieldTypeBindings(fnNode: SyntaxNode): Capt
const explicitType = node.childForFieldName('type');
const parameterType =
right?.type === 'identifier' ? parameterTypes.get(right.text) : undefined;
const typeName = explicitType?.text ?? parameterType;
// `self.x = Outer()` — the type comes from the CONSTRUCTOR being
// called (#2807). This is not the "arbitrary unannotated RHS" the
// header warns against: a call to a name that resolves to a class is
// the same positive evidence every other language reads from
// `= new X()`, and without it an unannotated instance field could
// never act as a call receiver at all. Weakest of the three tiers, so
// an explicit annotation or a parameter annotation still wins.
const constructedType =
explicitType === null && parameterType === undefined
? constructorCallTypeName(right, receiverName)
: undefined;
const typeName = explicitType?.text ?? parameterType ?? constructedType;
if (typeName !== undefined) {
const explicit = explicitType !== null;
const inferredFromConstructor = constructedType !== undefined;
const tier = explicit
? TIER_EXPLICIT
: inferredFromConstructor
? TIER_CONSTRUCTOR
: TIER_PARAMETER;
const existing = candidates.get(field.text);
if (existing === undefined || explicit || !existing.explicit) {
if (existing === undefined || tier >= existing.tier) {
candidates.set(field.text, {
explicit,
tier,
match: {
'@type-binding.name': syntheticCapture('@type-binding.name', field, field.text),
'@type-binding.type': syntheticCapture(
@ -192,15 +281,26 @@ export function synthesizeConstructorFieldTypeBindings(fnNode: SyntaxNode): Capt
explicitType ?? right ?? field,
typeName,
),
// The marker that tells `interpretPythonTypeBinding` which
// tier this is; an explicit annotation carries neither and
// reads as `annotation`.
...(explicit
? {}
: {
'@type-binding.parameter': syntheticCapture(
'@type-binding.parameter',
right ?? field,
'1',
),
}),
: inferredFromConstructor
? {
'@type-binding.constructor': syntheticCapture(
'@type-binding.constructor',
right ?? field,
'1',
),
}
: {
'@type-binding.parameter': syntheticCapture(
'@type-binding.parameter',
right ?? field,
'1',
),
}),
'@type-binding.instance-field': syntheticCapture(
'@type-binding.instance-field',
node,

View file

@ -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

View file

@ -17,6 +17,7 @@ import type {
} from 'gitnexus-shared';
import type { SyntaxNode } from 'tree-sitter';
import { findAncestorBeforeBoundary, FUNCTION_NODE_TYPES } from '../../utils/ast-helpers.js';
import { walkToScope } from '../../utils/scope-tree-walk.js';
const PYTHON_METHOD_CONTAINER_TYPES: ReadonlySet<string> = new Set(['class_definition']);
@ -46,12 +47,7 @@ export function pythonBindingScopeFor(
tree: ScopeTree,
): ScopeId | null {
if (decl['@type-binding.instance-field'] !== undefined) {
let current: Scope | undefined = innermost;
while (current !== undefined) {
if (current.kind === 'Class') return current.id;
if (current.parent === null) break;
current = tree.getScope(current.parent);
}
return walkToScope(innermost, tree, 'Class');
}
return null;
}

View file

@ -93,6 +93,123 @@ function buildEnclosingQualifiedName(callNode: SyntaxNode): string | undefined {
return segments.length > 0 ? segments.join('.') : undefined;
}
/**
* Does this `@ivar` write land on an INSTANCE of the enclosing LEXICAL class?
*
* In Ruby an instance variable belongs to whatever `self` is at the point of
* the write, so this is really a question about `self`, and `self` is an
* instance of the enclosing lexical class only inside an ordinary `def` whose
* own definition site is that class's body. Every other arrangement writes an
* ivar some instance of this class will never see:
*
* class C
* @shared = Outer.new # class body: self == C
* def self.build # singleton_method: self == C
* @pool = Outer.new
* end
* class << self # singleton_class: self == C
* def warm; @cache = Outer.new; end
* end
* Other.class_eval do # block receiver: self == Other
* def warm; @far = Outer.new; end
* end
* other.instance_eval { @alien = Outer.new } # self == other
* def run; @pool.inner; end # reads nil — @pool was never set on an instance
* end
*
* So the walk stops on the FIRST ancestor that decides who `self` is, and
* answers true only for an ordinary `method` reached without crossing any
* boundary that could have moved `self` elsewhere. `method` alone is NOT
* sufficient — a `def` nested in a `class << self` body, or in a `class_eval`
* block, is reached through a `method` node first — hence the flag rather than
* an early return (#2807).
*
* ── WHY BLOCKS ARE A HARD STOP, WITHOUT LOOKING AT THE CALL THEY BELONG TO ──
*
* A block is the one construct whose `self` (and whose "default definee", the
* class a `def` inside it attaches to) is chosen by its RECEIVER, not by its
* syntax. `Foo.class_eval do … end`, `Class.new do … end`, `Struct.new(:x) do …
* end`, `Data.define(:x) do … end`, `Module.new`, `refine`, `define_method`,
* `instance_eval`, `instance_exec`, `module_eval` and `class_exec` all rebind
* it; `[1].each do … end` does not.
*
* Telling those apart would need an enumeration of every method that rebinds a
* block's `self`, and that set is OPEN: any user-defined method can do it to a
* block it merely receives —
*
* def helper(&blk) = Foo.class_eval(&blk)
* helper { def warm; @far = Outer.new; end } # attaches to Foo, not here
*
* — so no allow-list of "safe" call names is sound, and a deny-list of known
* rebinders is exactly the incomplete enumeration that produced this bug. The
* only complete answer available from the block's own syntax is that ownership
* is unprovable, so every block boundary is a stop. That over-discards a plain
* `each`/`tap` block, whose `self` really is the instance; per the safety
* doctrine (see `scope-resolution/passes/compound-receiver.ts`) a missed edge is
* the acceptable cost and a fabricated edge on the wrong class is not.
*
* A `class` / `module` keyword nested INSIDE a block still terminates the walk
* with a true answer, and correctly so: that keyword sets the definee lexically
* no matter what surrounds it, so `Class.new do class Inner; def m; @a = …`
* writes a real `Inner` instance field.
*
* ── FORMS THAT CANNOT REACH HERE AT ALL ────────────────────────────────────
*
* `obj.instance_variable_set(:@a, Outer.new)` is a `call`, not an `assignment`,
* and `Foo.class_eval "def warm; @a = Outer.new; end"` hides its body in a
* `string` node. Neither parses into the `(assignment left: (instance_variable)
* …)` pattern in query.ts, so neither ever produces the marker this gate reads.
*
* Deliberately conservative. A write this returns false for simply binds no
* type at all, which costs at most a missed edge; returning true too eagerly
* invents a call from a receiver that is always nil.
*/
function isRubyInstanceIvarWrite(ivarNode: SyntaxNode | undefined): boolean {
if (ivarNode === undefined) return false;
let insideMethodBody = false;
for (let ancestor = ivarNode.parent; ancestor !== null; ancestor = ancestor.parent) {
switch (ancestor.type) {
// `def self.x` / `def obj.x`, and `class << self` / `class << obj`.
case 'singleton_method':
case 'singleton_class':
return false;
// Every block body: `do … end` and `{ … }` are the only two the grammar
// produces. `lambda` (`->`) always wraps its body in one of them, so it
// can never be the first boundary today — it is listed because the
// boundary IS the lambda, and a grammar change must not silently
// un-guard it.
case 'do_block':
case 'block':
case 'lambda':
return false;
// `BEGIN { … }` / `END { … }` are program-level bodies whose execution is
// relocated out of the enclosing method (before the program, and at exit);
// Ruby warns when they appear in a method body at all. Rather than assert
// whose `self` runs them, the doctrine applies: unprovable, so discard.
// Reachable — tree-sitter parses `END { @a = Outer.new }` inside a `def`
// as an `end_block` under that method's body.
case 'begin_block':
case 'end_block':
return false;
case 'method':
insideMethodBody = true;
break;
// The owning body. Reaching it without crossing any of the boundaries
// above means the write is an instance write exactly when a `def` body
// enclosed it.
case 'class':
case 'module':
case 'program':
return insideMethodBody;
// Everything else is control flow that cannot move `self`: `if`/`unless`
// (`then`), `case`/`when`, `while`, `for`/`do`, `begin`/`rescue`/`ensure`.
default:
break;
}
}
return false;
}
export function emitRubyScopeCaptures(
sourceText: string,
_filePath: string,
@ -137,6 +254,25 @@ export function emitRubyScopeCaptures(
}
if (Object.keys(grouped).length === 0) continue;
// A tree-sitter pattern cannot say "and no singleton ancestor", so the
// ownership test is a walk and the whole binding is dropped here when `self`
// is the class object rather than an instance.
//
// Dropping the MARKER alone is not enough, and the class-body shape is why:
// with the marker gone `rubyBindingScopeFor` declines to hoist and the
// binding falls back to its innermost scope — which for `@shared = Outer.new`
// written straight in the class body already IS the Class scope. It would
// arrive at the wrong place by default. Discarding the match is the only
// uniform answer, and it costs nothing that existed before: these ivar
// patterns are new in #2807, so a class-object ivar simply goes back to
// binding nothing, exactly as it did before the pattern was added.
if (
grouped['@type-binding.ivar-field'] !== undefined &&
!isRubyInstanceIvarWrite(nodeMap['@type-binding.ivar-field'])
) {
continue;
}
// Decompose require/require_relative/load into import captures
if (grouped['@import.statement'] !== undefined) {
const anchor = grouped['@import.statement']!;

View file

@ -178,6 +178,41 @@ const RUBY_SCOPE_QUERY = `
method: (identifier) @_new_method2
(#eq? @_new_method2 "new"))) @type-binding.constructor
;; Instance-variable constructor: \`@service = UserService.new\` (#2807).
;; The patterns above bind locals and constants; an instance variable — the
;; only way a Ruby object gets a field at all — bound nothing, so \`@service.run\`
;; had no receiver type and the fold declined the whole chain.
;;
;; \`@type-binding.name\` is captured on the \`instance_variable\` node, so the
;; bound name keeps its \`@\` sigil and matches the receiver text at the call
;; site verbatim. The narrow \`@type-binding.ivar-field\` marker rides the same
;; node for \`rubyBindingScopeFor\` to hoist on; anchorCaptureFor takes the
;; broadest range, so the assignment stays the anchor and the source stays
;; \`constructor-inferred\`.
;;
;; These patterns match unconditionally HERE; captures.ts then discards the
;; whole binding via \`isRubyInstanceIvarWrite\` unless \`self\` at the write is
;; provably an instance of the enclosing lexical class — which rules out
;; \`def self.x\`, \`class << self\`, the class body itself, and any write reached
;; through a block, whose \`self\` its receiver chooses (\`class_eval\`,
;; \`Class.new\`, \`instance_eval\`, \`define_method\`, …). A tree-sitter pattern
;; cannot state "and no singleton or block ancestor", so the ownership test has
;; to be a walk.
(assignment
left: (instance_variable) @type-binding.name @type-binding.ivar-field
right: (call
receiver: (constant) @type-binding.type
method: (identifier) @_new_ivar
(#eq? @_new_ivar "new"))) @type-binding.constructor
(assignment
left: (instance_variable) @type-binding.name @type-binding.ivar-field
right: (call
receiver: (scope_resolution) @type-binding.type
method: (identifier) @_new_ivar_q
(#eq? @_new_ivar_q "new"))) @type-binding.constructor
;; Constant constructor: SERVICE = UserService.new (left is constant, not identifier)
(assignment

View file

@ -8,17 +8,38 @@ import type {
NodeLabel,
} from 'gitnexus-shared';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { walkToScope } from '../../utils/scope-tree-walk.js';
export function rubyBindingScopeFor(
decl: CaptureMatch,
innermost: Scope,
_tree: ScopeTree,
tree: ScopeTree,
): ScopeId | null {
// Keep self typeBindings in the method's Function scope so
// populateClassOwnedMembers can match Method defs to their receiver types.
if (decl['@type-binding.self'] !== undefined) {
return innermost.id;
}
// `@ivar = Foo.new` in `initialize` (or any method) declares a FIELD of the
// enclosing class, so its type binding belongs on the Class scope — the only
// place `typeOfMemberOnClass` reads it. Left on the method's own Function
// scope it would be invisible to every other method (#2807).
//
// Gated on the marker that pattern emits, never on `@type-binding.constructor`
// at large: that capture also fires for `x = Foo.new` locals, and hoisting
// those to the class would leak a method local into every sibling method.
//
// Reaching this hook already means the write is an INSTANCE write. `Capture`
// carries only name/range/text — no AST node — so this hook cannot ask whose
// `self` owns the ivar; `isRubyInstanceIvarWrite` (captures.ts) answers that
// upstream and discards the binding entirely whenever `self` is anything but
// an instance of the enclosing lexical class — the class object itself
// (`def self.x`, `class << self`, the class body), or whatever object a block
// receiver rebinds it to (`class_eval`, `Class.new`, `instance_eval`, …).
// None of those reach an instance, so none may be published as its field.
if (decl['@type-binding.ivar-field'] !== undefined) {
return walkToScope(innermost, tree, 'Class');
}
return null;
}

View file

@ -43,7 +43,10 @@ import {
import { splitSwiftImport } from './import-decomposer.js';
import { swiftQualifiedBaseTail } from './base-type.js';
import { computeSwiftArityMetadata } from './arity-metadata.js';
import { synthesizeSwiftReceiverBinding } from './receiver-binding.js';
import {
findEnclosingTypeDeclaration,
synthesizeSwiftReceiverBinding,
} from './receiver-binding.js';
import { synthesizeSwiftSignatureBindings } from './signature-bindings.js';
import { getSwiftParser, getSwiftScopeQuery } from './query.js';
import { preprocessSwiftConditionalDirectives } from './conditional-directive-preprocess.js';
@ -53,6 +56,43 @@ import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
/**
* Name of the type that lexically owns `node` — the nearest enclosing
* `class_declaration` (which in tree-sitter-swift is also how `struct` and
* `extension` parse) or `protocol_declaration`.
*
* Qualifies a method def as `<Type>.<method>` (#2807 follow-up). Swift's
* structure phase already keys the graph node that way (`A.run#1`), but the
* resolver-side def carried only `run`, and the bridge's every label-scoped key
* is built from the def's name — so two classes in one file each declaring
* `func run` fell through to the label-agnostic simple key, which is
* first-write-wins. The result: EVERY call in both bodies was attributed to
* whichever `run` registered first, which collected duplicate edges while its
* twin collected none. Renaming one method, or moving it to another file, made
* both resolve — which is what identified the collision as name-keyed and
* per-file rather than positional.
*
* An `extension Foo` wraps the extended type in a `user_type`, which may be
* qualified (`extension Outer.Inner`) or carry generic arguments
* (`extension Set<Int>`), so the trailing `type_identifier` is taken — the
* spelling has to match the owner the structure phase used to build the node id.
*
* Both halves delegate rather than re-derive: `findEnclosingTypeDeclaration`
* (receiver-binding.ts) decides what the enclosing type IS, so a method's owner
* qualifier and its `self` binding cannot disagree, and `swiftBaseTypeIdentifier`
* reads the name STRUCTURALLY. An earlier version split the node text on `<` and
* `.`, which only approximates the tree: generic arguments live in a sibling
* `type_arguments` node that the walk skips outright.
*/
function swiftEnclosingTypeName(node: SyntaxNode): string | null {
const typeNode = findEnclosingTypeDeclaration(node);
if (typeNode === null) return null;
const nameNode = typeNode.childForFieldName('name');
if (nameNode === null) return null;
const tail = swiftBaseTypeIdentifier(nameNode)?.text.trim() ?? '';
return tail.length > 0 ? tail : null;
}
/** Declaration anchors that carry function-like arity metadata. */
const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const;
@ -247,6 +287,25 @@ export function emitSwiftScopeCaptures(
}
}
// ── Qualify a method/constructor def with its owning type (#2807). ──
// Emitted before the `@scope.function` branch below, which pushes and
// `continue`s; a Swift `function_declaration` matches both patterns.
if (grouped['@declaration.qualified_name'] === undefined) {
const ownerTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined);
const declaredName = grouped['@declaration.name']?.text;
const declNode = ownerTag === undefined ? null : nodeMap[ownerTag];
if (ownerTag !== undefined && declaredName !== undefined && declNode != null) {
const owner = swiftEnclosingTypeName(declNode);
if (owner !== null) {
grouped['@declaration.qualified_name'] = syntheticCapture(
'@declaration.qualified_name',
declNode,
`${owner}.${declaredName}`,
);
}
}
}
// ── @scope.function: arity + receiver + signature bindings. ──────
if (grouped['@scope.function'] !== undefined) {
const fnNodeForArity = nodeIfType(

View file

@ -107,6 +107,23 @@ const SWIFT_SCOPE_QUERY = `
(type_annotation
(user_type (type_identifier) @type-binding.type))) @type-binding.annotation
;; Optional property annotations: \`var owner: Owner?\` (#2807). The pattern
;; above requires the \`user_type\` to be a DIRECT child of the annotation;
;; an optional inserts an \`optional_type\` level between them, so an optional
;; field was never typed at all and \`self.owner!.method()\` could not resolve
;; its receiver. Declaring a field optional and assigning it later is the
;; idiomatic Swift way to express a field that has no value at init time, so
;; this is the common shape, not an edge case.
;;
;; The INNER \`type_identifier\` is captured, so the binding is \`Owner\` with no
;; reliance on \`stripOptional\` reducing a \`Owner?\` spelling.
(property_declaration
name: (pattern
bound_identifier: (simple_identifier) @type-binding.name)
(type_annotation
(optional_type
(user_type (type_identifier) @type-binding.type)))) @type-binding.annotation
;; ── Type bindings — stored / local-var constructor inference:
;; \`let p = Product(...)\` (constructor) and \`let u = getUser()\`
;; (free-call result; chain-follow resolves getUser → its return type).

View file

@ -39,8 +39,12 @@ const FUNCTION_NODE_TYPES = new Set([
/** Walk up to the enclosing type declaration (class/struct/extension/
* protocol). Nested local functions still see `self` from the enclosing
* type, so don't stop at function-like nodes. */
function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null {
* type, so don't stop at function-like nodes.
*
* Exported so `swiftEnclosingTypeName` (captures.ts) asks the same question
* the same way — the two must agree on what "the enclosing type" is or a
* method's owner qualifier and its `self` binding name different types. */
export function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null {
let cur: SyntaxNode | null = node.parent;
while (cur !== null) {
if (TYPE_DECL_NODE_TYPES.has(cur.type)) return cur;

View file

@ -43,6 +43,7 @@ import {
isUnexportedMemberAssignmentValue,
isUndeclarableThisMemberValue,
} from './cjs-export-assignment.js';
import { hasKeyword } from '../../field-extractors/configs/helpers.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
@ -199,6 +200,134 @@ function shouldEmitReadMember(memberNode: SyntaxNode): boolean {
}
}
/**
* Is this `(this)` node the `this` of a STATIC method?
*
* `this` in a static method is the class object, so `this.x = new Y()` there
* assigns a STATIC property and must never type the instance field of the same
* name (#2807). Every other context that rebinds `this` is excluded
* structurally, by the query's `class_body → method_definition →
* statement_block → expression_statement` nesting; `static` is the one
* constraint tree-sitter cannot carry, because it is an ANONYMOUS token with no
* field name and patterns cannot negate one.
*
* The caller only reaches here for a capture the query already pinned inside a
* class method, so the `method_definition` lookup is a short walk. Detection is
* the shared `hasKeyword`, which is what the TypeScript METHOD EXTRACTOR already
* uses to decide the same question — matching on child TEXT, and skipping the
* `name` field so a method literally called `static()` is not misread.
*
* Text, not node type: `static` reaches the tree as an anonymous token in some
* grammar versions and as a keyword node in others (see `isStaticMember` in
* `receiver-binding.ts`), so a `child.type === 'static'` test silently stops
* firing on a grammar bump and every static `this.x = new Y()` starts typing the
* instance field of that name. `hasKeyword` scans the whole `method_definition`
* rather than stopping at the name, which is safe here because a
* `method_definition`'s own children are its modifiers, name, parameters and
* body — a mention of `static` inside the BODY is a descendant of the body node,
* never a direct child.
*/
function isStaticMethodThis(thisNode: SyntaxNode): boolean {
const method = findSelfOrAncestorOfType(thisNode, 'method_definition');
if (method === null) return false;
return hasKeyword(method, 'static');
}
/** The class-field declaration node a field `@type-binding.*` match anchors on
* in TypeScript/TSX. JavaScript spells the same construct `field_definition`
* and passes its own set in — the predicate below is shared because both
* grammars carry `static` identically, but each language must NAME its own
* node type. Listing both here made `field_definition` a dead literal in the
* typescript grammar, which `grammar-literal-validation` fails on: the gate
* checks every literal against the grammar of the FILE it appears in, and a
* node type that is dead there is exactly how a guard silently stops firing. */
export const TS_CLASS_FIELD_DEFINITION_TYPES: ReadonlySet<string> = new Set([
'public_field_definition',
]);
/**
* Is this type-binding anchored on a **`static`** class field?
*
* A static member belongs to the CLASS OBJECT; an instance field belongs to
* instances. JavaScript and TypeScript keep the two in separate namespaces, so
* one class may legally declare both under one name:
*
* class Host {
* p = new Right();
* static p = new Wrong(); // legal — a different member
* hit() { return this.p.hit(); } // `this.p` is Right
* }
*
* Both field patterns anchor their binding on the same CLASS scope with the
* same `constructor-inferred` source, and `scope-extractor` breaks a
* same-strength tie with `>=` — last match wins. So the static field silently
* RETYPED the instance field of that name and `this.p.hit()` resolved to
* `Wrong.hit`: not a missing edge but a wrong one, the failure mode
* `scope-resolution/passes/compound-receiver.ts` exists to avoid. The scope
* tree has one `typeBindings` map per scope with no static/instance split, so a
* static field cannot be recorded separately — it is dropped instead.
*
* WHAT THAT COSTS, MEASURED rather than assumed (#2807 review, S7). An earlier
* version of this comment called the cost "a missed edge beats a wrong one".
* Only half of that is true, and the false half is the one that matters:
*
* shape with the drop without it
* -------------------------- ---------------- ----------------
* `this.p` (instance twin) Right ✓ Wrong ✗
* `Host.p` (static twin) Right ✗ WRONG Wrong ✓
* `Host.q` (static, no twin) — none, missed Wrong ✓
*
* For a class declaring BOTH twins the wrong edge does not disappear, it MOVES:
* `Host.p` now reads the INSTANCE twin's binding, because that is what is left
* in the map under that name. Only the no-twin case — the common static shape —
* is a true missed edge.
*
* The trade is still the right one, since `this.p` is overwhelmingly the more
* common access and a `Host.p` static chain is the cheaper place to be wrong.
* It is recorded here as a wrong edge rather than described as a missing one so
* that the next person weighing it is weighing the real thing. Closing it
* properly needs a static/instance split that the shared receiver fold cannot
* express today — `foldReceiverChain` in
* `scope-resolution/passes/compound-receiver.ts` explicitly discards whether a
* chain's base was a class reference or a value — so it is a separate change
* with a `SCHEMA_BUMP`, not a tweak here. Both shapes are pinned by rows in
* `test/integration/resolvers/inferred-field-receiver-matrix.test.ts`
* (`static-read-of-a-same-name-twin-picks-up-the-instance-type`,
* `static-read-without-a-twin-loses-its-type`), so the cost moves visibly.
*
* Sibling of {@link isStaticMethodThis}, which drops the ASSIGNMENT form
* (`this.x = new Y()` inside a static method). Together they cover both ways a
* static member can reach the field-typing path. This is an emit-side filter
* for the same reason that one is: `static` is an ANONYMOUS token on the
* declaration node, and a tree-sitter pattern cannot negate one.
*
* Detection is the shared `hasKeyword` — matching on child TEXT, never
* `child.type === 'static'`, because the token reaches the tree as an anonymous
* token in some grammar versions and a keyword node in others (see
* `isStaticMember` in `receiver-binding.ts`); a node-type test silently stops
* firing on a grammar bump and every static field starts retyping its instance
* twin again. Verified against both grammars in use here: `static` is an
* anonymous direct child of the caller's field-definition node —
* `public_field_definition` in TypeScript, `field_definition` in JavaScript —
* ahead of the name. Each language passes its OWN node-type set rather than the
* predicate holding both: a literal is only valid in the grammar of the file it
* appears in, and `grammar-literal-validation` fails a dead one.
*
* One deliberate over-fire: `hasKeyword` skips the node's `name` FIELD, and the
* JavaScript grammar names a field's name `property:`, not `name:` — so the
* legal-but-rare JavaScript field literally called `static`
* (`class C { static = new Right(); }`) reads as static and goes untyped. That
* is a declined binding, the safe direction of the same trade.
*/
export function isStaticClassFieldBinding(
anchorNode: SyntaxNode | undefined,
fieldDefinitionTypes: ReadonlySet<string>,
): boolean {
if (anchorNode === undefined) return false;
if (!fieldDefinitionTypes.has(anchorNode.type)) return false;
return hasKeyword(anchorNode, 'static');
}
/** Walks the parent chain from `node` (inclusive), returning the first node
* whose type matches, or null. Faster than `findNodeAtRange` when the caller
* already holds the anchor node — avoids re-scanning the tree from the root. */
@ -345,6 +474,37 @@ export function emitTsScopeCaptures(
}
}
// `this.<field> = new …` inside a STATIC method types a static property,
// not the instance field of the same name (#2807). Every other `this`-
// rebinding context is already excluded by the pattern's nesting — see the
// note on it in `query.ts`; `static` is an anonymous token, so no pattern
// can negate it and the last case is dropped here.
const thisFieldNode = groupedNodes['@type-binding.this-field'];
if (thisFieldNode !== undefined && isStaticMethodThis(thisFieldNode)) {
continue;
}
// …and a `static` FIELD is a member of the class object, not of instances,
// so it must not type the instance field of the same name either — see
// `isStaticClassFieldBinding`. Both class-field anchors are tested: the
// initializer form (`static p = new Wrong()`, `@type-binding.constructor`)
// and the annotated form (`static p: Wrong`, `@type-binding.annotation`),
// which collide on the Class scope the same way. The predicate self-gates on
// the anchor's node type, so the local `variable_declarator` patterns that
// share these tags are untouched.
if (
isStaticClassFieldBinding(
groupedNodes['@type-binding.constructor'],
TS_CLASS_FIELD_DEFINITION_TYPES,
) ||
isStaticClassFieldBinding(
groupedNodes['@type-binding.annotation'],
TS_CLASS_FIELD_DEFINITION_TYPES,
)
) {
continue;
}
// #1876: drop @declaration.function for array higher-order-method
// callbacks (`const x = arr.map(a => …)`). The HOC-wrapped-arrow
// pattern matches them, but the binding holds a value, not a callable.
@ -569,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
@ -586,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;

View file

@ -882,6 +882,40 @@ export const TYPESCRIPT_SCOPE_QUERY = `
type: (type_annotation
(type_identifier) @type-binding.type)) @type-binding.annotation
;; Type bindings — class field constructor-inferred: \`private p = new Outer()\`.
;; The annotation patterns above cover a field that DECLARES its type; a field
;; whose type must be inferred from its initializer matched nothing, so it had
;; no typeBinding, so \`this.p\` could not be typed and the receiver fold declined
;; the whole chain — losing even the first, ordinary named link (#2807).
;;
;; Anchored on \`public_field_definition\` exactly like the annotation patterns,
;; so the binding lands in the same (class body) scope with no bindingScopeFor
;; override. \`annotation\` outranks \`constructor-inferred\` in
;; typeBindingStrength, so \`private p: Outer = new Outer()\` still resolves
;; through its annotation regardless of which pattern matches first.
;;
;; Kotlin and Swift express both a local and a stored property with ONE grammar
;; node (property_declaration) and so needed no separate field pattern; the
;; TypeScript grammar splits them (variable_declarator vs
;; public_field_definition), which is why only the local form was ever covered.
(public_field_definition
name: (property_identifier) @type-binding.name
value: (new_expression
constructor: (identifier) @type-binding.type)) @type-binding.constructor
;; Qualified: \`private p = new models.Outer()\` — mirrors the local form above;
;; the member_expression's text is resolved via QualifiedNameIndex.
(public_field_definition
name: (property_identifier) @type-binding.name
value: (new_expression
constructor: (member_expression) @type-binding.type)) @type-binding.constructor
;; Private-name field: \`#p = new Outer()\`.
(public_field_definition
name: (private_property_identifier) @type-binding.name
value: (new_expression
constructor: (identifier) @type-binding.type)) @type-binding.constructor
;; Type bindings — method return type: \`save(): User { … }\` / \`function f(): User { … }\`.
;; Function/method return-type is the type_annotation that is a direct
;; child of the function node (not the parameter's annotation). Anchor on
@ -1010,6 +1044,78 @@ export const TYPESCRIPT_SCOPE_QUERY = `
right: (new_expression
constructor: (identifier) @type-binding.type)) @type-binding.constructor
;; Type bindings — field assigned through \`this\`: \`this.p = new Outer()\` on a
;; field that declares no type (#2807). The rebind pattern above only matches a
;; bare identifier LHS, so an unannotated field assigned in the constructor had
;; no typeBinding at all. (An ANNOTATED field does not need this — its
;; annotation already types it, which is why \`private p: Outer;\` + the same
;; assignment always resolved.)
;;
;; The nesting is the whole safety property. \`this.x = new Y()\` is a legal
;; statement ANYWHERE, and the binding it produces is hoisted onto the nearest
;; enclosing Class — so a context-free version of this pattern typed a class's
;; field from an assignment whose \`this\` was some OTHER object, overwriting the
;; field's real type (pass4CollectTypeBindings resolves a same-source tie in
;; favour of the LATER match). Requiring the chain
;; \`class_body → method_definition → statement_block → expression_statement\`
;; makes the marker fire only where \`this\` provably IS an instance of the class
;; the binding lands on. It rejects, in order of how easily each was hit:
;; a non-arrow callback inside a method (\`function () { this.x = new Y(); }\` —
;; \`this\` is the call's receiver), an object-literal method (also a
;; \`method_definition\`, but under \`object\`, not \`class_body\`), and module top
;; level (no enclosing Class at all, where the hoist used to fall back to the
;; innermost scope and could overwrite a module local of the same name).
;;
;; This mirrors \`synthesizeConstructorFieldBindings\` in
;; \`languages/javascript/captures.ts\`, which walks only \`method_definition\`
;; bodies and only their DIRECT \`expression_statement\` children — the two
;; languages must not read the same source differently. TypeScript accepts any
;; method rather than only \`constructor\` (a later \`setUp()\` assignment types
;; the field just as well), which is the one intended divergence.
;;
;; Two shapes are deliberately NOT matched, both erring toward no binding:
;; an assignment nested in a block (\`if (…) { this.p = new Outer(); }\`) and one
;; inside an arrow (\`() => { this.p = new Outer(); }\`, where \`this\` IS the
;; instance). Both are only ever a MISSING binding, never a wrong one, and JS
;; declines them too.
;;
;; \`@type-binding.this-field\` is a MARKER, not the anchor: it sits on the narrow
;; \`(this)\` node, so anchorCaptureFor's broadest-range rule keeps the whole
;; assignment_expression (@type-binding.constructor) as the anchor and the
;; source stays \`constructor-inferred\`. tsBindingScopeFor reads the marker to
;; hoist the binding onto the enclosing Class scope — without that hoist the
;; binding would land on the method's own Function scope, where
;; typeOfMemberOnClass never looks. The marker must stay specific to THIS
;; pattern: hoisting every constructor-inferred binding would move method-local
;; \`const o = new Outer()\` out of its own scope.
;;
;; One constraint the grammar cannot carry: \`static\` is an ANONYMOUS token on
;; \`method_definition\` with no field name, and tree-sitter patterns cannot
;; negate one. \`this\` in a static method is the class object, so that last
;; wrong receiver is dropped by \`emitTsScopeCaptures\` instead.
(class_body
(method_definition
body: (statement_block
(expression_statement
(assignment_expression
left: (member_expression
object: (this) @type-binding.this-field
property: (property_identifier) @type-binding.name)
right: (new_expression
constructor: (identifier) @type-binding.type)) @type-binding.constructor))))
;; Qualified form: \`this.p = new models.Outer()\`.
(class_body
(method_definition
body: (statement_block
(expression_statement
(assignment_expression
left: (member_expression
object: (this) @type-binding.this-field
property: (property_identifier) @type-binding.name)
right: (new_expression
constructor: (member_expression) @type-binding.type)) @type-binding.constructor))))
(assignment_expression
left: (identifier) @type-binding.name
right: (call_expression

View file

@ -13,6 +13,7 @@ import type {
ScopeTree,
TypeRef,
} from 'gitnexus-shared';
import { walkToScope } from '../../utils/scope-tree-walk.js';
// ─── bindingScopeFor ──────────────────────────────────────────────────────
@ -55,6 +56,28 @@ export function tsBindingScopeFor(
return walkToScope(innermost, tree, 'Class');
}
// `this.p = new Outer()` binds the FIELD, not a method-local, so the binding
// belongs on the class the way an annotated field's does — that is the only
// place `typeOfMemberOnClass` reads. Left on the innermost scope it would sit
// on the method's own Function scope and never be found (#2807). Same shape
// as the parameter-property branch above.
//
// Gated on the marker the `this.<field> = new …` pattern emits, never on
// `@type-binding.constructor` at large: that capture also fires for
// `const o = new Outer()` inside a method, and hoisting THOSE to the class
// would take method locals out of their own scope and mistype them.
//
// This walk is UNCONDITIONAL by design, and stays correct only because the
// marker's producers are bounded: the query nests the pattern under
// `class_body → method_definition → statement_block`, and `emitTsScopeCaptures`
// drops the static-method case. So `this` here provably IS an instance of the
// class this lands on, and a Class ancestor always exists — no `walkToScope`
// null-fallback onto some unrelated innermost scope. Anything that widens the
// marker's producers has to re-establish both, or restore the guard here.
if (decl['@type-binding.this-field'] !== undefined) {
return walkToScope(innermost, tree, 'Class');
}
// `var` declarations: hoist to nearest enclosing Function or Module.
const variable = decl['@declaration.variable'];
if (variable !== undefined && isVarDeclaration(variable.text)) {
@ -70,31 +93,6 @@ export function tsBindingScopeFor(
return null;
}
/**
* Walk up the scope chain to find the first scope whose `kind` matches
* any of `kinds`. Returns the matching scope's id or `null` when no
* ancestor matches (e.g., a return type binding emitted outside any
* Module scope — shouldn't happen in well-formed input).
*
* Exported so language-specific hook wrappers (e.g. `jsBindingScopeFor`)
* can reuse it without duplicating the traversal logic.
*/
export function walkToScope(
from: Scope,
tree: ScopeTree,
...kinds: readonly Scope['kind'][]
): ScopeId | null {
let cur: Scope | undefined = from;
const kindSet = new Set(kinds);
while (cur !== undefined) {
if (kindSet.has(cur.kind)) return cur.id;
const parentId: ScopeId | null = cur.parent ?? null;
if (parentId === null) break;
cur = tree.getScope(parentId);
}
return null;
}
/** `var x = 1;` vs `let x = 1;` / `const x = 1;`. The capture's text
* starts at the outer declaration's `startIndex` in source, which is
* the keyword's first character — no leading whitespace possible. */

View file

@ -1149,6 +1149,12 @@ function pass5CollectReferences(
// languages whose read pattern has no call-position exclusion; absent
// everywhere else, so the site stays byte-identical for them.
const inCalleePosition = match['@reference.callee-position'] !== undefined;
// Pointer-embedding marker: `struct S { *T }` rather than `struct S { T }`.
// Recorded, not acted on — Go's method-set rules make the two forms differ
// (see `ReferenceSite.embeddedAsPointer`), and only structural interface
// detection knows what to do with that. Absent for every language without
// pointer embedding, so their sites stay byte-identical.
const embeddedAsPointer = match['@reference.embedded-pointer'] !== undefined;
const site: ReferenceSite = {
name: nameCap.text,
@ -1168,6 +1174,7 @@ function pass5CollectReferences(
...(argumentTypeClasses !== undefined ? { argumentTypeClasses } : {}),
...(receiverChain !== undefined ? { receiverChain } : {}),
...(inCalleePosition ? { inCalleePosition: true } : {}),
...(embeddedAsPointer ? { embeddedAsPointer: true } : {}),
};
referenceSites.push(site);
}
@ -1569,6 +1576,7 @@ const KNOWN_SUB_TAGS: ReadonlySet<string> = new Set<string>([
'@reference.qualified-name',
'@reference.property-key',
'@reference.callee-position',
'@reference.embedded-pointer',
'@reference.receiver',
'@reference.operator',
'@reference.arity',

View file

@ -113,13 +113,24 @@
* 3. Case 0.5 implicit-`this` chain walk — GATED: fires only for
* languages that set `resolveThisViaEnclosingClass === true`;
* it intercepts every bare-`this` call/read/write site ahead of
* Case 4 and does NOT emit Case 4's interface-dispatch fan-out,
* so enabling the toggle for a language changes that language's
* `this` dispatch semantics (see the toggle's doc below)
* Case 4 and does NOT emit the interface-dispatch fan-out that
* 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.
@ -319,6 +330,13 @@ export type ElementAccessRoute =
| { readonly kind: 'index' }
| { readonly kind: 'accessor'; readonly name: string };
/** One structurally-detected implementor plus the receiver form in which it
* satisfies the interface (see `detectInterfaceImplementations`). */
export interface StructuralImplementor {
readonly structDefId: string;
readonly receiverForm: 'value' | 'pointer';
}
export interface ScopeResolver {
/** Identity for telemetry + per-language flag check. */
readonly language: SupportedLanguages;
@ -1076,6 +1094,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
@ -1230,14 +1286,23 @@ export interface ScopeResolver {
* Languages like Go use structural typing — a struct satisfies an
* interface if its method set is a superset, without an explicit
* `implements` keyword. Runs after finalize, before resolution passes.
* Returns: Map<interface_DefId, implementing_struct_DefId[]>.
* Returns: Map<interface_DefId, StructuralImplementor[]>, where each entry
* names the implementing type AND the form in which it implements.
*
* `receiverForm` is not a confidence signal — it is the language's own
* distinction. In Go the method set of `T` and of `*T` differ (a
* pointer-receiver method belongs only to `*T`), so `receiverForm: 'pointer'`
* means the VALUE type does not implement the interface and only `*T` does.
* `'value'` means both do. Consumers that only want blast radius can ignore
* it; consumers reasoning about assignability must not.
*
* Default: undefined (no structural interface detection).
*/
readonly detectInterfaceImplementations?: (
parsedFiles: readonly ParsedFile[],
indexes: ScopeResolutionIndexes,
model: SemanticModel,
) => Map<string, string[]>;
) => Map<string, readonly StructuralImplementor[]>;
/**
* Optional: mirror typeBindings from namespace-import target modules

View file

@ -194,6 +194,29 @@ function simpleNameOf(qualifiedName: string): string {
*/
const LOCAL_IDENTITY_SUFFIX = /@\d+:\d+$/;
/**
* The OTHER callable label the same construct may be registered under.
*
* A def and its graph node describe one construct, but they do not always agree
* on its LABEL: some structure phases emit a type's methods as `Function` nodes
* while the scope extractor derives `Method` from the `@declaration.method`
* anchor. Every key `resolveDefGraphId` builds is label-scoped, so such a pair
* misses ALL of them and lands on the label-agnostic, first-write-wins
* `simpleKey` at the bottom (#2807 follow-up, measured in Swift).
*
* ONE definition, consulted by all three key families — position, local-name
* guard, qualified — because they are not independent: leaving it out of the
* position key makes the fail-closed guard beside it UNREACHABLE for exactly the
* split the qualified retry serves, so the retry inherits a case the guard was
* written to stop (a function-local aliased onto a same-named class method).
* What each family may do with it differs and is documented at each site.
*/
function siblingCallableLabel(label: NodeLabel): NodeLabel | undefined {
if (label === 'Method') return 'Function';
if (label === 'Function') return 'Method';
return undefined;
}
export function resolveDefGraphId(
filePath: string,
def: {
@ -214,6 +237,10 @@ export function resolveDefGraphId(
const qn = def.qualifiedName;
if (qn === undefined || qn.length === 0) return undefined;
if (def.type !== undefined) {
// ONE binding for all three key families — see `siblingCallableLabel`, which
// documents why they cannot be given independent answers. What each family
// is allowed to DO with it still differs, and is documented at each site.
const siblingLabel = siblingCallableLabel(def.type);
// Position key FIRST (#2699). A def and its graph node are the same
// construct, so they share a source line — the only evidence that
// separates a function-local declaration from a same-named file-level one
@ -230,6 +257,29 @@ export function resolveDefGraphId(
const simple = simpleNameOf(qn);
const posHit = nodeLookup.get(positionKey(filePath, def.type, line - 1, simple));
if (posHit !== undefined && posHit !== AMBIGUOUS_POSITION) return posHit;
// Retry under the sibling callable label when the def's OWN label
// registered NOTHING here — see `siblingCallableLabel`. Both keys in this
// block are label-scoped, so under a split the position join misses and
// the guard below cannot fire, and the def falls through to the qualified
// retry that ends on the class method of the same name: a function-local
// `func helper` inside `Host.run` was aliased onto `Host.helper`, taking
// its calls with it, even at a different arity.
//
// Deliberately NOT dot-gated the way the qualified retry is: this key is
// not a name. `(file, line, simple name)` identifies one declaration by
// itself — that is why `positionKey` needs no qualifier at all — so
// crossing the two callable labels here cannot alias a top-level `save`
// onto a class's `save` the way a bare NAME would.
//
// Gated on `posHit === undefined` so an `AMBIGUOUS_POSITION` tombstone
// keeps meaning ambiguous: two callables already claim this line under the
// def's own label, and relabelling must not resolve by picking a third.
if (posHit === undefined && siblingLabel !== undefined) {
const siblingPosHit = nodeLookup.get(positionKey(filePath, siblingLabel, line - 1, simple));
if (siblingPosHit !== undefined && siblingPosHit !== AMBIGUOUS_POSITION) {
return siblingPosHit;
}
}
// FAIL CLOSED when a function-local of this name exists in the file (#2699
// follow-up). Falling through to the name keys would end at the label-agnostic,
// first-write-wins `simpleKey` below and alias this def onto whichever same-named
@ -245,6 +295,19 @@ export function resolveDefGraphId(
if (nodeLookup.get(localNameKey(filePath, def.type, simple)) !== undefined) {
return undefined;
}
// Same guard under the sibling label: a local the structure phase
// registered as `Function` must still stop a `Method`-labelled def of that
// name from reaching `simpleKey`, or the split re-opens the fabricated
// edge this guard exists to close. Unlike the position retry above this
// arm is NOT conditioned on the own-label lookup missing — it only ever
// returns `undefined`, and a missing edge is the correct failure
// direction; declining to check would be the risky choice, not this.
if (
siblingLabel !== undefined &&
nodeLookup.get(localNameKey(filePath, siblingLabel, simple)) !== undefined
) {
return undefined;
}
}
// Name forms to try for every keyed lookup below, most specific first.
//
@ -268,11 +331,32 @@ export function resolveDefGraphId(
const nsPrefix = def.namespacePrefix;
const nameForms =
nsPrefix !== undefined && nsPrefix.length > 0 ? [`${nsPrefix}.${qn}`, qn] : [qn];
// The label split described on `siblingCallableLabel` also kills every key
// above: they are all label-scoped, so a split pair misses all of them and
// lands on the label-agnostic simple key at the bottom of this function —
// which is first-write-wins, so two same-named methods in ONE file both
// resolved to whichever was registered first. That silently misattributed
// every call in the second method's body to the first (#2807 follow-up;
// measured in Swift, where `class A { func run }` + `class B { func run }`
// gave A.run both bodies' edges and B.run none).
//
// Crossing the two callable labels is sound HERE only for a name that
// carries its owner: `A.run` names exactly one construct whatever the
// label, while a bare `run` is precisely the aliasing the label was added
// to prevent (a top-level `save` vs a class's `save`). Hence the dot gate —
// it keeps the original guarantee intact for unqualified names. The
// position key above needs no such gate because it is not a name.
const lookupTagged = (tag: string): string | undefined => {
for (const form of nameForms) {
const hit = nodeLookup.get(qualifiedKey(filePath, defType, `${form}${tag}`));
if (hit !== undefined) return hit;
}
if (siblingLabel === undefined) return undefined;
for (const form of nameForms) {
if (!form.includes('.')) continue;
const hit = nodeLookup.get(qualifiedKey(filePath, siblingLabel, `${form}${tag}`));
if (hit !== undefined) return hit;
}
return undefined;
};

View file

@ -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<ScopeId>();
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.
*

View file

@ -8,14 +8,18 @@
*
* 1. **super branch** — `provider.isSuperReceiver(receiverName)` →
* MRO walk skipping self
* 2. **Case 0 (compound)** — receiver has `.` or `(` → compound resolver
* 2. **Case 0 (compound)** — receiver has `.` or `(` → compound resolver.
* Also emits the interface-dispatch fan-out when the folded receiver type
* is an Interface (#2829) — see Case 4, which does the same.
* 3. **Case 0.5 (implicit `this` receiver)** — GATED: fires only when
* the language sets `resolveThisViaEnclosingClass === true` AND the
* receiver is literally `this` → enclosing-class + MRO chain walk
* with C++ member-name-hiding semantics. Languages that leave the
* toggle unset skip this case entirely; their `this` sites fall
* through to Case 4 via the synthesized `this` typeBinding (which
* also emits interface-dispatch fan-out that this case does not).
* emits the interface-dispatch fan-out that this case does not —
* 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
@ -25,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`
@ -61,6 +67,7 @@ import {
findReceiverTypeBinding,
findValueBindingInScope,
isClassLike,
isNamespaceNameShadowed,
type DecorationStripper,
} from '../scope/walkers.js';
import {
@ -103,6 +110,7 @@ type ReceiverBoundProviderSubset = Pick<
| 'constructionSyntax'
| 'stripTypePreservingDecoration'
| 'resolveQualifiedReceiverMember'
| 'namespaceReceiverPaths'
| 'resolveReceiverMember'
| 'resolveThisViaEnclosingClass'
| 'conversionRankFn'
@ -283,6 +291,38 @@ export function classifyReceiverOrigin(
return 'unknown';
}
/**
* Upper bound on how many implementors ONE interface member may fan out to at a
* single call site (#2829).
*
* Mirrors `MAX_PROPERTY_DISPATCH_FANOUT` in `property-dispatch.ts`, deliberately
* including its reporting half: a bare cap would silently discard valid dispatch
* targets, which is the same false-safe silence #2813 was filed about. The
* default matches that sibling's 32 — the fan-out is a per-call-site product, so
* an interface with hundreds of implementors (mock proliferation is the usual
* cause) multiplies the graph without adding information a reader can act on.
*
* Override with `GITNEXUS_MAX_INTERFACE_DISPATCH_FANOUT` for a repo with
* legitimately high implementor counts.
*/
export const MAX_INTERFACE_DISPATCH_FANOUT = (() => {
const env = Number(process.env.GITNEXUS_MAX_INTERFACE_DISPATCH_FANOUT);
return Number.isInteger(env) && env >= 1 ? env : 32;
})();
/** Bound on the sample of over-cap interface members kept for the warning. */
const MAX_REPORTED_SKIPPED_INTERFACES = 20;
/** What `emitReceiverBoundCalls` reports back to the orchestrator. */
export interface ReceiverBoundResult {
/** CALLS/ACCESSES edges emitted by this pass. */
readonly emitted: number;
/** Dispatch targets DROPPED because a member exceeded the fan-out cap. */
readonly dispatchFanoutSkipped: number;
/** Bounded sample naming which interface members lost targets. */
readonly dispatchFanoutSkippedNames: readonly string[];
}
export function emitReceiverBoundCalls(
graph: KnowledgeGraph,
scopes: ScopeResolutionIndexes,
@ -308,8 +348,10 @@ export function emitReceiverBoundCalls(
* edge. */
readonly isBuiltInName?: (name: string) => boolean;
} = {},
): number {
): ReceiverBoundResult {
let emitted = 0;
let dispatchFanoutSkipped = 0;
const dispatchFanoutSkippedNames: string[] = [];
// Per-pass dedup so the multiple cases don't double-emit if two of
// them resolve the same site to the same target. NEVER pre-seed
// from the reference index — see Contract Invariant I5.
@ -345,23 +387,115 @@ export function emitReceiverBoundCalls(
if (graphId !== undefined) graphIdToClassDef.set(graphId, def);
}
}
const implementorsByInterfaceDefId = new Map<string, SymbolDefinition[]>();
for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) {
const ifaceDef = graphIdToClassDef.get(rel.targetId);
const implDef = graphIdToClassDef.get(rel.sourceId);
if (ifaceDef === undefined || implDef === undefined) continue;
let list = implementorsByInterfaceDefId.get(ifaceDef.nodeId);
// Direct subtypes of a type, keyed by the SUPERtype's def id.
//
// Built from IMPLEMENTS **and** EXTENDS (#2829). IMPLEMENTS alone is not the
// set of implementations: `preEmitInheritanceEdges` classifies heritage by the
// TARGET's kind, so `interface B extends A` is stored as `B IMPLEMENTS A` and
// an INTERFACE lands in A's list; and a concrete class reaches its interface
// through `class C extends AbstractBase` (EXTENDS) + `AbstractBase implements
// I` (IMPLEMENTS), so it is two hops away and invisible to a depth-1 walk.
// Both shapes previously ended the fan-out on a bodiless declaration while the
// only executable target got no edge at all.
const subtypesBySupertypeDefId = new Map<string, SymbolDefinition[]>();
const addSubtype = (superId: string, sub: SymbolDefinition): void => {
let list = subtypesBySupertypeDefId.get(superId);
if (list === undefined) {
list = [];
implementorsByInterfaceDefId.set(ifaceDef.nodeId, list);
subtypesBySupertypeDefId.set(superId, list);
}
list.push(sub);
};
for (const relType of ['IMPLEMENTS', 'EXTENDS'] as const) {
for (const rel of graph.iterRelationshipsByType(relType)) {
const superDef = graphIdToClassDef.get(rel.targetId);
const subDef = graphIdToClassDef.get(rel.sourceId);
if (superDef === undefined || subDef === undefined) continue;
addSubtype(superDef.nodeId, subDef);
}
list.push(implDef);
}
/** Emit secondary CALLS edges with reason='interface-dispatch'
* when the primary receiver-typed edge targeted an Interface's
* method. Each implementing class's same-named method gets a
* secondary edge (excluding the primary target itself). */
/**
* Is this member a bodiless DECLARATION rather than an implementation?
*
* An interface method, and an `abstract` method on an abstract base, are
* both declarations: dispatching to them names something with no body while
* the executable target sits further down the hierarchy. `isAbstract` lives
* on the graph NODE (the structure phase sets it), not on `SymbolDefinition`,
* so this resolves the def to its node. A def that cannot be resolved is
* treated as NOT abstract — the fail-open direction, matching how the rest of
* this pass treats an unresolvable lookup.
*/
const isDeclarationOnly = (def: SymbolDefinition): boolean => {
const graphId = resolveDefGraphId(def.filePath, def, nodeLookup);
if (graphId === undefined) return false;
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.
*
* Walks the SUBTYPE CLOSURE of the interface rather than its direct
* implementors (#2829). Two shapes made the depth-1 walk both wrong and
* incomplete, each reproduced in plain Java:
*
* interface ReadCloser extends Reader { int read(String p); } // re-declares
* abstract class AbstractHandler implements Handler { public abstract void handle(String s); }
*
* In both, the depth-1 list contains a type whose `read`/`handle` is a bodiless
* declaration, so the fan-out emitted an edge to *that* — while the only class
* with a body (`FileRC`, `RealHandler`) is one hop further down and received
* nothing at all. Descending and skipping declarations fixes both directions
* at once: the wrong edge disappears and the real implementation gains one.
*
* Descent continues THROUGH a type that supplied a concrete member, because an
* override further down is an equally real runtime target — dispatch is an
* over-approximation by design, and stopping early would silently prefer the
* base.
*/
const emitInterfaceDispatchFor = (
ownerDef: SymbolDefinition,
memberName: string,
@ -371,19 +505,56 @@ export function emitReceiverBoundCalls(
calleeCapture: CalleeIdCaptureCtx | undefined,
): number => {
if (ownerDef.type !== 'Interface') return 0;
const impls = implementorsByInterfaceDefId.get(ownerDef.nodeId);
if (impls === undefined) return 0;
let n = 0;
for (const implDef of impls) {
const implMember = pickOverload(implDef.nodeId, memberName, site, model, provider);
if (
implMember === undefined ||
implMember === OVERLOAD_AMBIGUOUS ||
implMember.isDeleted === true
) {
continue;
if (subtypesBySupertypeDefId.get(ownerDef.nodeId) === undefined) return 0;
// Collect concrete targets across the closure first, so the cap below counts
// real dispatch targets rather than types visited.
const targets: SymbolDefinition[] = [];
const seenTypes = new Set<string>([ownerDef.nodeId]);
const queue: string[] = [ownerDef.nodeId];
while (queue.length > 0) {
const superId = queue.shift() as string;
for (const subDef of subtypesBySupertypeDefId.get(superId) ?? []) {
if (seenTypes.has(subDef.nodeId)) continue;
seenTypes.add(subDef.nodeId);
queue.push(subDef.nodeId);
const implMember = pickOverload(subDef.nodeId, memberName, site, model, provider);
if (
implMember === undefined ||
implMember === OVERLOAD_AMBIGUOUS ||
implMember.isDeleted === true
) {
continue;
}
if (implMember.nodeId === primaryMemberDef.nodeId) continue;
// 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);
}
if (implMember.nodeId === primaryMemberDef.nodeId) continue;
}
// Bounded, and NEVER silently (#2829). An interface with a very large
// implementor set multiplies edges by every call site — Go, TypeScript and
// Kotlin do not set `collapseMemberCallsByCallerTarget`, so the product is
// per SITE. Truncating without saying so would recreate the false-safe
// silence this whole issue is about, which is why the sibling
// `MAX_PROPERTY_DISPATCH_FANOUT` reports its dropped keys too.
if (targets.length > MAX_INTERFACE_DISPATCH_FANOUT) {
dispatchFanoutSkipped += targets.length - MAX_INTERFACE_DISPATCH_FANOUT;
if (dispatchFanoutSkippedNames.length < MAX_REPORTED_SKIPPED_INTERFACES) {
dispatchFanoutSkippedNames.push(
`${ownerDef.qualifiedName ?? ownerDef.nodeId}.${memberName} (${targets.length} targets)`,
);
}
targets.length = MAX_INTERFACE_DISPATCH_FANOUT;
}
let n = 0;
for (const implMember of targets) {
const ok = tryEmitEdge(
graph,
scopes,
@ -402,7 +573,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`
@ -644,6 +818,48 @@ export function emitReceiverBoundCalls(
calleeCapture,
);
if (ok) emitted++;
// Interface dispatch, exactly as Case 4 does it (#2813). When the
// folded receiver type is an Interface, the primary edge above
// lands on the interface's own method DECLARATION; these secondary
// edges are what reach the implementations.
//
// Case 4 had this and Case 0 did not, which made the gap a property
// of receiver SYNTAX rather than of types: a struct-field receiver
// (`s.orderRepo`) contains a dot, so it always takes Case 0, while
// the same interface reached through a local or parameter is a bare
// name and reaches Case 4. Field-held interfaces — dependency
// injection, in other words — were the half that silently lost every
// implementation edge.
//
// `currentClass` is the receiver's own folded type, matching what
// Case 4 passes. `emitInterfaceDispatchFor` self-gates on
// `ownerDef.type !== 'Interface'`, so this is inert for every
// concrete receiver and needs no language check of its own — a
// member whose owner resolved to a Struct emits nothing extra.
//
// 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 (1.0 for
// read/write, `:1399-1405`) because ITS primary varies the same way.
// Case 0 does branch on `site.kind` when picking the member
// (`:713-716`), it simply does not vary reason/confidence with it,
// so there is no 1.0 arm here to mirror. That leaves a read/write
// ACCESSES through a compound receiver at 0.85 while the same access
// through a bare name is 1.0 — a PRE-EXISTING difference between the
// two cases' primaries, not something this fan-out introduces.
// Deliberately not "fixed" here: changing Case 0's primary
// confidence is a separate behavioural change affecting every
// language, and is out of scope for #2813.
emitted += emitInterfaceDispatchFor(
currentClass,
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
@ -829,7 +1045,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) {
@ -1177,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
@ -1541,7 +1818,7 @@ export function emitReceiverBoundCalls(
}
}
return emitted;
return { emitted, dispatchFanoutSkipped, dispatchFanoutSkippedNames };
}
/** Resolve a member by name on a class def, narrowing by argument

View file

@ -66,7 +66,10 @@ import type { FunctionCfg } from '../../cfg/types.js';
import { resolveDefGraphId } from '../graph-bridge/ids.js';
import { buildPopulatedMethodDispatch } from '../graph-bridge/method-dispatch.js';
import { propagateImportedReturnTypes } from '../passes/imported-return-types.js';
import { emitReceiverBoundCalls } from '../passes/receiver-bound-calls.js';
import {
emitReceiverBoundCalls,
MAX_INTERFACE_DISPATCH_FANOUT,
} from '../passes/receiver-bound-calls.js';
import { emitFreeCallFallback } from '../passes/free-call-fallback.js';
import {
emitPropertyDispatchCalls,
@ -244,8 +247,8 @@ function emitDetectedInterfaceImplementations(
for (const [interfaceDefId, implementorDefIds] of detected) {
const targetId = graphIdByDefId.get(interfaceDefId);
if (targetId === undefined) continue;
for (const implementorDefId of implementorDefIds) {
const sourceId = graphIdByDefId.get(implementorDefId);
for (const implementor of implementorDefIds) {
const sourceId = graphIdByDefId.get(implementor.structDefId);
if (sourceId === undefined) continue;
const edgeKey = `${sourceId}->${targetId}`;
if (existing.has(edgeKey)) continue;
@ -256,7 +259,17 @@ function emitDetectedInterfaceImplementations(
targetId,
type: 'IMPLEMENTS',
confidence: 0.85,
reason: `${provider.language}-structural-implements`,
// The receiver form rides in `reason` because relationships carry no
// arbitrary properties — adding one would change the relation DDL and
// move SCHEMA_FINGERPRINT, forcing a full re-analyze for a fact that a
// string already expresses. `-pointer` means ONLY the pointer type
// implements: `var x I = T{}` is invalid, `var x I = &T{}` is fine.
// The unsuffixed form is unchanged from before, so a consumer matching
// the old string keeps seeing exactly the value-form implementors.
reason:
implementor.receiverForm === 'pointer'
? `${provider.language}-structural-implements-pointer`
: `${provider.language}-structural-implements`,
});
emitted++;
}
@ -796,8 +809,12 @@ export function runScopeResolution(
: (filePath, line, col) => callableArgumentSites.has(`${filePath}:${line}:${col}`),
)
: undefined;
const receiverExtras = callableFlowOnly
? 0
const receiverBound = callableFlowOnly
? {
emitted: 0,
dispatchFanoutSkipped: 0,
dispatchFanoutSkippedNames: [] as readonly string[],
}
: emitReceiverBoundCalls(
graph,
indexes,
@ -816,6 +833,22 @@ export function runScopeResolution(
isBuiltInName: provider.languageProvider.isBuiltInName,
},
);
const receiverExtras = receiverBound.emitted;
if (receiverBound.dispatchFanoutSkipped > 0) {
// Never drop dispatch coverage silently (#2829) — same contract as the
// property-dispatch cap below. An interface member over the cap loses real
// implementors, so `impact()` on those implementations under-reports; an
// operator has to be able to see WHICH member lost them.
logger.warn(
{
lang: provider.language,
dispatchFanoutSkipped: receiverBound.dispatchFanoutSkipped,
dispatchFanoutSkippedNames: receiverBound.dispatchFanoutSkippedNames,
fanoutCap: MAX_INTERFACE_DISPATCH_FANOUT,
},
'interface-dispatch: members over the fan-out cap dropped implementors (their CALLS edges were not emitted)',
);
}
const unresolvedReceiverExtras =
!callableFlowOnly && provider.emitUnresolvedReceiverEdges !== undefined
? provider.emitUnresolvedReceiverEdges(

View file

@ -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<string, string[]> {
const out = new Map<string, string[]>();
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;
}

View file

@ -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<ScopeId>();
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,

View file

@ -555,12 +555,49 @@ export function extractCallChain(
* Deliberately EXCLUDES a cast (`x as T`, `(T)x`): a cast changes the type an
* expression denotes, so reading through one would type the receiver as the
* operand rather than as the cast target.
*
* Keyed by node type; the value is the operator text that has to TERMINATE the
* node for the peel to apply, or `null` for a node type that is transparent
* unconditionally.
*
* The gated entry exists because Swift force-unwrap (`self.a!`) is the exact
* semantic of TypeScript's `non_null_expression` — it yields the wrapped type —
* but Swift parses it as the general `postfix_expression`, which ALSO carries
* user-defined postfix operators. Those can return anything, so peeling that
* node type unconditionally would type the receiver as the operand and could
* produce a confidently wrong owner. Reading the operator keeps the peel to the
* case that is provably type-preserving.
*/
const TRANSPARENT_RECEIVER_WRAPPERS = new Set([
'non_null_expression', // TypeScript `svc!`
'parenthesized_expression', // `(svc)`
const TRANSPARENT_RECEIVER_WRAPPERS = new Map<string, string | null>([
['non_null_expression', null], // TypeScript `svc!`
['parenthesized_expression', null], // `(svc)`
// NOT Swift-only: Kotlin's `!!` non-null assertion parses as the same node type
// and is equally type-preserving, so it is peeled too. Measured — the
// receiver-resolution bench moved `kotlin.nonNullAssert` VISIBLE-GAP ->
// RESOLVES when this landed, which is how the Kotlin effect was discovered
// rather than assumed. Any other grammar emitting `postfix_expression` is
// affected as well; the `!` gate, not the language, is what bounds this.
['postfix_expression', '!'], // Swift `self.a!`, Kotlin `a!!`
]);
/** Is `node` a wrapper that denotes exactly what its operand denotes? */
function isTransparentReceiverWrapper(node: SyntaxNode): boolean {
// `node.type` is a native getter, and this predicate runs on every receiver
// node. Crossing it twice for two separate table lookups measured 376 ns/check
// against 188 ns for the single read below.
const operator = TRANSPARENT_RECEIVER_WRAPPERS.get(node.type);
// `undefined` is "not in the table"; `null` is "in the table, ungated". The
// two are distinguishable, so one `get` answers both questions — no separate
// `has` probe, and one crossing of the native `node.type` getter.
if (operator === undefined) return false;
if (operator === null) return true;
// The operator is an anonymous token, so it is not in `namedChildren`; the
// node's own text is the reliable place to read it. Deliberately NOT
// `node.lastChild` — measured 3x SLOWER (the child wrapper allocation
// dominates), and the token surfaces with type `bang`, not `!`.
return node.text.trimEnd().endsWith(operator);
}
/**
* Iteration bound for the wrapper peel. Its OWN constant, not `MAX_CHAIN_DEPTH`.
*
@ -575,11 +612,7 @@ const MAX_TRANSPARENT_WRAPPER_DEPTH = 3;
/** Peel transparent wrappers off a base receiver node. */
function unwrapTransparentReceiver(node: SyntaxNode): SyntaxNode {
let current = node;
for (
let i = 0;
i < MAX_TRANSPARENT_WRAPPER_DEPTH && TRANSPARENT_RECEIVER_WRAPPERS.has(current.type);
i++
) {
for (let i = 0; i < MAX_TRANSPARENT_WRAPPER_DEPTH && isTransparentReceiverWrapper(current); i++) {
const inner = current.namedChildren?.find((c) => c !== null);
if (inner === undefined || inner === null) break;
current = inner;

View file

@ -0,0 +1,38 @@
/**
* Scope-tree walking primitives shared by the providers' `bindingScopeFor`
* hooks.
*
* Language-neutral: parameterised over `ScopeKind` — the shared scope
* vocabulary — and names no language, so it belongs in the core pipeline
* rather than in any one provider (AGENTS.md § shared pipeline code).
*/
import type { Scope, ScopeId, ScopeTree } from 'gitnexus-shared';
/**
* Walk up the scope chain to find the first scope whose `kind` matches
* any of `kinds`. Returns the matching scope's id or `null` when no
* ancestor matches (e.g., a return type binding emitted outside any
* Module scope — shouldn't happen in well-formed input).
*
* Every provider that hoists a binding out of the scope it was captured in
* needs exactly this walk: a field typed from a constructor call in a method
* body belongs on the enclosing Class, a `var` on the enclosing Function or
* Module, a method return type on the Module. Which `kind` to stop at is the
* only per-language part, and that is the parameter.
*/
export function walkToScope(
from: Scope,
tree: ScopeTree,
...kinds: readonly Scope['kind'][]
): ScopeId | null {
let cur: Scope | undefined = from;
const kindSet = new Set(kinds);
while (cur !== undefined) {
if (kindSet.has(cur.kind)) return cur.id;
const parentId: ScopeId | null = cur.parent ?? null;
if (parentId === null) break;
cur = tree.getScope(parentId);
}
return null;
}

View file

@ -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 {

View file

@ -63,7 +63,16 @@ import {
GITHUB_TOKEN_HOSTS,
} from './git-clone.js';
import { createAnalyzeUploadHandler } from './analyze-upload.js';
import { createLocalhostOriginGuard, normalizeBoundHost } from './middleware.js';
import {
assertServeAuthForPublicOrigin,
createPublicOriginMatcher,
createWriteOriginGuard,
logOriginPolicy,
PUBLIC_ORIGIN_ENV,
resolveTrustProxy,
TRUST_PROXY_ENV,
warnIfRateLimitKeysCollapse,
} from './middleware.js';
import { createLaunchAnalysisWorker } from './analyze-launch.js';
import { UPLOAD_ROOT } from './upload-paths.js';
import { sweepStaleUploads } from './upload-sweep.js';
@ -85,6 +94,8 @@ const pkg = _require('../../package.json');
* 172.16.0.0/12 → 172.16.x.x – 172.31.x.x
* 192.168.0.0/16 → 192.168.x.x
* - https://gitnexus.vercel.app — the deployed GitNexus web UI
* - the origin named by GITNEXUS_PUBLIC_ORIGIN, when set — matched on hostname
* always, and on scheme and port when the configured value carries them
*
* @param origin - The value of the HTTP `Origin` request header, or `undefined`
* when the header is absent (non-browser request).
@ -110,21 +121,22 @@ export const isAllowedOrigin = (origin: string | undefined): boolean => {
// RFC 1918 private network ranges — allow any port on these hosts.
// We parse the hostname out of the origin URL and check against each range.
let hostname: string;
let protocol: string;
let parsed: URL;
try {
const parsed = new URL(origin);
hostname = parsed.hostname;
protocol = parsed.protocol;
parsed = new URL(origin);
} catch {
// Malformed origin — reject
return false;
}
// Only allow HTTP(S) origins — reject ftp://, file://, etc.
if (protocol !== 'http:' && protocol !== 'https:') return false;
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
return isRfc1918PrivateIpv4(hostname);
// The matcher is rebuilt per call, so changing the env var takes effect
// without a restart. The write guard in middleware.ts snapshots it instead.
if (createPublicOriginMatcher(process.env[PUBLIC_ORIGIN_ENV])?.matches(parsed)) return true;
return isRfc1918PrivateIpv4(parsed.hostname);
};
type GraphStreamRecord =
@ -703,6 +715,11 @@ export function validateAnalyzeToken(
}
export const createServer = async (port: number, host: string = '127.0.0.1') => {
// Refuse a public-origin config before anything is opened or bound: `serve`
// has no authentication yet, so the setting that makes a public bind usable
// must not be usable either. Throws — `serve` reports it and exits non-zero.
assertServeAuthForPublicOrigin();
// Surface a cleartext Azure DevOps PAT config at boot (operators rarely
// read per-request logs). Warn-only — http:// self-hosted stays supported.
warnIfInsecureAzureConfig();
@ -710,26 +727,13 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
const app = express();
app.disable('x-powered-by');
// Trust X-Forwarded-* headers only when the connection comes from the
// local loopback or RFC1918 private/link-local addresses — exactly the
// origins the CORS allowlist accepts. Without this, every request behind
// any reverse proxy / Docker bridge counts as the same `req.ip` and a
// single user can trip the per-IP rate limiter for everyone.
//
// SCOPE: this setting is process-wide. Every middleware and route in this
// Express app sees req.ip resolved from X-Forwarded-For when the upstream
// hop is in the trusted set above — not just the rate-limited routes.
// Future IP-based middleware (audit logging, IP-bound authz) inherits this
// behavior.
//
// CLOUD-DEPLOY CAVEAT: a public cloud LB (AWS ALB, Cloudflare, Fly.io
// edge, CGNAT 100.64/10) is NOT in the trusted set. In those topologies
// req.ip will collapse to the LB hop IP for every request and the per-IP
// rate limiter degrades to per-server. Add an explicit env-var override
// and document the cloud-deploy story before binding to a non-loopback
// host in those topologies (tracked as a follow-up; not blocking for the
// local-bound default).
app.set('trust proxy', 'loopback, linklocal, uniquelocal');
// Which upstream hops may set X-Forwarded-*. Process-wide: every route's
// req.ip, and so the per-IP rate limiter, resolves through this.
app.set('trust proxy', resolveTrustProxy(process.env[TRUST_PROXY_ENV]));
// resolveTrustProxy validates the value in isolation; only here do we know
// what we bound, and so whether the default is about to collapse the per-IP
// rate limit to one global limit behind a load balancer.
warnIfRateLimitKeysCollapse(host);
// Chromium Private Network Access (required since Chrome 130+). Must run before
// cors: the cors middleware ends OPTIONS preflight responses, so this header
@ -753,21 +757,10 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
);
app.use(express.json({ limit: '10mb' }));
// Same-host origin guard for write routes. Only allows loopback and the
// server's own bound host — scoped to prevent CSRF from other LAN devices.
const requireLocalhostOrigin = createLocalhostOriginGuard(host);
// A wildcard bind (`0.0.0.0`/`::`) has no single host identity for the
// same-host check, so browser write routes accept only loopback origins.
// Warn the operator so a remote-access deployment isn't silently write-blocked.
if (host && normalizeBoundHost(host) === undefined) {
logger.warn(
{ host },
`[gitnexus serve] Bound to a wildcard address (${host}); browser write routes ` +
`accept only loopback origins (localhost/127.0.0.1/[::1]). To allow writes from a ` +
`specific LAN address, bind --host <that-address> instead of a wildcard.`,
);
}
// Origin guard for write routes: loopback, the server's own bound host, and
// any configured public origin — prevents CSRF from other devices.
const requireTrustedOrigin = createWriteOriginGuard(host, port);
logOriginPolicy(host);
// No explicit OPTIONS route is registered. The Chromium Private Network
// Access header is set by the global middleware above (pre-cors), and
@ -988,7 +981,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
// Rate-limited (CodeQL js/missing-rate-limiting): destructive operation
// doing fs.rm of clone + storage dirs. Default 60 rpm/IP is generous for
// delete; tighten if abuse is observed.
app.delete('/api/repo', createRouteLimiter(), requireLocalhostOrigin, async (req, res) => {
app.delete('/api/repo', createRouteLimiter(), requireTrustedOrigin, async (req, res) => {
try {
const repoName = requestedRepo(req);
if (!repoName) {
@ -1496,7 +1489,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
app.post(
'/api/analyze',
createRouteLimiter({ limit: 10 }),
requireLocalhostOrigin,
requireTrustedOrigin,
async (req, res) => {
try {
const {
@ -1535,9 +1528,10 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
// (both collapse `..` identically) and only false-rejected trailing
// slashes, so it is dropped. Analyzing a local path the operator names
// is the tool's intended capability (same as the CLI); the dangerous
// part was cross-origin reach, which is closed by requireLocalhostOrigin
// on this route (scoped to the server's own bound host — other LAN
// devices are NOT trusted). We only require an absolute path here and
// part was cross-origin reach, which is closed by requireTrustedOrigin
// on this route (scoped to loopback, the server's own bound host, and a
// configured GITNEXUS_PUBLIC_ORIGIN — other LAN devices are NOT
// trusted). We only require an absolute path here and
// let the analyze worker surface a clear error if it does not exist.
// (We do NOT realpath/stat the path in-route: that would be a
// user-controlled filesystem read — CodeQL js/path-injection — for no
@ -1627,7 +1621,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
app.post(
'/api/analyze/upload',
createRouteLimiter({ limit: 5 }),
requireLocalhostOrigin,
requireTrustedOrigin,
createAnalyzeUploadHandler({
createJob: (params) => jobManager.createJob(params),
launch: (job, targetPath, opts) => launchAnalysisWorker(job, targetPath, opts),
@ -1659,7 +1653,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
mountSSEProgress(app, '/api/analyze/:jobId/progress', jobManager);
// DELETE /api/analyze/:jobId — cancel a running analysis job
app.delete('/api/analyze/:jobId', requireLocalhostOrigin, (req, res) => {
app.delete('/api/analyze/:jobId', requireTrustedOrigin, (req, res) => {
const jobId = req.params.jobId as string;
const job = jobManager.getJob(jobId);
if (!job) {
@ -1682,7 +1676,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
app.post(
'/api/embed',
createRouteLimiter({ limit: 20 }),
requireLocalhostOrigin,
requireTrustedOrigin,
async (req, res) => {
try {
const entry = await resolveRepo(requestedRepo(req));
@ -1981,7 +1975,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
mountSSEProgress(app, '/api/embed/:jobId/progress', embedJobManager);
// DELETE /api/embed/:jobId — cancel embedding job
app.delete('/api/embed/:jobId', requireLocalhostOrigin, (req, res) => {
app.delete('/api/embed/:jobId', requireTrustedOrigin, (req, res) => {
const jobId = req.params.jobId as string;
const job = embedJobManager.getJob(jobId);
if (!job) {

View file

@ -1,13 +1,36 @@
/**
* Shared Express route guards (alongside createRouteLimiter in validation.ts).
* Shared Express route guards (alongside createRouteLimiter in validation.ts),
* plus the `serve` configuration surface they read: GITNEXUS_PUBLIC_ORIGIN and
* GITNEXUS_TRUST_PROXY.
*/
import type { Request, Response } from 'express';
import proxyaddr from 'proxy-addr';
import { logger } from '../core/logger.js';
/** Port a browser omits from `Origin`, so both sides compare on one value. */
function effectivePort(url: URL): string {
if (url.port) return url.port;
return url.protocol === 'https:' ? '443' : '80';
}
/**
* Canonicalize a bound-host string into the form a browser `Origin` hostname
* takes after WHATWG URL parsing, so the same-host comparison in
* {@link createLocalhostOriginGuard} can use a plain `===`.
* The three loopback spellings a browser can send, and that
* {@link normalizeBoundHost} can return.
*
* Takes a hostname already through WHATWG URL parsing, so IPv6 arrives
* bracketed — unlike `isLoopbackHost` in `mcp/http-transport.ts`, which compares
* a raw `--host` value and so matches bare `::1`.
*/
function isLoopbackHostname(hostname: string | undefined): boolean {
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
}
/**
* Canonicalize a configured host — `--host`, or the one inside
* {@link PUBLIC_ORIGIN_ENV} — into the form a browser `Origin` hostname takes
* after WHATWG URL parsing, so the comparisons in
* {@link createWriteOriginGuard} can use a plain `===`.
*
* Returns `undefined` when the host carries no single comparable identity:
* - empty / not provided
@ -40,20 +63,128 @@ export function normalizeBoundHost(boundHost?: string): string | undefined {
}
/**
* Restrict a route to same-host browser origins. Allows:
* Browser origin a hosted deployment is reached through. A wildcard bind makes
* {@link normalizeBoundHost} undefined, so writes would stay loopback-only.
*/
export const PUBLIC_ORIGIN_ENV = 'GITNEXUS_PUBLIC_ORIGIN';
/**
* Matches a parsed browser `Origin` against {@link PUBLIC_ORIGIN_ENV}. Carries
* the hostname the env value resolved to, so startup can log what it parsed.
*/
export interface PublicOriginMatcher {
readonly hostname: string;
matches(origin: URL): boolean;
}
/**
* Build a matcher for {@link PUBLIC_ORIGIN_ENV}. Compares hostname and scheme
* always — a value with no scheme is read as `https`, never as either — and
* port only when the configured value carried an explicit one.
*
* `undefined` when unset or not a single reachable host, mirroring
* {@link normalizeBoundHost}: an invalid origin must never widen the
* allow-list, and must not read as a configured one either.
*/
export function createPublicOriginMatcher(rawOrigin?: string): PublicOriginMatcher | undefined {
const trimmed = rawOrigin?.trim();
if (!trimmed) return undefined;
const scheme = /^(https?):\/\//i.exec(trimmed)?.[1].toLowerCase();
// An `Origin` never carries a path, but a pasted URL often ends in a slash.
const raw = (scheme ? trimmed.slice(scheme.length + 3) : trimmed).replace(/\/$/, '');
const authority = splitAuthority(raw);
if (!authority) return undefined;
const hostname = normalizeBoundHost(authority.host);
if (!hostname) return undefined;
// A bare host stays permissive on the port, since platform service-discovery
// fields resolve to a hostname with neither — but it defaults to https rather
// than to any scheme, because those platforms always terminate TLS, and
// accepting either would make `app.example.com` an http downgrade path into
// both the CORS read allowlist and the write guard. Plain http needs the
// explicit `http://` form.
const expectedProtocol = `${scheme ?? 'https'}:`;
let expectedPort: string | undefined;
if (authority.port) {
let configured: URL;
try {
// Round-trip through `new URL` so the configured port normalizes the same
// way a request Origin's does — an elided default, a leading zero — and so
// an out-of-range port throws here rather than yielding a dead matcher.
configured = new URL(`${expectedProtocol}//${hostname}:${authority.port}`);
} catch {
return undefined;
}
// Port 0 survives that round-trip (so does `:00`) but no browser ever sends
// it, so keeping it would build exactly the dead-but-configured matcher this
// function exists to reject. `:0080` is unaffected — it normalizes to the
// elided default, not to 0.
if (configured.port === '0') return undefined;
expectedPort = effectivePort(configured);
}
return {
hostname,
matches: (origin: URL): boolean =>
origin.hostname === hostname &&
origin.protocol === expectedProtocol &&
(expectedPort === undefined || effectivePort(origin) === expectedPort),
};
}
/**
* Split `host[:port]` into parts `new URL` can reassemble, or `undefined` when
* the value is not a single reachable host.
*/
function splitAuthority(authority: string): { host: string; port?: string } | undefined {
const bracketed = /^(\[[0-9A-Fa-f:.]+\])(?::(\d{1,5}))?$/.exec(authority);
if (bracketed) return { host: bracketed[1], port: bracketed[2] };
// More than one colon means a bare IPv6 literal, which carries no port.
if ((authority.match(/:/g)?.length ?? 0) > 1) {
return /^[0-9A-Fa-f:.]+$/.test(authority) ? { host: `[${authority}]` } : undefined;
}
const parts = /^([^:]+)(?::(\d{1,5}))?$/.exec(authority);
if (!parts) return undefined;
const host = parts[1];
// `new URL` is far laxer than DNS: it takes `a.com,b.com` verbatim and reads
// `8080` as the integer IP 0.0.31.144, either of which yields a matcher that
// can never match a real Origin while still reading as configured. A trailing
// dot is the same failure — it is a legal FQDN that survives parsing as
// `example.com.`, but a browser sends `example.com`, so the matcher is dead.
if (/[\s,;*/?#@\\]/.test(host) || /^\d+$/.test(host) || host.endsWith('.')) return undefined;
return { host, port: parts[2] };
}
/**
* Restrict a route to the browser origins this server trusts. Allows:
* - loopback (`localhost`, `127.0.0.1`, `[::1]`)
* - the server's own bound host (when non-loopback, e.g. a LAN IP)
* - the configured public origin ({@link PUBLIC_ORIGIN_ENV}), if any
*
* Non-browser requests (no Origin header, e.g. curl / the CLI) pass through.
* This closes cross-origin reach to write routes without affecting read routes.
*
* Loopback stays port-agnostic — the dev UI and the server run on different
* ports — but the bound host is matched on its port too, when one is given.
*
* @param boundHost - The hostname/IP the server is listening on (from
* `createServer`'s `host` parameter). When `undefined`, `'localhost'`, or a
* wildcard (`0.0.0.0`/`::`), only loopback origins are admitted.
* @param boundPort - The port the server actually listens on. Omit it — and
* match the bound host on any port — when that is not known, as it is not
* for an ephemeral `--port 0` bind.
*/
export function createLocalhostOriginGuard(boundHost?: string) {
export function createWriteOriginGuard(boundHost?: string, boundPort?: number) {
const normalizedBoundHost = normalizeBoundHost(boundHost);
return function requireLocalhostOrigin(req: Request, res: Response, next: () => void): void {
const normalizedBoundPort = boundPort === undefined ? undefined : String(boundPort);
// Snapshotted at construction like normalizedBoundHost, so a later env
// mutation cannot widen a running server's write surface.
const publicOrigin = createPublicOriginMatcher(process.env[PUBLIC_ORIGIN_ENV]);
return function requireTrustedOrigin(req: Request, res: Response, next: () => void): void {
const origin = req.headers.origin;
if (origin === undefined) {
next();
@ -61,20 +192,14 @@ export function createLocalhostOriginGuard(boundHost?: string) {
}
try {
const parsed = new URL(origin);
const hostname = parsed.hostname;
const protocol = parsed.protocol;
const { hostname, protocol } = parsed;
if (protocol !== 'http:' && protocol !== 'https:') {
throw new Error('Unsupported origin protocol');
}
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]') {
next();
return;
}
// Allow origin matching the server's own bound host (same-host check).
// `normalizedBoundHost` is canonicalized to the WHATWG form `hostname`
// already carries; it is `undefined` for wildcard/no binds (loopback-only).
// This covers the case where the operator runs `gitnexus serve --host <LAN-IP>`.
if (normalizedBoundHost && hostname === normalizedBoundHost) {
const matchesBoundHost =
hostname === normalizedBoundHost &&
(normalizedBoundPort === undefined || effectivePort(parsed) === normalizedBoundPort);
if (isLoopbackHostname(hostname) || matchesBoundHost || publicOrigin?.matches(parsed)) {
next();
return;
}
@ -82,14 +207,192 @@ export function createLocalhostOriginGuard(boundHost?: string) {
/* malformed origin → reject */
}
res.status(403).json({
error: 'This endpoint is restricted to same-host origins',
error: 'This endpoint is restricted to trusted browser origins',
code: 'origin_not_allowed',
});
};
}
/**
* Default guard that only allows loopback origins. For use in tests or when
* the bound host is not available.
* Whether `serve` has any request authentication configured.
*
* Nothing can configure it yet: `serve` has no authentication of any kind, and
* {@link createWriteOriginGuard} passes every request that carries no `Origin`
* header, so `curl` reaches `POST /api/analyze` and `DELETE /api/repo`
* unauthenticated. That has been safe only because `serve` bound loopback.
*
* So this returns `false` unconditionally, and it is a placeholder on purpose:
* the `serve` auth change replaces this body, and {@link assertServeAuthForPublicOrigin}
* and its tests then hold without being rewritten.
*/
export const requireLocalhostOrigin = createLocalhostOriginGuard();
export function isServeAuthConfigured(): boolean {
return false;
}
/**
* Refuse to start when {@link PUBLIC_ORIGIN_ENV} is set and no `serve`
* authentication is configured.
*
* {@link PUBLIC_ORIGIN_ENV} is the setting that makes a public bind usable — it
* is what admits a non-loopback browser origin to the write routes. Until
* {@link isServeAuthConfigured} can return `true`, setting it opens the door
* with nothing behind it, so the door does not open at all. There is
* deliberately no override flag: an escape hatch is the thing an operator sets
* once and forgets, which is exactly the state this guards against.
*
* @throws when {@link PUBLIC_ORIGIN_ENV} is set without authentication. `serve`
* surfaces it as `serve.startFailed` and exits non-zero.
*/
export function assertServeAuthForPublicOrigin(): void {
const raw = process.env[PUBLIC_ORIGIN_ENV]?.trim();
if (!raw || isServeAuthConfigured()) return;
throw new Error(
`${PUBLIC_ORIGIN_ENV} is set (${raw}), but 'gitnexus serve' has no authentication yet. ` +
`It would admit browser writes from that origin, and requests without an Origin header ` +
`(curl, any script) already reach POST /api/analyze and DELETE /api/repo unauthenticated — ` +
`so a reachable deployment would let anyone index and delete repositories. Unset ` +
`${PUBLIC_ORIGIN_ENV} and bind loopback (the default), or reach the server through a proxy ` +
`that authenticates for it.`,
);
}
/**
* Report at startup what {@link createWriteOriginGuard} will admit, so an
* operator can see it without reproducing a 403. A wildcard bind always warns —
* gating that on {@link PUBLIC_ORIGIN_ENV} being constructible would diagnose a
* misconfigured value worse than an absent one.
*/
export function logOriginPolicy(boundHost?: string): void {
const raw = process.env[PUBLIC_ORIGIN_ENV]?.trim();
const publicOrigin = createPublicOriginMatcher(raw);
if (publicOrigin) {
logger.info(
{ [PUBLIC_ORIGIN_ENV]: raw, hostname: publicOrigin.hostname },
`[gitnexus serve] Browser write routes also accept origins on ${publicOrigin.hostname}.`,
);
} else if (raw) {
logger.warn(
{ [PUBLIC_ORIGIN_ENV]: raw },
`[gitnexus serve] Ignoring ${PUBLIC_ORIGIN_ENV}=${raw} — not a single reachable origin, ` +
`so it admits nothing. Set it to one host, optionally with a scheme and a port.`,
);
}
if (!boundHost || normalizeBoundHost(boundHost) !== undefined) return;
const admitted = publicOrigin
? `accept loopback origins (localhost/127.0.0.1/[::1]) and ${publicOrigin.hostname} via ` +
`${PUBLIC_ORIGIN_ENV}.`
: `accept only loopback origins (localhost/127.0.0.1/[::1]). To admit writes from a specific ` +
`LAN address, bind --host <that-address> instead of a wildcard; to admit them from a ` +
`public origin, set ${PUBLIC_ORIGIN_ENV} to it.`;
logger.warn(
{ host: boundHost },
`[gitnexus serve] Bound to a wildcard address (${boundHost}); browser write routes ${admitted}`,
);
}
/** Loopback + RFC1918 + link-local: the hops a self-hosted install sees. */
export const DEFAULT_TRUST_PROXY = 'loopback, linklocal, uniquelocal';
/** Overrides {@link DEFAULT_TRUST_PROXY}; a public cloud LB needs it set. */
export const TRUST_PROXY_ENV = 'GITNEXUS_TRUST_PROXY';
/**
* Sanity ceiling on a hop count, well past any real proxy chain — it exists to
* catch a digit string long enough to overflow to `Infinity`, not to make any
* value under it safe. The correct hop count is the exact number of proxies you
* control; each extra hop hands the caller one more entry of the chain.
*/
export const MAX_TRUST_PROXY_HOPS = 16;
/**
* Resolve {@link TRUST_PROXY_ENV} to a value Express accepts for `trust proxy`:
* `false` (`false`/`no`/`off`, and a `0` hop count), a hop count in
* `1..{@link MAX_TRUST_PROXY_HOPS}`, or a proxy list Express can compile.
* Anything else warns and returns {@link DEFAULT_TRUST_PROXY}. Express compiles
* this value inside `app.set`, so an unvalidated bad one takes `serve` down at
* startup; a number it accepts without any range check at all.
*
* `true` is rejected, not accepted-with-a-warning. It makes `req.ip` the
* client-controlled leftmost `X-Forwarded-For` entry, so a spoofed chain earns a
* fresh rate-limit key per request — and the limiter is the only thing in front
* of `/api/analyze` and `/api/embed`, both of which spawn workers. It is also
* not a working configuration: express-rate-limit's own `validations.trustProxy`
* throws `ERR_ERL_PERMISSIVE_TRUST_PROXY` on it. Any real chain has a knowable
* length, so a hop count or a proxy list covers every legitimate case.
*/
export function resolveTrustProxy(raw?: string): string | number | boolean {
const value = raw?.trim();
if (!value) return DEFAULT_TRUST_PROXY;
if (/^(true|yes|on)$/i.test(value)) {
return rejectTrustProxy(
value,
`it trusts every hop, so req.ip is read from the client-controlled leftmost ` +
`X-Forwarded-For entry and a spoofed chain earns a fresh rate-limit key per request; ` +
`express-rate-limit rejects it too. Set the number of proxies you control instead`,
);
}
if (/^(false|no|off)$/i.test(value)) return false;
if (/^\d+$/.test(value)) {
const hops = Number(value);
// Express tests a hop count as `i < hops`, so 0 trusts nothing, exactly as
// `false` does. Rejecting it would fall back to a default that trusts more
// than was asked for.
if (hops === 0) return false;
// The same test means a digit string long enough to overflow to Infinity
// trusts the whole chain rather than failing loudly.
if (Number.isInteger(hops) && hops <= MAX_TRUST_PROXY_HOPS) return hops;
return rejectTrustProxy(value, `expected a hop count of 0..${MAX_TRUST_PROXY_HOPS}`);
}
try {
// Mirror Express 5's compileTrust (`express/lib/utils.js`): it splits on `,`
// and trims before handing the list to proxy-addr, which rejects unknown
// subnet names itself. proxy-addr does not split, so pass the array form.
proxyaddr.compile(value.split(',').map((entry) => entry.trim()));
} catch (err) {
return rejectTrustProxy(value, err instanceof Error ? err.message : String(err));
}
return value;
}
/**
* Warn when the rate limiter is about to key every request to the same address.
*
* {@link resolveTrustProxy} cannot detect this — it sees the env value and not
* what the server bound. A non-loopback bind is the shape of a deployment behind
* a load balancer, and {@link DEFAULT_TRUST_PROXY} matches only loopback and the
* private ranges, so a cloud LB outside them is never trusted: `req.ip` is the
* LB on every request and the per-IP limit silently becomes one global limit.
*
* Silent when {@link TRUST_PROXY_ENV} is set — including to a value that then
* fails validation, which {@link resolveTrustProxy} has already warned about.
*
* @param boundHost - `createServer`'s `host`. A wildcard bind warns too: it
* accepts traffic on every interface, a load balancer included.
*/
export function warnIfRateLimitKeysCollapse(boundHost?: string): void {
if (process.env[TRUST_PROXY_ENV]?.trim()) return;
if (!boundHost) return;
// normalizeBoundHost returns undefined for a wildcard or unparseable host,
// neither of which is loopback — so both warn.
if (isLoopbackHostname(normalizeBoundHost(boundHost))) return;
logger.warn(
{ host: boundHost, trustProxy: DEFAULT_TRUST_PROXY },
`[gitnexus serve] Bound to ${boundHost} with ${TRUST_PROXY_ENV} unset, so 'trust proxy' is ` +
`'${DEFAULT_TRUST_PROXY}'. A load balancer outside those ranges is not trusted, so req.ip ` +
`is the balancer on every request and the per-IP rate limit becomes one shared limit across ` +
`all callers. Set ${TRUST_PROXY_ENV} to the number of proxies you control.`,
);
}
function rejectTrustProxy(value: string, reason: string): string {
logger.warn(
{ [TRUST_PROXY_ENV]: value },
`[gitnexus serve] Ignoring ${TRUST_PROXY_ENV}=${value} (${reason}); falling back to ` +
`'${DEFAULT_TRUST_PROXY}'.`,
);
return DEFAULT_TRUST_PROXY;
}

View file

@ -174,7 +174,61 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// "pick a bigger number": it is that the check must happen immediately before
// merge, because the window between review and merge is exactly when `main`
// allocates. Re-check against origin/main before merging this.
const SCHEMA_BUMP = 39;
// v40: inference-typed class fields emit type-binding captures in SIX languages
// (#2807) — TypeScript/JavaScript `public_field_definition|field_definition` with a
// `new_expression` value and `this.<field> = new X()`; Python `self.x = Outer()`;
// Ruby `@ivar = Foo.new`; Swift optional property annotations; Dart inferred-type
// and final field declarations plus constructor-body field writes. Every one of
// these is PARSE-TIME capture emission, so a warm cache replays the pre-fix
// capture set verbatim for byte-unchanged files and the new receiver edges never
// appear — silently, with no error, exactly the v27/v30 failure mode. `analyze`
// skips tree-sitter dispatch for unchanged chunks (GUARDRAILS.md), so a plain
// re-analyze does NOT surface them without this bump.
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING — main was also at 39
// when this was allocated, and this file records eight prior collisions.
// v41: the v40 Dart field-write binding gained its READ-side mask (#2807 review)
// — a Dart class-member body that rebinds one of its class's field names now
// emits `@receiver-owner.shadowed-fields` on its synthesized `@scope.function`
// match, which becomes `Scope.ownsReceivers`. Parse-time capture emission again,
// so a v40 warm cache replays scope matches with no marker and the receiver walk
// still reaches the class field — i.e. it keeps serving the WRONG edge this
// bump's fix removes, silently. Same bump-or-nothing situation as v40.
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING.
// v42: the v40/v41 Python constructor-field arm stopped accepting a DOTTED
// callee (#2807 review). `self.svc = f.Alpha()` no longer emits a
// `@type-binding.constructor` capture at all, which is what removes the
// fabricated edge to the same-named class `Alpha` and what stops
// `self.conn = Registry.get()` displacing an earlier real `self.conn = Outer()`.
// A within-PR re-bump, not a collision fix: v41 was allocated by this same
// unmerged branch, so v41-stamped caches exist only on it — but they exist on
// every reviewer's and CI runner's checkout of it, and parse-time emission means
// they replay the pre-fix capture set for byte-unchanged files and keep serving
// the fabricated edge. main is at 39, so 40/41/42 are all this branch's.
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING.
// v43: Go embedded fields now emit `@reference.embedded-pointer` when written
// as `*T` rather than `T` (#2813 exact method sets). Go's method-set rules make
// the two forms genuinely different — `struct{ Base }` does NOT get Base's
// pointer-receiver methods in its value method set while `struct{ *Base }` does
// — so structural interface satisfaction cannot be exact without the spelling.
// This is PARSE-TIME capture emission, so a warm cache replays the pre-fix
// capture set for byte-unchanged files and the distinction never appears:
// silently, with no error, the v27/v30 failure mode. `analyze` skips tree-sitter
// dispatch for unchanged chunks (GUARDRAILS.md), so a plain re-analyze does NOT
// surface it without this bump.
//
// 42 -> 43: this branch originally allocated 43 while sitting at 39, because
// main had already taken 40/41/42 for #2807. main has since merged those and
// this branch rebased onto it, so 43 remains the next free number and the
// 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.
// 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

View file

@ -127,6 +127,38 @@
"captureGroups": 23,
"digest": "fd35d917bc7a039ef4bcb0a6501c31ffbea0fe39bf3604938191597415aa33f7"
},
"go-interface-field-dispatch/internal/handlers/orders.go": {
"captureGroups": 22,
"digest": "438b02c0990fa6c830767327d07de642f7aff6287e95217c3dfd7423a520942c"
},
"go-interface-field-dispatch/internal/handlers/picking.go": {
"captureGroups": 28,
"digest": "0ad401ba19e7cf7deace88ea7973572cb6ee2a63a6006aa5cc06f55bac56e14b"
},
"go-interface-field-dispatch/internal/repository/audit_repo.go": {
"captureGroups": 18,
"digest": "2710feb4bee288248e346d7ab0a32a8511b002d8c0fad4cd7db3588ee1926bc2"
},
"go-interface-field-dispatch/internal/repository/interfaces.go": {
"captureGroups": 10,
"digest": "ee5b78fc03ea3283b2e06b9b8d0cf4999c2de091262571966d1d4670036c479d"
},
"go-interface-field-dispatch/internal/repository/mock_repo.go": {
"captureGroups": 47,
"digest": "7b9219cc06e8c45fb08c304b71ebc58e1a9e052cb9c92fd01b9f22ab8328a91d"
},
"go-interface-field-dispatch/internal/repository/order_repo.go": {
"captureGroups": 26,
"digest": "00e0f98586043ff84f96af92d9e6b3593018b6b37f92d183b5c05c6de745e2b0"
},
"go-interface-field-dispatch/internal/services/pick_service.go": {
"captureGroups": 39,
"digest": "036d1a4b7304bbbf963f640710fd286702ed34809df2940411c14da8f9de9a6f"
},
"go-interface-field-dispatch/internal/services/wave_service.go": {
"captureGroups": 46,
"digest": "62a3830311106e4a349c5b604e914531249aacc47001a888ecf5d38e2bc4c821"
},
"go-local-shadow/cmd/main.go": {
"captureGroups": 14,
"digest": "ce7220df98df08741f772150b5572bec0364d576741bc8c76647615fab859f92"
@ -285,7 +317,7 @@
},
"go-qualified-base/consumers/qualified.go": {
"captureGroups": 14,
"digest": "01fe99cadcdf3ce6f00cafce8db0474ad152983f766c8dee9b9da42c9256ae2f"
"digest": "4680bb7a7c62a54a91d1d7b8ab3150dfb0c54b1683bee8cf7fc747afe99ca0f8"
},
"go-receiver-method-free-call/example.go": {
"captureGroups": 8,

View file

@ -0,0 +1,3 @@
module github.com/example/wms
go 1.21

View file

@ -0,0 +1,13 @@
package handlers
import "github.com/example/wms/internal/repository"
type OrderHandlers struct {
repo repository.OrderRepository
auditRepo *repository.AuditRepo
}
func (h *OrderHandlers) Delete(id string) error {
h.auditRepo.LogAuditEventAsync("delete")
return h.repo.DeleteItem(id)
}

View file

@ -0,0 +1,15 @@
package handlers
import "github.com/example/wms/internal/repository"
type PickHandlers struct {
repo repository.OrderRepository
}
func (h *PickHandlers) Queue(id string) ([]string, error) {
return h.repo.GetPickQueue(id)
}
func (h *PickHandlers) Unsplit(id string) error {
return h.repo.UnsplitOrder(id)
}

View file

@ -0,0 +1,12 @@
package repository
// AuditRepo and CartRepo are the CONCRETE-field controls: calls through a
// concrete-typed field already resolved before #2813 and must keep resolving
// to the implementation, with no interface-dispatch fan-out.
type AuditRepo struct{}
func (a *AuditRepo) LogAuditEventAsync(msg string) {}
type CartRepo struct{}
func (c *CartRepo) Get(id string) string { return "" }

View file

@ -0,0 +1,18 @@
package repository
// OrderRepository is satisfied only by types whose methods use POINTER
// receivers (#2813). In Go the method set of *T includes both value- and
// pointer-receiver methods, so *OrderRepo implements this interface even
// though OrderRepo (the value type) does not.
type OrderRepository interface {
DeleteItem(id string) error
GetPickQueue(id string) ([]string, error)
UnsplitOrder(id string) error
}
// PartialRepository is deliberately satisfied by NOTHING in this fixture —
// the negative control for structural detection.
type PartialRepository interface {
DeleteItem(id string) error
NeverImplemented(id string) error
}

View file

@ -0,0 +1,23 @@
package repository
// MockOrderRepo mirrors the mock proliferation of a real codebase: a second
// pointer-receiver implementor, so the fan-out must reach BOTH.
type MockOrderRepo struct{}
func (m *MockOrderRepo) DeleteItem(id string) error { return nil }
func (m *MockOrderRepo) GetPickQueue(id string) ([]string, error) { return nil, nil }
func (m *MockOrderRepo) UnsplitOrder(id string) error { return nil }
// WrongSigRepo has every method NAME the interface requires, at the same arity,
// but with an incompatible parameter type. It must NOT be detected as an
// implementor — signature comparison is the only guard left now that
// pointer-receiver methods count toward the method set (#2813).
type WrongSigRepo struct{}
func (w *WrongSigRepo) DeleteItem(id int) error { return nil }
func (w *WrongSigRepo) GetPickQueue(id int) ([]string, error) { return nil, nil }
func (w *WrongSigRepo) UnsplitOrder(id int) error { return nil }

View file

@ -0,0 +1,11 @@
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 }
func (r *OrderRepo) UnsplitOrder(id string) error { return nil }

View file

@ -0,0 +1,21 @@
package services
import "github.com/example/wms/internal/repository"
// PickService holds BOTH an interface-typed field and concrete-typed fields,
// so one fixture covers the failing case and its control.
type PickService struct {
orderRepo repository.OrderRepository
cartRepo *repository.CartRepo
auditRepo *repository.AuditRepo
}
func (s *PickService) GetPickQueue(id string) ([]string, error) {
s.auditRepo.LogAuditEventAsync("pick")
return s.orderRepo.GetPickQueue(id)
}
func (s *PickService) StartSession(id string) error {
_ = s.cartRepo.Get(id)
return s.orderRepo.DeleteItem(id)
}

View file

@ -0,0 +1,28 @@
package services
import "github.com/example/wms/internal/repository"
// WaveService declares the SAME field shape as PickService — the issue
// reported these two behaving differently at scale (#2813).
type WaveService struct {
orderRepo repository.OrderRepository
orderRepo2 *repository.OrderRepo
auditRepo *repository.AuditRepo
}
func (s *WaveService) Release(id string) error {
s.auditRepo.LogAuditEventAsync("wave")
return s.orderRepo.DeleteItem(id)
}
func (s *WaveService) Queue(id string) ([]string, error) {
return s.orderRepo.GetPickQueue(id)
}
// Recount calls through a CONCRETE field whose own type (*OrderRepo) is itself
// an implementor of OrderRepository. This is the shape where the dispatch type
// gate is load-bearing: the call must resolve to OrderRepo only and must NOT
// fan out to the sibling implementor MockOrderRepo (#2829 review).
func (s *WaveService) Recount(id string) error {
return s.orderRepo2.UnsplitOrder(id)
}

View file

@ -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');
}

View file

@ -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;
}

View file

@ -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 {}
}

View file

@ -0,0 +1,7 @@
import { Repo } from './repository';
export class MemRepo implements Repo {
save(entity: string): boolean {
return entity !== '';
}
}

View file

@ -0,0 +1,5 @@
export class PlainCache {
run(): void {
// No interface in sight — the negative control for the fan-out gate.
}
}

View file

@ -0,0 +1,3 @@
export interface Repo {
save(entity: string): boolean;
}

View file

@ -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 === '';
}
}

View file

@ -0,0 +1,7 @@
import { Repo } from './repository';
export class SqlRepo implements Repo {
save(entity: string): boolean {
return entity.length > 0;
}
}

View file

@ -5,7 +5,7 @@
},
"swift-abstract-dispatch/Sources/Repository.swift": {
"captureGroups": 26,
"digest": "36d177980181b97f016d834a9a57b7e559e61558a9f4e1c45809604886b18aa7"
"digest": "d8a8c59b7236d6b25a063b8584637d5c89a86cf022776272f80aeb4f1e4826b9"
},
"swift-await-try/App.swift": {
"captureGroups": 13,
@ -13,7 +13,7 @@
},
"swift-await-try/Models.swift": {
"captureGroups": 21,
"digest": "eb9e2b04b9dd55d161c58b5004adde0f881a138b790985fd523e110b87035ffd"
"digest": "df2a06182275e023c6f40e978f60a0ff933fccc711d7c15f4a2434e8e282a653"
},
"swift-call-result-binding/App.swift": {
"captureGroups": 7,
@ -21,11 +21,11 @@
},
"swift-call-result-binding/Models.swift": {
"captureGroups": 15,
"digest": "7f068c35624ff48635c1f51ec79e871511200a044eb4920bedd6673c4f690911"
"digest": "d312b4003cbe7f3eec8e1b6f924b0e1afb7023ca086ec26ef0ad69078cc67925"
},
"swift-child-extends-parent/Sources/App.swift": {
"captureGroups": 10,
"digest": "19b16f002f2e10b661ada767769723ed92c6101fbf8dd895a9077c563c06946d"
"digest": "9532b3989cd104dd468deb8d671e727cdf944ebb858e002072875519a014e968"
},
"swift-child-extends-parent/Sources/Child.swift": {
"captureGroups": 4,
@ -33,11 +33,11 @@
},
"swift-child-extends-parent/Sources/Parent.swift": {
"captureGroups": 7,
"digest": "ee1086e1a0cbfc57f381401b81b2e150f422a64cd3b937f083a1fbbd85e5a27e"
"digest": "09d26410736d5ca5ec8dfe598212e1eefc64075bc2199fda170bb08a478392a4"
},
"swift-class-func-receiver/Service.swift": {
"captureGroups": 27,
"digest": "6e9c6bd2ebb65624a5079a1ce18b5b8c6a73af5e3216580bb558f0e22a0ec689"
"digest": "5d7e5b278bdc94cc2014ebf2f8c975afb10aa80bc2e1bd62ec5559a102fc8e41"
},
"swift-constructor-fallback/App.swift": {
"captureGroups": 7,
@ -45,15 +45,15 @@
},
"swift-constructor-fallback/Service.swift": {
"captureGroups": 7,
"digest": "685538adb8aede40f7969572a4c8ba3d491413de22798e109800a121d8d88223"
"digest": "5744bba4c9b821b2264dea36de3e864723f8ca1be9face21ec9de55117a9e7a5"
},
"swift-constructor-type-inference/Models/Repo.swift": {
"captureGroups": 15,
"digest": "9f932b70d43c83b403a456b5854586f29238cfb573dff7e1183aea20d7e7f18d"
"digest": "e8ea96d49fc2113e29b88961686e39472b92481e929b3f887d53b65201e2e7bb"
},
"swift-constructor-type-inference/Models/User.swift": {
"captureGroups": 15,
"digest": "1fcd2298bb1b7d0d6ff42248cedd66d31a2e3e0de8645162ee3eb31924ac9987"
"digest": "fe1039891719c640bfa7d362040460addcf744eed1350521cbcc7c9399395087"
},
"swift-constructor-type-inference/Services/App.swift": {
"captureGroups": 12,
@ -61,7 +61,7 @@
},
"swift-enum-members/Direction.swift": {
"captureGroups": 18,
"digest": "6378c8860113e9d0331f4da4d05e64e6933a995493131d2f2165f64f82978b0b"
"digest": "bd0bf779b08305fcd7dfadfcad45319123393aadc17044784314a7b34cb9e6ae"
},
"swift-export-visibility/App.swift": {
"captureGroups": 10,
@ -69,7 +69,7 @@
},
"swift-export-visibility/Visible.swift": {
"captureGroups": 15,
"digest": "9531822091fc521783d6815f03ed503f8d1010289b401f6f2b2057b422de3e76"
"digest": "c8515702b94de2f9e3ad009daec61bad6f9328fb39796e57ec03348438446c4c"
},
"swift-extension-dedup/App.swift": {
"captureGroups": 7,
@ -77,11 +77,11 @@
},
"swift-extension-dedup/Product.swift": {
"captureGroups": 15,
"digest": "81ef7e833c46fdedfd8f632fe0918b768e88d4490e886a763760f26549346009"
"digest": "c0f08c41829db294b2c89876a329cfffd7f3fb0a4b2bca315ae867a1d263a068"
},
"swift-extension-dedup/ProductExtensions.swift": {
"captureGroups": 8,
"digest": "1e072eb427bb17ea1a225bcc85e0defe74297e3e0ae05c7dc9c04a7e870b0508"
"digest": "228f2f32494a51fb371c754d9ae11a94e7181a14d875a339c631e26f82df542e"
},
"swift-field-types/App.swift": {
"captureGroups": 7,
@ -89,7 +89,7 @@
},
"swift-field-types/Models.swift": {
"captureGroups": 20,
"digest": "e55d0207f950d63ace5efb93551971b10e1fc6cc264e17098f006aa606d1e19c"
"digest": "cad215dd5ce4d0c93056ec5600663b8d17eebbefa06d09675fb64392196531ab"
},
"swift-for-loop-inference/App.swift": {
"captureGroups": 5,
@ -97,7 +97,7 @@
},
"swift-for-loop-inference/Models.swift": {
"captureGroups": 11,
"digest": "5423257303f45305269592ec1d4e64e1fcdc0aa1387f5adab5fa53a9906fc8e2"
"digest": "6799fe82225f3f2f256df49d7238e4f241dbe2ea8109d7095bdac1d16de132ff"
},
"swift-if-let-guard-let/App.swift": {
"captureGroups": 11,
@ -105,7 +105,7 @@
},
"swift-if-let-guard-let/Models.swift": {
"captureGroups": 19,
"digest": "a21d2c56f0d5665da624993acbf14b2ede5aa4e2b9f161b8f9b21fc261707609"
"digest": "51d6986620c07de553b2c8d848d908899a96c7174a7ae56bf68c7de930c8d5c5"
},
"swift-implicit-imports/App.swift": {
"captureGroups": 7,
@ -113,11 +113,11 @@
},
"swift-implicit-imports/Models.swift": {
"captureGroups": 7,
"digest": "9d0a738f1fdd31c1e204b20f1ccc76428f9f300eed82191da63f70799ff18f09"
"digest": "995e604bfd03b42eaeabff130fd1671e524832c64ee52f01cd193a88283d3764"
},
"swift-init-cross-file/User.swift": {
"captureGroups": 18,
"digest": "4e7df3b46d39126ab8d6a21a6a4103453f38e1b8a2fe159447abaa85381b6295"
"digest": "13d60237fb85ddeba695f3e5f0a202c327309bf159a04e696531c419c42e8cf1"
},
"swift-init-cross-file/main.swift": {
"captureGroups": 8,
@ -129,11 +129,11 @@
},
"swift-member-write-access/Models.swift": {
"captureGroups": 26,
"digest": "25be33df18e7eb8fcb9005c8a963205a3d93f37d0950ca187e9caf569fc5dfa4"
"digest": "376359897f8706015f702b2f3346ad4ca4e78e9f11ae6425746ae57116c2cc51"
},
"swift-method-enrichment/Sources/Animal.swift": {
"captureGroups": 24,
"digest": "a49732a1d0fdf5d33b1f0c321a0413163d5af3a353b83510dae5d801109788c5"
"digest": "48bd53d1e69d4a48569985296f5aa44d4b41ff474566445f4e3d33390165c84d"
},
"swift-method-enrichment/Sources/App.swift": {
"captureGroups": 10,
@ -145,7 +145,7 @@
},
"swift-multi-if-let/Models.swift": {
"captureGroups": 24,
"digest": "0c072ac3f476a9e563190de555eeaff09a62128d9e7760912655cea9bdefbe6e"
"digest": "e6add0cb3679cb2800094e41d4afc66c5d714c19ffefc38edfb5686ed9d46d51"
},
"swift-multidir-target/Package.swift": {
"captureGroups": 5,
@ -153,7 +153,7 @@
},
"swift-multidir-target/Sources/Alpha/Core/User.swift": {
"captureGroups": 7,
"digest": "b5fa3ef978d5bb5322a433761891ffaa86988f805bc9d419cfa1f24e9fcffa39"
"digest": "a67cb60787680595b43af3fdd371ad84237e17fa7de928610a8c14e8ac062043"
},
"swift-multidir-target/Sources/Alpha/Entry/App.swift": {
"captureGroups": 7,
@ -161,11 +161,11 @@
},
"swift-multidir-target/Sources/Beta/Core/User.swift": {
"captureGroups": 7,
"digest": "10c5e3b4724499ce2e43f1838a516e0387f74045450560aec033e3357d99fd62"
"digest": "434aaaf05cc49f019807499f5f0f08a2b383dc289f3e3308a2ba37e6fdca2330"
},
"swift-multifolder-nopackage/Models/User.swift": {
"captureGroups": 7,
"digest": "4a05d8ee43df38acb8c91ee66bd0b9c5f4ede20f1330a2977981cf431c16fb1e"
"digest": "356dcd75eb93636607d406e26ce0e62dfa1fe9c78dcd1f950adca304a14d3ad1"
},
"swift-multifolder-nopackage/Services/App.swift": {
"captureGroups": 7,
@ -173,11 +173,11 @@
},
"swift-nested-extension/Extension.swift": {
"captureGroups": 7,
"digest": "3e6dfc7895f9c257be1524e43f004497faca6f765311474fc4bcc85f1d222940"
"digest": "c69b554d6a1372a449198db7581a2713a1850bded67874101d3cf2faa784f646"
},
"swift-nested-extension/Types.swift": {
"captureGroups": 13,
"digest": "16d599d9805cba59b1bb22598b09e1d25e3dd80da52a4a2c04c23581a8b65d6e"
"digest": "2738a4d77473a2641166b95bc1107f99afca55e9b9d4186eb102e053d378d8ab"
},
"swift-overload-dispatch/App.swift": {
"captureGroups": 7,
@ -185,23 +185,23 @@
},
"swift-overload-dispatch/Repository.swift": {
"captureGroups": 11,
"digest": "b5ed80965325806c715d1c3a65163b014e0bbba5ca7e2df9d7c81b084fada15e"
"digest": "07c6509becf6ae01ae336c53ddeeff38b486bea4a7facad0edf129eb67401408"
},
"swift-overload-dispatch/SqlRepository.swift": {
"captureGroups": 28,
"digest": "40caf38a1bbe958294ffaacb921f1ef08131445ed959f549fe9be86ea0cdb289"
"digest": "59d11755ba6796355751608bbf74bd03b99edd6c601c51dcd3e46ff33a4af659"
},
"swift-parent-resolution/Sources/Models/BaseModel.swift": {
"captureGroups": 7,
"digest": "f8a7f98e0df2132df1bd48efef94135214a4072cf47ac5e42e64c99da50bcaa6"
"digest": "0d9056d5dbd480f04ad915c628c81a71e9c1b4ec8981d8027d3a4edcd0f06427"
},
"swift-parent-resolution/Sources/Models/Serializable.swift": {
"captureGroups": 6,
"digest": "684be5a2e9d7c03c4a9ce209fe5719a776ad4fe0ed12a7a79ba64eed6f34a40e"
"digest": "502ce9136303235cb8e1cdd459afeb4bb76c658d186c63731a3dbb3c42fadac9"
},
"swift-parent-resolution/Sources/Models/User.swift": {
"captureGroups": 10,
"digest": "bd01b5adcd523ceee95772cc74ded0dbc1d70449fc9e9d17e975299dcbac783f"
"digest": "9bc07345adb74d5e2b4711354175825ef00918dbd61785e472d091f14567f9d5"
},
"swift-protocol-property/Repository.swift": {
"captureGroups": 7,
@ -213,7 +213,7 @@
},
"swift-qualified-base/Sources/Outer.swift": {
"captureGroups": 9,
"digest": "8674f64c110ed91d7e63add63a90612c56d7b827e1b0ade54841677e82c56d80"
"digest": "1ff59349dc4d45a5e1f6fdebf19caf65427d3904eddbc50efeb0879e31574a17"
},
"swift-return-type-inference/App.swift": {
"captureGroups": 21,
@ -221,7 +221,7 @@
},
"swift-return-type-inference/Models.swift": {
"captureGroups": 29,
"digest": "fd0c6c7970e237bed4a305a84ee33d00dbc7c06d97c2e076911ae6ca741cad98"
"digest": "bc539da3c97ac77543b7a1cce457c6ec0f8072c1e5861bfc027cf8fe8f4b541c"
},
"swift-return-type/App.swift": {
"captureGroups": 7,
@ -229,18 +229,18 @@
},
"swift-return-type/Models.swift": {
"captureGroups": 21,
"digest": "d92ce7a1ec19c86a1848fa1d72d151c8059714c9129b70e8d260135a6a4a4993"
"digest": "dc230a5f6085165fb717632f62b65fd3c99825b5fcc7a3a597a8d9143627729e"
},
"swift-self-this-resolution/Sources/Models/Repo.swift": {
"captureGroups": 7,
"digest": "2f104d81deb40413f23cec9ed5e1e11585cd7075c101158fc95ed3c44cb7e778"
"digest": "61fe84aa9826726ee02bce54c62507cc4f27e56e9be2d57c10f55dd8043e13d9"
},
"swift-self-this-resolution/Sources/Models/User.swift": {
"captureGroups": 11,
"digest": "c0c8e583896b1d9c889b43682d53b0d6b8194f54a27ae16a4d89a5ad79354fdc"
"digest": "1841837c805e19746138dbb80ccdfccbce483d68d1e7ea08d92daec255cfbdc7"
},
"synthetic:dao-20": {
"captureGroups": 361,
"digest": "d38aec3ca6fad59b943b3515691adc169dc4b40b70711139e9f0301be844e6fa"
"digest": "aa1747e2f297f5a5a71bd0361273f5505adf93db3d4a2ae3111916bc109c1c7b"
}
}

View file

@ -12,12 +12,11 @@
* impact-pdg-fullchain-e2e; what those cannot catch is a chain link silently
* missing from the column they both read.
*
* ── WHERE THE SUPPORT ACTUALLY STOPS ──────────────────────────────────────────
* ── WHAT REACHES THE CELL ─────────────────────────────────────────────────────
*
* Chained resolution is NOT general. Measured against this fixture (one repo per
* shape and all shapes in one repo agree, so the rows do not contaminate each
* other), the discriminator is whether the receiver's type is DECLARED, not
* whether it is a local or a field:
* Measured against this fixture (one repo per shape and all shapes in one repo
* agree, so the rows do not contaminate each other). Every receiver form now
* carries its whole chain, whether the receiver's type is declared or inferred:
*
* receiver form calleeIds cell
* --------------------------------------------------- ------------------------
@ -26,29 +25,16 @@
* field `private p: Outer;` + ctor `this.p = new ...` Outer.inner + Inner.compute
* receiver is a call result `makeOuter().inner()...` makeOuter + both links
* three links `o.inner().mid().compute()` all three links
* field `private p = new Outer()` (INFERRED) EMPTY <- known gap
* field `private p;` + ctor `this.p = new Outer()` EMPTY <- known gap
* field `private p = new Outer()` (INFERRED) Outer.inner + Inner.compute
* field `private p;` + ctor `this.p = new Outer()` Outer.inner + Inner.compute
*
* An inference-typed receiver does not merely lose the CHAINED link — it empties
* the whole cell, so the descent cannot cross into `Outer.inner` either, even
* though that call has a perfectly ordinary named receiver. Those two rows are
* pinned by `KNOWN GAP: an inference-typed receiver empties the WHOLE calleeIds
* cell` below, which asserts the current empty value EXACTLY.
*
* ── THIS PIN IS SELF-DIFFING: IT WILL GO RED ON PURPOSE ───────────────────────
*
* The gap is tracked as issue #2807 ("Inference-typed field receivers resolve to
* no CALLS edges at all"); PR #2810 is open against it at the time of writing.
* The `KNOWN GAP` test asserts that the gap EXISTS — the empty cell, exactly —
* so it is not a regression guard, it is a record. Whoever closes #2807 will see
* it fail with the newly resolved ids in the diff; that is the intended signal,
* and the fix is to update this file (the table above, the rows' `resolution`,
* and the pin's expected value), not to relax the assertion. A single-shape
* fixture, or an `it.fails` row, would instead have kept quietly implying that
* chained receivers work in general.
*
* The gap was FOUND during #2802 work but is PRE-EXISTING and independent of it:
* nothing on that branch touches receiver typing. Track the gap itself at #2807.
* The last two rows were EMPTY before #2807 — not a truncated chain, an empty
* cell, so the descent could not cross into `Outer.inner` either even though
* that call has a perfectly ordinary named receiver. The cause was upstream of
* the PDG entirely: an untyped field had no type binding, so the receiver fold
* declined at its first step and no link was ever resolved to put here. The
* resolver-level view of the same fact, with the full shape table, lives in
* `test/integration/resolvers/typescript-inferred-field-receiver.test.ts`.
*
* Self-contained fixture rather than an addition to `fixtures/pdg-repo` — that
* fixture is shared by eight suites including a snapshot test, so growing it to
@ -162,15 +148,13 @@ const INNER_MID = `Method:${FIXTURE_PATH}:Inner.mid#0`;
const MID_COMPUTE = `Method:${FIXTURE_PATH}:Mid.compute#1`;
const MAKE_OUTER = `Function:${FIXTURE_PATH}:makeOuter`;
/**
* `reaches-pdg` — every link's id lands in the cell today.
* `known-gap-empty-cell` — the resolver cannot type the receiver, so the cell is
* emitted EMPTY and the descent cannot cross ANY link of the chain.
*/
type ChainResolution = 'reaches-pdg' | 'known-gap-empty-cell';
/** Every link's id lands in the cell. The only value today — the
* inference-typed rows joined it in #2807 — but kept as a named type so a
* future gap row has somewhere to say so instead of being a bare boolean. */
type ChainResolution = 'reaches-pdg';
interface ReceiverShape {
/** Row name; also the key of the known-gap pin below. */
/** Row name; also the assertion key in the diff when a row moves. */
readonly name: string;
/** Unique fragment of the chained statement, used to find its block. */
readonly marker: string;
@ -210,20 +194,22 @@ const RECEIVER_SHAPES: readonly ReceiverShape[] = [
links: [OUTER_INNER, INNER_MID, MID_COMPUTE],
resolution: 'reaches-pdg',
},
// ── Known gaps ────────────────────────────────────────────────────────────
// Identical to the two rows above except that the field has no type
// annotation, so its type would have to be inferred from the initializer.
// ── Inference-typed fields (#2807) ────────────────────────────────────────
// Identical to the two annotated rows above except that the field declares no
// type, so its type comes from the initializer. Both emitted an EMPTY cell
// until #2807 — the descent could not cross even `Outer.inner`, a plainly
// named receiver call.
{
name: 'inferred-field',
marker: 'this.inferred.inner().compute(',
links: [OUTER_INNER, INNER_COMPUTE],
resolution: 'known-gap-empty-cell',
resolution: 'reaches-pdg',
},
{
name: 'ctor-assigned-inferred',
marker: 'this.ctorUntyped.inner().compute(',
links: [OUTER_INNER, INNER_COMPUTE],
resolution: 'known-gap-empty-cell',
resolution: 'reaches-pdg',
},
];
@ -256,7 +242,7 @@ function assertChainReachesPdg(shape: ReceiverShape): void {
expect(ids).toEqual(expect.arrayContaining([...shape.links]));
}
describe('PDG calleeIds — chained receiver calls by receiver form (known gap: #2807)', () => {
describe('PDG calleeIds — chained receiver calls by receiver form (#2802 follow-up)', () => {
beforeAll(async () => {
const dir = repos.dir();
fs.mkdirSync(path.join(dir, path.dirname(FIXTURE_PATH)));
@ -281,25 +267,28 @@ describe('PDG calleeIds — chained receiver calls by receiver form (known gap:
expect(counts).toEqual(Object.fromEntries(RECEIVER_SHAPES.map((s) => [s.name, 1])));
});
// The `known-gap-empty-cell` rows are deliberately absent here — an `it.fails`
// row over them would be strictly weaker than the exact pin below, since
// `it.fails` is satisfied by ANY throw, including `idsFor`'s own non-vacuity
// guard. Fixture drift that renamed a marker would keep it green while the
// premise had rotted.
for (const shape of RECEIVER_SHAPES.filter((s) => s.resolution === 'reaches-pdg')) {
it(`${shape.name}: every chain link's exact id reaches calleeIds`, () => {
assertChainReachesPdg(shape);
});
}
// Pins the CURRENT broken value, not merely that the chain fails: both known
// gaps emit an EMPTY cell — the first link (`Outer.inner`, a plainly named
// receiver) is gone too. This asserts the gap EXISTS (issue #2807), so closing
// #2807 turns it red BY DESIGN; update it together with the header table and
// the rows' `resolution` rather than loosening it.
it('KNOWN GAP (#2807): an inference-typed receiver empties the WHOLE calleeIds cell', () => {
const gaps = RECEIVER_SHAPES.filter((s) => s.resolution === 'known-gap-empty-cell');
const observed = Object.fromEntries(gaps.map((s) => [s.name, idsFor(s.marker)]));
expect(observed).toEqual({ 'inferred-field': [], 'ctor-assigned-inferred': [] });
// The inference-typed rows are asserted as a SET, in one assertion, on top of
// their per-row checks above: #2807's signature was that both of them emptied
// together, so a regression that reopened the gap for only one shape has to
// show up as a diff here rather than as a single quiet row failure.
it('both inference-typed receivers carry the whole chain, not just the first link', () => {
const inferred = ['inferred-field', 'ctor-assigned-inferred'] as const;
const observed = Object.fromEntries(
inferred.map((name) => {
const shape = RECEIVER_SHAPES.find((s) => s.name === name);
if (shape === undefined) throw new Error(`fixture drift: no row named ${name}`);
return [name, [...idsFor(shape.marker)].sort()];
}),
);
expect(observed).toEqual({
'inferred-field': [INNER_COMPUTE, OUTER_INNER].sort(),
'ctor-assigned-inferred': [INNER_COMPUTE, OUTER_INNER].sort(),
});
});
});

View file

@ -355,14 +355,20 @@ describe('Go structural interface dispatch', () => {
}
it('emits signature-checked structural IMPLEMENTS edges only for valid implementors', () => {
const implementsEdges = getRelationships(result, 'IMPLEMENTS').filter(
(edge) => edge.rel.reason === 'go-structural-implements',
const implementsEdges = getRelationships(result, 'IMPLEMENTS').filter((edge) =>
(edge.rel.reason ?? '').startsWith('go-structural-implements'),
);
expect(edgeSet(implementsEdges)).toEqual([
'File → ReadCloser',
'File → Reader',
'FileBase → Reader',
'MemoryRepository → Repository',
// ADDED in #2813, same deliberate reversal as the PointerOnlyThing pin
// below: `func (p *PointerOnlyThing) Touch()` puts Touch in the method
// set of *PointerOnlyThing, which is the type idiomatic Go stores in a
// PointerOnly-typed field. This exact-set assertion was the second place
// the #1966 value-only reading was encoded.
'PointerOnlyThing → PointerOnly',
'SqlRepository → Repository',
]);
expect(implementsEdges.every((edge) => edge.rel.confidence === 0.85)).toBe(true);
@ -409,9 +415,36 @@ describe('Go structural interface dispatch', () => {
expect(edgeSet(implementsEdges)).not.toContain('ShadowReadFile → ReadCloser');
});
it('does not emit value-type IMPLEMENTS for pointer-receiver-only methods', () => {
// POLARITY DELIBERATELY REVERSED in #2813 (was: `.not.toContain`).
//
// #1966 read Go's method-set rule for the VALUE type `T`, where pointer-
// receiver methods genuinely do not count. But GitNexus has one Struct node
// per type and no separate `*T` node, so that reading left `*T` — the shape
// idiomatic Go stores in an interface-typed field — unable to implement
// anything. The cost was silence, not caution: calls through such a field
// stopped at the interface declaration and `impact()` on the implementation
// reported zero callers. See the rationale block in interface-impls.ts.
it('emits IMPLEMENTS for a pointer-receiver-only implementor', () => {
const implementsEdges = getRelationships(result, 'IMPLEMENTS');
expect(edgeSet(implementsEdges)).not.toContain('PointerOnlyThing → PointerOnly');
expect(edgeSet(implementsEdges)).toContain('PointerOnlyThing → PointerOnly');
});
// The FORM is the exact fact, not a confidence hedge. `func (p *PointerOnlyThing)
// Touch()` puts Touch in MS(*PointerOnlyThing) only, so `var x PointerOnly =
// PointerOnlyThing{}` is a Go compile error while `&PointerOnlyThing{}` is fine.
// Value-satisfying implementors keep the unsuffixed reason.
it('records WHICH method set satisfies the interface', () => {
const byPair = new Map(
getRelationships(result, 'IMPLEMENTS').map((e) => [
`${e.source} → ${e.target}`,
e.rel.reason,
]),
);
expect(byPair.get('PointerOnlyThing → PointerOnly')).toBe('go-structural-implements-pointer');
// SqlRepository/MemoryRepository use VALUE receivers, so the value type
// itself implements and the reason stays unsuffixed.
expect(byPair.get('SqlRepository → Repository')).toBe('go-structural-implements');
expect(byPair.get('MemoryRepository → Repository')).toBe('go-structural-implements');
});
it('fans out embedded-interface receivers only to complete implementors', () => {
@ -446,8 +479,8 @@ describe('Go cross-package structural interface dispatch', () => {
}
it('matches local interface types against package-qualified implementation signatures', () => {
const implementsEdges = getRelationships(result, 'IMPLEMENTS').filter(
(edge) => edge.rel.reason === 'go-structural-implements',
const implementsEdges = getRelationships(result, 'IMPLEMENTS').filter((edge) =>
(edge.rel.reason ?? '').startsWith('go-structural-implements'),
);
expect(edgeSet(implementsEdges)).toEqual([
'File → ReadCloser',
@ -1794,3 +1827,203 @@ describe('Go pointer-receiver field chains (#2766)', () => {
expect(imports.map((e) => `${e.source} → ${e.target}`)).toContain('handler.go → repo.go');
});
});
// ---------------------------------------------------------------------------
// #2813: calls through an interface-typed struct field must reach the
// IMPLEMENTATION, not stop at the interface declaration.
// ---------------------------------------------------------------------------
//
// Two stacked defects produced the reported symptom, and either alone is
// enough to reproduce it — which is why the pre-existing fixtures, uniformly
// value-receiver, could not observe it:
//
// D1 `buildDetectionIndexes` skipped every POINTER-receiver method, so a
// struct whose methods are all `func (r *T)` had an empty method set,
// structurally satisfied nothing, and got no IMPLEMENTS edge. Go's rule
// is that the method set of *T includes pointer-receiver methods, and
// idiomatic Go stores *T in an interface-typed field.
// D2 Case 0 (compound receiver) emitted its primary edge and short-circuited
// without the interface-dispatch fan-out Case 4 performs. A struct-field
// receiver `s.orderRepo` contains a dot, so it always takes Case 0; a
// local or parameter receiver is a bare name and reaches Case 4.
//
// The fixture is deliberately pointer-receiver throughout, cross-package, and
// carries concrete-field controls in the same structs.
describe('Go interface-typed struct field dispatch (#2813)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-interface-field-dispatch'),
() => {},
);
}, 120000);
const calls = (): string[] => edgeSet(getRelationships(result, 'CALLS'));
const implementsEdges = (): string[] => edgeSet(getRelationships(result, 'IMPLEMENTS'));
/** CALLS rows carrying the emitting reason, so a fan-out edge is
* distinguishable from the primary edge to the interface declaration. */
const callsWithReason = (): string[] =>
getRelationships(result, 'CALLS').map((e) => `${e.source} → ${e.target}:${e.rel.reason}`);
/** CALLS rows qualified by the target's FILE — `→ DeleteItem` alone cannot
* tell the interface declaration apart from the implementation, which is
* the entire distinction under test. */
const callsToFile = (): string[] =>
getRelationships(result, 'CALLS').map(
(e) => `${e.source} → ${e.target}@${e.targetFilePath.split('/').slice(-1)[0]}`,
);
/** Both ENDS file-qualified. Required wherever two callers share a method
* name — `callsToFile()` alone cannot tell them apart, so a row asserting
* per-file behaviour must use this instead. */
const callsFromFileToFile = (): string[] =>
getRelationships(result, 'CALLS').map(
(e) =>
`${e.sourceFilePath.split('/').slice(-1)[0]}:${e.source} → ` +
`${e.target}@${e.targetFilePath.split('/').slice(-1)[0]}`,
);
/** CALLS rows carrying the emitted confidence, so a change to the literal
* is caught rather than silently accepted. */
const callsWithConfidence = (): string[] =>
getRelationships(result, 'CALLS').map(
(e) =>
`${e.source} → ${e.target}@${e.targetFilePath.split('/').slice(-1)[0]}=${e.rel.confidence}`,
);
// D1: a pointer-receiver implementor must be discoverable at all.
it('detects a pointer-receiver struct as an interface implementor', () => {
expect(implementsEdges()).toContain('OrderRepo → OrderRepository');
});
it('detects every pointer-receiver implementor, not just the first', () => {
expect(implementsEdges()).toContain('MockOrderRepo → OrderRepository');
});
// D2: the headline defect. Before the fix these were absent entirely.
it('resolves an interface-typed field call to the implementation', () => {
expect(callsToFile()).toContain('StartSession → DeleteItem@order_repo.go');
});
it('resolves an interface-typed field call from a handler to the implementation', () => {
expect(callsToFile()).toContain('Delete → DeleteItem@order_repo.go');
});
it('fans out to every implementor, not only the first', () => {
expect(callsToFile()).toContain('StartSession → DeleteItem@mock_repo.go');
});
it('emits the implementation edge with reason interface-dispatch', () => {
expect(callsWithReason()).toContain('StartSession → DeleteItem:interface-dispatch');
});
// R11-style control: the fan-out must ADD edges, never MOVE them. If the
// primary edge disappears, the fix relocated resolution instead of widening
// it and every consumer of the interface node silently loses its callers.
it('keeps the primary edge to the interface declaration', () => {
expect(callsToFile()).toContain('StartSession → DeleteItem@interfaces.go');
});
// The epistemic half of #2813, pinned at its MECHANISM.
//
// The reporter's disqualifying complaint was that `impact()` returned
// `impactedCount 0, epistemic "exact"` for a method reached only through an
// interface field — byte-identical to a symbol that genuinely has no callers,
// so a zero could not be trusted defensively. That verdict is produced by
// `computeEpistemicBoundary`, which walks HERITAGE edges out of the queried
// symbol; with no IMPLEMENTS/METHOD_IMPLEMENTS edge it found no boundary, and
// the call sites were never *dropped* (they resolved, to the wrong node), so
// neither of its two producers fired.
//
// These are the edges that make the hedge fire. Measured on this fixture
// after the fix, `impact(OrderRepo.DeleteItem, upstream)` returns
// impactedCount 3 with epistemic "lower-bound" and an interface-boundary
// note, while the concrete `CartRepo.Get` still returns "exact" — so the
// signal discriminates rather than hedging on everything. Asserting the edges
// here keeps that mechanism from silently regressing without requiring the
// resolver suite to reach into the MCP layer.
it('emits METHOD_IMPLEMENTS from the implementation to the interface method', () => {
const methodImpls = getRelationships(result, 'METHOD_IMPLEMENTS').map(
(e) =>
`${e.sourceFilePath.split('/').slice(-1)[0]}:${e.source} → ` +
`${e.targetFilePath.split('/').slice(-1)[0]}:${e.target}`,
);
expect(methodImpls).toContain('order_repo.go:DeleteItem → interfaces.go:DeleteItem');
expect(methodImpls).toContain('mock_repo.go:DeleteItem → interfaces.go:DeleteItem');
});
// Concrete-field control: resolved before #2813 and must be untouched.
it('keeps resolving a concrete-typed field to its implementation', () => {
expect(callsToFile()).toContain('GetPickQueue → LogAuditEventAsync@audit_repo.go');
});
// `AuditRepo` implements nothing, so it is never a key in the implementor
// index — this row is satisfied by that map miss alone and would still pass
// with the `ownerDef.type !== 'Interface'` gate deleted. Kept as a regression
// test for the user-visible property, NOT as a control for the gate.
it('does not fan out a concrete-typed field receiver that implements nothing', () => {
expect(callsWithReason()).not.toContain('GetPickQueue → LogAuditEventAsync:interface-dispatch');
});
// The gate's real control. `OrderRepo` IS an implementor of `OrderRepository`,
// so it IS a live participant in the dispatch index; a call through a
// *concrete* `*OrderRepo` field must still resolve only to `OrderRepo` and
// must not fan out to its sibling implementor `MockOrderRepo`. This is the
// shape where the type gate does the work, so deleting the gate fails here.
it('does not fan out a concrete field whose own type is an implementor', () => {
expect(callsToFile()).toContain('Recount → UnsplitOrder@order_repo.go');
expect(callsWithReason()).not.toContain('Recount → UnsplitOrder:interface-dispatch');
expect(callsToFile()).not.toContain('Recount → UnsplitOrder@mock_repo.go');
});
// Negative control for structural detection: a partial match is not an
// implementation. Without this, "everything implements everything" passes.
it('does not treat a partial signature match as an implementation', () => {
expect(implementsEdges()).not.toContain('OrderRepo → PartialRepository');
});
// Signature comparison is the ONLY remaining guard now that pointer-receiver
// methods are admitted, so it needs a same-name/same-arity/different-type row
// and not just the missing-method negative above.
it('does not treat a same-name same-arity method with a different signature as an implementation', () => {
expect(implementsEdges()).not.toContain('WrongSigRepo → OrderRepository');
});
// The fan-out emits at the same confidence as the primary edge it hangs off.
// Without this row the 0.85 literal can be changed with nothing failing —
// the sibling IMPLEMENTS block already pins its own confidence.
it('emits dispatch edges at the same confidence as the primary edge', () => {
expect(callsWithConfidence()).toContain('StartSession → DeleteItem@order_repo.go=0.85');
expect(callsWithConfidence()).toContain('StartSession → DeleteItem@interfaces.go=0.85');
});
// Bounds the cross product. Two implementors x the call sites below is the
// whole fan-out for this interface, so a future cap, collapse or dedup change
// becomes visible here instead of silently multiplying or truncating edges.
it('emits exactly one dispatch edge per implementor per call site', () => {
const deleteItemDispatch = getRelationships(result, 'CALLS').filter(
(e) => e.target === 'DeleteItem' && e.rel.reason === 'interface-dispatch',
);
// 3 call sites on DeleteItem (pick_service StartSession, wave_service
// Release, handlers Delete) x 2 implementors (OrderRepo, MockOrderRepo).
expect(deleteItemDispatch.length).toBe(6);
const targets = [...new Set(deleteItemDispatch.map((e) => e.targetFilePath.split('/').pop()))];
expect(targets.sort()).toEqual(['mock_repo.go', 'order_repo.go']);
});
// The issue reported these two files behaving differently at scale despite
// declaring the same field shape; both must resolve here.
//
// The SOURCE is file-qualified on purpose. Two methods in this fixture are
// named `Queue` (services/wave_service.go and handlers/picking.go), and
// `callsToFile()` qualifies only the target — so a bare `'Queue → …'` row is
// satisfied by EITHER of them and cannot detect one file resolving while the
// other does not. That per-file divergence is the exact #2813 symptom this
// row exists to catch, so it has to name both sides.
it('resolves the same field shape identically across two service files', () => {
expect(callsFromFileToFile()).toContain('wave_service.go:Release → DeleteItem@order_repo.go');
expect(callsFromFileToFile()).toContain('wave_service.go:Queue → GetPickQueue@order_repo.go');
expect(callsFromFileToFile()).toContain(
'pick_service.go:StartSession → DeleteItem@order_repo.go',
);
expect(callsFromFileToFile()).toContain('picking.go:Queue → GetPickQueue@order_repo.go');
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,10 @@
import { beforeAll, describe, expect, it } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js';
import { writeFixtureRepo } from './helpers.js';
import { cleanupTempDirSync } from '../../helpers/test-db.js';
describe('Python calls through constructor-assigned receiver fields', () => {
let result: PipelineResult;
@ -39,3 +43,289 @@ describe('Python calls through constructor-assigned receiver fields', () => {
expect(misresolved).toEqual([]);
});
});
/**
* `constructorCallTypeName` accepts a BARE-NAME callee only (#2807 review).
*
* ── WHY A DOTTED CALLEE IS REFUSED ───────────────────────────────────────────
*
* The first cut of #2807 handed a dotted callee's full text to the resolver, on
* the theory that `models.User` resolves through `QualifiedNameIndex` the way
* the module-level `u = models.User()` capture in `query.ts` does. Measured
* here, that arm never produced a correct edge and did produce wrong ones. Both
* halves of that claim have rows below, because the fix is only defensible if
* BOTH hold — refusing a shape that worked would be a regression, not a fix.
*
* The wrong edge: a dotted rawName is matched by its TRAILING segment against a
* same-named class, so `self.svc = f.Alpha()` — where `Alpha` is a METHOD on an
* unrelated object and `Alpha` is also a class — typed the field as `Alpha` and
* `self.svc.ping()` emitted a FABRICATED `Alpha.ping`. `Factory.Alpha` returns
* a `str`; there is no sense in which that field is an `Alpha`.
*
* The absent right edge: `self.u = models.User()` emitted nothing with the arm
* or without it, because an instance field's binding lands in CLASS scope,
* which never reaches the namespace split that makes the module-level LOCAL
* form resolve. That local form is pinned below and is untouched by this
* change — it comes from `query.ts`, not from `receiver-binding.ts` — which is
* what keeps "dotted construction works somewhere" true while this module
* refuses it.
*
* ── HOW TO READ A ROW ────────────────────────────────────────────────────────
*
* No row asserts an empty set. Where the correct outcome IS "this field does
* not type", the method also calls `Alien.ping()`, so the assertion is
* `{Alien.ping}` and a regression SWAPS a target in rather than emptying the
* set — an empty-set row would pass just as well if the fixture stopped
* parsing. `Alien` exists only to be that witness.
*
* ── THE DISPLACEMENT THIS ALSO CLOSED ────────────────────────────────────────
*
* `self.conn = Outer()` followed by `self.conn = Registry.get()` used to emit NO
* edge at all: both candidates sat in the weakest tier, so the later dotted one
* — which resolves to nothing — displaced the real constructor binding under
* the tier tie-break's last-write-wins. It needed no separate mechanism. Once a
* dotted callee yields no candidate, `Registry.get()` is not a candidate to
* displace with, and `Outer` survives. The tie-break is deliberately left as
* last-write-wins: between two REAL constructions the last write in `__init__`
* genuinely is the live one, so tightening it would have been the wrong fix to
* a symptom of this one.
*/
const PY_FILE = 'src/app.py';
const PY_SOURCE = `class Inner:
def compute(self, v):
return v * 2
class Outer:
def inner(self):
return Inner()
class Alpha:
def ping(self):
return 1
class Alien:
def ping(self):
return 2
class Factory:
# A METHOD whose name collides with the class \`Alpha\`. The collision is the
# entire trigger — without it a dotted callee's trailing segment matches
# nothing and the defect is invisible.
def Alpha(self):
return "not an Alpha"
def build(self):
return Outer()
class Registry:
def get(self):
return Outer()
class ParamRootCollision:
def __init__(self, f):
self.svc = f.Alpha()
def run(self):
witness = Alien()
witness.ping()
return self.svc.ping()
class ModuleRootCollision:
def __init__(self):
self.svc = shared_factory.Alpha()
def run(self):
witness = Alien()
witness.ping()
return self.svc.ping()
class PlainMethodCallControl:
def __init__(self, factory):
self.svc = factory.build()
def run(self):
witness = Alien()
witness.ping()
return self.svc.inner()
class DoubleAssign:
def __init__(self):
self.conn = Outer()
self.conn = Registry.get()
def run(self):
return self.conn.inner()
class SingleAssign:
def __init__(self):
self.conn = Outer()
def run(self):
return self.conn.inner()
shared_factory = Factory()
`;
const MODELS_FILE = 'src/models.py';
const MODELS_SOURCE = `class User:
def greet(self):
return "hi"
`;
const IMPORTS_FILE = 'src/imports_app.py';
const IMPORTS_SOURCE = `import models
from models import User as User2
class ImportedBareConstructor:
def __init__(self):
self.u = User2()
def run(self):
return self.u.greet()
def module_level_dotted_local():
u = models.User()
return u.greet()
`;
interface Row {
readonly name: string;
/** Exact node id of the method holding the statement under test. */
readonly callerId: string;
/** Every distinct CALLS target id this caller emits, sorted. */
readonly targets: readonly string[];
}
const ROWS: readonly Row[] = [
// ── S4: the fabrication, and that it is not about the root's binding form ──
//
// Pre-fix both of these also emitted `Alpha.ping`. The root is a PARAMETER in
// the first and a MODULE-LEVEL VARIABLE in the second; both fabricate, which
// is why the fix tests the callee's SHAPE and not what its root binds to.
{
name: 's4-param-rooted-dotted-callee-does-not-type-the-field',
callerId: `Method:${PY_FILE}:ParamRootCollision.run#0`,
targets: [`Method:${PY_FILE}:Alien.ping#0`],
},
{
name: 's4-module-rooted-dotted-callee-does-not-type-the-field',
callerId: `Method:${PY_FILE}:ModuleRootCollision.run#0`,
targets: [`Method:${PY_FILE}:Alien.ping#0`],
},
// The control the fix must not disturb: an ordinary `factory.build()` has a
// trailing segment that names no class, so it never fabricated and must still
// emit nothing for the field. Unchanged by the fix in both directions.
{
name: 's4-control-ordinary-method-call-still-emits-no-field-edge',
callerId: `Method:${PY_FILE}:PlainMethodCallControl.run#0`,
targets: [`Method:${PY_FILE}:Alien.ping#0`],
},
// ── S5: the displacement, closed by the same change ────────────────────────
//
// Pre-fix this row's target set was EMPTY — the one place this file asserts a
// recovered edge rather than a removed one.
{
name: 's5-later-dotted-assignment-no-longer-displaces-the-constructor',
callerId: `Method:${PY_FILE}:DoubleAssign.run#0`,
targets: [`Method:${PY_FILE}:Outer.inner#0`],
},
{
name: 's5-control-single-assignment-types-the-field',
callerId: `Method:${PY_FILE}:SingleAssign.run#0`,
targets: [`Method:${PY_FILE}:Outer.inner#0`],
},
// ── The over-tightening guards ─────────────────────────────────────────────
//
// These are the rows that go red if the bare-name arm is narrowed past the
// dotted case — a same-file construction (`SingleAssign` above) and an
// IMPORTED one, which is the shape that dies first if the rule is written
// against the callee's binding rather than its syntax.
{
name: 'bare-name-imported-constructor-still-types-the-field',
callerId: `Method:${IMPORTS_FILE}:ImportedBareConstructor.run#0`,
targets: [`Method:${MODELS_FILE}:User.greet#0`],
},
// Dotted construction still resolves where it is actually implemented: a
// module-level LOCAL, via `query.ts`'s own capture. This row is what makes
// the docblock's "re-enabling dotted callees is a resolution-side change"
// checkable — it is untouched by `receiver-binding.ts` and must stay green.
{
name: 'module-level-dotted-local-construction-is-untouched',
callerId: `Function:${IMPORTS_FILE}:module_level_dotted_local`,
targets: [`Class:${MODELS_FILE}:User`, `Method:${MODELS_FILE}:User.greet#0`],
},
];
describe('Python constructor-field receiver typing refuses a dotted callee (#2807 review)', () => {
let result: PipelineResult;
beforeAll(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-py-ctor-field-'));
try {
writeFixtureRepo(dir, {
[PY_FILE]: PY_SOURCE,
[MODELS_FILE]: MODELS_SOURCE,
[IMPORTS_FILE]: IMPORTS_SOURCE,
});
// CALLS resolution is complete before the graph phases run and nothing
// here reads what they produce.
result = await runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true });
} finally {
// Not a bare `rmSync`: a pipeline run can still hold a handle open when
// this fires, which surfaces as EBUSY/EPERM on Windows.
cleanupTempDirSync(dir);
}
}, 600_000);
function callTargets(callerId: string): string[] {
return [
...new Set(
getRelationships(result, 'CALLS')
.filter((edge) => edge.rel.sourceId === callerId)
.map((edge) => edge.rel.targetId),
),
].sort();
}
// Every row's caller must exist before any target assertion means anything:
// an id-scheme change or fixture drift would otherwise turn a row into a
// silently vacuous empty-vs-empty comparison.
it('every row has a live caller node', () => {
const found = Object.fromEntries(
ROWS.map((row) => [row.name, result.graph.getNode(row.callerId) !== undefined]),
);
expect(found).toEqual(Object.fromEntries(ROWS.map((row) => [row.name, true])));
});
// Non-vacuity, the other half: the two fabrication rows are only meaningful
// while a class named `Alpha` and a same-named METHOD both exist for the
// trailing-segment match to find. If either disappears the rows keep passing
// for the wrong reason, so assert the collision itself.
it('the name collision the fabrication needs is actually present', () => {
expect({
classAlpha: result.graph.getNode(`Class:${PY_FILE}:Alpha`) !== undefined,
methodAlpha: result.graph.getNode(`Method:${PY_FILE}:Factory.Alpha#0`) !== undefined,
}).toEqual({ classAlpha: true, methodAlpha: true });
});
for (const row of ROWS) {
it(row.name, () => {
expect(callTargets(row.callerId)).toEqual([...row.targets].sort());
});
}
});

View file

@ -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',
]);
});
});

View file

@ -0,0 +1,134 @@
/**
* #2807 follow-up — a FUNCTION-LOCAL callable must keep its own graph node when
* the def and the node disagree about the callable LABEL.
*
* Swift's structure phase emits a type's methods as `Function` nodes while the
* scope extractor derives `Method` from the `@declaration.method` anchor. Every
* key `resolveDefGraphId` builds is scoped to one label, so under that split the
* position key (#2699) missed AND the fail-closed guard beside it could never
* fire — the guard is scoped to the def's own label too, so it was unreachable
* in exactly the case the sibling-label retry below it serves. The local then
* fell through to that retry and was aliased onto the class method of the same
* name.
*
* Measured before the fix, on this fixture: the local `func helper` inside
* `Host.run` resolved to `Function:src/app.swift:Host.helper#1`, so the `sink()`
* call in the LOCAL's body was emitted as an outgoing edge of the public
* one-argument method — a caller that does not make that call, present in the
* graph, in a file whose two `helper`s do not even share an arity.
*
* The two `helper`s are deliberately given DIFFERENT arities. Arity is the
* disambiguator every name-keyed bridge lookup falls back on, so a fixture where
* they matched could pass on a lookup that still cannot tell the two apart.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { getRelationships, runPipelineFromRepo, writeFixtureRepo } from './helpers.js';
import type { PipelineResult } from './helpers.js';
import { cleanupTempDirSync } from '../../helpers/test-db.js';
import { isLanguageAvailable } from '../../../src/core/tree-sitter/parser-loader.js';
import { SupportedLanguages } from '../../../src/config/supported-languages.js';
const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift);
const FILE = 'src/app.swift';
/**
* Line/column-sensitive: the local's node id encodes its declaration position
* (`@8:8` — 0-based row 8, column 8), so reindenting or moving a line renames
* that node. The inventory test below fails loudly rather than silently
* measuring nothing if this fixture is edited.
*/
const SOURCE = `func sink(_ v: Int) -> Int { return v }
func probe(_ v: Int) -> Int { return v }
class Host {
func helper(_ a: Int) -> Int {
return probe(a)
}
func run(_ x: Int) -> Int {
func helper(_ v: Int, _ w: Int) -> Int {
return sink(v + w)
}
return helper(x, x)
}
}
`;
const METHOD_HELPER = `Function:${FILE}:Host.helper#1`;
const LOCAL_HELPER = `Function:${FILE}:Host.run.helper@8:8#2`;
const RUN = `Function:${FILE}:Host.run#1`;
const SINK = `Function:${FILE}:sink`;
const PROBE = `Function:${FILE}:probe`;
describe.skipIf(!swiftAvailable)('a function-local callable keeps its own node (#2807)', () => {
let result: PipelineResult;
beforeAll(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-label-split-'));
try {
writeFixtureRepo(dir, { [FILE]: SOURCE });
// CALLS resolution completes before the graph phases run and nothing here
// reads what they produce.
result = await runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true });
} finally {
// Not a bare `rmSync`: a pipeline run can still hold a handle open when
// this fires, which surfaces as EBUSY/EPERM on Windows — `force` does not
// suppress that — and this suite runs in the sharded Windows CI.
cleanupTempDirSync(dir);
}
}, 120000);
/** Distinct CALLS targets emitted by one exact caller id, sorted. */
const targetsFrom = (callerId: string): string[] =>
[
...new Set(
getRelationships(result, 'CALLS')
.filter((edge) => edge.rel.sourceId === callerId)
.map((edge) => edge.rel.targetId),
),
].sort();
// Non-vacuity: the whole point is that the local and the method are TWO
// nodes. If the structure phase ever stopped minting the local — or the
// fixture drifted and renamed it — every edge assertion below would degrade
// into an empty-vs-empty comparison and pass while measuring nothing.
it('mints a distinct node for the method and for the function-local', () => {
expect({
method: result.graph.getNode(METHOD_HELPER) !== undefined,
local: result.graph.getNode(LOCAL_HELPER) !== undefined,
run: result.graph.getNode(RUN) !== undefined,
}).toEqual({ method: true, local: true, run: true });
});
// The regression itself. Asserted as ONE object over both callers on purpose:
// the defect moved an edge from one to the other, so checking either side
// alone would let a fix that merely dropped the edge look correct.
it('attributes each body’s call to the callable that actually contains it', () => {
expect({
[METHOD_HELPER]: targetsFrom(METHOD_HELPER),
[LOCAL_HELPER]: targetsFrom(LOCAL_HELPER),
}).toEqual({
[METHOD_HELPER]: [PROBE],
[LOCAL_HELPER]: [SINK],
});
});
/**
* KNOWN GAP, pinned so it cannot be mistaken for part of the fix above.
*
* `helper(x, x)` inside `run` still targets the one-argument METHOD instead of
* the two-argument local. That is decided upstream of the graph bridge: the
* free-call binding hands `emitFreeCallFallback` the class-member def
* (`def:src/app.swift#5:4:Method:helper`), never the local's
* (`def:src/app.swift#9:8:Method:helper`), so no def→node mapping can correct
* it — both defs carry the same `qualifiedName` and the same label, and the
* binding walk picks the member. Recorded here rather than fixed because it
* lives in the scope walk, not in `resolveDefGraphId`.
*/
it('KNOWN GAP: the call to the local still binds to the same-named method', () => {
expect(targetsFrom(RUN)).toEqual([METHOD_HELPER]);
});
});

View file

@ -1,14 +1,12 @@
/**
* Resolver pin: a TypeScript class field whose type must be INFERRED from its
* initializer cannot act as a call receiver — the calling method emits NO CALLS
* edges at all, not even for the first, ordinary named-receiver link.
* Resolver pin: every TypeScript receiver FORM resolves a chained call, whether
* the receiver's type is declared or inferred from its initializer (#2807).
*
* ── WHERE THE SUPPORT ACTUALLY STOPS ──────────────────────────────────────────
* ── WHAT THIS FILE PINS ───────────────────────────────────────────────────────
*
* Measured against the single-file fixture below (nine receiver shapes in one
* Measured against the single-file fixture below (all receiver shapes in one
* repo). Every caller runs the same statement, `<receiver>.inner().compute(x)`;
* only the receiver FORM varies. The discriminator is whether the receiver's
* type is DECLARED, not whether it is a local or a field:
* only the receiver FORM varies:
*
* receiver form CALLS edges emitted
* ------------------------------------------------------- --------------------------
@ -19,44 +17,34 @@
* field `constructor(private p: Outer)` (param property) Outer.inner + Inner.compute
* result `makeOuter().inner().compute()` makeOuter + both links
* chain `o.inner().mid().compute()` (three links) all three links
* field `private p = new Outer()` (INFERRED) NONE <- known gap
* field `private p;` + ctor `this.p = new Outer()` NONE <- known gap
* field `private p = new Outer()` (INFERRED) Outer.inner + Inner.compute
* field `private p;` + ctor `this.p = new Outer()` Outer.inner + Inner.compute
* field `private p;` + method `this.p = new Outer()` Outer.inner + Inner.compute
*
* The two gap rows do not merely lose the CHAINED link. They emit nothing: the
* caller has no outgoing CALLS edge whatsoever, so `Outer.inner` — a plainly
* named receiver call — is lost too. `KNOWN GAP` below asserts that empty value
* EXACTLY, alongside a `callerExists` probe so the assertion cannot pass
* vacuously if fixture drift or an id-scheme change moved the caller node.
* ── WHY THE LAST THREE ROWS ARE HERE (#2807) ──────────────────────────────────
*
* `only the receiver TYPE is lost` narrows where to look: the `new Outer()`
* initializer of an inferred field IS resolved (it emits its own constructor
* CALLS edge, exactly as the annotated twin does). What is missing is the step
* that turns that initializer into a type binding for the field.
* They used to emit NOTHING — not a partial chain, no outgoing CALLS edge at
* all, so even `Outer.inner`, a plainly named receiver call, was lost. The
* discriminator was whether the field DECLARED its type: an untyped field had
* no entry in its class scope's `typeBindings`, so `typeOfMemberOnClass` came
* back empty and `foldReceiverChain` declined at the very first step.
*
* The annotated twins are pinned in the same file on purpose — a gap test that
* shows only the broken shape does not tell the next engineer where the
* boundary is.
* The `new Outer()` initializer was never the problem — it always emitted its
* own constructor edge, exactly as the annotated twin does (still asserted
* below). What was missing was the step turning that initializer into a TYPE
* BINDING for the field, i.e. a `@type-binding.constructor` capture pattern
* anchored on `public_field_definition` and on `this.<field> = new …`.
*
* ── THIS PIN IS SELF-DIFFING: IT WILL GO RED ON PURPOSE ───────────────────────
* The annotated twins stay pinned alongside on purpose: they are what proves a
* regression would be a regression, and one of them —
* `AnnotationBeatsInitializerCaller` — deliberately mistypes its annotation so
* that the annotation-over-initializer source-strength tie-break is asserted
* executably rather than assumed.
*
* The gap is tracked as issue #2807 ("Inference-typed field receivers resolve to
* no CALLS edges at all"); PR #2810 is open against it at the time of writing.
* `KNOWN GAP` asserts that the gap EXISTS — no CALLS edges, exactly — so it is a
* record rather than a regression guard. When the resolver learns to infer a
* field's type from its initializer it fails with the newly resolved ids in the
* diff; that is the intended signal. The fix is to update this file (the table
* above, the rows' `resolution`, and the pin's expected value), not to relax the
* assertion into something a passing fix would also satisfy.
*
* The gap was FOUND during #2802 work but is PRE-EXISTING and independent of it:
* nothing on that branch touches receiver typing. Track the gap itself at #2807.
*
* The same fact is also observable one layer down, as an empty
* `BasicBlock.calleeIds` cell, in `test/integration/cfg/
* pdg-chained-receiver-callees.test.ts` — but that is the PDG's view of a
* RESOLVER fact, behind a full `--pdg` pipeline. Whoever closes this gap will
* be working in the resolver suite, so the fact is pinned here too, at the
* level the fix actually changes.
* The same fact is observable one layer down as a `BasicBlock.calleeIds` cell
* in `test/integration/cfg/pdg-chained-receiver-callees.test.ts` — that is the
* PDG's view of this RESOLVER fact, behind a full `--pdg` pipeline. Keep the
* two files in step: whoever changes receiver typing changes both.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import path from 'node:path';
@ -169,6 +157,37 @@ export class CtorAssignedInferredCaller {
return r;
}
}
export class MethodAssignedInferredCaller {
private lateBound;
setUp(): void {
this.lateBound = new Outer();
}
runMethodAssignedInferred(x: number): number {
const r = this.lateBound.inner().compute(x);
return r;
}
}
// Deliberately mistyped: the annotation says \`Mismatch\`, the initializer
// constructs an \`Outer\`. TypeScript would reject it; the resolver must still
// prefer the ANNOTATION, because \`annotation\` outranks \`constructor-inferred\`
// in \`typeBindingStrength\`. \`Mismatch\` has no \`inner\`, so a resolver that let
// the initializer win would emit \`Outer.inner\` here — the one row in this file
// that fails if the source-strength tie-break regresses.
export class Mismatch {
notInner(): number {
return 0;
}
}
export class AnnotationBeatsInitializerCaller {
private mistyped: Mismatch = new Outer();
runAnnotationBeatsInitializer(x: number): number {
const r = this.mistyped.inner().compute(x);
return r;
}
}
`;
// EXACT node ids — never names or substrings. `compute` alone is ambiguous
@ -182,15 +201,13 @@ const INNER_MID = `Method:${FIXTURE_PATH}:Inner.mid#0`;
const MID_COMPUTE = `Method:${FIXTURE_PATH}:Mid.compute#1`;
const MAKE_OUTER = `Function:${FIXTURE_PATH}:makeOuter`;
/**
* `resolves` — every chain link becomes a CALLS edge today.
* `known-gap-no-calls-edges` — the resolver cannot type the receiver, so the
* caller emits no CALLS edge at all and even the named first link is lost.
*/
type ChainResolution = 'resolves' | 'known-gap-no-calls-edges';
/** Every chain link becomes a CALLS edge. The only value today — the
* inference-typed rows joined it in #2807 — but kept as a named type so a
* future gap row has somewhere to say so instead of being a bare boolean. */
type ChainResolution = 'resolves';
interface ReceiverShape {
/** Row name; also the key of the known-gap pin below. */
/** Row name; also the assertion key in the diff when a row moves. */
readonly name: string;
/** Exact node id of the function or method holding the chained statement. */
readonly callerId: string;
@ -242,25 +259,35 @@ const RECEIVER_SHAPES: readonly ReceiverShape[] = [
targets: [OUTER_CLASS, OUTER_INNER, INNER_MID, MID_COMPUTE],
resolution: 'resolves',
},
// ── Known gaps ────────────────────────────────────────────────────────────
// ── Inference-typed fields (#2807) ────────────────────────────────────────
// Identical to `annotated-field-initializer` / `ctor-assigned-annotated`
// above except that the field carries no type annotation, so its type would
// have to be inferred from the initializer.
// above except that the field carries no type annotation, so its type is
// inferred from the initializer. These two emitted NOTHING before #2807 —
// not even the first, plainly named link — because an untyped field had no
// type binding for the receiver fold to stand on.
{
name: 'inferred-field-initializer',
callerId: `Method:${FIXTURE_PATH}:InferredFieldCaller.runInferredField#1`,
targets: [],
resolution: 'known-gap-no-calls-edges',
targets: [OUTER_INNER, INNER_COMPUTE],
resolution: 'resolves',
},
{
name: 'ctor-assigned-inferred',
callerId: `Method:${FIXTURE_PATH}:CtorAssignedInferredCaller.runCtorAssignedInferred#1`,
targets: [],
resolution: 'known-gap-no-calls-edges',
targets: [OUTER_INNER, INNER_COMPUTE],
resolution: 'resolves',
},
// The assignment that types the field need not be in the constructor — the
// capture matches any `this.<field> = new …`, so a setter binds it too.
{
name: 'method-assigned-inferred',
callerId: `Method:${FIXTURE_PATH}:MethodAssignedInferredCaller.runMethodAssignedInferred#1`,
targets: [OUTER_INNER, INNER_COMPUTE],
resolution: 'resolves',
},
];
describe('TypeScript chained receiver calls by field-type form (known gap: #2807)', () => {
describe('TypeScript chained receiver calls by field-type form (#2807)', () => {
let result: PipelineResult;
let repoDir: string | undefined;
@ -304,26 +331,33 @@ describe('TypeScript chained receiver calls by field-type form (known gap: #2807
});
}
// Pins the CURRENT broken value, not merely that the chain fails. An
// `it.fails` row here would be strictly weaker — it is satisfied by ANY
// throw, so a renamed fixture symbol would keep it green on a rotted
// premise. `callerExists` is folded into the same object so an empty
// `calls` list can never be read as "resolved fine, wrong node id".
// This asserts the gap EXISTS (issue #2807), so closing #2807 turns it red
// BY DESIGN; update it together with the header table and the rows'
// `resolution` rather than loosening it.
it('KNOWN GAP (#2807): an inference-typed field receiver emits NO CALLS edges at all', () => {
const gaps = RECEIVER_SHAPES.filter((s) => s.resolution === 'known-gap-no-calls-edges');
const observed = Object.fromEntries(
gaps.map((s) => [
s.name,
{ callerExists: nodeExists(s.callerId), calls: callTargetsFrom(s.callerId) },
]),
);
expect(observed).toEqual({
'inferred-field-initializer': { callerExists: true, calls: [] },
'ctor-assigned-inferred': { callerExists: true, calls: [] },
// The source-strength tie-break, as an executable row rather than a comment.
// `private mistyped: Mismatch = new Outer()` matches BOTH the annotation
// pattern and the field constructor-inferred pattern added for #2807;
// `annotation` outranks `constructor-inferred` in `typeBindingStrength`, so
// the field must stay typed as `Mismatch` — which has no `inner` — and the
// caller must emit NO call edge. If the inferred binding ever wins instead,
// this is the only row in the file that notices: every other row would keep
// resolving, because for them the two sources agree.
//
// The load-bearing half of the assertion is the ABSENCE of `Outer.inner`:
// that is the edge a resolver would emit if the initializer had won.
//
// `Inner.compute` IS present, and deliberately pinned rather than filtered
// out. It does not come from the field at all — `Mismatch` has no `inner`, so
// the fold falls through to the hoisted branch in `typeOfMemberOnClass`,
// finds the module-level return-type binding `inner -> Inner` that
// `hoistTypeBindingsToModule` puts there, and types the NEXT position from
// it. Verified byte-identical on the pre-#2807 tree (same fixture, same
// single id), so it is a pre-existing property of the hoisted lookup and not
// something the field-initializer patterns introduced. Pinning the exact list
// rather than asserting "no Outer.inner" means a future change to either
// mechanism has to come through this row.
it('an annotated field beats its own initializer — the tie-break is by source strength', () => {
const callerId = `Method:${FIXTURE_PATH}:AnnotationBeatsInitializerCaller.runAnnotationBeatsInitializer#1`;
expect({ callerExists: nodeExists(callerId), calls: callTargetsFrom(callerId) }).toEqual({
callerExists: true,
calls: [INNER_COMPUTE],
});
});

View file

@ -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']);
});
});

View file

@ -3,7 +3,7 @@
*
* The unit tests (api-analyze-token.test.ts) cover validateAnalyzeToken in
* isolation; this proves the REAL production route actually wires it in —
* express.json body parsing, the requireLocalhostOrigin guard, the route
* express.json body parsing, the requireTrustedOrigin guard, the route
* handler invoking the validator, and the 400 status/error shape on the wire.
* Closes the gap the PR #2223 tri-review noted: "the route validation is
* otherwise only reachable by booting the server."

View file

@ -86,7 +86,21 @@ describeServeStartup('gitnexus serve HTTP startup (Express 5)', () => {
}
});
it('serve boots and GET /api/health returns ok', async () => {
/**
* Spawn the built CLI's `serve` on an ephemeral port with a throwaway
* GITNEXUS_HOME, registering both for afterEach cleanup. The streams are
* captured separately so a failure message can attribute output to the right
* one, and returned as getters because they fill in after this resolves.
*/
const spawnServe = async (
host: string,
extraEnv: Record<string, string> = {},
): Promise<{
child: ChildProcessWithoutNullStreams;
port: number;
stdout: () => string;
stderr: () => string;
}> => {
if (!fs.existsSync(DIST_CLI)) {
throw new Error(`Missing ${DIST_CLI} — run npm run build before integration tests`);
}
@ -94,34 +108,40 @@ describeServeStartup('gitnexus serve HTTP startup (Express 5)', () => {
const port = await allocateFreePort();
homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-serve-home-'));
proc = spawn(
const child = spawn(
process.execPath,
[DIST_CLI, 'serve', '--port', String(port), '--host', '127.0.0.1'],
[DIST_CLI, 'serve', '--port', String(port), '--host', host],
{
cwd: REPO_ROOT,
env: { ...process.env, GITNEXUS_HOME: homeDir, NODE_OPTIONS: '' },
env: { ...process.env, GITNEXUS_HOME: homeDir, NODE_OPTIONS: '', ...extraEnv },
stdio: ['ignore', 'pipe', 'pipe'],
},
);
proc = child;
let stdout = '';
let stderr = '';
proc.stdout.on('data', (buf) => {
child.stdout.on('data', (buf) => {
stdout += buf.toString();
});
proc.stderr.on('data', (buf) => {
child.stderr.on('data', (buf) => {
stderr += buf.toString();
});
return { child, port, stdout: () => stdout, stderr: () => stderr };
};
it('serve boots and GET /api/health returns ok', async () => {
const { child, port, stdout, stderr } = await spawnServe('127.0.0.1');
const startedAt = Date.now();
let status = 0;
let body = '';
while (Date.now() - startedAt < STARTUP_BUDGET_MS) {
if (proc.exitCode !== null) {
if (child.exitCode !== null) {
throw new Error(
`serve exited ${proc.exitCode} before ready.\nstdout:\n${stdout}\nstderr:\n${stderr}`,
`serve exited ${child.exitCode} before ready.\nstdout:\n${stdout()}\nstderr:\n${stderr()}`,
);
}
try {
@ -138,4 +158,40 @@ describeServeStartup('gitnexus serve HTTP startup (Express 5)', () => {
expect(status).toBe(200);
expect(body).toContain('"status":"ok"');
}, 60_000);
// GITNEXUS_PUBLIC_ORIGIN is what makes a public bind usable, and `serve` has
// no authentication — so it must not boot at all. Asserted end-to-end rather
// than at the unit level, because what matters is the exit code the operator
// (or a platform health check) sees, not that a function threw.
it('serve refuses to start when GITNEXUS_PUBLIC_ORIGIN is set', async () => {
const { child, port, stdout, stderr } = await spawnServe('0.0.0.0', {
GITNEXUS_PUBLIC_ORIGIN: 'https://gitnexus.example.com',
});
const exitCode = await new Promise<number | null>((resolve, reject) => {
const timer = setTimeout(
() =>
reject(
new Error(
`serve did not exit within budget.\nstdout:\n${stdout()}\nstderr:\n${stderr()}`,
),
),
STARTUP_BUDGET_MS,
);
child.on('exit', (code) => {
clearTimeout(timer);
resolve(code);
});
child.on('error', reject);
});
expect(exitCode).toBe(1);
// The CLI prints the failure through cliError, so it lands on stderr; assert
// against both streams rather than pinning which one.
const output = stdout() + stderr();
expect(output).toContain('GITNEXUS_PUBLIC_ORIGIN');
expect(output).toContain('no authentication');
// And it never got as far as listening.
await expect(probeHealth(port)).rejects.toThrow();
}, 60_000);
});

View file

@ -1,10 +1,21 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import path from 'node:path';
import fs from 'node:fs/promises';
import { Readable } from 'node:stream';
import type { IncomingMessage } from 'node:http';
import { createAnalyzeUploadHandler } from '../../src/server/analyze-upload.js';
import { requireLocalhostOrigin, createLocalhostOriginGuard } from '../../src/server/middleware.js';
import { PUBLIC_ORIGIN_ENV, createWriteOriginGuard } from '../../src/server/middleware.js';
// The guard admits GITNEXUS_PUBLIC_ORIGIN as well as loopback, and the
// rejection cases below assume none is configured. Clear the developer's
// ambient value for the file rather than inheriting it.
const ambientPublicOrigin = process.env[PUBLIC_ORIGIN_ENV];
beforeAll(() => {
delete process.env[PUBLIC_ORIGIN_ENV];
});
afterAll(() => {
if (ambientPublicOrigin !== undefined) process.env[PUBLIC_ORIGIN_ENV] = ambientPublicOrigin;
});
const BOUNDARY = '----gitnexusuploadtest';
@ -258,7 +269,7 @@ describe('createAnalyzeUploadHandler', () => {
});
});
describe('requireLocalhostOrigin', () => {
describe('createWriteOriginGuard (no bound host)', () => {
function call(origin: string | undefined): { passed: boolean; status: number } {
let passed = false;
let status = 0;
@ -269,7 +280,7 @@ describe('requireLocalhostOrigin', () => {
return { json: () => {} };
},
} as never;
requireLocalhostOrigin(req, res, () => {
createWriteOriginGuard()(req, res, () => {
passed = true;
});
return { passed, status };
@ -288,7 +299,7 @@ describe('requireLocalhostOrigin', () => {
expect(r.status).toBe(403);
});
it('rejects RFC1918 origins when no boundHost is set (default guard)', () => {
it('rejects RFC1918 origins when no boundHost is set', () => {
expect(call('http://10.0.0.1:4173').passed).toBe(false);
expect(call('http://172.16.1.21:4173').passed).toBe(false);
expect(call('http://192.168.1.100:4173').passed).toBe(false);
@ -301,12 +312,12 @@ describe('requireLocalhostOrigin', () => {
});
});
describe('createLocalhostOriginGuard (bound host)', () => {
describe('createWriteOriginGuard (bound host)', () => {
function callWith(
boundHost: string,
origin: string | undefined,
): { passed: boolean; status: number; body?: { error?: string; code?: string } } {
const guard = createLocalhostOriginGuard(boundHost);
const guard = createWriteOriginGuard(boundHost);
let passed = false;
let status = 0;
let body: { error?: string; code?: string } | undefined;

View file

@ -13,8 +13,20 @@
* - https://gitnexus.vercel.app → allowed
* - Everything else → rejected
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, afterAll, afterEach, beforeAll } from 'vitest';
import { isAllowedOrigin } from '../../src/server/api.js';
import { createPublicOriginMatcher } from '../../src/server/middleware.js';
// isAllowedOrigin consults GITNEXUS_PUBLIC_ORIGIN, so every expectation below
// assumes it is unset unless the test sets it. Clear the developer's ambient
// value for the file rather than inheriting it.
const ambientPublicOrigin = process.env.GITNEXUS_PUBLIC_ORIGIN;
beforeAll(() => {
delete process.env.GITNEXUS_PUBLIC_ORIGIN;
});
afterAll(() => {
if (ambientPublicOrigin !== undefined) process.env.GITNEXUS_PUBLIC_ORIGIN = ambientPublicOrigin;
});
// ─── No origin (non-browser / curl) ──────────────────────────────────
@ -181,3 +193,147 @@ describe('isAllowedOrigin: rejected origins', () => {
expect(isAllowedOrigin('http://172.16.5.1:3000')).toBe(true);
});
});
describe('isAllowedOrigin: GITNEXUS_PUBLIC_ORIGIN', () => {
// Back to the file's cleared baseline, not to the ambient value afterAll
// restores — the tests above assume it stays unset.
afterEach(() => {
delete process.env.GITNEXUS_PUBLIC_ORIGIN;
});
it('allows the configured origin as a full URL or a bare host', () => {
process.env.GITNEXUS_PUBLIC_ORIGIN = 'https://app.example.com';
expect(isAllowedOrigin('https://app.example.com')).toBe(true);
process.env.GITNEXUS_PUBLIC_ORIGIN = 'app.example.com';
expect(isAllowedOrigin('https://app.example.com')).toBe(true);
});
it('enforces the port when the configured value carries one', () => {
process.env.GITNEXUS_PUBLIC_ORIGIN = 'https://app.example.com:8443';
expect(isAllowedOrigin('https://app.example.com:8443')).toBe(true);
expect(isAllowedOrigin('https://app.example.com:9999')).toBe(false);
// The browser elides the default port, so a bare origin is port 443 here.
expect(isAllowedOrigin('https://app.example.com')).toBe(false);
});
it('accepts any port when the configured value carries none', () => {
process.env.GITNEXUS_PUBLIC_ORIGIN = 'app.example.com';
expect(isAllowedOrigin('https://app.example.com')).toBe(true);
expect(isAllowedOrigin('https://app.example.com:8443')).toBe(true);
expect(isAllowedOrigin('https://app.example.com:9999')).toBe(true);
});
it('matches a configured default port against an origin that elides it', () => {
process.env.GITNEXUS_PUBLIC_ORIGIN = 'https://app.example.com:443';
expect(isAllowedOrigin('https://app.example.com')).toBe(true);
expect(isAllowedOrigin('https://app.example.com:8443')).toBe(false);
});
it('handles a bracketed IPv6 literal with a port, with and without a scheme', () => {
// No scheme means https, so the http probes below are scheme mismatches.
process.env.GITNEXUS_PUBLIC_ORIGIN = '[2001:db8::1]:8080';
expect(isAllowedOrigin('https://[2001:db8::1]:8080')).toBe(true);
expect(isAllowedOrigin('https://[2001:db8::1]:9090')).toBe(false);
expect(isAllowedOrigin('http://[2001:db8::1]:8080')).toBe(false);
process.env.GITNEXUS_PUBLIC_ORIGIN = 'http://[2001:db8::1]:8080';
expect(isAllowedOrigin('http://[2001:db8::1]:8080')).toBe(true);
expect(isAllowedOrigin('https://[2001:db8::1]:8080')).toBe(false);
});
it('accepts any port on a bare IPv6 literal, which carries none', () => {
process.env.GITNEXUS_PUBLIC_ORIGIN = '[2001:db8::1]';
expect(isAllowedOrigin('https://[2001:db8::1]:4173')).toBe(true);
// Non-canonical forms compress to the same hostname a browser Origin has.
process.env.GITNEXUS_PUBLIC_ORIGIN = '2001:db8:0:0:0:0:0:1';
expect(isAllowedOrigin('https://[2001:db8::1]:4173')).toBe(true);
});
it('does not widen to other hosts', () => {
process.env.GITNEXUS_PUBLIC_ORIGIN = 'app.example.com';
expect(isAllowedOrigin('https://evil.example.com')).toBe(false);
expect(isAllowedOrigin('https://app.example.com.evil.com')).toBe(false);
});
it('enforces the scheme when the configured value carries one', () => {
process.env.GITNEXUS_PUBLIC_ORIGIN = 'https://app.example.com';
expect(isAllowedOrigin('http://app.example.com')).toBe(false);
});
// A bare host is the platform service-discovery form, and those terminate
// TLS — so it means https, not either scheme. Accepting either would make it
// an http downgrade path into the read allowlist and the write guard alike.
it('reads a bare host as https, not as either scheme', () => {
process.env.GITNEXUS_PUBLIC_ORIGIN = 'app.example.com';
expect(isAllowedOrigin('https://app.example.com')).toBe(true);
expect(isAllowedOrigin('http://app.example.com')).toBe(false);
});
it('accepts http on a bare host only when http:// is spelled out', () => {
process.env.GITNEXUS_PUBLIC_ORIGIN = 'http://app.example.com';
expect(isAllowedOrigin('http://app.example.com')).toBe(true);
expect(isAllowedOrigin('https://app.example.com')).toBe(false);
});
it('still rejects non-http protocols on the configured host', () => {
process.env.GITNEXUS_PUBLIC_ORIGIN = 'app.example.com';
expect(isAllowedOrigin('ftp://app.example.com')).toBe(false);
});
it('is inert when unset', () => {
delete process.env.GITNEXUS_PUBLIC_ORIGIN;
expect(isAllowedOrigin('https://app.example.com')).toBe(false);
});
});
// A value that yields a matcher no real Origin can satisfy is worse than no
// value at all: it reads as configured, so the wildcard-bind warning in
// createServer reports a working origin where there is none.
describe('createPublicOriginMatcher: values that are not one reachable host', () => {
it.each([
['', 'empty'],
[' ', 'whitespace only'],
['*', 'a wildcard'],
['a.com,b.com', 'a comma-separated list'],
['a.com;b.com', 'a semicolon-separated list'],
['8080', 'a bare port number — new URL reads it as the integer IP 0.0.31.144'],
['under score', 'an embedded space'],
['a.com:99999', 'a port out of range'],
['a.com:0', 'port 0, which parses but which no browser ever sends'],
['https://a.com:00', 'port 00, which normalizes to 0 rather than to a default'],
['ftp://a.com', 'a non-http scheme'],
['https://a.com/ui', 'a path, which an Origin never has'],
['0.0.0.0', 'a wildcard bind, which has no host identity'],
['a.com.', 'a trailing dot — a legal FQDN, but not what a browser sends'],
['https://a.com.:8443', 'a trailing dot with a scheme and a port'],
])('returns undefined for %j (%s)', (raw) => {
expect(createPublicOriginMatcher(raw)).toBeUndefined();
});
it('returns undefined when the env var is unset', () => {
expect(createPublicOriginMatcher(undefined)).toBeUndefined();
});
});
describe('createPublicOriginMatcher: values that resolve to one host', () => {
it('tolerates the trailing slash a pasted URL carries', () => {
const matcher = createPublicOriginMatcher('https://app.example.com/');
expect(matcher?.hostname).toBe('app.example.com');
expect(matcher?.matches(new URL('https://app.example.com'))).toBe(true);
});
// Guards the port-0 rejection above from over-reaching: a leading zero on a
// real port normalizes to that port, not to 0.
it('reads a leading-zero port as the port it normalizes to', () => {
const matcher = createPublicOriginMatcher('http://a.com:0080');
expect(matcher?.matches(new URL('http://a.com'))).toBe(true);
expect(matcher?.matches(new URL('http://a.com:80'))).toBe(true);
expect(matcher?.matches(new URL('http://a.com:8080'))).toBe(false);
});
it('reports the hostname it resolved, for the startup log line', () => {
expect(createPublicOriginMatcher('https://App.Example.com:8443')?.hostname).toBe(
'app.example.com',
);
expect(createPublicOriginMatcher('2001:db8:0:0:0:0:0:1')?.hostname).toBe('[2001:db8::1]');
});
});

View file

@ -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 });
}
});
});

View file

@ -106,7 +106,10 @@ describe('PARSE_CACHE_VERSION', () => {
// conditional-directive parse-semantics change (#2771), 38 -> 39 for
// receiver-chain wire format v2: every persisted chain string changed prefix
// and a v2 decoder refuses v1 by design, so a stale cache replays chains this
// build silently discards.
// build silently discards. 39 -> 40 for inference-typed field captures in six
// languages (#2807) — all parse-time emission, so a warm cache replays the
// pre-fix capture set for byte-unchanged files and the new receiver edges
// never appear.
//
// This pin has now earned its keep EIGHT times, and twice it caught an EXACT
// clash rather than a near-miss: main took 37 for #2416 while this branch
@ -115,8 +118,13 @@ describe('PARSE_CACHE_VERSION', () => {
// second clash was caught — after review, while the branch sat waiting to
// merge — which is precisely the window in which `main` allocates. Re-check
// against origin/main immediately before merge, not at review time.
it('pins SCHEMA_BUMP to 39 so concurrent bumps cannot silently collide (#2766)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(39);
// 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.
// 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)', () => {

View file

@ -0,0 +1,294 @@
/**
* Unit Tests: the write-route origin guard's port awareness, and what it
* reports at startup.
*
* cors.test.ts covers the read side (isAllowedOrigin) of the same matcher.
* This file covers the write side — createWriteOriginGuard — where a
* mismatch is a 403 rather than a missing CORS header, plus the port-aware
* bound-host comparison and logOriginPolicy's startup diagnostics.
*/
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import {
PUBLIC_ORIGIN_ENV,
assertServeAuthForPublicOrigin,
createWriteOriginGuard,
isServeAuthConfigured,
logOriginPolicy,
} from '../../src/server/middleware.js';
import { _captureLogger, type LoggerCapture } from '../../src/core/logger.js';
// The bound-host cases never set the var, so clear the developer's ambient
// value for the file and return each test to that cleared baseline.
const ambientPublicOrigin = process.env[PUBLIC_ORIGIN_ENV];
beforeAll(() => {
delete process.env[PUBLIC_ORIGIN_ENV];
});
afterAll(() => {
if (ambientPublicOrigin !== undefined) process.env[PUBLIC_ORIGIN_ENV] = ambientPublicOrigin;
});
afterEach(() => {
delete process.env[PUBLIC_ORIGIN_ENV];
});
function setPublicOrigin(value: string | undefined): void {
if (value === undefined) delete process.env[PUBLIC_ORIGIN_ENV];
else process.env[PUBLIC_ORIGIN_ENV] = value;
}
interface GuardResult {
passed: boolean;
status: number;
body?: { error?: string; code?: string };
}
// The guard snapshots the env var at construction, so callers set it first.
function callGuard(
boundHost: string | undefined,
boundPort: number | undefined,
origin: string,
): GuardResult {
const guard = createWriteOriginGuard(boundHost, boundPort);
let passed = false;
let status = 0;
let body: { error?: string; code?: string } | undefined;
const req = { headers: { origin } } as never;
const res = {
status: (c: number) => {
status = c;
return {
json: (b: { error?: string; code?: string }) => {
body = b;
},
};
},
} as never;
guard(req, res, () => {
passed = true;
});
return { passed, status, body };
}
describe('createWriteOriginGuard — bound host is matched on its port', () => {
it('admits the bound host on the bound port', () => {
expect(callGuard('192.168.1.10', 8443, 'http://192.168.1.10:8443').passed).toBe(true);
});
it('rejects the bound host on a different port with origin_not_allowed', () => {
const res = callGuard('192.168.1.10', 8443, 'http://192.168.1.10:9999');
expect(res.passed).toBe(false);
expect(res.status).toBe(403);
expect(res.body?.code).toBe('origin_not_allowed');
});
it('rejects the bound host on the default port when it is not the bound one', () => {
// The pre-existing bug: `hostname === normalizedBoundHost` matched here.
expect(callGuard('192.168.1.10', 8443, 'http://192.168.1.10').passed).toBe(false);
expect(callGuard('192.168.1.10', 8443, 'https://192.168.1.10').passed).toBe(false);
});
it('treats an elided default port as that port', () => {
expect(callGuard('192.168.1.10', 80, 'http://192.168.1.10').passed).toBe(true);
expect(callGuard('192.168.1.10', 443, 'https://192.168.1.10').passed).toBe(true);
expect(callGuard('192.168.1.10', 80, 'https://192.168.1.10').passed).toBe(false);
});
it('matches any port when no bound port is given', () => {
expect(callGuard('192.168.1.10', undefined, 'http://192.168.1.10:9999').passed).toBe(true);
});
it('keeps loopback port-agnostic — the dev UI runs on its own port', () => {
expect(callGuard('192.168.1.10', 8443, 'http://localhost:5173').passed).toBe(true);
expect(callGuard('192.168.1.10', 8443, 'http://127.0.0.1:4173').passed).toBe(true);
expect(callGuard('192.168.1.10', 8443, 'http://[::1]:4173').passed).toBe(true);
});
});
describe('createWriteOriginGuard — public origin is matched on its port', () => {
it('admits an exact scheme/host/port match on a wildcard bind', () => {
setPublicOrigin('https://app.example.com:8443');
expect(callGuard('0.0.0.0', 3000, 'https://app.example.com:8443').passed).toBe(true);
});
it('rejects a port mismatch with a 403 and origin_not_allowed', () => {
setPublicOrigin('https://app.example.com:8443');
const res = callGuard('0.0.0.0', 3000, 'https://app.example.com:9999');
expect(res.passed).toBe(false);
expect(res.status).toBe(403);
expect(res.body?.code).toBe('origin_not_allowed');
});
it('rejects a scheme mismatch when the configured value carries a scheme', () => {
setPublicOrigin('https://app.example.com');
expect(callGuard('0.0.0.0', 3000, 'http://app.example.com').passed).toBe(false);
});
// A bare host is permissive on the port but NOT on the scheme: it defaults to
// https, so `app.example.com` is not an http downgrade path into the write
// routes. Plain http needs the explicit form.
it('admits any port for a bare configured host, but only over https', () => {
setPublicOrigin('app.example.com');
expect(callGuard('0.0.0.0', 3000, 'https://app.example.com:9999').passed).toBe(true);
expect(callGuard('0.0.0.0', 3000, 'https://app.example.com').passed).toBe(true);
expect(callGuard('0.0.0.0', 3000, 'http://app.example.com').passed).toBe(false);
expect(callGuard('0.0.0.0', 3000, 'http://app.example.com:9999').passed).toBe(false);
});
it('admits http for a bare host only when http:// is spelled out', () => {
setPublicOrigin('http://app.example.com');
expect(callGuard('0.0.0.0', 3000, 'http://app.example.com:9999').passed).toBe(true);
expect(callGuard('0.0.0.0', 3000, 'https://app.example.com').passed).toBe(false);
});
it('admits a bracketed IPv6 literal, on any port when configured without one', () => {
setPublicOrigin('[2001:db8::1]');
expect(callGuard('0.0.0.0', 3000, 'https://[2001:db8::1]:4173').passed).toBe(true);
setPublicOrigin('https://[2001:db8::1]:8080');
expect(callGuard('0.0.0.0', 3000, 'https://[2001:db8::1]:8080').passed).toBe(true);
expect(callGuard('0.0.0.0', 3000, 'https://[2001:db8::1]:4173').passed).toBe(false);
});
// A trailing dot is a legal FQDN that survives `new URL` as `example.com.`,
// but a browser sends `example.com` — so it built a matcher nothing could
// satisfy while logOriginPolicy reported it as working.
it('admits nothing for a trailing-dot hostname', () => {
setPublicOrigin('app.example.com.');
expect(callGuard('0.0.0.0', 3000, 'https://app.example.com').passed).toBe(false);
expect(callGuard('0.0.0.0', 3000, 'https://app.example.com.').passed).toBe(false);
});
it('admits nothing extra when the configured value is not one reachable host', () => {
setPublicOrigin('a.com,b.com');
expect(callGuard('0.0.0.0', 3000, 'https://a.com').passed).toBe(false);
expect(callGuard('0.0.0.0', 3000, 'https://b.com').passed).toBe(false);
});
it('leaves the Origin-less passthrough alone — the CLI sends no Origin', () => {
setPublicOrigin('app.example.com');
const guard = createWriteOriginGuard('0.0.0.0', 3000);
let passed = false;
guard({ headers: {} } as never, {} as never, () => {
passed = true;
});
expect(passed).toBe(true);
});
});
describe('logOriginPolicy', () => {
let cap: LoggerCapture;
beforeEach(() => {
cap = _captureLogger();
});
afterEach(() => {
cap.restore();
});
const infos = () => cap.records().filter((r) => r.level === 30);
const warns = () => cap.records().filter((r) => r.level === 40);
it('says nothing on a specific bind with no public origin', () => {
setPublicOrigin(undefined);
logOriginPolicy('192.168.1.10');
expect(cap.records()).toEqual([]);
});
it('names the resolved hostname at info when a public origin is configured', () => {
setPublicOrigin('https://App.Example.com:8443');
logOriginPolicy('192.168.1.10');
expect(infos()).toHaveLength(1);
expect(String(infos()[0].msg)).toContain('app.example.com');
expect(infos()[0].hostname).toBe('app.example.com');
expect(warns()).toEqual([]);
});
// The four-way matrix: wildcard bind × public origin absent/valid/invalid.
it('warns on a wildcard bind with no public origin, pointing at both remedies', () => {
setPublicOrigin(undefined);
logOriginPolicy('0.0.0.0');
expect(warns()).toHaveLength(1);
expect(String(warns()[0].msg)).toContain('wildcard address (0.0.0.0)');
expect(String(warns()[0].msg)).toContain('--host');
expect(String(warns()[0].msg)).toContain(PUBLIC_ORIGIN_ENV);
});
it('still warns on a wildcard bind with a valid public origin, and names it', () => {
setPublicOrigin('https://app.example.com');
logOriginPolicy('::');
expect(infos()).toHaveLength(1);
expect(warns()).toHaveLength(1);
expect(String(warns()[0].msg)).toContain('app.example.com');
});
it('diagnoses an unusable public origin rather than reporting one', () => {
setPublicOrigin('a.com,b.com');
logOriginPolicy('0.0.0.0');
expect(infos()).toEqual([]);
// One for the unusable value, one for the wildcard bind it fails to rescue.
expect(warns()).toHaveLength(2);
expect(String(warns()[0].msg)).toContain('a.com,b.com');
expect(String(warns()[1].msg)).toContain('--host');
});
it('diagnoses an unusable public origin on a specific bind too', () => {
setPublicOrigin('*');
logOriginPolicy('192.168.1.10');
expect(warns()).toHaveLength(1);
expect(String(warns()[0].msg)).toContain(PUBLIC_ORIGIN_ENV);
});
it('does not report a trailing-dot hostname as a working origin', () => {
setPublicOrigin('app.example.com.');
logOriginPolicy('0.0.0.0');
expect(infos()).toEqual([]);
expect(String(warns()[0].msg)).toContain('app.example.com.');
});
});
/**
* `serve` has no authentication, and the guard above passes every request with
* no Origin header — so GITNEXUS_PUBLIC_ORIGIN, the setting that makes a public
* bind usable, must not be usable until the lock exists.
*/
describe('assertServeAuthForPublicOrigin', () => {
it('is a no-op when no public origin is configured', () => {
setPublicOrigin(undefined);
expect(() => assertServeAuthForPublicOrigin()).not.toThrow();
});
it.each([' ', ''])('treats a blank value (%j) as unset', (raw) => {
setPublicOrigin(raw);
expect(() => assertServeAuthForPublicOrigin()).not.toThrow();
});
it('throws when a public origin is configured and no auth is', () => {
setPublicOrigin('https://app.example.com');
expect(() => assertServeAuthForPublicOrigin()).toThrow(/has no authentication yet/);
});
it('names the variable and the value in the failure, and points at both remedies', () => {
setPublicOrigin('https://app.example.com');
let message = '';
try {
assertServeAuthForPublicOrigin();
} catch (err) {
message = err instanceof Error ? err.message : String(err);
}
expect(message).toContain(PUBLIC_ORIGIN_ENV);
expect(message).toContain('https://app.example.com');
expect(message).toContain('DELETE /api/repo');
expect(message).toContain('loopback');
});
// Even a value the matcher would reject throws: the operator's intent to serve
// a public origin is the risk, and an unusable value is not a safer one.
it.each(['a.com,b.com', '*', '8080'])('throws on an unusable value too: %s', (raw) => {
setPublicOrigin(raw);
expect(() => assertServeAuthForPublicOrigin()).toThrow();
});
// The auth change flips this predicate; the gate above is then satisfiable
// without rewriting it. Pinned so the flip cannot happen unnoticed.
it('reports no auth configured, since serve has none', () => {
expect(isServeAuthConfigured()).toBe(false);
});
});

View file

@ -0,0 +1,191 @@
/**
* Unit Tests: GITNEXUS_TRUST_PROXY resolution
*
* Express accepts a boolean, a hop count, or a comma-separated proxy list for
* `trust proxy`, and compiles the value inside `app.set` — so an unvalidated
* env value takes the server down at startup, or (for a number it cannot
* range-check) silently trusts every hop. resolveTrustProxy validates first.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
DEFAULT_TRUST_PROXY,
MAX_TRUST_PROXY_HOPS,
TRUST_PROXY_ENV,
resolveTrustProxy,
warnIfRateLimitKeysCollapse,
} from '../../src/server/middleware.js';
import { _captureLogger, type LoggerCapture } from '../../src/core/logger.js';
// `logger` is a Proxy with only a `get` trap, so vi.spyOn cannot replace
// `warn` on it; the module's own capture helper redirects the destination.
let cap: LoggerCapture;
beforeEach(() => {
cap = _captureLogger();
});
afterEach(() => {
cap.restore();
});
const warnings = (): string[] =>
cap
.records()
.filter((r) => r.level === 40)
.map((r) => String(r.msg));
describe('resolveTrustProxy — accepted', () => {
it('falls back to the loopback-scoped default when unset or blank', () => {
expect(resolveTrustProxy(undefined)).toBe(DEFAULT_TRUST_PROXY);
expect(resolveTrustProxy('')).toBe(DEFAULT_TRUST_PROXY);
expect(resolveTrustProxy(' ')).toBe(DEFAULT_TRUST_PROXY);
expect(warnings()).toEqual([]);
});
it('accepts the default it falls back to, so the fallback can never throw', () => {
expect(() => resolveTrustProxy(DEFAULT_TRUST_PROXY)).not.toThrow();
expect(resolveTrustProxy(DEFAULT_TRUST_PROXY)).toBe(DEFAULT_TRUST_PROXY);
expect(warnings()).toEqual([]);
});
const hopCounts = Array.from({ length: MAX_TRUST_PROXY_HOPS }, (_, i) => i + 1);
it.each(hopCounts)('accepts hop count %i', (hops) => {
expect(() => resolveTrustProxy(String(hops))).not.toThrow();
expect(resolveTrustProxy(String(hops))).toBe(hops);
expect(warnings()).toEqual([]);
});
it('trims surrounding whitespace off a hop count', () => {
expect(resolveTrustProxy(' 2 ')).toBe(2);
});
// Express tests a hop count as `i < hops`, so 0 and false are the same
// setting. Rejecting 0 would fall back to a default that trusts more.
it('normalizes a hop count of 0 to false rather than rejecting it', () => {
expect(resolveTrustProxy('0')).toBe(false);
expect(warnings()).toEqual([]);
});
it.each([
['false', false],
['FALSE', false],
['no', false],
['NO', false],
['off', false],
['OFF', false],
] as const)('accepts %s as a boolean without warning', (raw, expected) => {
expect(resolveTrustProxy(raw)).toBe(expected);
expect(warnings()).toEqual([]);
});
it.each(['loopback', 'linklocal', 'uniquelocal', '10.0.0.0/8, 127.0.0.1'])(
'accepts the proxy list %s verbatim',
(raw) => {
expect(() => resolveTrustProxy(raw)).not.toThrow();
expect(resolveTrustProxy(raw)).toBe(raw);
expect(warnings()).toEqual([]);
},
);
});
// `true` trusts every hop, which makes req.ip the client-controlled leftmost
// X-Forwarded-For entry — a fresh rate-limit key per spoofed request, in front
// of the two routes that spawn workers. express-rate-limit's own
// validations.trustProxy throws ERR_ERL_PERMISSIVE_TRUST_PROXY on it, so it was
// never a working configuration either. Rejected, not warned.
describe('resolveTrustProxy — rejects a trust-everything value', () => {
it.each(['true', 'TRUE', 'yes', 'YES', 'on', 'ON'])('falls back to the default on %s', (raw) => {
expect(resolveTrustProxy(raw)).toBe(DEFAULT_TRUST_PROXY);
const warned = warnings();
expect(warned).toHaveLength(1);
expect(warned[0]).toContain(TRUST_PROXY_ENV);
expect(warned[0]).toContain(raw);
expect(warned[0]).toContain('X-Forwarded-For');
});
it('never returns true, so express-rate-limit cannot reject the value we set', () => {
for (const raw of ['true', 'yes', 'on', 'TRUE', '1', '16', 'loopback', '0', 'false']) {
expect(resolveTrustProxy(raw)).not.toBe(true);
}
});
});
describe('resolveTrustProxy — rejected', () => {
it.each([
['garbage', 'an unknown subnet name'],
['*', 'a wildcard'],
['9'.repeat(400), 'a hop count that overflows to Infinity'],
[String(MAX_TRUST_PROXY_HOPS + 1), 'a hop count above the range'],
['-1', 'a negative hop count'],
['1.5', 'a fractional hop count'],
['a.com;b.com', 'a semicolon-separated list'],
])('falls back to the default on %#: %s', (raw) => {
expect(resolveTrustProxy(raw)).toBe(DEFAULT_TRUST_PROXY);
const warned = warnings();
expect(warned).toHaveLength(1);
expect(warned[0]).toContain(TRUST_PROXY_ENV);
expect(warned[0]).toContain(raw);
});
});
// resolveTrustProxy sees only the env value; whether the default is about to
// collapse the per-IP rate limit to one global limit depends on what we bound.
describe('warnIfRateLimitKeysCollapse', () => {
const original = process.env[TRUST_PROXY_ENV];
beforeEach(() => {
delete process.env[TRUST_PROXY_ENV];
});
afterEach(() => {
if (original === undefined) delete process.env[TRUST_PROXY_ENV];
else process.env[TRUST_PROXY_ENV] = original;
});
it.each(['localhost', '127.0.0.1', '::1', '[::1]'])('stays silent on a %s bind', (host) => {
warnIfRateLimitKeysCollapse(host);
expect(warnings()).toEqual([]);
});
it.each([undefined, ''])('stays silent when no host is given (%o)', (host) => {
warnIfRateLimitKeysCollapse(host);
expect(warnings()).toEqual([]);
});
it.each([
['0.0.0.0', 'a wildcard bind accepts LB traffic too'],
['::', 'the IPv6 wildcard likewise'],
['192.168.1.10', 'a LAN bind'],
['203.0.113.7', 'a public bind'],
])('warns on %s (%s)', (host) => {
warnIfRateLimitKeysCollapse(host);
const warned = warnings();
expect(warned).toHaveLength(1);
expect(warned[0]).toContain(TRUST_PROXY_ENV);
expect(warned[0]).toContain(host);
expect(warned[0]).toContain('one shared limit');
});
it.each(['1', 'loopback', 'garbage'])(
'stays silent when %s is configured, valid or not',
(raw) => {
// An invalid value is resolveTrustProxy's warning to make, not a second one
// here — the operator has already been told about that value.
process.env[TRUST_PROXY_ENV] = raw;
warnIfRateLimitKeysCollapse('0.0.0.0');
expect(warnings()).toEqual([]);
},
);
it('treats a whitespace-only value as unset', () => {
process.env[TRUST_PROXY_ENV] = ' ';
warnIfRateLimitKeysCollapse('0.0.0.0');
expect(warnings()).toHaveLength(1);
});
});
describe('resolveTrustProxy — contract', () => {
it('names the env var it reads', () => {
expect(TRUST_PROXY_ENV).toBe('GITNEXUS_TRUST_PROXY');
});
it('defaults to loopback plus the private ranges', () => {
expect(DEFAULT_TRUST_PROXY).toBe('loopback, linklocal, uniquelocal');
});
});

View file

@ -23,6 +23,12 @@ import path from 'node:path';
import fs from 'node:fs/promises';
import os from 'node:os';
import { createRouteLimiter } from '../../src/server/validation.js';
import {
DEFAULT_TRUST_PROXY,
TRUST_PROXY_ENV,
resolveTrustProxy,
} from '../../src/server/middleware.js';
import { _captureLogger, type LoggerCapture } from '../../src/core/logger.js';
let tmpFile: string;
@ -246,13 +252,13 @@ describe('production routes — rate-limit middleware wiring', () => {
it('POST /api/analyze is wired with createRouteLimiter', () => {
// Tolerate Prettier wrapping the registration across lines (it does once
// the route carries extra middleware like requireLocalhostOrigin).
// the route carries extra middleware like requireTrustedOrigin).
expect(apiSource).toMatch(/app\.post\(\s*'\/api\/analyze',\s*createRouteLimiter\(/);
});
it('POST /api/embed is wired with createRouteLimiter', () => {
// Tolerate Prettier wrapping the registration across lines (it does once
// the route carries extra middleware like requireLocalhostOrigin).
// the route carries extra middleware like requireTrustedOrigin).
expect(apiSource).toMatch(/app\.post\(\s*'\/api\/embed',\s*createRouteLimiter\(/);
});
@ -269,9 +275,13 @@ describe('production routes — rate-limit middleware wiring', () => {
expect(apiSource).not.toMatch(/app\.options\(\s*'\/\*'/);
});
it('createServer wires trust proxy to loopback/linklocal/uniquelocal', () => {
// Source-level because createServer listens and cannot be built here. Kept
// deliberately loose: the effective-value describe below covers resolution,
// so all this has to pin down is that createServer routes the env var
// through resolveTrustProxy rather than setting a literal.
it('createServer reads trust proxy from GITNEXUS_TRUST_PROXY', () => {
expect(apiSource).toMatch(
/app\.set\(\s*'trust proxy'\s*,\s*'loopback,\s*linklocal,\s*uniquelocal'\s*\)/,
/app\.set\(\s*'trust proxy'\s*,\s*resolveTrustProxy\([^)]*TRUST_PROXY_ENV/,
);
});
@ -318,3 +328,107 @@ describe('validation.ts — IPv6 key normalisation (#1360)', () => {
expect(validationSource).toMatch(/ipKeyGenerator\(ip\)/);
});
});
// The effective `trust proxy` Express ends up with, rather than the text of the
// line that sets it. `createServer` listens and installs signal handlers, so it
// cannot be built here; this mirrors its one `app.set` expression instead.
describe('trust proxy — effective value from GITNEXUS_TRUST_PROXY', () => {
const saved = process.env[TRUST_PROXY_ENV];
// The rejection cases below warn; capture keeps them out of the suite output.
let cap: LoggerCapture;
beforeEach(() => {
cap = _captureLogger();
});
afterEach(() => {
cap.restore();
if (saved === undefined) delete process.env[TRUST_PROXY_ENV];
else process.env[TRUST_PROXY_ENV] = saved;
});
const effectiveTrustProxy = (value: string | undefined): unknown => {
if (value === undefined) delete process.env[TRUST_PROXY_ENV];
else process.env[TRUST_PROXY_ENV] = value;
const app = express();
app.set('trust proxy', resolveTrustProxy(process.env[TRUST_PROXY_ENV]));
return app.get('trust proxy');
};
it('defaults to the loopback-scoped list when the env var is unset', () => {
expect(DEFAULT_TRUST_PROXY).toBe('loopback, linklocal, uniquelocal');
expect(effectiveTrustProxy(undefined)).toBe('loopback, linklocal, uniquelocal');
});
it('carries a configured hop count through to Express', () => {
expect(effectiveTrustProxy('3')).toBe(3);
});
it('carries a configured proxy list through to Express', () => {
expect(effectiveTrustProxy('10.0.0.0/8, 127.0.0.1')).toBe('10.0.0.0/8, 127.0.0.1');
});
// Express 5 compiles `trust proxy` inside `app.set`, so an unvalidated bad
// value would throw during createServer instead of resolving to a default.
it('never hands Express a value it rejects', () => {
expect(() => effectiveTrustProxy('garbage')).not.toThrow();
expect(effectiveTrustProxy('garbage')).toBe(DEFAULT_TRUST_PROXY);
expect(effectiveTrustProxy('9'.repeat(400))).toBe(DEFAULT_TRUST_PROXY);
});
// The behaviour the describe block below demonstrates is why: `true` cannot
// reach Express through the env var at all.
it('never hands Express `true`', () => {
expect(effectiveTrustProxy('true')).toBe(DEFAULT_TRUST_PROXY);
expect(effectiveTrustProxy('yes')).toBe(DEFAULT_TRUST_PROXY);
expect(effectiveTrustProxy('on')).toBe(DEFAULT_TRUST_PROXY);
});
});
// Why resolveTrustProxy rejects `true`: Express then reads the leftmost
// X-Forwarded-For entry, which the client controls, so rotating it hands the
// limiter a fresh key per request — unbounded, in front of the two routes that
// spawn workers. A hop count reads from the right instead and is immune. Both
// apps below see the same requests; only the setting differs. `true` is set
// directly here, since the env var can no longer produce it.
describe('trust proxy — a rotating X-Forwarded-For defeats `true` but not a hop count', () => {
const buildProxiedApp = (trustProxy: boolean | number): Express => {
const app = express();
app.set('trust proxy', trustProxy);
app.get('/test/file', createRouteLimiter({ windowMs: 2000, limit: 2 }), async (_req, res) => {
const content = await fs.readFile(tmpFile, 'utf-8');
res.json({ content });
});
return app;
};
// Leftmost entry rotates per request; the rightmost (the hop the loopback
// proxy claims to have received from) stays fixed.
const statusesUnderRotatingForwardedFor = async (app: Express): Promise<number[]> => {
const { server, baseUrl } = await startServer(app);
try {
const statuses: number[] = [];
for (let i = 1; i <= 4; i++) {
const res = await fetch(`${baseUrl}/test/file`, {
headers: { 'X-Forwarded-For': `203.0.113.${i}, 198.51.100.7` },
});
statuses.push(res.status);
}
return statuses;
} finally {
await stopServer(server);
}
};
it('collapses to a single limiter key with a hop count of 1', async () => {
expect(await statusesUnderRotatingForwardedFor(buildProxiedApp(1))).toEqual([
200, 200, 429, 429,
]);
});
it('gets a fresh limiter key per request with `true`', async () => {
expect(await statusesUnderRotatingForwardedFor(buildProxiedApp(true))).toEqual([
200, 200, 200, 200,
]);
});
});

View file

@ -15,6 +15,7 @@ import {
goReceiverBinding,
} from '../../../../src/core/ingestion/languages/go/index.js';
import { detectGoInterfaceImplementations } from '../../../../src/core/ingestion/languages/go/interface-impls.js';
import { populateGoOwners } from '../../../../src/core/ingestion/languages/go/method-owners.js';
describe('Go arity compatibility', () => {
const makeDef = (overrides: Partial<SymbolDefinition> = {}): SymbolDefinition => ({
@ -272,6 +273,23 @@ function inheritsSite(name: string, inScope: ScopeId): ReferenceSite {
};
}
/**
* Structural detection now returns the receiver FORM alongside each
* implementor (`{ structDefId, receiverForm }`), because Go's method sets for
* `T` and `*T` genuinely differ. Most rows below only care about WHICH types
* implement, so this extracts the ids; the rows that care about the form assert
* it explicitly.
*/
function implIds(
result: Map<string, readonly { readonly structDefId: string }[]>,
ifaceId: string,
): string[] | undefined {
const found = result.get(ifaceId);
// Deliberately NOT sorted: several rows below assert detection ORDER
// (shallowest-promoted-first), which sorting would silently destroy.
return found === undefined ? undefined : found.map((i) => i.structDefId);
}
describe('Go structural interface detection', () => {
it('detects a struct implementing every interface method with matching signatures', () => {
const iface = goDef('iface:Repository', 'Interface', 'Repository');
@ -319,10 +337,18 @@ describe('Go structural interface detection', () => {
{} as any,
);
expect(result.get(iface.nodeId)).toEqual([struct.nodeId]);
expect(implIds(result, iface.nodeId)).toEqual([struct.nodeId]);
});
it('does not treat pointer-receiver-only methods as value type implementations', () => {
// POLARITY DELIBERATELY REVERSED in #2813 (was: expected `undefined`).
// A type whose methods use pointer receivers is exactly what idiomatic Go
// stores in an interface-typed field; excluding it emitted no IMPLEMENTS edge
// at all, so calls through such a field never reached the implementation.
// See the rationale block in interface-impls.ts.
// Exactness, not just presence: a pointer-receiver-only type implements the
// interface ONLY in pointer form. `var x Closer = PointerOnlyCloser{}` is a
// compile error in Go and the graph must be able to say so.
it('treats pointer-receiver-only methods as implementations in POINTER form only', () => {
const iface = goDef('iface:Closer', 'Interface', 'Closer');
const struct = goDef('struct:PointerOnlyCloser', 'Struct', 'PointerOnlyCloser');
const ifaceClose = goDef('iface:Closer.Close', 'Method', 'Closer.Close', iface.nodeId, {
@ -347,7 +373,8 @@ describe('Go structural interface detection', () => {
{} as any,
);
expect(result.get(iface.nodeId)).toBeUndefined();
expect(implIds(result, iface.nodeId)).toEqual([struct.nodeId]);
expect(result.get(iface.nodeId)?.[0]?.receiverForm).toBe('pointer');
});
it('rejects same-name methods with incompatible parameter types', () => {
@ -577,7 +604,7 @@ describe('Go structural interface detection', () => {
{} as any,
);
expect(result.get(readCloser.nodeId)).toEqual([struct.nodeId]);
expect(implIds(result, readCloser.nodeId)).toEqual([struct.nodeId]);
});
it('accepts structs implementing interface methods through promoted embedded struct methods', () => {
@ -606,7 +633,7 @@ describe('Go structural interface detection', () => {
{} as any,
);
expect(result.get(reader.nodeId)).toEqual([base.nodeId, file.nodeId]);
expect(implIds(result, reader.nodeId)).toEqual([base.nodeId, file.nodeId]);
});
it('lets direct struct methods shadow promoted embedded struct methods', () => {
@ -650,7 +677,7 @@ describe('Go structural interface detection', () => {
{} as any,
);
expect(result.get(reader.nodeId)).toEqual([base.nodeId]);
expect(implIds(result, reader.nodeId)).toEqual([base.nodeId]);
});
it('does not use ambiguous promoted embedded struct methods for interface matching', () => {
@ -689,7 +716,7 @@ describe('Go structural interface detection', () => {
{} as any,
);
expect(result.get(reader.nodeId)).toEqual([baseA.nodeId, baseB.nodeId]);
expect(implIds(result, reader.nodeId)).toEqual([baseA.nodeId, baseB.nodeId]);
});
it('uses the shallowest promoted embedded struct method when deeper methods share the name', () => {
@ -734,7 +761,7 @@ describe('Go structural interface detection', () => {
{} as any,
);
expect(result.get(reader.nodeId)).toEqual([
expect(implIds(result, reader.nodeId)).toEqual([
shallow.nodeId,
deepBase.nodeId,
deepWrapper.nodeId,
@ -840,7 +867,7 @@ describe('Go structural interface detection', () => {
{} as any,
);
expect(result.get(readCloser.nodeId)).toEqual([file.nodeId]);
expect(implIds(result, readCloser.nodeId)).toEqual([file.nodeId]);
});
it('does not emit implementations for cyclic embedded interfaces', () => {
@ -908,8 +935,8 @@ describe('Go structural interface detection', () => {
{} as any,
);
expect(result.get(reader.nodeId)).toEqual([file.nodeId]);
expect(result.get(closer.nodeId)).toEqual([file.nodeId]);
expect(implIds(result, reader.nodeId)).toEqual([file.nodeId]);
expect(implIds(result, closer.nodeId)).toEqual([file.nodeId]);
});
it('does not emit implementations when an embedded interface cannot be resolved', () => {
@ -977,7 +1004,7 @@ describe('Go structural interface detection', () => {
{} as any,
);
expect(result.get(iface.nodeId)).toEqual([struct.nodeId]);
expect(implIds(result, iface.nodeId)).toEqual([struct.nodeId]);
});
it('preserves package qualifiers when checking signatures', () => {
@ -1113,3 +1140,54 @@ describe('Go structural interface detection', () => {
expect(result.get(iface.nodeId)).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// #2829 review: `goReceiverKind` must stay stamped even though nothing filters
// on it any more.
// ---------------------------------------------------------------------------
//
// #2813 removed the only functional READER of `goReceiverKind` (the
// pointer-receiver exclusion in `buildDetectionIndexes`). The field is kept
// deliberately — it is the hook a future value/pointer-aware model would read,
// and `interpret.ts` preserves the raw `*T` shape specifically to feed it. But
// a written-and-never-read field rots: before these rows you could delete the
// assignment in `method-owners.ts` and the whole suite stayed green.
//
// These pin the stamp itself, so the promise the comment makes stays true.
describe('Go method-owner receiver-kind stamping (#2829)', () => {
const ownerScope = (receiverRaw: string, methodDef: SymbolDefinition): Scope => {
const s = scope('fn:1' as ScopeId, 'Function', [methodDef]);
(s.typeBindings as Map<string, unknown>).set('r', {
rawName: receiverRaw,
source: 'self',
});
return s;
};
const stampFor = (receiverRaw: string): SymbolDefinition => {
const struct = goDef('struct:Repo', 'Struct', 'Repo');
const method = goDef('struct:Repo.Save', 'Method', 'Save');
const parsed = {
filePath: 'repo.go',
language: 'go',
scopes: [scope('mod' as ScopeId, 'Module', [struct]), ownerScope(receiverRaw, method)],
imports: [],
localDefs: [struct, method],
referenceSites: [],
} as any;
populateGoOwners(parsed);
return method;
};
it('stamps a POINTER receiver as pointer', () => {
const m = stampFor('*Repo') as SymbolDefinition & { goReceiverKind?: string };
expect(m.goReceiverKind).toBe('pointer');
expect(m.ownerId).toBe('struct:Repo');
});
it('stamps a VALUE receiver as value', () => {
const m = stampFor('Repo') as SymbolDefinition & { goReceiverKind?: string };
expect(m.goReceiverKind).toBe('value');
expect(m.ownerId).toBe('struct:Repo');
});
});

View file

@ -0,0 +1,118 @@
/**
* #2807 follow-up — `resolveDefGraphId` under a def/node LABEL SPLIT.
*
* Some structure phases emit a type's methods as `Function` nodes while the
* scope extractor derives `Method` from the `@declaration.method` anchor. Every
* key the resolver builds is label-scoped, so under that split all of them miss
* and the def lands on the label-agnostic, first-write-wins `simpleKey`.
*
* The shipped fix retried the QUALIFIED keys under the sibling callable label,
* but left the two keys ABOVE them — the #2699 position key and its fail-closed
* guard — scoped to the def's own label. Since the split is the premise, both
* were dead there: the guard could never fire for the case the retry serves, so
* the retry inherited the aliasing the guard exists to stop. These cases pin the
* mirrored behaviour at the unit level, where a lookup can be posed directly
* instead of being coaxed out of a language.
*/
import { describe, expect, it } from 'vitest';
import { resolveDefGraphId } from '../../../src/core/ingestion/scope-resolution/graph-bridge/ids.js';
import {
AMBIGUOUS_POSITION,
localNameKey,
positionKey,
qualifiedKey,
simpleKey,
} from '../../../src/core/ingestion/scope-resolution/graph-bridge/node-lookup.js';
const FILE = 'src/app.swift';
const METHOD_NODE = `Function:${FILE}:Host.helper#1`;
const LOCAL_NODE = `Function:${FILE}:Host.run.helper@8:8#2`;
/**
* The scope-side def for the function-local `helper`: labelled `Method` (the
* declaration anchor), declared on 1-based line 9, and qualified by its owning
* TYPE — which is why it collides with the class method's name at all.
*/
const LOCAL_DEF = {
nodeId: `def:${FILE}#9:8:Method:helper`,
qualifiedName: 'Host.helper',
type: 'Method',
} as const;
/** The class method's own def: same name, same label, 1-based line 5. */
const METHOD_DEF = {
nodeId: `def:${FILE}#5:4:Method:helper`,
qualifiedName: 'Host.helper',
type: 'Method',
} as const;
/** Keys the method's `Function` node always registers. */
const methodNameKeys: readonly (readonly [string, string])[] = [
[qualifiedKey(FILE, 'Function', 'Host.helper'), METHOD_NODE],
[qualifiedKey(FILE, 'Function', 'Host.helper#1'), METHOD_NODE],
[simpleKey(FILE, 'helper'), METHOD_NODE],
];
const lookupOf = (entries: readonly (readonly [string, string])[]): ReadonlyMap<string, string> =>
new Map(entries.map(([k, v]) => [k, v]));
describe('resolveDefGraphId across a def/node label split (#2807)', () => {
it('consults the position key under the sibling label', () => {
const lookup = lookupOf([
...methodNameKeys,
[positionKey(FILE, 'Function', 4, 'helper'), METHOD_NODE],
[positionKey(FILE, 'Function', 8, 'helper'), LOCAL_NODE],
]);
expect({
local: resolveDefGraphId(FILE, LOCAL_DEF, lookup),
method: resolveDefGraphId(FILE, METHOD_DEF, lookup),
}).toEqual({ local: LOCAL_NODE, method: METHOD_NODE });
});
// The guard's whole job: when the position join misses but a function-local of
// this name is registered, emitting NO edge is correct and aliasing onto the
// same-named method is not. Without the sibling arm this def reaches the
// qualified retry and comes back as the method.
it('fails closed under the sibling label when a same-named local exists', () => {
const lookup = lookupOf([
...methodNameKeys,
[localNameKey(FILE, 'Function', 'helper'), LOCAL_NODE],
]);
expect(resolveDefGraphId(FILE, LOCAL_DEF, lookup)).toBeUndefined();
});
// …and only then. A file with no such local keeps resolving through the
// sibling qualified retry, which is what the shipped #2807 fix added; a guard
// that fired here would delete every Swift method edge in the repo.
it('still resolves through the sibling qualified key when no local exists', () => {
expect(resolveDefGraphId(FILE, METHOD_DEF, lookupOf(methodNameKeys))).toBe(METHOD_NODE);
});
// An `AMBIGUOUS_POSITION` tombstone means two callables already claim this
// line under the def's OWN label. Relabelling must not resolve that by
// picking a third node — the tombstone keeps falling through to the name keys.
it('does not let the sibling label resolve an AMBIGUOUS_POSITION tombstone', () => {
const lookup = lookupOf([
...methodNameKeys,
[positionKey(FILE, 'Method', 8, 'helper'), AMBIGUOUS_POSITION],
[positionKey(FILE, 'Function', 8, 'helper'), LOCAL_NODE],
]);
expect(resolveDefGraphId(FILE, LOCAL_DEF, lookup)).toBe(METHOD_NODE);
});
// The dot gate on the qualified retry is untouched: a bare name carries no
// owner, so crossing the labels there is precisely the top-level-vs-method
// aliasing the label was introduced to prevent.
it('keeps the dot gate on the qualified retry', () => {
const lookup = lookupOf([
[qualifiedKey(FILE, 'Function', 'helper'), `Function:${FILE}:helper`],
]);
expect(
resolveDefGraphId(
FILE,
{ nodeId: `def:${FILE}#9:8:Method:helper`, qualifiedName: 'helper', type: 'Method' },
lookup,
),
).toBeUndefined();
});
});

View file

@ -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>): 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']);
});
});

View file

@ -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();
});
});