From 11476465180b47a53f263a158092e9095a776cc5 Mon Sep 17 00:00:00 2001 From: MyShining <249674729@qq.com> Date: Sun, 2 Aug 2026 00:22:12 +0800 Subject: [PATCH 01/13] feat(spring): model AOP transactions, caching, and security (#2783) * feat(spring): model AOP advice and proxy behavior * fix(spring): address AOP review findings --------- Co-authored-by: Shining --- ARCHITECTURE.md | 276 ++--- gitnexus-shared/src/graph/types.ts | 6 + gitnexus-shared/src/lbug/schema-constants.ts | 1 + .../src/core/incremental/subgraph-extract.ts | 18 +- .../frameworks/spring/analysis-features.ts | 16 + .../frameworks/spring/annotation-arguments.ts | 34 +- .../frameworks/spring/aop-candidates.ts | 118 +++ .../core/ingestion/frameworks/spring/aop.ts | 707 +++++++++++++ .../languages/java/capture-side-channel.ts | 21 + .../core/ingestion/languages/java/captures.ts | 14 + .../languages/java/scope-resolver.ts | 2 + .../ingestion/languages/java/spring-aop.ts | 61 ++ .../languages/kotlin/capture-side-channel.ts | 25 + .../ingestion/languages/kotlin/captures.ts | 17 + .../languages/kotlin/scope-resolver.ts | 2 + .../ingestion/languages/kotlin/spring-aop.ts | 77 ++ .../core/ingestion/pipeline-phases/index.ts | 6 + .../ingestion/pipeline-phases/spring-aop.ts | 618 +++++++++++ gitnexus/src/core/ingestion/pipeline.ts | 16 +- gitnexus/src/core/lbug/graph-emit-sink.ts | 6 +- gitnexus/src/core/lbug/lbug-adapter.ts | 48 + gitnexus/src/core/lbug/schema.ts | 1 + gitnexus/src/core/run-analyze.ts | 17 +- gitnexus/src/mcp/local/aop-metadata.ts | 352 +++++++ gitnexus/src/mcp/local/bean-metadata.ts | 1 + gitnexus/src/mcp/local/local-backend.ts | 72 +- gitnexus/src/mcp/tools.ts | 2 +- gitnexus/src/storage/parse-cache.ts | 5 +- gitnexus/src/storage/repo-manager.ts | 7 +- .../integration/lbug-core-adapter.test.ts | 64 ++ .../test/integration/local-backend.test.ts | 1 + .../integration/spring-aop-benchmark.test.ts | 507 +++++++++ .../test/integration/spring-aop-mcp.test.ts | 457 ++++++++ .../integration/spring-aop-pipeline.test.ts | 997 ++++++++++++++++++ gitnexus/test/unit/analysis-features.test.ts | 4 + .../unit/call-summary-schema-version.test.ts | 9 +- .../unit/incremental-orchestration.test.ts | 207 ++++ .../test/unit/incremental-parse-cache.test.ts | 8 +- .../unit/incremental-subgraph-extract.test.ts | 30 + .../ingestion/pipeline-phase-registry.test.ts | 9 +- gitnexus/test/unit/schema.test.ts | 4 +- gitnexus/test/unit/security.test.ts | 2 + .../unit/spring-annotation-arguments.test.ts | 26 + gitnexus/test/unit/spring-aop.test.ts | 372 +++++++ gitnexus/test/unit/spring-bean-schema.test.ts | 2 + gitnexus/vitest.config.ts | 2 + 46 files changed, 5075 insertions(+), 172 deletions(-) create mode 100644 gitnexus/src/core/ingestion/frameworks/spring/aop-candidates.ts create mode 100644 gitnexus/src/core/ingestion/frameworks/spring/aop.ts create mode 100644 gitnexus/src/core/ingestion/languages/java/spring-aop.ts create mode 100644 gitnexus/src/core/ingestion/languages/kotlin/spring-aop.ts create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/spring-aop.ts create mode 100644 gitnexus/src/mcp/local/aop-metadata.ts create mode 100644 gitnexus/test/integration/spring-aop-benchmark.test.ts create mode 100644 gitnexus/test/integration/spring-aop-mcp.test.ts create mode 100644 gitnexus/test/integration/spring-aop-pipeline.test.ts create mode 100644 gitnexus/test/unit/spring-aop.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 86ad6c426..c8063f848 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,18 +4,18 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`). ## Repository layout -| Path | Role | -|------|------| -| `gitnexus/` | npm package `gitnexus`: CLI, MCP server (stdio), HTTP API, ingestion pipeline, LadybugDB graph, embeddings. | -| `gitnexus-web/` | Vite + React thin client: graph explorer + AI chat. All queries via `gitnexus serve` HTTP API. | -| `gitnexus-shared/` | Shared TypeScript types and constants (consumed by CLI and Web). | -| `.claude/`, `gitnexus-claude-plugin/`, `gitnexus-cursor-integration/` | Agent skills and plugin metadata. | -| `eval/` | Evaluation harnesses for benchmarking tool usage. | -| `.github/` | CI workflows + composite actions (`setup-gitnexus/`, `setup-gitnexus-web/`). | +| Path | Role | +| --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `gitnexus/` | npm package `gitnexus`: CLI, MCP server (stdio), HTTP API, ingestion pipeline, LadybugDB graph, embeddings. | +| `gitnexus-web/` | Vite + React thin client: graph explorer + AI chat. All queries via `gitnexus serve` HTTP API. | +| `gitnexus-shared/` | Shared TypeScript types and constants (consumed by CLI and Web). | +| `.claude/`, `gitnexus-claude-plugin/`, `gitnexus-cursor-integration/` | Agent skills and plugin metadata. | +| `eval/` | Evaluation harnesses for benchmarking tool usage. | +| `.github/` | CI workflows + composite actions (`setup-gitnexus/`, `setup-gitnexus-web/`). | ## End-to-end flow: index → graph → tools -1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). DAG of 15 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery. +1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). The default DAG of 19 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery. 2. **Persistence** — `repo-manager.ts` (paths, registry, LadybugDB cleanup). `lbug-adapter.ts` (graph load, queries, embedding batches). @@ -28,53 +28,53 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`). ## MCP tools -| Tool | Purpose | -|------|---------| -| `list_repos` | Discover indexed repos | -| `query` | Hybrid BM25 + vector search over the graph | -| `cypher` | Ad hoc Cypher against the schema | -| `context` | Callers, callees, processes for one symbol | -| `impact` | Blast radius (upstream/downstream) with risk summary | -| `detect_changes` | Map git diffs to affected symbols and processes | -| `rename` | Graph-assisted multi-file rename with `dry_run` preview | -| `api_impact` | Pre-change impact report for an API route handler | -| `trace` | Shortest directed path between two symbols (call + class-member edges); group-aware (`repo: "@"`) for cross-repo traces | -| `route_map` | API route → handler → consumer mappings | -| `tool_map` | MCP/RPC tool definitions and handlers | -| `shape_check` | Response shape vs consumer property access mismatches | -| `explain` | Persisted taint findings (source→sink data flows) — needs `analyze --pdg` | -| `pdg_query` | Control/data dependence — CDG (`mode: controls`) / REACHING_DEF (`mode: flows`) — needs `analyze --pdg` | -| `group_list` | List repo groups or details for one group | -| `group_sync` | Rebuild group Contract Registry (`contracts.json`) and bridge graph | +| Tool | Purpose | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `list_repos` | Discover indexed repos | +| `query` | Hybrid BM25 + vector search over the graph | +| `cypher` | Ad hoc Cypher against the schema | +| `context` | Callers, callees, processes for one symbol | +| `impact` | Blast radius (upstream/downstream) with risk summary | +| `detect_changes` | Map git diffs to affected symbols and processes | +| `rename` | Graph-assisted multi-file rename with `dry_run` preview | +| `api_impact` | Pre-change impact report for an API route handler | +| `trace` | Shortest directed path between two symbols (call + class-member edges); group-aware (`repo: "@"`) for cross-repo traces | +| `route_map` | API route → handler → consumer mappings | +| `tool_map` | MCP/RPC tool definitions and handlers | +| `shape_check` | Response shape vs consumer property access mismatches | +| `explain` | Persisted taint findings (source→sink data flows) — needs `analyze --pdg` | +| `pdg_query` | Control/data dependence — CDG (`mode: controls`) / REACHING_DEF (`mode: flows`) — needs `analyze --pdg` | +| `group_list` | List repo groups or details for one group | +| `group_sync` | Rebuild group Contract Registry (`contracts.json`) and bridge graph | `query`, `context`, and `impact` are group-aware: pass `repo: "@"` (or `"@/"` to scope to one member) plus optional `service: ""`. Group-mode `query` merges per-repo results via Reciprocal Rank Fusion; group-mode `impact` runs the local walk in the chosen member and fans out across boundaries via the Contract Bridge (`gitnexus/src/core/group/cross-impact.ts`). `trace` is also group-aware via `repo: "@"` — but, unlike the others, it resolves `from`/`to` across **all** members (a `@/` suffix is advisory for trace, not a scope); pass `from_uid`/`to_uid` to disambiguate a symbol name that occurs in more than one member. Group-mode `trace` (`gitnexus/src/core/group/cross-trace.ts`) stitches a path that crosses repositories: it resolves `from`/`to` across all members, and when they live in different repos it joins the home-repo segment to the target-repo segment over a single `ContractLink` boundary (an HTTP consumer→provider link, joined on `Contract.symbolUid`), reported as a `CONTRACT_LINK` hop in `crossings[]`. The crossing is clamped to one boundary (`MAX_SUPPORTED_CROSS_DEPTH`, shared with cross-impact); deeper `crossDepth` is reported via `notes[]`. With `pdg: true` (experimental, opt-in), each boundary-adjacent segment is enriched with its intra-procedural REACHING_DEF data-flow when that repo was indexed with `--pdg` (reusing the same anchored `flows` query as `pdg_query`); data flow never crosses the repo boundary, and a missing PDG layer degrades to call-level hops with a note. Two stores meet only at the `symbolUid` grain — the per-repo PDG/call graph and the group bridge — so this is the documented join; full cross-program (SDG-like) data flow across the boundary remains deferred (see `docs/plans/2026-06-18-002-feat-unified-pdg-impact-evaluation-plan.md`). The previously-planned `group_query`, `group_context`, `group_impact`, `group_contracts`, `group_status` MCP tools are intentionally not introduced — group-level state is exposed via resources instead: -| Resource URI | Purpose | -|--------------|---------| +| Resource URI | Purpose | +| ----------------------------------- | -------------------------------------------------------- | | `gitnexus://group/{name}/contracts` | Contract Registry (provider/consumer rows + cross-links) | -| `gitnexus://group/{name}/status` | Per-member index + Contract Registry staleness | +| `gitnexus://group/{name}/status` | Per-member index + Contract Registry staleness | ## Where to change what -| Concern | Start in | -|---------|----------| -| CLI commands/flags | `src/cli/` (`index.ts`, per-command modules) | -| Parsing/graph construction | `src/core/ingestion/pipeline-phases/` + `pipeline.ts` | -| Graph schema/DB | `src/core/lbug/` (`schema.ts`, `lbug-adapter.ts`) | -| MCP tools/resources | `src/mcp/server.ts`, `tools.ts`, `resources.ts` | -| Cross-repo groups (sync, contracts, `@` routing) | `src/core/group/` (`service.ts`, `cross-impact.ts`, `sync.ts`, `bridge-db.ts`) | -| Search ranking | `src/core/search/` (BM25, hybrid fusion) | -| Embeddings | `src/core/embeddings/` + `src/core/run-analyze.ts` | -| Wiki generation | `src/core/wiki/` | -| Language support | `src/core/ingestion/languages/` + `tree-sitter-queries.ts` + `gitnexus-shared/src/languages.ts` | -| Import resolution | `src/core/ingestion/import-processor.ts` + `import-resolvers/configs/` + `model/resolution-context.ts` | -| Call resolution/inheritance/MRO | `src/core/ingestion/scope-resolution/` (pipeline, passes, graph-bridge) | -| Type extraction | `src/core/ingestion/type-extractors/` | -| Worker pool | `src/core/ingestion/workers/` | -| Web UI | `gitnexus-web/src/` | -| CI | `.github/workflows/*.yml`, `.github/actions/` | +| Concern | Start in | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| CLI commands/flags | `src/cli/` (`index.ts`, per-command modules) | +| Parsing/graph construction | `src/core/ingestion/pipeline-phases/` + `pipeline.ts` | +| Graph schema/DB | `src/core/lbug/` (`schema.ts`, `lbug-adapter.ts`) | +| MCP tools/resources | `src/mcp/server.ts`, `tools.ts`, `resources.ts` | +| Cross-repo groups (sync, contracts, `@` routing) | `src/core/group/` (`service.ts`, `cross-impact.ts`, `sync.ts`, `bridge-db.ts`) | +| Search ranking | `src/core/search/` (BM25, hybrid fusion) | +| Embeddings | `src/core/embeddings/` + `src/core/run-analyze.ts` | +| Wiki generation | `src/core/wiki/` | +| Language support | `src/core/ingestion/languages/` + `tree-sitter-queries.ts` + `gitnexus-shared/src/languages.ts` | +| Import resolution | `src/core/ingestion/import-processor.ts` + `import-resolvers/configs/` + `model/resolution-context.ts` | +| Call resolution/inheritance/MRO | `src/core/ingestion/scope-resolution/` (pipeline, passes, graph-bridge) | +| Type extraction | `src/core/ingestion/type-extractors/` | +| Worker pool | `src/core/ingestion/workers/` | +| Web UI | `gitnexus-web/src/` | +| CI | `.github/workflows/*.yml`, `.github/actions/` | > Paths above are relative to `gitnexus/` unless they start with `gitnexus-web/` or `.github/`. @@ -82,30 +82,35 @@ Group-mode `trace` (`gitnexus/src/core/group/cross-trace.ts`) stitches a path th ## Pipeline Phase DAG -15 phases defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output. +19 default phases are defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output. `--pdg` adds `taintSummaries` and `callSummaries` (21 total). ``` -scan → structure → [markdown, cobol] → parse → [routes, tools, orm] - → crossFile → scopeResolution → pruneLocalSymbols → mro → di → communities → processes +scan → structure → [springConfig, markdown, cobol] → parse → [routes, tools, orm] + → crossFile → scopeResolution → [springAutoConfiguration, springAop] + → pruneLocalSymbols → mro → springAopInheritance → di → communities → processes ``` -| Phase | File | Deps | Output | -|-------|------|------|--------| -| `scan` | `scan.ts` | (root) | File paths + sizes | -| `structure` | `structure.ts` | `scan` | File/Folder nodes, CONTAINS edges, `allPathSet` | -| `markdown` | `markdown.ts` | `structure` | Section nodes, cross-link edges from .md/.mdx | -| `cobol` | `cobol.ts` | `structure` | COBOL program/paragraph/section nodes (regex, no tree-sitter) | -| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Symbol nodes, IMPORTS/CALLS/EXTENDS edges, extracted routes/tools/ORM queries | -| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators) | -| `tools` | `tools.ts` | `parse` | Tool nodes + HANDLES_TOOL edges | -| `orm` | `orm.ts` | `parse` | QUERIES edges (Prisma, Supabase) | -| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological import order | -| `scopeResolution` | `scope-resolution/pipeline/phase.ts` | `parse`, `crossFile`, `structure` | Binding/reference + inheritance edges; disposes BindingAccumulator | -| `pruneLocalSymbols` | `prune-local-symbols.ts` | `scopeResolution` | Drops inert block-local `Const`/`Variable`/`Static` nodes (only a `File→DEFINES` edge) post-resolution | -| `mro` | `mro.ts` | `crossFile`, `scopeResolution`, `pruneLocalSymbols`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges | -| `di` | `di.ts` | `mro` | INJECTS edges from consumer Classes or factory Methods to provider Classes/declaration CodeElements (framework-neutral DI resolution; per-language matchers registered in `di-extractors/`) | -| `communities` | `communities.ts` | `mro`, `pruneLocalSymbols`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) | -| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `pruneLocalSymbols`, `structure` | Process nodes + STEP_IN_PROCESS edges | +| Phase | File | Deps | Output | +| ------------------------- | -------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `scan` | `scan.ts` | (root) | File paths + sizes | +| `structure` | `structure.ts` | `scan` | File/Folder nodes, CONTAINS edges, `allPathSet` | +| `springConfig` | `spring-config.ts` | `structure` | Spring configuration-property nodes and metadata | +| `markdown` | `markdown.ts` | `structure` | Section nodes, cross-link edges from .md/.mdx | +| `cobol` | `cobol.ts` | `structure` | COBOL program/paragraph/section nodes (regex, no tree-sitter) | +| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Symbol nodes, IMPORTS/CALLS/EXTENDS edges, extracted routes/tools/ORM queries | +| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators) | +| `tools` | `tools.ts` | `parse` | Tool nodes + HANDLES_TOOL edges | +| `orm` | `orm.ts` | `parse` | QUERIES edges (Prisma, Supabase) | +| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological import order | +| `scopeResolution` | `scope-resolution/pipeline/phase.ts` | `parse`, `crossFile`, `structure` | Binding/reference + inheritance edges; disposes BindingAccumulator | +| `springAutoConfiguration` | `spring-auto-configuration.ts` | `structure`, `scopeResolution` | DECLARES and CONDITIONAL_ON metadata for Spring configuration candidates | +| `springAop` | `spring-aop.ts` | `scopeResolution` | Direct declarative/advice ADVISED_BY edges and pointcut evidence | +| `pruneLocalSymbols` | `prune-local-symbols.ts` | `scopeResolution` | Drops inert block-local `Const`/`Variable`/`Static` nodes (only a `File→DEFINES` edge) post-resolution | +| `mro` | `mro.ts` | `crossFile`, `scopeResolution`, `pruneLocalSymbols`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges | +| `springAopInheritance` | `spring-aop.ts` | `springAop`, `mro` | Propagates declarative behavior through class/interface inheritance decisions | +| `di` | `di.ts` | `mro` | INJECTS edges from consumer Classes or factory Methods to provider Classes/declaration CodeElements (framework-neutral DI resolution; per-language matchers registered in `di-extractors/`) | +| `communities` | `communities.ts` | `mro`, `pruneLocalSymbols`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) | +| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `pruneLocalSymbols`, `structure` | Process nodes + STEP_IN_PROCESS edges | **Non-phase files in the same directory:** `parse-impl.ts`, `cross-file-impl.ts` (implementation), `wildcard-synthesis.ts` (whole-module import expansion), `types.ts`, `runner.ts`, `index.ts`. @@ -124,6 +129,7 @@ scan → structure → [markdown, cobol] → parse → [routes, tools, orm] 4. **Timing** — per-phase `durationMs` in `PhaseResult`, dev-mode console logging. **Design patterns:** + - **Single graph accumulator** — all phases mutate the same `KnowledgeGraph` in `ctx`; the graph is the primary output. - **Typed phase access** — `getPhaseOutput(deps, 'name')` for type-safe upstream results. - **Binding accumulator lifecycle** — created in `parse`, disposed by `crossFile` (in `finally`). No other phase should take ownership. @@ -141,7 +147,9 @@ import type { PipelinePhase, PhaseResult } from './types.js'; import { getPhaseOutput } from './types.js'; import type { ParseOutput } from './parse.js'; -export interface MyPhaseOutput { /* ... */ } +export interface MyPhaseOutput { + /* ... */ +} export const myPhase: PipelinePhase = { name: 'myPhase', @@ -149,7 +157,9 @@ export const myPhase: PipelinePhase = { async execute(ctx, deps) { const { allPaths } = getPhaseOutput(deps, 'parse'); // ... write to ctx.graph ... - return { /* typed output */ }; + return { + /* typed output */ + }; }, }; ``` @@ -228,7 +238,7 @@ Standalone (regex-based) providers such as COBOL participate via `ScopeResolver. On a `--pdg` run the parse worker builds a per-function control-flow graph from the tree-sitter AST (`LanguageProvider.cfgVisitor`; TypeScript/JavaScript today) and serializes it onto `ParsedFile.cfgSideChannel` as plain data. Scope-resolution then emits the program-dependence layers from that side-channel **inside Phase 4 of `runScopeResolution`, while the disk-backed ParsedFile store is still live** — the only window where the worker-built CFGs are loaded (the store is cleared right after the phase returns). A standalone post-`mro` phase would read an empty store, so the emit deliberately lives in-phase, mirroring the `applyCaptureSideChannel` pattern. The opt-in is off by default (graph byte-identical), folded into the parse-cache key (a pdg-off warm cache is never reused on a `--pdg` run), and each layer is bounded by a per-function edge cap that logs any dropped edges. All layers are `BasicBlock → BasicBlock` edges in the single `CodeRelation` table, keyed by `type`; there is **no** `Function → BasicBlock` edge — the symbol↔block join is reconstructed from the BasicBlock id prefix + line span. The layers build on each other: -- **M1 — CFG** (#2081): `BasicBlock` nodes + `CFG` edges. Edge *kind* (`seq`/`cond-true`/`loop-back`/…) rides the `reason` column (CFG is one `CodeRelation` type, not one per kind). +- **M1 — CFG** (#2081): `BasicBlock` nodes + `CFG` edges. Edge _kind_ (`seq`/`cond-true`/`loop-back`/…) rides the `reason` column (CFG is one `CodeRelation` type, not one per kind). - **M2 — REACHING_DEF** (#2082): GEN/KILL def→use data dependence from a pure fixpoint solver; the variable name rides `reason`. - **M3/M4 — TAINTED / SANITIZES / TAINT_PATH** (#2083–#2084): intra- and inter-procedural taint (source→sink) — the `explain` tool's data. - **M5 — CDG** (#2085): Ferrante control dependence over a Cooper–Harvey–Kennedy post-dominator tree (the EXIT-rooted reverse CFG); branch sense (`'T'`/`'F'`) rides `reason`. A CFG whose EXIT is unreachable from some block is skipped for CDG (post-dominance would be unsound) while its CFG/REACHING_DEF layers are kept. @@ -241,25 +251,25 @@ See `core/ingestion/cfg/` (emit + the pure CFG / post-dominator / control-depend Single interface a language implements to plug into the pipeline. Contract fully documented in `scope-resolution/contract/scope-resolver.ts`. -| Hook | Purpose | -|------|---------| -| `languageProvider` | Base `LanguageProvider` (tree-sitter query, `emitScopeCaptures`, import/binding interpreters, hooks) | -| `populateOwners(parsed)` | Fill deferred `ownerId` fields on method defs (captures can't always know the owning class at parse time) | -| `buildMro(graph, parsed, nodeLookup)` | Produce `mroByClassDefId: Map` — C3, Ruby-mixin, or first-wins per language | -| `resolveImportTarget(target, fromFile, allFiles)` | `(rawImportPath, sourceFile) → targetFilePath` (PEP-328 for Python, etc.) | -| `isNamespaceImport(parsedImport, targetFile, fromFile)` | Optionally reclassify a resolved named import as a namespace handle when the imported symbol is itself a module | -| `mergeBindings(existing, incoming, scopeId)` | Shadowing / LEGB precedence | -| `arityCompatibility` | Provider consumed by registry during `MethodRegistry.lookup` Step 2 | -| `importEdgeReason` | Confidence-tier string for IMPORTS edge reason field | -| `propagatesReturnTypesAcrossImports?` | Opt out of cross-file return-type propagation (default on) | -| `fieldFallbackOnMethodLookup?` | Statically-typed languages turn this OFF — the heuristic over-connects (default on) | -| `unwrapCollectionAccessor?` | Property-style collection views (`data.Values` on Dictionary-like receivers) — default off | -| `collapseMemberCallsByCallerTarget?` | One CALLS edge per (caller, target) instead of per-site — default off | -| `populateNamespaceSiblings?` | Cross-file implicit visibility (compiler-implicit namespace sharing) — default off; ctx carries `treeCache` | -| `hoistTypeBindingsToModule?` | Walk up to Module scope when looking up a method's return-type typeBinding — default off; enable only when bindings are stored at module level | -| `hasFileLocalCallableLinkage?` | Precise internal-linkage predicate used only when joining callable declarations/prototypes to cross-file definitions; C/C++ use it for `static` free functions | -| `constructorCallTargetsClass?` | A constructor-form call `Type(...)` links to the Class def rather than its explicit Constructor def — default off; Swift and Dart opt in | -| `constructionSyntax?` | How the language spells construction, so an INLINE constructor receiver (`Service(db).m()`, `new Service(db).m()`, `Service.new.m()`) can be typed — `bare` / `keyword` / `selector`; default off, opt in per language only where measured to be needed (#2708) | +| Hook | Purpose | +| ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `languageProvider` | Base `LanguageProvider` (tree-sitter query, `emitScopeCaptures`, import/binding interpreters, hooks) | +| `populateOwners(parsed)` | Fill deferred `ownerId` fields on method defs (captures can't always know the owning class at parse time) | +| `buildMro(graph, parsed, nodeLookup)` | Produce `mroByClassDefId: Map` — C3, Ruby-mixin, or first-wins per language | +| `resolveImportTarget(target, fromFile, allFiles)` | `(rawImportPath, sourceFile) → targetFilePath` (PEP-328 for Python, etc.) | +| `isNamespaceImport(parsedImport, targetFile, fromFile)` | Optionally reclassify a resolved named import as a namespace handle when the imported symbol is itself a module | +| `mergeBindings(existing, incoming, scopeId)` | Shadowing / LEGB precedence | +| `arityCompatibility` | Provider consumed by registry during `MethodRegistry.lookup` Step 2 | +| `importEdgeReason` | Confidence-tier string for IMPORTS edge reason field | +| `propagatesReturnTypesAcrossImports?` | Opt out of cross-file return-type propagation (default on) | +| `fieldFallbackOnMethodLookup?` | Statically-typed languages turn this OFF — the heuristic over-connects (default on) | +| `unwrapCollectionAccessor?` | Property-style collection views (`data.Values` on Dictionary-like receivers) — default off | +| `collapseMemberCallsByCallerTarget?` | One CALLS edge per (caller, target) instead of per-site — default off | +| `populateNamespaceSiblings?` | Cross-file implicit visibility (compiler-implicit namespace sharing) — default off; ctx carries `treeCache` | +| `hoistTypeBindingsToModule?` | Walk up to Module scope when looking up a method's return-type typeBinding — default off; enable only when bindings are stored at module level | +| `hasFileLocalCallableLinkage?` | Precise internal-linkage predicate used only when joining callable declarations/prototypes to cross-file definitions; C/C++ use it for `static` free functions | +| `constructorCallTargetsClass?` | A constructor-form call `Type(...)` links to the Class def rather than its explicit Constructor def — default off; Swift and Dart opt in | +| `constructionSyntax?` | How the language spells construction, so an INLINE constructor receiver (`Service(db).m()`, `new Service(db).m()`, `Service.new.m()`) can be typed — `bare` / `keyword` / `selector`; default off, opt in per language only where measured to be needed (#2708) | ### Per-language registration @@ -270,21 +280,21 @@ CI auto-discovers the set via `tsx`. No workflow edit required. ### Code references -| Module | Purpose | -|--------|---------| -| `scope-resolution/contract/scope-resolver.ts` | `ScopeResolver` interface + shared types | -| `scope-resolution/pipeline/run.ts` | Generic orchestrator | -| `scope-resolution/pipeline/phase.ts` | Pipeline-phase wrapper (deps: `parse`, `structure`) | -| `scope-resolution/pipeline/registry.ts` | `SCOPE_RESOLVERS` map | -| `scope-resolution/passes/*.ts` | Reference-resolution passes (receiver-bound, free-call fallback, compound-receiver, MRO, cross-file return-type propagation) | -| `scope-resolution/graph-bridge/*.ts` | CLI-local translation from resolved references → `KnowledgeGraph` edges | -| `scope-resolution/scope/*.ts` | Generic scope-chain walkers + namespace targets | -| `scope-resolution/workspace-index.ts` | Build-once O(1) lookup index | -| `languages/python/index.ts` | Python `ScopeResolver` hooks + known-limitation docs | -| `languages/python/captures.ts` | `emitPythonScopeCaptures` (honors cross-phase Tree cache) | -| `languages/csharp/index.ts` | C# `ScopeResolver` hooks + known-limitation docs | -| `languages/csharp/captures.ts` | `emitCsharpScopeCaptures` (honors cross-phase Tree cache) | -| `languages/csharp/namespace-siblings.ts` | Cross-file implicit-namespace visibility hook (reads `treeCache`) | +| Module | Purpose | +| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `scope-resolution/contract/scope-resolver.ts` | `ScopeResolver` interface + shared types | +| `scope-resolution/pipeline/run.ts` | Generic orchestrator | +| `scope-resolution/pipeline/phase.ts` | Pipeline-phase wrapper (deps: `parse`, `structure`) | +| `scope-resolution/pipeline/registry.ts` | `SCOPE_RESOLVERS` map | +| `scope-resolution/passes/*.ts` | Reference-resolution passes (receiver-bound, free-call fallback, compound-receiver, MRO, cross-file return-type propagation) | +| `scope-resolution/graph-bridge/*.ts` | CLI-local translation from resolved references → `KnowledgeGraph` edges | +| `scope-resolution/scope/*.ts` | Generic scope-chain walkers + namespace targets | +| `scope-resolution/workspace-index.ts` | Build-once O(1) lookup index | +| `languages/python/index.ts` | Python `ScopeResolver` hooks + known-limitation docs | +| `languages/python/captures.ts` | `emitPythonScopeCaptures` (honors cross-phase Tree cache) | +| `languages/csharp/index.ts` | C# `ScopeResolver` hooks + known-limitation docs | +| `languages/csharp/captures.ts` | `emitCsharpScopeCaptures` (honors cross-phase Tree cache) | +| `languages/csharp/namespace-siblings.ts` | Cross-file implicit-namespace visibility hook (reads `treeCache`) | ### Performance notes @@ -314,15 +324,15 @@ CI auto-discovers the set via `tsx`. No workflow edit required. Each language implements `LanguageProvider` (`language-provider.ts`). Key fields: -| Field | Purpose | -|-------|---------| -| `id`, `extensions` | Language identity and file matching | -| `treeSitterQueries` | S-expression queries for AST extraction | -| `importSemantics` | `named` / `wildcard-leaf` / `wildcard-transitive` / `namespace` | -| `importResolver` | Language-specific path → file resolution | -| `exportChecker` | Public/exported symbol detection | -| `typeConfig` | Type annotation extraction rules | -| `mroStrategy` | `first-wins` / `c3` / `none` | +| Field | Purpose | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id`, `extensions` | Language identity and file matching | +| `treeSitterQueries` | S-expression queries for AST extraction | +| `importSemantics` | `named` / `wildcard-leaf` / `wildcard-transitive` / `namespace` | +| `importResolver` | Language-specific path → file resolution | +| `exportChecker` | Public/exported symbol detection | +| `typeConfig` | Type annotation extraction rules | +| `mroStrategy` | `first-wins` / `c3` / `none` | | `descriptionExtractor` | Optional hook returning a symbol's doc-comment text as its `description`; feeds the embedding metadata header so doc-only terms are semantically searchable (issue #2270). Most languages register `createLeadingDocDescriptionExtractor` (shared, language-neutral; per-language comment/wrapper config passed at the call site) | 16 providers in `languages/index.ts` via `satisfies Record` — missing a language is a compile error. @@ -337,22 +347,23 @@ Per-language import resolution uses the **configs + factory** pattern (like call Unified 3-tier algorithm (`model/resolution-context.ts`), per-language `importSemantics` controls which tier activates: -| Tier | Confidence | Mechanism | -|------|-----------|-----------| -| 1 — same-file | 0.95 | Symbol table for caller's file | -| 2 — import-scoped | 0.9 | `NamedImportMap` chains (named) or all files in `importMap` (wildcard) | -| 3 — global | 0.5 | O(1) index lookups: class, impl, callable. Fallback only | +| Tier | Confidence | Mechanism | +| ----------------- | ---------- | ---------------------------------------------------------------------- | +| 1 — same-file | 0.95 | Symbol table for caller's file | +| 2 — import-scoped | 0.9 | `NamedImportMap` chains (named) or all files in `importMap` (wildcard) | +| 3 — global | 0.5 | O(1) index lookups: class, impl, callable. Fallback only | -| Import strategy | Languages | Behavior | -|----------------|-----------|----------| -| `named` | TS, JS, Java, C#, Rust, PHP, Kotlin | Only explicitly imported names visible | -| `wildcard-leaf` | Go, Ruby, Swift, Dart | Whole-package import, no transitive re-exports | -| `wildcard-transitive` | C, C++ | `#include` closure chains through re-exports | -| `namespace` | Python | Module aliases resolved at call site | +| Import strategy | Languages | Behavior | +| --------------------- | ----------------------------------- | ---------------------------------------------- | +| `named` | TS, JS, Java, C#, Rust, PHP, Kotlin | Only explicitly imported names visible | +| `wildcard-leaf` | Go, Ruby, Swift, Dart | Whole-package import, no transitive re-exports | +| `wildcard-transitive` | C, C++ | `#include` closure chains through re-exports | +| `namespace` | Python | Module aliases resolved at call site | ### Chunked parse-and-resolve `parse` processes files in ~20 MB byte-budget chunks to bound memory. Per chunk: + 1. Worker pool dispatches files (the sole parse path — there is no sequential fallback; `skipWorkers`, `--workers 0`, and `GITNEXUS_WORKER_POOL_SIZE=0` are rejected with an actionable error) 2. Each worker: detect language → load grammar → run queries → return unified `ParseWorkerResult` 3. Synthesize wildcard bindings (`wildcard-synthesis.ts`) @@ -363,11 +374,12 @@ Inheritance edges are emitted later, by the scope-resolution phase (`preEmitInhe Workers: `workers/worker-pool.ts`, `workers/parse-worker.ts`. -**Worker-serialized ParsedFiles (#2038).** To index very large repos (e.g. the Linux kernel) without OOM, the worker pool is the *sole* parse path and workers serialize each file's `ParsedFile` (plus its capture side-channel) in parallel, streaming them to scope-resolution through a disk-backed store. Scope-resolution consumes the pre-extracted artifact instead of re-parsing every file on the main thread — tree-sitter's native input buffers are not GC-reclaimable, so the former main-thread re-parse leaked native memory until the process died. Pool creation is lazy / cache-miss-gated, so a warm all-cache-hit run replays cached worker output without spawning a worker (hence `usedWorkerPool` can be false even when the repo has parseable files). +**Worker-serialized ParsedFiles (#2038).** To index very large repos (e.g. the Linux kernel) without OOM, the worker pool is the _sole_ parse path and workers serialize each file's `ParsedFile` (plus its capture side-channel) in parallel, streaming them to scope-resolution through a disk-backed store. Scope-resolution consumes the pre-extracted artifact instead of re-parsing every file on the main thread — tree-sitter's native input buffers are not GC-reclaimable, so the former main-thread re-parse leaked native memory until the process died. Pool creation is lazy / cache-miss-gated, so a warm all-cache-hit run replays cached worker output without spawning a worker (hence `usedWorkerPool` can be false even when the repo has parseable files). ### Inheritance and MRO Inheritance is captured by the `@reference.inherits` tag and emitted by the scope-resolution phase: `preEmitInheritanceEdges` resolves each base in scope, then `emitHeritageEdges` writes the `EXTENDS`/`IMPLEMENTS` edges. The phase then computes method resolution order via each `ScopeResolver`'s `buildMro` hook, feeding a `MethodDispatchIndex` used for owner-scoped lookups. Per-language strategy: + - **`first-wins`** — Java, C#, C++, TS, Ruby, Go - **`c3`** — Python (C3 linearization) - **`ruby-mixin`** — Ruby (mixin-aware linearization) @@ -419,7 +431,7 @@ Defined in `lbug/schema.ts`. Separate node tables per type, single `CodeRelation **Node tables:** File, Folder, Function, Class, Interface, Method, Constructor, CodeElement, Struct, Enum, Macro, Typedef, Union, Namespace, Trait, Impl, TypeAlias, Const, Static, Property, Record, Delegate, Annotation, Template, Module, Community, Process, Route, Tool, Section, Embedding. -**Relation types** (`CodeRelation.type`): CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, ACCESSES, METHOD_OVERRIDES, METHOD_IMPLEMENTS, MEMBER_OF, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF. +**Relation types** (`CodeRelation.type`): CONTAINS, DEFINES, CALLS, IMPORTS, INHERITS, EXTENDS, IMPLEMENTS, USES, DECORATES, HAS_METHOD, HAS_PROPERTY, ACCESSES, METHOD_OVERRIDES, METHOD_IMPLEMENTS, MEMBER_OF, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF, WRAPS, QUERIES, INJECTS, CONDITIONAL_ON, DECLARES, ADVISED_BY, BINDS_EVENT_HANDLER, EMITS_EVENT. **Optional `--pdg` additions** (off by default, opt-in via `gitnexus analyze --pdg`; see _Optional CFG/PDG emission_ above): a `BasicBlock` node table, plus the PDG relation types `CFG`, `REACHING_DEF`, `CDG`, `TAINTED`, `SANITIZES`, and `TAINT_PATH` on the same `CodeRelation` table. These are deliberately kept out of the default `VALID_RELATION_TYPES` / web graph schema — query them via `cypher`, `explain`, or `pdg_query`. @@ -447,12 +459,12 @@ Node IDs use arity suffix (`#`): `Method:file:Class.method#1` vs `#2 **METHOD_IMPLEMENTS confidence tiering:** -| Match quality | Confidence | -|---|---| -| Exact parameter types match | 1.0 | -| Arity match, types unavailable | 1.0 | -| Variadic vs fixed | 0.7 | -| Insufficient info | 0.7 | +| Match quality | Confidence | +| ------------------------------ | ---------- | +| Exact parameter types match | 1.0 | +| Arity match, types unavailable | 1.0 | +| Variadic vs fixed | 0.7 | +| Insufficient info | 0.7 | ## Related docs diff --git a/gitnexus-shared/src/graph/types.ts b/gitnexus-shared/src/graph/types.ts index cf6d7252b..d9d916e0d 100644 --- a/gitnexus-shared/src/graph/types.ts +++ b/gitnexus-shared/src/graph/types.ts @@ -153,6 +153,12 @@ export type RelationshipType = * semantics belong in `reason` so the relationship can be reused by other * metadata-driven systems. */ | 'DECLARES' + /** Framework advice relationship. Source = the class-like/Method whose behavior + * is intercepted; target = either the concrete advice Method or a synthetic + * CodeElement describing a declarative interceptor (transaction, cache, or + * method security). Runtime activation remains explicitly unknown in the + * relationship reason; this edge records statically visible advice only. */ + | 'ADVISED_BY' /** Vue component event system: a handler function in a parent component is * bound to an event emitted by a child component (`@event="handlerFn"`). * Source = handler Function/Method node in the parent. diff --git a/gitnexus-shared/src/lbug/schema-constants.ts b/gitnexus-shared/src/lbug/schema-constants.ts index ff3f4535b..350aa273d 100644 --- a/gitnexus-shared/src/lbug/schema-constants.ts +++ b/gitnexus-shared/src/lbug/schema-constants.ts @@ -72,6 +72,7 @@ export const REL_TYPES = [ 'INJECTS', 'CONDITIONAL_ON', 'DECLARES', + 'ADVISED_BY', // Taint/PDG substrate (issue #2080) — reserved edge types, emitted by no // phase yet (CFG → M1, REACHING_DEF → M2, TAINTED/SANITIZES/TAINT_PATH → // M3/M4). REACHING_DEF's variable name rides the relation's `reason` column. diff --git a/gitnexus/src/core/incremental/subgraph-extract.ts b/gitnexus/src/core/incremental/subgraph-extract.ts index 9f2e49a56..e0f0e41eb 100644 --- a/gitnexus/src/core/incremental/subgraph-extract.ts +++ b/gitnexus/src/core/incremental/subgraph-extract.ts @@ -55,10 +55,12 @@ import { isSpringAutoConfigurationDeclaration, isSpringAutoConfigurationSyntheticClass, } from '../ingestion/frameworks/spring/auto-configuration.js'; +import { isSpringAopEvidenceNode } from '../ingestion/frameworks/spring/aop.js'; const isGraphWideNode = (node: GraphNode): boolean => node.label === 'Community' || node.label === 'Process' || + isSpringAopEvidenceNode(node) || isSpringAutoConfigurationSyntheticClass(node); /** @@ -98,6 +100,10 @@ const isGraphWideRelationship = (relationship: GraphRelationship): boolean => relationship.type === 'TAINT_PATH' || relationship.type === 'CALL_SUMMARY' || relationship.type === 'INJECTS' || + // Spring pointcut matching (#2416) is repository-wide. A third-file change + // can alter annotation-name visibility or the set matched by a wildcard, + // even when neither endpoint file changed. + relationship.type === 'ADVISED_BY' || isSpringAutoConfigurationDeclaration(relationship); /** @@ -144,10 +150,13 @@ export const extractChangedSubgraph = ( /** * Public — derive the EFFECTIVE write-set: `toWriteSet` expanded by one - * hop along every edge in the new graph that crosses the writable - * boundary (one endpoint in a writable file, the other in an unchanged - * file). The unchanged-side file is pulled in so its stale rows are - * deleted + rewritten in lockstep with the changed side. + * hop along every file-owned edge in the new graph that crosses the + * writable boundary (one endpoint in a writable file, the other in an + * unchanged file). Graph-wide relationships are excluded: their owner + * phase delete-alls and re-extracts them independently, so following them + * here would turn high-fan-out metadata into a near-full-repository write. + * For ordinary edges, the unchanged-side file is pulled in so its stale + * rows are deleted + rewritten in lockstep with the changed side. * * Single pass over the edge list. Does NOT mutate `toWriteSet`. The * orchestrator MUST feed the returned set to both `deleteNodesForFiles` @@ -161,6 +170,7 @@ export const computeEffectiveWriteSet = ( const nodeFilePaths = indexNodeFilePaths(fullGraph); const expanded = new Set(toWriteSet); fullGraph.forEachRelationship((r: GraphRelationship) => { + if (isGraphWideRelationship(r)) return; const sourcePath = nodeFilePaths.get(r.sourceId); const targetPath = nodeFilePaths.get(r.targetId); if (!sourcePath || !targetPath) return; // skip edges to graph-wide nodes diff --git a/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts b/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts index 58edeca7d..37b5f2b14 100644 --- a/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts +++ b/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts @@ -27,3 +27,19 @@ export const SPRING_CONDITIONALS_FEATURE: AnalysisFeatureDescriptor = { version: 1, appliesTo: (filePaths) => filePaths.some(isSpringConditionOrAutoConfigurationFile), }; + +/** + * Candidate-language approximation, not a claim that the file contains AOP. + * Kotlin scripts are included because `.kts` is a supported Kotlin input. + */ +function isJvmSourceFile(filePath: string): boolean { + const normalized = filePath.replaceAll('\\', '/').toLowerCase(); + return normalized.endsWith('.java') || normalized.endsWith('.kt') || normalized.endsWith('.kts'); +} + +/** Durable completeness contract for Spring proxy/advice evidence (#2416). */ +export const SPRING_AOP_FEATURE: AnalysisFeatureDescriptor = { + id: 'spring.aop-advice', + version: 1, + appliesTo: (filePaths) => filePaths.some(isJvmSourceFile), +}; diff --git a/gitnexus/src/core/ingestion/frameworks/spring/annotation-arguments.ts b/gitnexus/src/core/ingestion/frameworks/spring/annotation-arguments.ts index eb05e44f2..928f3e805 100644 --- a/gitnexus/src/core/ingestion/frameworks/spring/annotation-arguments.ts +++ b/gitnexus/src/core/ingestion/frameworks/spring/annotation-arguments.ts @@ -6,7 +6,7 @@ export interface SpringAnnotationArgument { function splitTopLevel(value: string, separator: string): string[] | null { const parts: string[] = []; const stack: string[] = []; - let quote: '"' | "'" | null = null; + let quote: '"' | "'" | '"""' | null = null; let escaped = false; let start = 0; @@ -22,12 +22,24 @@ function splitTopLevel(value: string, separator: string): string[] | null { for (let index = 0; index < value.length; index++) { const char = value[index]; + if (quote === '"""') { + if (value.startsWith('"""', index)) { + quote = null; + index += 2; + } + continue; + } if (quote !== null) { if (escaped) escaped = false; else if (char === '\\') escaped = true; else if (char === quote) quote = null; continue; } + if (value.startsWith('"""', index)) { + quote = '"""'; + index += 2; + continue; + } if (char === '"' || char === "'") { quote = char; continue; @@ -53,7 +65,7 @@ function splitTopLevel(value: string, separator: string): string[] | null { function topLevelAssignment(argument: string): number { const stack: string[] = []; - let quote: '"' | "'" | null = null; + let quote: '"' | "'" | '"""' | null = null; let escaped = false; // Keep the same deliberate generic-delimiter policy as splitTopLevel. const closing = new Map([ @@ -65,12 +77,24 @@ function topLevelAssignment(argument: string): number { for (let index = 0; index < argument.length; index++) { const char = argument[index]; + if (quote === '"""') { + if (argument.startsWith('"""', index)) { + quote = null; + index += 2; + } + continue; + } if (quote !== null) { if (escaped) escaped = false; else if (char === '\\') escaped = true; else if (char === quote) quote = null; continue; } + if (argument.startsWith('"""', index)) { + quote = '"""'; + index += 2; + continue; + } if (char === '"' || char === "'") { quote = char; continue; @@ -119,6 +143,12 @@ export function parseSpringAnnotationArguments( export function parseStaticStringLiteral(value: string): string | null { const trimmed = value.trim(); + if (trimmed.startsWith('"""')) { + if (trimmed.length < 6 || !trimmed.endsWith('"""')) return null; + const raw = trimmed.slice(3, -3); + if (raw.includes('"""') || /\$(?:\{|[A-Za-z_])/.test(raw)) return null; + return raw; + } const match = /^"((?:\\.|[^"\\])*)"$/s.exec(trimmed); if (match === null) return null; if (/(^|[^\\])\$(?:\{|[A-Za-z_])/.test(match[1])) return null; diff --git a/gitnexus/src/core/ingestion/frameworks/spring/aop-candidates.ts b/gitnexus/src/core/ingestion/frameworks/spring/aop-candidates.ts new file mode 100644 index 000000000..3e1e8b2c4 --- /dev/null +++ b/gitnexus/src/core/ingestion/frameworks/spring/aop-candidates.ts @@ -0,0 +1,118 @@ +import type { GraphNode } from 'gitnexus-shared'; +import type { SpringAopStaticPointcut } from './aop.js'; + +export interface SpringAopOwnedMethod { + readonly method: GraphNode; + readonly owner: GraphNode; +} + +export interface SpringAopCandidateIndex { + readonly totalCandidates: number; + candidatesFor(pointcut: SpringAopStaticPointcut): readonly SpringAopOwnedMethod[]; +} + +interface OwnerBucket { + readonly id: string; + readonly qualifiedName: string; + readonly candidates: SpringAopOwnedMethod[]; +} + +function ownerPatternLiteralPrefix(pattern: string): string { + const wildcardIndex = pattern.indexOf('*'); + const descendantIndex = pattern.indexOf('..'); + const firstDynamicIndex = [wildcardIndex, descendantIndex] + .filter((index) => index >= 0) + .reduce((first, index) => Math.min(first, index), pattern.length); + return pattern.slice(0, firstDynamicIndex); +} + +function lowerBoundByQualifiedName(buckets: readonly OwnerBucket[], prefix: string): number { + let low = 0; + let high = buckets.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + if ((buckets[middle]?.qualifiedName ?? '') < prefix) low = middle + 1; + else high = middle; + } + return low; +} + +function compareStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +/** + * Build immutable candidate lists once per pipeline run. The pointcut matcher + * remains the final authority; this index only returns safe supersets. + */ +export function createSpringAopCandidateIndex( + candidates: readonly SpringAopOwnedMethod[], + methodAnnotations: ReadonlyMap>, +): SpringAopCandidateIndex { + const allCandidates = [...candidates]; + const candidatesByAnnotation = new Map(); + const candidatesByExactOwner = new Map(); + const ownerBucketsById = new Map(); + + for (const candidate of allCandidates) { + for (const annotation of methodAnnotations.get(candidate.method.id) ?? []) { + const annotated = candidatesByAnnotation.get(annotation) ?? []; + annotated.push(candidate); + candidatesByAnnotation.set(annotation, annotated); + } + + const qualifiedName = candidate.owner.properties.qualifiedName; + if (typeof qualifiedName !== 'string' || qualifiedName.length === 0) continue; + const exactOwnerCandidates = candidatesByExactOwner.get(qualifiedName) ?? []; + exactOwnerCandidates.push(candidate); + candidatesByExactOwner.set(qualifiedName, exactOwnerCandidates); + + let bucket = ownerBucketsById.get(candidate.owner.id); + if (bucket === undefined) { + bucket = { id: candidate.owner.id, qualifiedName, candidates: [] }; + ownerBucketsById.set(candidate.owner.id, bucket); + } + bucket.candidates.push(candidate); + } + + const ownerBuckets = [...ownerBucketsById.values()].sort( + (left, right) => + compareStrings(left.qualifiedName, right.qualifiedName) || compareStrings(left.id, right.id), + ); + const candidatesByOwnerPattern = new Map(); + + return { + totalCandidates: allCandidates.length, + candidatesFor(pointcut) { + if (pointcut.kind === 'annotation') { + return candidatesByAnnotation.get(pointcut.annotation) ?? []; + } + if (!pointcut.ownerPattern.includes('*') && !pointcut.ownerPattern.includes('..')) { + return candidatesByExactOwner.get(pointcut.ownerPattern) ?? []; + } + + // Unqualified wildcard patterns (for example `*Service` or `Order*`) + // match the owner simple name. The qualified-name prefix index cannot + // safely narrow those, so preserve the full candidate superset. + if (!pointcut.ownerPattern.includes('.')) return allCandidates; + + const prefix = ownerPatternLiteralPrefix(pointcut.ownerPattern); + if (prefix.length === 0) return allCandidates; + const cached = candidatesByOwnerPattern.get(pointcut.ownerPattern); + if (cached !== undefined) return cached; + + const selected: SpringAopOwnedMethod[] = []; + for ( + let index = lowerBoundByQualifiedName(ownerBuckets, prefix); + index < ownerBuckets.length; + index += 1 + ) { + const bucket = ownerBuckets[index]; + if (bucket === undefined || !bucket.qualifiedName.startsWith(prefix)) break; + selected.push(...bucket.candidates); + } + candidatesByOwnerPattern.set(pointcut.ownerPattern, selected); + return selected; + }, + }; +} diff --git a/gitnexus/src/core/ingestion/frameworks/spring/aop.ts b/gitnexus/src/core/ingestion/frameworks/spring/aop.ts new file mode 100644 index 000000000..fe82eaed6 --- /dev/null +++ b/gitnexus/src/core/ingestion/frameworks/spring/aop.ts @@ -0,0 +1,707 @@ +import type { GraphNode, ParsedFile, Range, ScopeId } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../../../graph/types.js'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { + resolveCallerGraphId, + resolveDefGraphId, +} from '../../scope-resolution/graph-bridge/ids.js'; +import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import { stripBidiAndZeroWidth } from '../../utils/ast-helpers.js'; +import { + parseSpringAnnotationArguments, + parseStaticStringLiteral, +} from './annotation-arguments.js'; +import { createSpringAnnotationNameResolver } from './bean-candidates.js'; + +export const SPRING_AOP_REASON_PREFIX = 'spring-aop:v1:'; +export const SPRING_AOP_EVIDENCE_DESCRIPTION_PREFIX = 'Spring AOP: '; +export const SPRING_AOP_EVIDENCE_ID_PREFIX = 'CodeElement:spring-aop:'; +const MAX_POINTCUT_LENGTH = 1_000; + +export type SpringAopBehavior = + | 'transactional' + | 'caching' + | 'cacheable' + | 'cache-evict' + | 'cache-put' + | 'authorization'; + +export type SpringAopAdviceKind = + | 'around' + | 'before' + | 'after' + | 'after-returning' + | 'after-throwing' + | 'pointcut'; + +export interface SpringAopAnnotationFact { + readonly name: string; + readonly text: string; + readonly line: number; + /** Kotlin use-site targets do not describe a callable annotation here. */ + readonly useSiteTarget?: string; +} + +export interface SpringAopOwnerFact< + Annotation extends SpringAopAnnotationFact = SpringAopAnnotationFact, +> { + readonly ownerScopeId: ScopeId; + readonly ownerKind: 'class' | 'callable'; + readonly ownerFilePath?: string; + /** Exact syntax range used only as a fail-closed bridge for collapsed language scopes. */ + readonly ownerRange?: Range; + /** The language models this owner/member as static, but it belongs to a singleton instance. */ + readonly singletonInstance?: true; + readonly annotations: readonly Annotation[]; +} + +export interface SpringAopMetadataAdapter { + getFacts(filePath: string): readonly SpringAopOwnerFact[]; + isPackageVisibilityIncomplete(filePath: string): boolean; +} + +export interface SpringAopBehaviorReason { + readonly kind: 'behavior'; + readonly annotation: string; + readonly behavior: SpringAopBehavior; + readonly declaredOn: 'class' | 'method'; + readonly activation: 'unknown'; + readonly proxy: 'possible'; +} + +export interface SpringAopAdviceReason { + readonly kind: 'advice'; + readonly annotation: string; + readonly advice: Exclude; + readonly pointcut: string; + readonly match: 'static'; + readonly activation: 'unknown'; + readonly proxy: 'possible'; +} + +export interface SpringAopPointcutReason { + readonly kind: 'pointcut'; + readonly annotation: string; + readonly pointcut: string | null; + readonly match: 'static' | 'unresolved'; + readonly resolution: 'resolved' | 'unknown'; +} + +export interface SpringAopAspectReason { + readonly kind: 'aspect'; + readonly annotation: string; + readonly activation: 'unknown'; + readonly registration: 'unknown'; +} + +export type SpringAopReason = + | SpringAopBehaviorReason + | SpringAopAdviceReason + | SpringAopPointcutReason + | SpringAopAspectReason; + +export interface SpringAopAspectRecord { + readonly ownerId: string; + readonly annotation: string; + readonly line: number; +} + +export interface SpringAopBehaviorRecord { + readonly ownerId: string; + readonly ownerKind: 'class' | 'callable'; + readonly annotation: string; + readonly behavior: SpringAopBehavior; + readonly line: number; +} + +export interface SpringAopAdviceRecord { + readonly ownerId: string; + readonly annotation: string; + readonly advice: SpringAopAdviceKind; + readonly pointcut: string | null; + readonly line: number; +} + +export interface SpringAopGraphMetadata { + readonly candidateFilePaths: ReadonlySet; + readonly aspectClassIds: ReadonlySet; + readonly singletonInstanceClassIds: ReadonlySet; + readonly aspects: readonly SpringAopAspectRecord[]; + readonly behaviors: readonly SpringAopBehaviorRecord[]; + readonly advices: readonly SpringAopAdviceRecord[]; +} + +interface MutableSpringAopGraphMetadata { + readonly candidateFilePaths: Set; + readonly aspectClassIds: Set; + readonly singletonInstanceClassIds: Set; + readonly aspects: SpringAopAspectRecord[]; + readonly behaviors: SpringAopBehaviorRecord[]; + readonly advices: SpringAopAdviceRecord[]; +} + +const metadataByGraph = new WeakMap(); + +function graphMetadata(graph: KnowledgeGraph): MutableSpringAopGraphMetadata { + let metadata = metadataByGraph.get(graph); + if (metadata === undefined) { + metadata = { + candidateFilePaths: new Set(), + aspectClassIds: new Set(), + singletonInstanceClassIds: new Set(), + aspects: [], + behaviors: [], + advices: [], + }; + metadataByGraph.set(graph, metadata); + } + return metadata; +} + +export function getSpringAopGraphMetadata(graph: KnowledgeGraph): SpringAopGraphMetadata { + return graphMetadata(graph); +} + +const ASPECT_ANNOTATION = 'org.aspectj.lang.annotation.Aspect'; + +const BEHAVIOR_ANNOTATIONS = new Map([ + ['org.springframework.transaction.annotation.Transactional', 'transactional'], + ['jakarta.transaction.Transactional', 'transactional'], + ['javax.transaction.Transactional', 'transactional'], + ['org.springframework.cache.annotation.Cacheable', 'cacheable'], + ['org.springframework.cache.annotation.CacheEvict', 'cache-evict'], + ['org.springframework.cache.annotation.CachePut', 'cache-put'], + ['org.springframework.cache.annotation.Caching', 'caching'], + ['org.springframework.security.access.prepost.PreAuthorize', 'authorization'], + ['org.springframework.security.access.prepost.PostAuthorize', 'authorization'], + ['org.springframework.security.access.prepost.PreFilter', 'authorization'], + ['org.springframework.security.access.prepost.PostFilter', 'authorization'], + ['org.springframework.security.access.annotation.Secured', 'authorization'], + ['jakarta.annotation.security.RolesAllowed', 'authorization'], + ['javax.annotation.security.RolesAllowed', 'authorization'], +]); + +const ADVICE_ANNOTATIONS = new Map([ + ['org.aspectj.lang.annotation.Around', 'around'], + ['org.aspectj.lang.annotation.Before', 'before'], + ['org.aspectj.lang.annotation.After', 'after'], + ['org.aspectj.lang.annotation.AfterReturning', 'after-returning'], + ['org.aspectj.lang.annotation.AfterThrowing', 'after-throwing'], + ['org.aspectj.lang.annotation.Pointcut', 'pointcut'], +]); + +const RECOGNIZED_AOP_ANNOTATIONS = new Set([ + ASPECT_ANNOTATION, + ...BEHAVIOR_ANNOTATIONS.keys(), + ...ADVICE_ANNOTATIONS.keys(), +]); + +const CAPTURE_RELEVANT_SIMPLE_NAMES = new Set( + [...RECOGNIZED_AOP_ANNOTATIONS].map((name) => simpleName(name)), +); + +function simpleName(name: string): string { + const separator = name.lastIndexOf('.'); + return separator === -1 ? name : name.slice(separator + 1); +} + +export function hasSpringAopRelevantAnnotation( + annotations: readonly Pick[], +): boolean { + return annotations.some((annotation) => + CAPTURE_RELEVANT_SIMPLE_NAMES.has(simpleName(annotation.name)), + ); +} + +function ownerGraphNode( + fact: SpringAopOwnerFact, + indexes: ScopeResolutionIndexes, + nodeLookup: GraphNodeLookup, + graph: KnowledgeGraph, + exactOwnerByRange: ReadonlyMap, +): GraphNode | undefined { + const ownerScope = indexes.scopeTree.getScope(fact.ownerScopeId); + + let ownerId: string | undefined; + if (fact.ownerKind === 'class' && ownerScope !== undefined) { + const classDef = ownerScope.ownedDefs.find( + (definition) => definition.type === 'Class' || definition.type === 'Interface', + ); + if (classDef !== undefined) + ownerId = resolveDefGraphId(classDef.filePath, classDef, nodeLookup); + } else if (ownerScope !== undefined) { + ownerId = resolveCallerGraphId(fact.ownerScopeId, indexes, nodeLookup); + } + + if (ownerId === undefined && fact.ownerFilePath !== undefined && fact.ownerRange !== undefined) { + const kind = fact.ownerKind === 'class' ? 'class' : 'callable'; + const fallback = exactOwnerByRange.get( + `${kind}\0${fact.ownerFilePath}\0${fact.ownerRange.startLine - 1}\0${fact.ownerRange.endLine - 1}`, + ); + if (fallback !== null && fallback !== undefined) ownerId = fallback.id; + } + if (ownerId === undefined) return undefined; + const owner = graph.getNode(ownerId); + return owner === undefined || owner.label === 'File' ? undefined : owner; +} + +function staticPointcutExpression(annotationText: string): string | null { + const args = parseSpringAnnotationArguments(annotationText); + if (args === null) return null; + const pointcutArguments = args.filter( + (argument) => + argument.name === undefined || argument.name === 'value' || argument.name === 'pointcut', + ); + if (pointcutArguments.length !== 1) return null; + const allowedCompanionArguments = new Set(['returning', 'throwing', 'argNames']); + if ( + args.some( + (argument) => + argument !== pointcutArguments[0] && + (argument.name === undefined || !allowedCompanionArguments.has(argument.name)), + ) + ) { + return null; + } + const argument = pointcutArguments[0]; + if (argument === undefined) return null; + const parsed = parseStaticStringLiteral(argument.value); + return parsed === null ? null : sanitizePointcut(parsed); +} + +function sanitizePointcut(value: string): string | null { + const normalized = stripBidiAndZeroWidth(value).replace(/\s+/g, ' ').trim(); + return normalized.length > 0 && normalized.length <= MAX_POINTCUT_LENGTH ? normalized : null; +} + +/** + * Resolve syntax facts after imports and package visibility are complete. + * Language adapters own AST shape only; this shared layer owns framework FQNs. + */ +export function createSpringAopMetadataAttacher( + adapter: SpringAopMetadataAdapter, +) { + return ( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, + indexes: ScopeResolutionIndexes, + ): void => { + const resolveAnnotation = createSpringAnnotationNameResolver(indexes); + const metadata = graphMetadata(graph); + const exactOwnerByRange = new Map(); + for (const node of graph.iterNodes()) { + const kind = + node.label === 'Method' + ? 'callable' + : node.label === 'Class' || node.label === 'Interface' + ? 'class' + : undefined; + if (kind === undefined || typeof node.properties.filePath !== 'string') continue; + const key = `${kind}\0${node.properties.filePath}\0${node.properties.startLine}\0${node.properties.endLine}`; + exactOwnerByRange.set(key, exactOwnerByRange.has(key) ? null : node); + } + let classIdByMethod: ReadonlyMap | undefined; + + const singletonOwnerId = (owner: GraphNode): string | undefined => { + if (owner.label === 'Class' || owner.label === 'Interface') return owner.id; + if (owner.label !== 'Method') return undefined; + if (classIdByMethod === undefined) { + const owners = new Map(); + for (const relationship of graph.iterRelationshipsByType('HAS_METHOD')) { + owners.set(relationship.targetId, relationship.sourceId); + } + classIdByMethod = owners; + } + return classIdByMethod.get(owner.id); + }; + + for (const parsed of parsedFiles) { + // The set is populated only by registered language adapters. The shared + // matcher can therefore reject same-qualified-name symbols from other + // languages without naming Java/Kotlin in framework-generic code. + metadata.candidateFilePaths.add(parsed.filePath); + const incomplete = adapter.isPackageVisibilityIncomplete(parsed.filePath); + const resolvedAnnotations = new Map(); + for (const fact of adapter.getFacts(parsed.filePath)) { + const owner = ownerGraphNode(fact, indexes, nodeLookup, graph, exactOwnerByRange); + const ownerScope = indexes.scopeTree.getScope(fact.ownerScopeId); + if (owner === undefined) continue; + if (fact.singletonInstance === true) { + const singletonId = singletonOwnerId(owner); + if (singletonId !== undefined) metadata.singletonInstanceClassIds.add(singletonId); + } + + for (const annotation of fact.annotations) { + // `@get:`, `@field:`, etc. target generated/property elements rather + // than the callable represented by this fact. Guessing would overstate + // proxy behavior, so Kotlin use-site targets fail closed. + if (annotation.useSiteTarget !== undefined) continue; + const enclosingScope = ownerScope?.parent ?? null; + const cacheKey = `${enclosingScope ?? ''}\0${annotation.name}`; + let resolved = resolvedAnnotations.get(cacheKey); + if (!resolvedAnnotations.has(cacheKey)) { + resolved = resolveAnnotation( + annotation.name, + parsed, + enclosingScope, + RECOGNIZED_AOP_ANNOTATIONS, + incomplete, + ); + resolvedAnnotations.set(cacheKey, resolved); + } + if (resolved === undefined) continue; + + if (resolved === ASPECT_ANNOTATION && fact.ownerKind === 'class') { + metadata.aspectClassIds.add(owner.id); + metadata.aspects.push({ + ownerId: owner.id, + annotation: resolved, + line: annotation.line, + }); + continue; + } + + const behavior = BEHAVIOR_ANNOTATIONS.get(resolved); + if (behavior !== undefined) { + metadata.behaviors.push({ + ownerId: owner.id, + ownerKind: fact.ownerKind, + annotation: resolved, + behavior, + line: annotation.line, + }); + continue; + } + + const advice = ADVICE_ANNOTATIONS.get(resolved); + if (advice !== undefined && fact.ownerKind === 'callable' && owner.label === 'Method') { + metadata.advices.push({ + ownerId: owner.id, + annotation: resolved, + advice, + pointcut: staticPointcutExpression(annotation.text), + line: annotation.line, + }); + } + } + } + } + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isBoundedString(value: unknown, maxLength = MAX_POINTCUT_LENGTH): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= maxLength; +} + +const BEHAVIORS = new Set([ + 'transactional', + 'caching', + 'cacheable', + 'cache-evict', + 'cache-put', + 'authorization', +]); +const ADVICES = new Set>([ + 'around', + 'before', + 'after', + 'after-returning', + 'after-throwing', +]); + +export function encodeSpringAopReason(reason: SpringAopReason): string { + return `${SPRING_AOP_REASON_PREFIX}${JSON.stringify(reason)}`; +} + +/** Decode only the current, validated reason contract; malformed/foreign rows fail closed. */ +export function decodeSpringAopReason(value: unknown): SpringAopReason | undefined { + if (typeof value !== 'string' || !value.startsWith(SPRING_AOP_REASON_PREFIX)) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(value.slice(SPRING_AOP_REASON_PREFIX.length)); + } catch { + return undefined; + } + if (!isRecord(parsed) || !isBoundedString(parsed.annotation)) return undefined; + + if ( + parsed.kind === 'behavior' && + typeof parsed.behavior === 'string' && + BEHAVIORS.has(parsed.behavior as SpringAopBehavior) && + BEHAVIOR_ANNOTATIONS.get(parsed.annotation) === parsed.behavior && + (parsed.declaredOn === 'class' || parsed.declaredOn === 'method') && + parsed.activation === 'unknown' && + parsed.proxy === 'possible' + ) { + return parsed as unknown as SpringAopBehaviorReason; + } + if ( + parsed.kind === 'advice' && + typeof parsed.advice === 'string' && + ADVICES.has(parsed.advice as Exclude) && + ADVICE_ANNOTATIONS.get(parsed.annotation) === parsed.advice && + isBoundedString(parsed.pointcut) && + parsed.match === 'static' && + parsed.activation === 'unknown' && + parsed.proxy === 'possible' + ) { + return parsed as unknown as SpringAopAdviceReason; + } + if ( + parsed.kind === 'pointcut' && + ADVICE_ANNOTATIONS.has(parsed.annotation) && + (parsed.pointcut === null || isBoundedString(parsed.pointcut)) && + ((parsed.match === 'static' && + parsed.resolution === 'resolved' && + typeof parsed.pointcut === 'string') || + (parsed.match === 'unresolved' && parsed.resolution === 'unknown')) + ) { + return parsed as unknown as SpringAopPointcutReason; + } + if ( + parsed.kind === 'aspect' && + parsed.annotation === ASPECT_ANNOTATION && + parsed.activation === 'unknown' && + parsed.registration === 'unknown' + ) { + return parsed as unknown as SpringAopAspectReason; + } + return undefined; +} + +export function isSpringAopEvidenceNode(node: GraphNode): boolean { + return node.label === 'CodeElement' && node.id.startsWith(SPRING_AOP_EVIDENCE_ID_PREFIX); +} + +export interface SpringAopExecutionPointcut { + readonly kind: 'execution'; + readonly ownerPattern: string; + readonly methodPattern: string; + readonly visibility?: 'public'; + readonly parameterCount?: number; +} + +export interface SpringAopWithinPointcut { + readonly kind: 'within'; + readonly ownerPattern: string; +} + +export interface SpringAopAnnotationPointcut { + readonly kind: 'annotation'; + readonly annotation: string; +} + +export type SpringAopStaticPointcut = + | SpringAopExecutionPointcut + | SpringAopWithinPointcut + | SpringAopAnnotationPointcut; + +const TYPE_PATTERN = /^[A-Za-z_$*][A-Za-z0-9_$.*]*$/; +const METHOD_PATTERN = /^[A-Za-z_$*][A-Za-z0-9_$*]*$/; + +/** Parse the deliberately narrow, fully static pointcut subset supported in v1. */ +export function parseSpringAopPointcut(expression: string): SpringAopStaticPointcut | null { + const normalized = sanitizePointcut(expression); + if (normalized === null) return null; + const annotation = /^@annotation\s*\(\s*([A-Za-z_$][A-Za-z0-9_$.]*)\s*\)$/.exec(normalized); + if (annotation !== null) { + const annotationName = annotation[1]; + return annotationName !== undefined && BEHAVIOR_ANNOTATIONS.has(annotationName) + ? { kind: 'annotation', annotation: annotationName } + : null; + } + const within = /^within\s*\(\s*([^()]+?)\s*\)$/.exec(normalized); + if (within !== null) { + const ownerPattern = within[1]?.trim(); + return ownerPattern !== undefined && validTypePattern(ownerPattern) + ? { kind: 'within', ownerPattern } + : null; + } + + const execution = /^execution\s*\(\s*([^()]*)\(([^()]*)\)\s*\)$/.exec(normalized); + if (execution === null) return null; + const head = execution[1]?.trim(); + const parameters = execution[2]?.trim(); + if (head === undefined || parameters === undefined) return null; + const tokens = head.split(/\s+/); + let visibility: 'public' | undefined; + let returnPattern: string; + let qualifiedMethod: string; + if (tokens.length === 2) { + [returnPattern, qualifiedMethod] = tokens as [string, string]; + } else if (tokens.length === 3 && tokens[0] === 'public') { + const publicTokens = tokens as [string, string, string]; + visibility = 'public'; + returnPattern = publicTokens[1]; + qualifiedMethod = publicTokens[2]; + } else { + return null; + } + if (returnPattern !== '*') return null; + + const separator = qualifiedMethod.lastIndexOf('.'); + const ownerPattern = separator === -1 ? '*' : qualifiedMethod.slice(0, separator); + const methodPattern = separator === -1 ? qualifiedMethod : qualifiedMethod.slice(separator + 1); + if (!validTypePattern(ownerPattern) || !METHOD_PATTERN.test(methodPattern)) return null; + + let parameterCount: number | undefined; + if (parameters === '..') parameterCount = undefined; + else if (parameters === '') parameterCount = 0; + else { + const parameterPatterns = parameters.split(',').map((part) => part.trim()); + if (parameterPatterns.some((part) => part !== '*')) return null; + parameterCount = parameterPatterns.length; + } + + return { + kind: 'execution', + ownerPattern, + methodPattern, + ...(visibility === undefined ? {} : { visibility }), + ...(parameterCount === undefined ? {} : { parameterCount }), + }; +} + +function validTypePattern(pattern: string): boolean { + return ( + TYPE_PATTERN.test(pattern) && + // A simple exact type name needs the aspect's package/import scope to + // resolve correctly. That context is not retained in the v1 record, so + // fail closed instead of claiming a static match. Unqualified wildcard + // patterns are self-contained and match the declaring type's simple name. + (pattern.includes('.') || pattern.includes('*')) && + !pattern.includes('...') && + pattern.split('..').length <= 2 && + !pattern.endsWith('.') + ); +} + +function findLiteral( + value: string, + literal: string, + startIndex: number, + endExclusive: number, +): number { + const prefixLengths = new Array(literal.length).fill(0); + for (let index = 1, prefixLength = 0; index < literal.length; index += 1) { + while (prefixLength > 0 && literal[index] !== literal[prefixLength]) { + prefixLength = prefixLengths[prefixLength - 1] ?? 0; + } + if (literal[index] === literal[prefixLength]) prefixLength += 1; + prefixLengths[index] = prefixLength; + } + + for (let index = startIndex, prefixLength = 0; index < endExclusive; index += 1) { + while (prefixLength > 0 && value[index] !== literal[prefixLength]) { + prefixLength = prefixLengths[prefixLength - 1] ?? 0; + } + if (value[index] === literal[prefixLength]) prefixLength += 1; + if (prefixLength === literal.length) return index - literal.length + 1; + } + return -1; +} + +/** Match a single-segment glob in O(pattern + value) without regex backtracking. */ +function segmentGlobMatches(pattern: string, value: string): boolean { + if (!pattern.includes('*')) return pattern === value; + const literalChunks = pattern.split('*').filter((chunk) => chunk.length > 0); + if (literalChunks.length === 0) return true; + + let firstMiddleChunk = 0; + let lastMiddleChunkExclusive = literalChunks.length; + let cursor = 0; + let middleEndExclusive = value.length; + + if (!pattern.startsWith('*')) { + const prefix = literalChunks[0] ?? ''; + if (!value.startsWith(prefix)) return false; + cursor = prefix.length; + firstMiddleChunk = 1; + } + + if (!pattern.endsWith('*')) { + const suffix = literalChunks[literalChunks.length - 1] ?? ''; + const suffixStart = value.length - suffix.length; + if (suffixStart < cursor || !value.endsWith(suffix)) return false; + middleEndExclusive = suffixStart; + lastMiddleChunkExclusive -= 1; + } + + for (let index = firstMiddleChunk; index < lastMiddleChunkExclusive; index += 1) { + const chunk = literalChunks[index] ?? ''; + const matchIndex = findLiteral(value, chunk, cursor, middleEndExclusive); + if (matchIndex === -1) return false; + cursor = matchIndex + chunk.length; + } + return cursor <= middleEndExclusive; +} + +function patternSegmentsMatch( + patternSegments: readonly string[], + valueSegments: readonly string[], +) { + return ( + patternSegments.length === valueSegments.length && + patternSegments.every((segment, index) => + segmentGlobMatches(segment, valueSegments[index] ?? ''), + ) + ); +} + +/** Match Spring's narrow type-pattern subset without compiling repository input as regex. */ +function typePatternMatches(pattern: string, value: string): boolean { + if (!validTypePattern(pattern) || value.length === 0) return false; + if (pattern === '*') return true; + + const valueSegments = value.split('.'); + if (valueSegments.some((segment) => segment.length === 0)) return false; + if (!pattern.includes('.')) { + return segmentGlobMatches(pattern, valueSegments[valueSegments.length - 1] ?? ''); + } + const pieces = pattern.split('..'); + if (pieces.length === 1) return patternSegmentsMatch(pattern.split('.'), valueSegments); + + const leftSegments = (pieces[0] ?? '').split('.'); + const rightSegments = (pieces[1] ?? '').split('.'); + if (valueSegments.length < leftSegments.length + rightSegments.length) return false; + return ( + patternSegmentsMatch(leftSegments, valueSegments.slice(0, leftSegments.length)) && + patternSegmentsMatch(rightSegments, valueSegments.slice(-rightSegments.length)) + ); +} + +export function springAopPointcutMatches( + pointcut: SpringAopStaticPointcut, + owner: GraphNode, + method: GraphNode, + methodAnnotations: ReadonlySet = new Set(), +): boolean { + if (method.label !== 'Method') return false; + if (pointcut.kind === 'annotation') return methodAnnotations.has(pointcut.annotation); + const qualifiedName = owner.properties.qualifiedName; + if (typeof qualifiedName !== 'string') return false; + if (!typePatternMatches(pointcut.ownerPattern, qualifiedName)) return false; + if (pointcut.kind === 'within') return true; + if ( + pointcut.visibility === 'public' && + method.properties.visibility !== 'public' && + // Java and Kotlin interface methods are public when no visibility modifier + // is present. Their extractors retain that absence as `package`; explicit + // private members remain private and therefore fail this exception. + !(owner.label === 'Interface' && method.properties.visibility === 'package') + ) { + return false; + } + if ( + pointcut.parameterCount !== undefined && + method.properties.parameterCount !== pointcut.parameterCount + ) { + return false; + } + return segmentGlobMatches(pointcut.methodPattern, method.properties.name); +} diff --git a/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts b/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts index 3fd9db774..7a7f82685 100644 --- a/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts +++ b/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts @@ -10,6 +10,7 @@ import { } from '../jvm/package-facts.js'; import { getJavaPackageFact, setJavaPackageFact } from './package-facts.js'; import type { JavaSpringConfigConsumerFact } from './spring-config-bindings.js'; +import type { JavaSpringAopFact } from './spring-aop.js'; import type { JavaSpringConditionalFact } from './spring-conditionals.js'; import type { JavaSpringDiClassFact } from './spring-di.js'; @@ -19,12 +20,14 @@ export interface JavaCaptureSideChannel { readonly kind: 'java'; readonly packageFact: JvmPackageFact; readonly classAnnotations: readonly JavaClassAnnotationFact[]; + readonly springAopFacts?: readonly JavaSpringAopFact[]; readonly springConfigConsumers?: readonly JavaSpringConfigConsumerFact[]; readonly springConditionalFacts?: readonly JavaSpringConditionalFact[]; readonly springDiFacts?: readonly JavaSpringDiClassFact[]; } const classAnnotations = createClassAnnotationFactStore(); +const springAopFacts = new Map(); const springConfigConsumers = new Map(); const springConditionalFacts = new Map(); const springDiFacts = new Map(); @@ -32,11 +35,21 @@ const springDiFacts = new Map(); /** Clear facts retained by a prior workspace pass in a long-lived process. */ export function clearJavaClassAnnotationFacts(): void { classAnnotations.clear(); + springAopFacts.clear(); springConfigConsumers.clear(); springConditionalFacts.clear(); springDiFacts.clear(); } +export function setJavaSpringAopFacts(filePath: string, facts: readonly JavaSpringAopFact[]): void { + if (facts.length === 0) springAopFacts.delete(filePath); + else springAopFacts.set(filePath, facts); +} + +export function getJavaSpringAopFacts(filePath: string): readonly JavaSpringAopFact[] { + return springAopFacts.get(filePath) ?? []; +} + /** Store the annotation syntax collected by Java's existing scope-query traversal. */ export function setJavaClassAnnotationFacts( filePath: string, @@ -90,12 +103,14 @@ export function collectJavaCaptureSideChannel( filePath: string, ): JavaCaptureSideChannel | undefined { const facts = classAnnotations.get(filePath); + const aopFacts = springAopFacts.get(filePath) ?? []; const configConsumers = springConfigConsumers.get(filePath) ?? []; const conditionFacts = springConditionalFacts.get(filePath) ?? []; const diFacts = springDiFacts.get(filePath) ?? []; const packageFact = getJavaPackageFact(filePath); if ( facts.length === 0 && + aopFacts.length === 0 && configConsumers.length === 0 && conditionFacts.length === 0 && diFacts.length === 0 && @@ -107,6 +122,7 @@ export function collectJavaCaptureSideChannel( kind: 'java', packageFact: packageFact ?? UNKNOWN_JVM_PACKAGE_FACT, classAnnotations: facts, + ...(aopFacts.length > 0 ? { springAopFacts: aopFacts } : {}), ...(configConsumers.length > 0 ? { springConfigConsumers: configConsumers } : {}), ...(conditionFacts.length > 0 ? { springConditionalFacts: conditionFacts } : {}), ...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}), @@ -128,6 +144,7 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void { !Array.isArray(data.classAnnotations) ) { setJavaClassAnnotationFacts(parsed.filePath, []); + setJavaSpringAopFacts(parsed.filePath, []); setJavaSpringConfigConsumerFacts(parsed.filePath, []); setJavaSpringConditionalFacts(parsed.filePath, []); setJavaSpringDiFacts(parsed.filePath, []); @@ -135,6 +152,10 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void { return; } setJavaClassAnnotationFacts(parsed.filePath, data.classAnnotations); + setJavaSpringAopFacts( + parsed.filePath, + Array.isArray(data.springAopFacts) ? data.springAopFacts : [], + ); setJavaSpringConfigConsumerFacts( parsed.filePath, Array.isArray(data.springConfigConsumers) ? data.springConfigConsumers : [], diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index dd463c83e..60d315df5 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -35,6 +35,7 @@ import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { setJavaClassAnnotationFacts, + setJavaSpringAopFacts, setJavaSpringConfigConsumerFacts, setJavaSpringConditionalFacts, setJavaSpringDiFacts, @@ -44,6 +45,7 @@ import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captur import { captureJavaSpringConfigConsumerFacts } from './spring-config-bindings.js'; import { captureJavaSpringDiClassFact, type JavaSpringDiClassFact } from './spring-di.js'; import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; +import { captureJavaSpringAopFacts, type JavaSpringAopFact } from './spring-aop.js'; import { captureJavaSpringConditionalFacts, type JavaSpringConditionalFact, @@ -132,6 +134,8 @@ export function emitJavaScopeCaptures( const rawMatches = getJavaScopeQuery().matches(tree.rootNode); const out: CaptureMatch[] = []; const classAnnotations = new Map>(); + const springAopFacts: JavaSpringAopFact[] = []; + const springAopTypeNodeIds = new Set(); const springConditionalFacts: JavaSpringConditionalFact[] = []; const springDiFacts: JavaSpringDiClassFact[] = []; const springDiClassNodeIds = new Set(); @@ -154,6 +158,15 @@ export function emitJavaScopeCaptures( } if (Object.keys(grouped).length === 0) continue; + const springAopTypeNode = [ + nodeIfType(nodeMap['@scope.class'], 'class_declaration'), + nodeIfType(nodeMap['@scope.class'], 'interface_declaration'), + ].find((node): node is SyntaxNode => node !== null); + if (springAopTypeNode !== undefined && !springAopTypeNodeIds.has(springAopTypeNode.id)) { + springAopTypeNodeIds.add(springAopTypeNode.id); + springAopFacts.push(...captureJavaSpringAopFacts(springAopTypeNode, filePath)); + } + const springDiClassNode = nodeIfType(nodeMap['@scope.class'], 'class_declaration'); if (springDiClassNode !== null && !springDiClassNodeIds.has(springDiClassNode.id)) { springDiClassNodeIds.add(springDiClassNode.id); @@ -375,6 +388,7 @@ export function emitJavaScopeCaptures( filePath, captureJavaSpringConfigConsumerFacts(tree.rootNode, filePath), ); + setJavaSpringAopFacts(filePath, springAopFacts); setJavaSpringConditionalFacts(filePath, springConditionalFacts); setJavaSpringDiFacts(filePath, springDiFacts); diff --git a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts index 3b27c5d41..86c94bccb 100644 --- a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts @@ -30,6 +30,7 @@ import { } from './index.js'; import { populateJavaPackageSiblings } from './package-siblings.js'; import { attachSpringBeanCandidateMetadata } from './spring-bean-metadata.js'; +import { attachJavaSpringAopMetadata } from './spring-aop.js'; import { attachJavaSpringConfigBindings } from './spring-config-bindings.js'; import { attachJavaSpringConditionalMetadata } from './spring-conditionals.js'; import { attachJavaSpringDiMetadata } from './spring-di.js'; @@ -88,6 +89,7 @@ const javaScopeResolver: ScopeResolver = { populateRangeBindings: populateJavaCrossFileReturnTypes, emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes, ctx) => { attachSpringBeanCandidateMetadata(graph, parsedFiles, nodeLookup, indexes); + attachJavaSpringAopMetadata(graph, parsedFiles, nodeLookup, indexes); attachJavaSpringConditionalMetadata(graph, parsedFiles, nodeLookup, indexes); attachJavaSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes); attachJavaSpringConfigBindings(graph, parsedFiles, nodeLookup, indexes, ctx); diff --git a/gitnexus/src/core/ingestion/languages/java/spring-aop.ts b/gitnexus/src/core/ingestion/languages/java/spring-aop.ts new file mode 100644 index 000000000..724e30691 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/spring-aop.ts @@ -0,0 +1,61 @@ +import { makeScopeId } from 'gitnexus-shared'; +import { + createSpringAopMetadataAttacher, + hasSpringAopRelevantAnnotation, + type SpringAopOwnerFact, +} from '../../frameworks/spring/aop.js'; +import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; +import { getJavaSpringAopFacts } from './capture-side-channel.js'; +import { isJavaPackageSiblingVisibilityIncomplete } from './package-siblings.js'; +import { javaSpringAnnotationFacts, type JavaAnnotationSyntaxFact } from './spring-di.js'; + +export type JavaSpringAopAnnotationFact = JavaAnnotationSyntaxFact; + +export type JavaSpringAopFact = SpringAopOwnerFact; + +function scopeId(filePath: string, node: SyntaxNode, kind: 'Class' | 'Function') { + return makeScopeId({ + filePath, + range: nodeToCapture('@spring-aop.owner', node).range, + kind, + }); +} + +/** + * Capture Spring AOP syntax while Java's existing class traversal already has + * the AST node in hand. Import/FQN resolution and pointcut matching remain in + * the shared post-resolution layer. + */ +export function captureJavaSpringAopFacts( + classNode: SyntaxNode, + filePath: string, +): JavaSpringAopFact[] { + const facts: JavaSpringAopFact[] = []; + const classAnnotations = javaSpringAnnotationFacts(classNode); + if (hasSpringAopRelevantAnnotation(classAnnotations)) { + facts.push({ + ownerScopeId: scopeId(filePath, classNode, 'Class'), + ownerKind: 'class', + annotations: classAnnotations, + }); + } + + const body = classNode.childForFieldName('body'); + if (body === null) return facts; + for (const member of body.namedChildren) { + if (member.type !== 'method_declaration') continue; + const annotations = javaSpringAnnotationFacts(member); + if (!hasSpringAopRelevantAnnotation(annotations)) continue; + facts.push({ + ownerScopeId: scopeId(filePath, member, 'Function'), + ownerKind: 'callable', + annotations, + }); + } + return facts; +} + +export const attachJavaSpringAopMetadata = createSpringAopMetadataAttacher({ + getFacts: getJavaSpringAopFacts, + isPackageVisibilityIncomplete: isJavaPackageSiblingVisibilityIncomplete, +}); diff --git a/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts b/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts index c1e8ce4f8..3c211bcc0 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts @@ -49,10 +49,12 @@ import { } from '../jvm/package-facts.js'; import { getCompanionScopesForFile, markCompanionScope } from './companion-scopes.js'; import { getKotlinPackageFact, setKotlinPackageFact } from './package-facts.js'; +import type { KotlinSpringAopFact } from './spring-aop.js'; import type { KotlinSpringConditionalFact } from './spring-conditionals.js'; import type { KotlinSpringDiClassFact } from './spring-di.js'; const classAnnotations = createClassAnnotationFactStore(); +const springAopFacts = new Map(); const springConditionalFacts = new Map(); const springDiFacts = new Map(); @@ -70,6 +72,8 @@ export interface KotlinCaptureSideChannel { readonly packageFact: JvmPackageFact; /** Class annotation syntax collected by the existing scope traversal. */ readonly classAnnotations: readonly ClassAnnotationFact[]; + /** Spring proxy/advice syntax captured per class or callable owner. */ + readonly springAopFacts?: readonly KotlinSpringAopFact[]; /** Profile, conditional, and auto-configuration syntax captured per owner. */ readonly springConditionalFacts?: readonly KotlinSpringConditionalFact[]; /** Constructor, property, and method injection syntax captured per class. */ @@ -78,10 +82,23 @@ export interface KotlinCaptureSideChannel { export function clearKotlinClassAnnotationFacts(): void { classAnnotations.clear(); + springAopFacts.clear(); springConditionalFacts.clear(); springDiFacts.clear(); } +export function setKotlinSpringAopFacts( + filePath: string, + facts: readonly KotlinSpringAopFact[], +): void { + if (facts.length === 0) springAopFacts.delete(filePath); + else springAopFacts.set(filePath, facts); +} + +export function getKotlinSpringAopFacts(filePath: string): readonly KotlinSpringAopFact[] { + return springAopFacts.get(filePath) ?? []; +} + export function setKotlinClassAnnotationFacts( filePath: string, facts: readonly ClassAnnotationFact[], @@ -129,12 +146,14 @@ export function collectKotlinCaptureSideChannel( ): KotlinCaptureSideChannel | undefined { const companionScopes = getCompanionScopesForFile(filePath); const annotationFacts = classAnnotations.get(filePath); + const aopFacts = springAopFacts.get(filePath) ?? []; const conditionFacts = springConditionalFacts.get(filePath) ?? []; const diFacts = springDiFacts.get(filePath) ?? []; const packageFact = getKotlinPackageFact(filePath); if ( companionScopes.length === 0 && annotationFacts.length === 0 && + aopFacts.length === 0 && conditionFacts.length === 0 && diFacts.length === 0 && packageFact === undefined @@ -146,6 +165,7 @@ export function collectKotlinCaptureSideChannel( companionScopes, packageFact: packageFact ?? UNKNOWN_JVM_PACKAGE_FACT, classAnnotations: annotationFacts, + ...(aopFacts.length > 0 ? { springAopFacts: aopFacts } : {}), ...(conditionFacts.length > 0 ? { springConditionalFacts: conditionFacts } : {}), ...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}), }; @@ -170,6 +190,7 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void { !Array.isArray(data.classAnnotations) ) { classAnnotations.set(parsed.filePath, []); + setKotlinSpringAopFacts(parsed.filePath, []); setKotlinSpringConditionalFacts(parsed.filePath, []); setKotlinSpringDiFacts(parsed.filePath, []); setKotlinPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT); @@ -179,6 +200,10 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void { markCompanionScope(parsed.filePath, scopeId); } classAnnotations.set(parsed.filePath, data.classAnnotations); + setKotlinSpringAopFacts( + parsed.filePath, + Array.isArray(data.springAopFacts) ? data.springAopFacts : [], + ); setKotlinSpringConditionalFacts( parsed.filePath, Array.isArray(data.springConditionalFacts) ? data.springConditionalFacts : [], diff --git a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts index 00868c174..4e063c3cf 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts @@ -20,6 +20,7 @@ import { getKotlinParser, getKotlinScopeQuery } from './query.js'; import { markCompanionScope } from './companion-scopes.js'; import { setKotlinClassAnnotationFacts, + setKotlinSpringAopFacts, setKotlinSpringConditionalFacts, setKotlinSpringDiFacts, } from './capture-side-channel.js'; @@ -27,6 +28,7 @@ import { captureKotlinPackageFact } from './package-facts.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; import { captureKotlinSpringDiClassFact, type KotlinSpringDiClassFact } from './spring-di.js'; import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; +import { captureKotlinSpringAopFacts, type KotlinSpringAopFact } from './spring-aop.js'; import { captureKotlinSpringConditionalFacts, type KotlinSpringConditionalFact, @@ -93,6 +95,8 @@ export function emitKotlinScopeCaptures( const out: CaptureMatch[] = []; const classAnnotations = new Map>(); + const springAopFacts: KotlinSpringAopFact[] = []; + const springAopTypeNodeIds = new Set(); const springConditionalFacts: KotlinSpringConditionalFact[] = []; const springDiFacts: KotlinSpringDiClassFact[] = []; const springDiClassNodeIds = new Set(); @@ -119,6 +123,18 @@ export function emitKotlinScopeCaptures( } if (Object.keys(grouped).length === 0) continue; + // tree-sitter-kotlin represents both classes and interfaces with + // `class_declaration`; `object_declaration` is the separate object form. + const springAopTypeNode = [ + nodeIfType(groupedNodes['@scope.class'], 'class_declaration'), + nodeIfType(groupedNodes['@scope.class'], 'object_declaration'), + nodeIfType(groupedNodes['@scope.class'], 'companion_object'), + ].find((node): node is SyntaxNode => node !== null); + if (springAopTypeNode !== undefined && !springAopTypeNodeIds.has(springAopTypeNode.id)) { + springAopTypeNodeIds.add(springAopTypeNode.id); + springAopFacts.push(...captureKotlinSpringAopFacts(springAopTypeNode, filePath)); + } + const springDiClassNode = nodeIfType(groupedNodes['@scope.class'], 'class_declaration'); if (springDiClassNode !== null && !springDiClassNodeIds.has(springDiClassNode.id)) { springDiClassNodeIds.add(springDiClassNode.id); @@ -323,6 +339,7 @@ export function emitKotlinScopeCaptures( } setKotlinClassAnnotationFacts(filePath, materializeClassAnnotationFacts(classAnnotations)); + setKotlinSpringAopFacts(filePath, springAopFacts); setKotlinSpringConditionalFacts(filePath, springConditionalFacts); setKotlinSpringDiFacts(filePath, springDiFacts); out.push(...synthesizeCallableFlowCaptures(tree.rootNode, KOTLIN_CALLABLE_CAPTURE_OPTIONS)); diff --git a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts index bad2b85d0..f2a7c9d21 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts @@ -21,6 +21,7 @@ import { import { isKotlinStaticOnly } from './owners.js'; import { populateKotlinPackageSiblings } from './package-siblings.js'; import { attachKotlinSpringBeanCandidateMetadata } from './spring-bean-metadata.js'; +import { attachKotlinSpringAopMetadata } from './spring-aop.js'; import { clearKotlinPackageFacts } from './package-facts.js'; import { attachKotlinSpringDiMetadata } from './spring-di.js'; import { attachKotlinSpringConditionalMetadata } from './spring-conditionals.js'; @@ -127,6 +128,7 @@ export const kotlinScopeResolver: ScopeResolver = { populateNamespaceSiblings: populateKotlinPackageSiblings, emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes) => { attachKotlinSpringBeanCandidateMetadata(graph, parsedFiles, nodeLookup, indexes); + attachKotlinSpringAopMetadata(graph, parsedFiles, nodeLookup, indexes); attachKotlinSpringConditionalMetadata(graph, parsedFiles, nodeLookup, indexes); attachKotlinSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes); }, diff --git a/gitnexus/src/core/ingestion/languages/kotlin/spring-aop.ts b/gitnexus/src/core/ingestion/languages/kotlin/spring-aop.ts new file mode 100644 index 000000000..797651619 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/spring-aop.ts @@ -0,0 +1,77 @@ +import { makeScopeId } from 'gitnexus-shared'; +import { + createSpringAopMetadataAttacher, + type SpringAopOwnerFact, +} from '../../frameworks/spring/aop.js'; +import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; +import { getKotlinSpringAopFacts } from './capture-side-channel.js'; +import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.js'; +import { kotlinSpringAnnotationFacts, type KotlinAnnotationSyntaxFact } from './spring-di.js'; + +export type KotlinSpringAopAnnotationFact = KotlinAnnotationSyntaxFact; + +export type KotlinSpringAopFact = SpringAopOwnerFact; + +function scopeId(filePath: string, node: SyntaxNode, kind: 'Class' | 'Function') { + return makeScopeId({ + filePath, + range: nodeToCapture('@spring-aop.owner', node).range, + kind, + }); +} + +function ownerRange(node: SyntaxNode) { + return nodeToCapture('@spring-aop.owner', node).range; +} + +/** + * Capture Spring AOP syntax from the class node already surfaced by Kotlin's + * scope query. The shared layer resolves annotations and rejects non-default + * use-site targets after imports and package visibility have finalized. + */ +export function captureKotlinSpringAopFacts( + classNode: SyntaxNode, + filePath: string, +): KotlinSpringAopFact[] { + const facts: KotlinSpringAopFact[] = []; + const classAnnotations = kotlinSpringAnnotationFacts(classNode); + const singletonInstance = + classNode.type === 'object_declaration' || classNode.type === 'companion_object'; + // Kotlin import aliases can give a Spring annotation any local simple name. + // Capture annotated owners conservatively, then let the post-import shared + // resolver keep only recognized Spring AOP annotations. Objects also retain + // an empty owner fact so the shared phase can distinguish their singleton + // instance members from true static methods without naming Kotlin. + if (classAnnotations.length > 0 || singletonInstance) { + facts.push({ + ownerScopeId: scopeId(filePath, classNode, 'Class'), + ownerKind: 'class', + ownerFilePath: filePath, + ownerRange: ownerRange(classNode), + ...(singletonInstance ? { singletonInstance: true } : {}), + annotations: classAnnotations, + }); + } + + const body = classNode.namedChildren.find((child) => child.type === 'class_body'); + if (body === undefined) return facts; + for (const member of body.namedChildren) { + if (member.type !== 'function_declaration') continue; + const annotations = kotlinSpringAnnotationFacts(member); + if (annotations.length === 0) continue; + facts.push({ + ownerScopeId: scopeId(filePath, member, 'Function'), + ownerKind: 'callable', + ownerFilePath: filePath, + ownerRange: ownerRange(member), + ...(singletonInstance ? { singletonInstance: true } : {}), + annotations, + }); + } + return facts; +} + +export const attachKotlinSpringAopMetadata = createSpringAopMetadataAttacher({ + getFacts: getKotlinSpringAopFacts, + isPackageVisibilityIncomplete: isKotlinPackageSiblingVisibilityIncomplete, +}); diff --git a/gitnexus/src/core/ingestion/pipeline-phases/index.ts b/gitnexus/src/core/ingestion/pipeline-phases/index.ts index 0095c6bef..359d80a65 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/index.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/index.ts @@ -25,6 +25,12 @@ export { springAutoConfigurationPhase, type SpringAutoConfigurationOutput, } from './spring-auto-configuration.js'; +export { + springAopPhase, + springAopInheritancePhase, + type SpringAopOutput, + type SpringAopInheritanceOutput, +} from './spring-aop.js'; export { pruneLocalSymbolsPhase, type PruneLocalSymbolsOutput } from './prune-local-symbols.js'; export { taintSummariesPhase, type TaintSummariesOutput } from './taint-summaries.js'; export { callSummariesPhase, type CallSummariesOutput } from './call-summaries.js'; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/spring-aop.ts b/gitnexus/src/core/ingestion/pipeline-phases/spring-aop.ts new file mode 100644 index 000000000..8e4ac3cc5 --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/spring-aop.ts @@ -0,0 +1,618 @@ +/** + * Phase: springAop + * + * Materializes statically visible Spring proxy/advice behavior after every + * language resolver has attached normalized metadata to the shared graph. + * The post-MRO export in this file propagates declarative behavior through + * METHOD_OVERRIDES/METHOD_IMPLEMENTS without changing @annotation semantics. + * + * @deps scopeResolution + * @reads Class/Method nodes, HAS_METHOD edges, shared Spring AOP metadata + * @writes synthetic CodeElement nodes, DEFINES/DECLARES/ADVISED_BY edges + */ + +import type { GraphNode } from 'gitnexus-shared'; +import { generateId } from '../../../lib/utils.js'; +import { + decodeSpringAopReason, + encodeSpringAopReason, + getSpringAopGraphMetadata, + parseSpringAopPointcut, + SPRING_AOP_EVIDENCE_DESCRIPTION_PREFIX, + springAopPointcutMatches, + type SpringAopAdviceRecord, + type SpringAopAspectRecord, + type SpringAopBehaviorRecord, + type SpringAopPointcutReason, +} from '../frameworks/spring/aop.js'; +import { + createSpringAopCandidateIndex, + type SpringAopCandidateIndex, + type SpringAopOwnedMethod, +} from '../frameworks/spring/aop-candidates.js'; +import { toZeroBasedLine } from '../utils/line-base.js'; +import type { PipelineContext, PipelinePhase } from './types.js'; +import { logger } from '../../logger.js'; + +export const DEFAULT_SPRING_AOP_MAX_CANDIDATE_INSPECTIONS_PER_ADVICE = 100_000; +export const DEFAULT_SPRING_AOP_MAX_CANDIDATE_INSPECTIONS = 2_000_000; +export const DEFAULT_SPRING_AOP_MAX_ADVISED_EDGES_PER_ADVICE = 25_000; +export const DEFAULT_SPRING_AOP_MAX_ADVISED_EDGES = 100_000; + +export interface SpringAopOutput { + readonly advisedByEdges: number; + readonly evidenceNodes: number; + readonly unresolvedPointcuts: number; + readonly candidateInspections: number; + readonly truncatedAdvices: number; +} + +export interface SpringAopInheritanceOutput { + readonly inheritedBehaviorEdges: number; +} + +function simpleName(name: string): string { + const separator = name.lastIndexOf('.'); + return separator === -1 ? name : name.slice(separator + 1); +} + +function eligibleMethod( + node: GraphNode, + owner: GraphNode | undefined, + singletonInstanceClassIds: ReadonlySet, +): boolean { + return ( + node.label === 'Method' && + (node.properties.isStatic !== true || + (owner !== undefined && singletonInstanceClassIds.has(owner.id))) && + node.properties.visibility !== 'private' + ); +} + +function eligibleOwner(node: GraphNode): boolean { + return node.label === 'Class' || node.label === 'Interface'; +} + +function addEvidenceNode( + ctx: PipelineContext, + owner: GraphNode, + annotation: string, + line: number, + discriminator: string, + description: string, +): GraphNode { + const evidenceId = generateId( + 'CodeElement', + `spring-aop:${owner.id}:${line}:${annotation}:${discriminator}`, + ); + const evidence: GraphNode = { + id: evidenceId, + label: 'CodeElement', + properties: { + name: `@${simpleName(annotation)}`, + filePath: owner.properties.filePath, + startLine: toZeroBasedLine(line), + endLine: toZeroBasedLine(line), + isExported: false, + description: `${SPRING_AOP_EVIDENCE_DESCRIPTION_PREFIX}${description}`, + }, + }; + ctx.graph.addNode(evidence); + + const fileId = generateId('File', owner.properties.filePath); + if (ctx.graph.getNode(fileId) !== undefined) { + ctx.graph.addRelationship({ + id: generateId('DEFINES', `${fileId}->${evidenceId}`), + sourceId: fileId, + targetId: evidenceId, + type: 'DEFINES', + confidence: 1, + reason: 'spring-aop:evidence', + }); + } + return evidence; +} + +function emitBehavior( + ctx: PipelineContext, + record: SpringAopBehaviorRecord, + classMethods: ReadonlyMap, + ownerByMethod: ReadonlyMap, + singletonInstanceClassIds: ReadonlySet, +): { edges: number; evidence: number } { + const owner = ctx.graph.getNode(record.ownerId); + if (owner === undefined || (!eligibleOwner(owner) && owner.label !== 'Method')) { + return { edges: 0, evidence: 0 }; + } + if ( + owner.label === 'Method' && + !eligibleMethod(owner, ownerByMethod.get(owner.id), singletonInstanceClassIds) + ) { + return { edges: 0, evidence: 0 }; + } + const evidence = addEvidenceNode( + ctx, + owner, + record.annotation, + record.line, + `behavior:${record.behavior}`, + `${record.behavior} interceptor; activation unknown; proxy possible`, + ); + const reason = encodeSpringAopReason({ + kind: 'behavior', + annotation: record.annotation, + behavior: record.behavior, + declaredOn: record.ownerKind === 'class' ? 'class' : 'method', + activation: 'unknown', + proxy: 'possible', + }); + + const sources = + record.ownerKind === 'class' ? [owner, ...(classMethods.get(owner.id) ?? [])] : [owner]; + let edges = 0; + for (const source of sources) { + if ( + !eligibleOwner(source) && + !eligibleMethod(source, ownerByMethod.get(source.id), singletonInstanceClassIds) + ) { + continue; + } + ctx.graph.addRelationship({ + id: generateId( + 'ADVISED_BY', + `${source.id}->${evidence.id}:${record.line}:${record.behavior}`, + ), + sourceId: source.id, + targetId: evidence.id, + type: 'ADVISED_BY', + confidence: 1, + reason, + }); + edges++; + } + return { edges, evidence: 1 }; +} + +function emitAspect(ctx: PipelineContext, record: SpringAopAspectRecord): number { + const owner = ctx.graph.getNode(record.ownerId); + if (owner === undefined || !eligibleOwner(owner)) return 0; + const evidence = addEvidenceNode( + ctx, + owner, + record.annotation, + record.line, + 'aspect', + 'AspectJ aspect marker; registration unknown; activation unknown', + ); + ctx.graph.addRelationship({ + id: generateId('DECLARES', `${owner.id}->${evidence.id}`), + sourceId: owner.id, + targetId: evidence.id, + type: 'DECLARES', + confidence: 1, + reason: encodeSpringAopReason({ + kind: 'aspect', + annotation: record.annotation, + activation: 'unknown', + registration: 'unknown', + }), + }); + return 1; +} + +function pointcutReason(record: SpringAopAdviceRecord, resolved: boolean): SpringAopPointcutReason { + return { + kind: 'pointcut', + annotation: record.annotation, + pointcut: record.pointcut, + match: resolved ? 'static' : 'unresolved', + resolution: resolved ? 'resolved' : 'unknown', + }; +} + +interface SpringAopAdviceBudget { + readonly maxInspectionsPerAdvice: number; + readonly maxInspections: number; + readonly maxEdgesPerAdvice: number; + readonly maxEdges: number; + inspections: number; + edges: number; +} + +function resolveBudget(value: number | undefined, fallback: number): number { + return Number.isSafeInteger(value) && value !== undefined && value >= 0 ? value : fallback; +} + +function budgetReached(limit: number, used: number): boolean { + return limit !== 0 && used >= limit; +} + +function emitAdvice( + ctx: PipelineContext, + record: SpringAopAdviceRecord, + aspectClassIds: ReadonlySet, + ownerByMethod: ReadonlyMap, + candidateIndex: SpringAopCandidateIndex, + methodAnnotations: ReadonlyMap>, + budget: SpringAopAdviceBudget, +): { + edges: number; + evidence: number; + unresolved: number; + inspections: number; + truncated: boolean; +} { + const adviceNode = ctx.graph.getNode(record.ownerId); + if (adviceNode === undefined || adviceNode.label !== 'Method') { + return { edges: 0, evidence: 0, unresolved: 0, inspections: 0, truncated: false }; + } + const staticPointcut = record.pointcut; + const parsed = staticPointcut === null ? null : parseSpringAopPointcut(staticPointcut); + const isAdviceMethod = record.advice !== 'pointcut'; + const adviceOwner = ownerByMethod.get(adviceNode.id); + const activeAspect = adviceOwner !== undefined && aspectClassIds.has(adviceOwner.id); + const resolved = parsed !== null && (!isAdviceMethod || activeAspect); + const evidence = addEvidenceNode( + ctx, + adviceNode, + record.annotation, + record.line, + `pointcut:${record.advice}:${record.pointcut ?? ''}`, + `${record.advice} pointcut ${record.pointcut ?? ''}; resolution ${ + resolved ? 'resolved' : 'unknown' + }`, + ); + ctx.graph.addRelationship({ + id: generateId('DECLARES', `${adviceNode.id}->${evidence.id}`), + sourceId: adviceNode.id, + targetId: evidence.id, + type: 'DECLARES', + confidence: 1, + reason: encodeSpringAopReason(pointcutReason(record, resolved)), + }); + + if (!isAdviceMethod || !resolved || parsed === null || staticPointcut === null) { + return { + edges: 0, + evidence: 1, + unresolved: resolved ? 0 : 1, + inspections: 0, + truncated: false, + }; + } + + let edges = 0; + let inspections = 0; + let truncated = false; + for (const candidate of candidateIndex.candidatesFor(parsed)) { + if ( + budgetReached(budget.maxInspectionsPerAdvice, inspections) || + budgetReached(budget.maxInspections, budget.inspections) + ) { + truncated = true; + break; + } + inspections += 1; + budget.inspections += 1; + if (candidate.method.id === adviceNode.id) continue; + if (aspectClassIds.has(candidate.owner.id)) continue; + if ( + !springAopPointcutMatches( + parsed, + candidate.owner, + candidate.method, + methodAnnotations.get(candidate.method.id), + ) + ) { + continue; + } + if ( + budgetReached(budget.maxEdgesPerAdvice, edges) || + budgetReached(budget.maxEdges, budget.edges) + ) { + truncated = true; + break; + } + ctx.graph.addRelationship({ + id: generateId( + 'ADVISED_BY', + `${candidate.method.id}->${adviceNode.id}:${record.line}:${record.advice}`, + ), + sourceId: candidate.method.id, + targetId: adviceNode.id, + type: 'ADVISED_BY', + confidence: 0.95, + reason: encodeSpringAopReason({ + kind: 'advice', + annotation: record.annotation, + advice: record.advice, + pointcut: staticPointcut, + match: 'static', + activation: 'unknown', + proxy: 'possible', + }), + }); + edges++; + budget.edges += 1; + } + return { edges, evidence: 1, unresolved: 0, inspections, truncated }; +} + +export const springAopPhase: PipelinePhase = { + name: 'springAop', + deps: ['scopeResolution'], + + async execute(ctx: PipelineContext): Promise { + const metadata = getSpringAopGraphMetadata(ctx.graph); + if ( + metadata.aspects.length === 0 && + metadata.behaviors.length === 0 && + metadata.advices.length === 0 + ) { + return { + advisedByEdges: 0, + evidenceNodes: 0, + unresolvedPointcuts: 0, + candidateInspections: 0, + truncatedAdvices: 0, + }; + } + + ctx.onProgress({ + phase: 'enriching', + percent: 97, + message: 'Resolving Spring proxy and advice edges...', + stats: { filesProcessed: 0, totalFiles: 0, nodesCreated: ctx.graph.nodeCount }, + }); + + const classMethods = new Map(); + const ownerByMethod = new Map(); + const methodAnnotations = new Map>(); + const candidates: SpringAopOwnedMethod[] = []; + for (const relationship of ctx.graph.iterRelationshipsByType('HAS_METHOD')) { + const owner = ctx.graph.getNode(relationship.sourceId); + const method = ctx.graph.getNode(relationship.targetId); + if (owner === undefined || !eligibleOwner(owner) || method?.label !== 'Method') continue; + const ownerFilePath = owner.properties.filePath; + if (typeof ownerFilePath !== 'string' || !metadata.candidateFilePaths.has(ownerFilePath)) { + continue; + } + ownerByMethod.set(method.id, owner); + if (!eligibleMethod(method, owner, metadata.singletonInstanceClassIds)) continue; + const methods = classMethods.get(owner.id) ?? []; + methods.push(method); + classMethods.set(owner.id, methods); + candidates.push({ method, owner }); + } + + let advisedByEdges = 0; + let evidenceNodes = 0; + let unresolvedPointcuts = 0; + let candidateInspections = 0; + let truncatedAdvices = 0; + const budget: SpringAopAdviceBudget = { + maxInspectionsPerAdvice: resolveBudget( + ctx.options?.springAopMaxCandidateInspectionsPerAdvice, + DEFAULT_SPRING_AOP_MAX_CANDIDATE_INSPECTIONS_PER_ADVICE, + ), + maxInspections: resolveBudget( + ctx.options?.springAopMaxCandidateInspections, + DEFAULT_SPRING_AOP_MAX_CANDIDATE_INSPECTIONS, + ), + maxEdgesPerAdvice: resolveBudget( + ctx.options?.springAopMaxAdvisedEdgesPerAdvice, + DEFAULT_SPRING_AOP_MAX_ADVISED_EDGES_PER_ADVICE, + ), + maxEdges: resolveBudget( + ctx.options?.springAopMaxAdvisedEdges, + DEFAULT_SPRING_AOP_MAX_ADVISED_EDGES, + ), + inspections: 0, + edges: 0, + }; + for (const aspect of metadata.aspects) evidenceNodes += emitAspect(ctx, aspect); + for (const behavior of metadata.behaviors) { + // `@annotation` matches annotations declared directly on the method. + // Class-level behavior is fanned out by emitBehavior, but must not be + // copied here (that would model @within/@target semantics instead). + if (behavior.ownerKind === 'callable') { + const annotations = methodAnnotations.get(behavior.ownerId) ?? new Set(); + annotations.add(behavior.annotation); + methodAnnotations.set(behavior.ownerId, annotations); + } + const emitted = emitBehavior( + ctx, + behavior, + classMethods, + ownerByMethod, + metadata.singletonInstanceClassIds, + ); + advisedByEdges += emitted.edges; + evidenceNodes += emitted.evidence; + } + const candidateIndex = createSpringAopCandidateIndex(candidates, methodAnnotations); + for (const advice of metadata.advices) { + const emitted = emitAdvice( + ctx, + advice, + metadata.aspectClassIds, + ownerByMethod, + candidateIndex, + methodAnnotations, + budget, + ); + advisedByEdges += emitted.edges; + evidenceNodes += emitted.evidence; + unresolvedPointcuts += emitted.unresolved; + candidateInspections += emitted.inspections; + if (emitted.truncated) { + truncatedAdvices += 1; + logger.warn( + `[spring-aop] truncated advice ${advice.ownerId}: ` + + `${emitted.inspections} candidate inspections, ${emitted.edges} edges emitted; ` + + `run totals ${budget.inspections} inspections/${budget.edges} edges`, + ); + } + } + + if (truncatedAdvices > 0) { + ctx.onProgress({ + phase: 'enriching', + percent: 97, + message: 'Spring AOP advice resolution truncated by configured budgets', + detail: `${truncatedAdvices} advice method(s); ${candidateInspections} inspections; ${budget.edges} advice edges`, + stats: { filesProcessed: 0, totalFiles: 0, nodesCreated: ctx.graph.nodeCount }, + }); + } + + return { + advisedByEdges, + evidenceNodes, + unresolvedPointcuts, + candidateInspections, + truncatedAdvices, + }; + }, +}; + +interface InheritedBehaviorWorkItem { + readonly sourceId: string; + readonly evidenceId: string; + readonly reason: string; +} + +function sameMethodSignature(left: GraphNode, right: GraphNode): boolean { + if (left.properties.name !== right.properties.name) return false; + const leftCount = left.properties.parameterCount; + const rightCount = right.properties.parameterCount; + if (typeof leftCount === 'number' && typeof rightCount === 'number' && leftCount !== rightCount) { + return false; + } + const leftTypes = left.properties.parameterTypes; + const rightTypes = right.properties.parameterTypes; + return !( + Array.isArray(leftTypes) && + Array.isArray(rightTypes) && + leftTypes.length > 0 && + rightTypes.length > 0 && + (leftTypes.length !== rightTypes.length || + leftTypes.some((type, index) => type !== rightTypes[index])) + ); +} + +/** + * Propagate behavior evidence across the inheritance decisions materialized by + * MRO. This is deliberately separate from pointcut matching: `@annotation` + * continues to mean an annotation declared directly on the callable. + */ +export const springAopInheritancePhase: PipelinePhase = { + name: 'springAopInheritance', + deps: ['springAop', 'mro'], + + async execute(ctx: PipelineContext): Promise { + const metadata = getSpringAopGraphMetadata(ctx.graph); + const ownerByMethod = new Map(); + const classMethods = new Map(); + for (const relationship of ctx.graph.iterRelationshipsByType('HAS_METHOD')) { + const owner = ctx.graph.getNode(relationship.sourceId); + const method = ctx.graph.getNode(relationship.targetId); + if (owner === undefined || !eligibleOwner(owner) || method?.label !== 'Method') continue; + ownerByMethod.set(method.id, owner); + const methods = classMethods.get(owner.id) ?? []; + methods.push(method); + classMethods.set(owner.id, methods); + } + + const childrenByParent = new Map>(); + for (const type of ['EXTENDS', 'IMPLEMENTS'] as const) { + for (const relationship of ctx.graph.iterRelationshipsByType(type)) { + const children = childrenByParent.get(relationship.targetId) ?? new Set(); + children.add(relationship.sourceId); + childrenByParent.set(relationship.targetId, children); + } + } + + const implementingMethods = new Map>(); + for (const relationship of ctx.graph.iterRelationshipsByType('METHOD_IMPLEMENTS')) { + const methods = implementingMethods.get(relationship.targetId) ?? new Set(); + methods.add(relationship.sourceId); + implementingMethods.set(relationship.targetId, methods); + } + + const overridingClasses = new Map>(); + for (const relationship of ctx.graph.iterRelationshipsByType('METHOD_OVERRIDES')) { + const classes = overridingClasses.get(relationship.targetId) ?? new Set(); + classes.add(relationship.sourceId); + overridingClasses.set(relationship.targetId, classes); + } + + const queue: InheritedBehaviorWorkItem[] = []; + const emittedKeys = new Set(); + for (const relationship of ctx.graph.iterRelationshipsByType('ADVISED_BY')) { + if (decodeSpringAopReason(relationship.reason)?.kind !== 'behavior') continue; + const key = `${relationship.sourceId}\0${relationship.targetId}\0${relationship.reason}`; + emittedKeys.add(key); + queue.push({ + sourceId: relationship.sourceId, + evidenceId: relationship.targetId, + reason: relationship.reason, + }); + } + + let inheritedBehaviorEdges = 0; + const enqueue = (source: GraphNode, item: InheritedBehaviorWorkItem): void => { + const owner = ownerByMethod.get(source.id); + if ( + !eligibleOwner(source) && + !eligibleMethod(source, owner, metadata.singletonInstanceClassIds) + ) { + return; + } + const key = `${source.id}\0${item.evidenceId}\0${item.reason}`; + if (emittedKeys.has(key)) return; + emittedKeys.add(key); + ctx.graph.addRelationship({ + id: generateId('ADVISED_BY', `${source.id}->${item.evidenceId}:inherited:${item.reason}`), + sourceId: source.id, + targetId: item.evidenceId, + type: 'ADVISED_BY', + confidence: 1, + reason: item.reason, + }); + inheritedBehaviorEdges += 1; + queue.push({ sourceId: source.id, evidenceId: item.evidenceId, reason: item.reason }); + }; + + for (let cursor = 0; cursor < queue.length; cursor += 1) { + const item = queue[cursor]; + if (item === undefined) continue; + const source = ctx.graph.getNode(item.sourceId); + if (source === undefined) continue; + + if (eligibleOwner(source)) { + for (const childId of childrenByParent.get(source.id) ?? []) { + const child = ctx.graph.getNode(childId); + if (child === undefined || !eligibleOwner(child)) continue; + enqueue(child, item); + for (const method of classMethods.get(child.id) ?? []) enqueue(method, item); + } + continue; + } + if (source.label !== 'Method') continue; + + for (const methodId of implementingMethods.get(source.id) ?? []) { + const method = ctx.graph.getNode(methodId); + if (method !== undefined) enqueue(method, item); + } + for (const classId of overridingClasses.get(source.id) ?? []) { + const matches = (classMethods.get(classId) ?? []).filter((method) => + sameMethodSignature(method, source), + ); + const [match] = matches; + if (matches.length === 1 && match !== undefined) enqueue(match, item); + } + } + + return { inheritedBehaviorEdges }; + }, +}; diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index e99515468..4e858bd3f 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -34,6 +34,8 @@ import { scopeResolutionPhase, springConfigPhase, springAutoConfigurationPhase, + springAopPhase, + springAopInheritancePhase, pruneLocalSymbolsPhase, taintSummariesPhase, callSummariesPhase, @@ -56,6 +58,14 @@ export interface PipelineOptions { * to retain those nodes under `skipGraphPhases`. */ skipGraphPhases?: boolean; + /** Per-advice Spring AOP candidate inspection cap. `0` disables this cap. */ + springAopMaxCandidateInspectionsPerAdvice?: number; + /** Aggregate Spring AOP candidate inspection cap for one analysis. `0` disables this cap. */ + springAopMaxCandidateInspections?: number; + /** Per-advice Spring AOP `ADVISED_BY` edge cap. `0` disables this cap. */ + springAopMaxAdvisedEdgesPerAdvice?: number; + /** Aggregate Spring AOP advice-edge cap for one analysis. `0` disables this cap. */ + springAopMaxAdvisedEdges?: number; /** * Build the control-flow-graph / PDG substrate (#2081 M1, opt-in via `--pdg`). * Off by default: workers skip all CFG work and emit no `cfgSideChannel`, and @@ -262,8 +272,8 @@ export interface PipelineOptions { * Phase dependency graph: * * scan → structure → [springConfig, markdown, cobol] → parse → [routes, tools, orm] - * → crossFile → scopeResolution → springAutoConfiguration → pruneLocalSymbols - * → mro → di → communities → processes + * → crossFile → scopeResolution → [springAutoConfiguration, springAop] → pruneLocalSymbols + * → mro → springAopInheritance → di → communities → processes * * To add a new phase: create a file in pipeline-phases/, export the phase * object, and `.register()` it at the appropriate position below. Opt-in @@ -290,6 +300,7 @@ export function buildPhaseList(options?: PipelineOptions): PipelinePhase[] { .register(crossFilePhase) .register(scopeResolutionPhase) .register(springAutoConfigurationPhase) + .register(springAopPhase) .register(pruneLocalSymbolsPhase) // M4 (#2084): interprocedural taint fixpoint — the first real opt-in // pdg-gated phase. Off ⇒ absent ⇒ byte-identical graph. No always-on @@ -297,6 +308,7 @@ export function buildPhaseList(options?: PipelineOptions): PipelinePhase[] { .register(taintSummariesPhase, { enabledWhen: (o) => o.pdg === true }) .register(callSummariesPhase, { enabledWhen: (o) => o.pdg === true }) .register(mroPhase, { enabledWhen: (o) => !o.skipGraphPhases }) + .register(springAopInheritancePhase, { enabledWhen: (o) => !o.skipGraphPhases }) .register(diPhase, { enabledWhen: (o) => !o.skipGraphPhases }) .register(communitiesPhase, { enabledWhen: (o) => !o.skipGraphPhases }) .register(processesPhase, { enabledWhen: (o) => !o.skipGraphPhases }) diff --git a/gitnexus/src/core/lbug/graph-emit-sink.ts b/gitnexus/src/core/lbug/graph-emit-sink.ts index c03bacf01..a0491ab14 100644 --- a/gitnexus/src/core/lbug/graph-emit-sink.ts +++ b/gitnexus/src/core/lbug/graph-emit-sink.ts @@ -127,8 +127,8 @@ import { DEFAULT_EMIT_CHUNK_ROWS, SyncCsvWriter } from './sync-csv-writer.js'; * `routes`/`tools`, never read back mid-pipeline). * * Adding a relationship type that a phase reads back WITHOUT adding it here is - * a silent-wrong-graph bug, not a crash — and NOTHING automated catches it. - * The differential round-trip test cannot: `addRelationship` partitions edges + * a silent-wrong-graph bug, not a crash. The differential round-trip test cannot: + * `addRelationship` partitions edges * between the graph and the CSVs, and the union of a partition is invariant * under where the partition line falls, so that test stays green no matter how * this set is drawn. Only the read-site audit protects this invariant; re-run it @@ -144,6 +144,8 @@ export const RETAINED_REL_TYPES: ReadonlySet = new Set => deleteAllRelationshipsOfType('INJECTS', 'di', 'duplicate INJECTS edges'); +/** + * Drop every Spring AOP `ADVISED_BY` relationship before incremental + * writeback. Pointcut/annotation resolution is whole-program: adding a type in + * a third file can shadow a wildcard annotation import or change a wildcard + * execution match between two otherwise unchanged endpoint files. + */ +export const deleteAllAdvisedBy = async (): Promise<{ edgesDeleted: number }> => + deleteAllRelationshipsOfType('ADVISED_BY', 'spring-aop', 'duplicate ADVISED_BY edges'); + +/** Drop all synthetic Spring AOP evidence nodes before incremental writeback. */ +export const deleteSpringAopEvidenceNodes = async (): Promise<{ nodesDeleted: number }> => { + const c = conn; + if (!c) { + throw new Error('LadybugDB not initialized. Call initLbug first.'); + } + return withConnLock(async () => { + let countResult: lbug.QueryResult | lbug.QueryResult[] | undefined; + const idPrefix = escapeCypherString(SPRING_AOP_EVIDENCE_ID_PREFIX); + const predicate = `n.id STARTS WITH '${idPrefix}'`; + try { + countResult = await c.query( + `MATCH (n:CodeElement) WHERE ${predicate} RETURN count(n) AS cnt`, + ); + const result = Array.isArray(countResult) ? countResult[0] : countResult; + const rows = await result.getAll(); + const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0); + if (count > 0) { + await closeQueryResults( + await c.query(`MATCH (n:CodeElement) WHERE ${predicate} DETACH DELETE n`), + ); + } + if (countResult) await closeQueryResults(countResult); + return { nodesDeleted: count }; + } catch (err) { + if (countResult) await closeQueryResults(countResult); + if (classifyDeleteAllError(err) === 'benign-missing-table') { + return { nodesDeleted: 0 }; + } + const message = err instanceof Error ? err.message : String(err); + throw new Error( + '[spring-aop] failed to clear synthetic evidence before incremental re-write ' + + `(${message}) — aborting to avoid stale advice metadata; the next run will full-rebuild`, + ); + } + }); +}; + /** * Drop Spring-owned auto-configuration `DECLARES` relationships before * incremental writeback. `DECLARES` is generic, so exact reason filtering is diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index 92aceae1f..7af15c962 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -360,6 +360,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM Interface TO Method, FROM Interface TO Class, FROM Interface TO Interface, + FROM Interface TO CodeElement, FROM Interface TO \`TypeAlias\`, FROM Interface TO \`Struct\`, FROM Interface TO \`Constructor\`, diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 754c1e6b7..ef57b4977 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -33,6 +33,8 @@ import { deleteAllInterprocTaintPaths, deleteAllCallSummaries, deleteAllInjects, + deleteAllAdvisedBy, + deleteSpringAopEvidenceNodes, deleteSpringAutoConfigurationDeclarations, deleteSpringAutoConfigurationSyntheticClasses, queryImportersBatch, @@ -141,6 +143,7 @@ import { STALE_HASH_SENTINEL } from './lbug/schema.js'; import { isSpringBeanCandidateSourceFile } from './ingestion/frameworks/spring/bean-catalog.js'; import { isSpringBeanFactoryDeclaration } from './ingestion/frameworks/spring/bean-factories.js'; import { + SPRING_AOP_FEATURE, SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, } from './ingestion/frameworks/spring/analysis-features.js'; @@ -158,6 +161,7 @@ import { const ANALYSIS_FEATURES = [ CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, + SPRING_AOP_FEATURE, SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, SPRING_CONFIG_BINDINGS_FEATURE, @@ -2121,16 +2125,21 @@ async function runFullAnalysisInner( // deleting on every non-pdg incremental run (N runs = N copies of // every INJECTS row; CodeRelation has no PK and no read-side dedup). await deleteAllInjects(); - // 2b. Drop Spring-owned DECLARES edges (#2415). The + // 2b. Spring AOP pointcuts are matched against the full resolved graph; + // a third-file change can invalidate an edge between unchanged files. + // Rebuild the complete ADVISED_BY set on every incremental writeback. + await deleteAllAdvisedBy(); + await deleteSpringAopEvidenceNodes(); + // 2c. Drop Spring-owned DECLARES edges (#2415). The // auto-configuration phase scans every metadata file and recomputes // the full set each run; exact reason filtering leaves declarations // owned by other metadata systems untouched. await deleteSpringAutoConfigurationDeclarations(); - // 2c. Drop source-unavailable auto-configuration placeholders. Fresh + // 2d. Drop source-unavailable auto-configuration placeholders. Fresh // synthetic nodes are graph-wide in extractChangedSubgraph, so this // also removes an orphan when a newly-added real class takes over. await deleteSpringAutoConfigurationSyntheticClasses(); - // 2d. Drop interprocedural TAINT_PATH edges (#2084 M4 U6) when pdg is on + // 2e. Drop interprocedural TAINT_PATH edges (#2084 M4 U6) when pdg is on // — their validity is a whole-program property (an A→C flow can be // invalidated by a change to an intermediate function on a third // file), so endpoint-writability extraction can't refresh them. @@ -2138,7 +2147,7 @@ async function runFullAnalysisInner( // graph (isGraphWideRelType), mirroring Community/Process. if (options.pdg === true) { await deleteAllInterprocTaintPaths(); - // 2e. Drop CALL_SUMMARY edges (PDG FU-C) on an incremental `--pdg` + // 2f. Drop CALL_SUMMARY edges (PDG FU-C) on an incremental `--pdg` // writeback. They are re-included from the FULL fresh graph // (isGraphWideRelType) and the callSummaries phase recomputes every // summary each run, so delete-all-then-rebuild keeps an unchanged diff --git a/gitnexus/src/mcp/local/aop-metadata.ts b/gitnexus/src/mcp/local/aop-metadata.ts new file mode 100644 index 000000000..a3506d9c5 --- /dev/null +++ b/gitnexus/src/mcp/local/aop-metadata.ts @@ -0,0 +1,352 @@ +import { executeParameterized } from '../../core/lbug/pool-adapter.js'; +import { + decodeSpringAopReason, + type SpringAopReason, +} from '../../core/ingestion/frameworks/spring/aop.js'; + +type SpringAopBehaviorReason = Extract; +type SpringAopAdviceReason = Extract; +type SpringAopAspectReason = Extract; +type SpringAopPointcutReason = Extract; + +export interface SpringAopBehaviorMetadata { + readonly annotation: string; + readonly behavior: SpringAopBehaviorReason['behavior']; + readonly declaredOn: SpringAopBehaviorReason['declaredOn']; + readonly activation: SpringAopBehaviorReason['activation']; + readonly evidenceId: string; +} + +export interface SpringAopAdviceMetadata { + readonly annotation: string; + readonly advice: SpringAopAdviceReason['advice']; + readonly pointcut: string; + readonly match: SpringAopAdviceReason['match']; + readonly activation: SpringAopAdviceReason['activation']; + readonly adviceId: string; + readonly adviceName?: string; + readonly adviceFilePath?: string; + readonly advisedId: string; + readonly advisedName?: string; + readonly advisedFilePath?: string; +} + +export interface SpringAopUnresolvedPointcutMetadata { + readonly annotation: string; + readonly pointcut: string | null; + readonly adviceId: string; + readonly adviceName?: string; + readonly adviceFilePath?: string; + readonly evidenceId: string; +} + +export interface SpringAopResolvedPointcutMetadata { + readonly annotation: string; + readonly pointcut: string; + readonly match: Extract; + readonly resolution: Extract; + readonly adviceId: string; + readonly adviceName?: string; + readonly adviceFilePath?: string; + readonly evidenceId: string; +} + +export interface SpringAopAspectMetadata { + readonly annotation: string; + readonly activation: SpringAopAspectReason['activation']; + readonly registration: SpringAopAspectReason['registration']; + readonly evidenceId: string; +} + +export interface SpringAopMetadata { + readonly framework: 'spring'; + readonly proxied?: 'possible'; + readonly truncated?: true; + readonly aspect?: SpringAopAspectMetadata; + readonly behaviors: readonly SpringAopBehaviorMetadata[]; + readonly advices: readonly SpringAopAdviceMetadata[]; + readonly resolvedPointcuts: readonly SpringAopResolvedPointcutMetadata[]; + readonly unresolvedPointcuts: readonly SpringAopUnresolvedPointcutMetadata[]; +} + +interface RelationshipRow { + readonly sourceId: string; + readonly sourceName?: string; + readonly sourceFilePath?: string; + readonly targetId: string; + readonly targetName?: string; + readonly targetFilePath?: string; + readonly reason: unknown; +} + +const SUPPORTED_SYMBOL_TYPES = new Set(['Class', 'Interface', 'Method', 'CodeElement']); +const QUERY_RESULT_LIMIT = 1_000; +const QUERY_FETCH_LIMIT = QUERY_RESULT_LIMIT + 1; + +function readRowValue(row: unknown, name: string, index: number): unknown { + if (typeof row === 'object' && row !== null && name in row) { + return (row as Record)[name]; + } + return Array.isArray(row) ? row[index] : undefined; +} + +function readRequiredString(row: unknown, name: string, index: number): string | undefined { + const value = readRowValue(row, name, index); + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function readOptionalString(row: unknown, name: string, index: number): string | undefined { + const value = readRowValue(row, name, index); + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function normalizeRelationshipRow(row: unknown): RelationshipRow | undefined { + const sourceId = readRequiredString(row, 'sourceId', 0); + const targetId = readRequiredString(row, 'targetId', 3); + if (sourceId === undefined || targetId === undefined) return undefined; + + const sourceName = readOptionalString(row, 'sourceName', 1); + const sourceFilePath = readOptionalString(row, 'sourceFilePath', 2); + const targetName = readOptionalString(row, 'targetName', 4); + const targetFilePath = readOptionalString(row, 'targetFilePath', 5); + + return { + sourceId, + ...optionalField('sourceName', sourceName), + ...optionalField('sourceFilePath', sourceFilePath), + targetId, + ...optionalField('targetName', targetName), + ...optionalField('targetFilePath', targetFilePath), + reason: readRowValue(row, 'reason', 6), + }; +} + +function optionalField( + key: Key, + value: string | undefined, +): { readonly [K in Key]?: string } { + return value === undefined ? {} : ({ [key]: value } as { readonly [K in Key]?: string }); +} + +function stableDedupe(values: readonly T[], keyOf: (value: T) => string): T[] { + const unique = new Map(); + for (const value of values) unique.set(keyOf(value), value); + return [...unique.entries()] + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([, value]) => value); +} + +const RELATIONSHIP_PROJECTION = ` + RETURN source.id AS sourceId, source.name AS sourceName, source.filePath AS sourceFilePath, + target.id AS targetId, target.name AS targetName, target.filePath AS targetFilePath, + r.reason AS reason, r.step AS step +`; + +const DETERMINISTIC_RELATIONSHIP_ORDER = 'ORDER BY sourceId, targetId, reason, step'; + +/** + * Read additive Spring proxy/advice metadata for context and impact results. + * + * The helper intentionally trusts only versioned reasons accepted by the + * shared decoder. Other DECLARES edges (for example Spring Bean factories) + * and malformed/forward-version evidence are ignored. Query failures are + * fail-soft because older or partially upgraded indexes must remain readable. + */ +export async function querySpringAopMetadata( + lbugPath: string, + symbolId: string, + symbolType: string, +): Promise { + if (!SUPPORTED_SYMBOL_TYPES.has(symbolType)) return undefined; + + try { + const [outgoingAdviceRows, incomingAdviceRows, outgoingPointcutRows, incomingPointcutRows] = + await Promise.all([ + executeParameterized( + lbugPath, + `MATCH (source {id: $symbolId})-[r:CodeRelation]->(target) + WHERE r.type = 'ADVISED_BY' + AND r.reason STARTS WITH 'spring-aop:v1:' + ${RELATIONSHIP_PROJECTION} + ${DETERMINISTIC_RELATIONSHIP_ORDER} + LIMIT 1001`, + { symbolId }, + ), + executeParameterized( + lbugPath, + `MATCH (source)-[r:CodeRelation]->(target {id: $symbolId}) + WHERE r.type = 'ADVISED_BY' + AND r.reason STARTS WITH 'spring-aop:v1:' + ${RELATIONSHIP_PROJECTION} + ${DETERMINISTIC_RELATIONSHIP_ORDER} + LIMIT 1001`, + { symbolId }, + ), + executeParameterized( + lbugPath, + `MATCH (source {id: $symbolId})-[r:CodeRelation]->(target:CodeElement) + WHERE r.type = 'DECLARES' + AND r.reason STARTS WITH 'spring-aop:v1:' + ${RELATIONSHIP_PROJECTION} + ${DETERMINISTIC_RELATIONSHIP_ORDER} + LIMIT 1001`, + { symbolId }, + ), + executeParameterized( + lbugPath, + `MATCH (source)-[r:CodeRelation]->(target:CodeElement {id: $symbolId}) + WHERE r.type = 'DECLARES' + AND r.reason STARTS WITH 'spring-aop:v1:' + ${RELATIONSHIP_PROJECTION} + ${DETERMINISTIC_RELATIONSHIP_ORDER} + LIMIT 1001`, + { symbolId }, + ), + ]); + + const behaviors: SpringAopBehaviorMetadata[] = []; + const advices: SpringAopAdviceMetadata[] = []; + const resolvedPointcuts: SpringAopResolvedPointcutMetadata[] = []; + const unresolvedPointcuts: SpringAopUnresolvedPointcutMetadata[] = []; + const aspects: SpringAopAspectMetadata[] = []; + const truncated = [ + outgoingAdviceRows, + incomingAdviceRows, + outgoingPointcutRows, + incomingPointcutRows, + ].some((rows) => rows.length >= QUERY_FETCH_LIMIT); + let queriedSymbolIsAdvisedSource = false; + + for (const [rows, isOutgoing] of [ + [outgoingAdviceRows, true], + [incomingAdviceRows, false], + ] as const) { + for (const rawRow of rows.slice(0, QUERY_RESULT_LIMIT)) { + const row = normalizeRelationshipRow(rawRow); + if (row === undefined) continue; + const reason = decodeSpringAopReason(row.reason); + if (reason?.kind === 'behavior') { + if (isOutgoing) queriedSymbolIsAdvisedSource = true; + behaviors.push({ + annotation: reason.annotation, + behavior: reason.behavior, + declaredOn: reason.declaredOn, + activation: reason.activation, + evidenceId: row.targetId, + }); + } else if (reason?.kind === 'advice') { + if (isOutgoing) queriedSymbolIsAdvisedSource = true; + advices.push({ + annotation: reason.annotation, + advice: reason.advice, + pointcut: reason.pointcut, + match: reason.match, + activation: reason.activation, + adviceId: row.targetId, + ...optionalField('adviceName', row.targetName), + ...optionalField('adviceFilePath', row.targetFilePath), + advisedId: row.sourceId, + ...optionalField('advisedName', row.sourceName), + ...optionalField('advisedFilePath', row.sourceFilePath), + }); + } + } + } + + for (const rows of [outgoingPointcutRows, incomingPointcutRows]) { + for (const rawRow of rows.slice(0, QUERY_RESULT_LIMIT)) { + const row = normalizeRelationshipRow(rawRow); + if (row === undefined) continue; + const reason = decodeSpringAopReason(row.reason); + if (reason?.kind === 'aspect') { + aspects.push({ + annotation: reason.annotation, + activation: reason.activation, + registration: reason.registration, + evidenceId: row.targetId, + }); + } else if ( + reason?.kind === 'pointcut' && + reason.match === 'static' && + reason.resolution === 'resolved' && + typeof reason.pointcut === 'string' + ) { + resolvedPointcuts.push({ + annotation: reason.annotation, + pointcut: reason.pointcut, + match: reason.match, + resolution: reason.resolution, + adviceId: row.sourceId, + ...optionalField('adviceName', row.sourceName), + ...optionalField('adviceFilePath', row.sourceFilePath), + evidenceId: row.targetId, + }); + } else if (reason?.kind === 'pointcut' && reason.match === 'unresolved') { + unresolvedPointcuts.push({ + annotation: reason.annotation, + pointcut: reason.pointcut, + adviceId: row.sourceId, + ...optionalField('adviceName', row.sourceName), + ...optionalField('adviceFilePath', row.sourceFilePath), + evidenceId: row.targetId, + }); + } + } + } + + const dedupedAspects = stableDedupe(aspects, (aspect) => + JSON.stringify([aspect.annotation, aspect.evidenceId]), + ); + + const dedupedBehaviors = stableDedupe(behaviors, (behavior) => + JSON.stringify([ + behavior.behavior, + behavior.annotation, + behavior.declaredOn, + behavior.evidenceId, + ]), + ); + const dedupedAdvices = stableDedupe(advices, (advice) => + JSON.stringify([advice.advisedId, advice.adviceId, advice.advice, advice.pointcut]), + ); + const dedupedPointcuts = stableDedupe(unresolvedPointcuts, (pointcut) => + JSON.stringify([ + pointcut.adviceId, + pointcut.evidenceId, + pointcut.annotation, + pointcut.pointcut, + ]), + ); + const dedupedResolvedPointcuts = stableDedupe(resolvedPointcuts, (pointcut) => + JSON.stringify([ + pointcut.adviceId, + pointcut.evidenceId, + pointcut.annotation, + pointcut.pointcut, + ]), + ); + + if ( + dedupedAspects.length === 0 && + dedupedBehaviors.length === 0 && + dedupedAdvices.length === 0 && + dedupedResolvedPointcuts.length === 0 && + dedupedPointcuts.length === 0 + ) { + return undefined; + } + + return { + framework: 'spring', + ...(queriedSymbolIsAdvisedSource ? { proxied: 'possible' as const } : {}), + ...(truncated ? { truncated: true as const } : {}), + ...(dedupedAspects[0] === undefined ? {} : { aspect: dedupedAspects[0] }), + behaviors: dedupedBehaviors, + advices: dedupedAdvices, + resolvedPointcuts: dedupedResolvedPointcuts, + unresolvedPointcuts: dedupedPointcuts, + }; + } catch { + return undefined; + } +} diff --git a/gitnexus/src/mcp/local/bean-metadata.ts b/gitnexus/src/mcp/local/bean-metadata.ts index cd08ad327..c99a24e20 100644 --- a/gitnexus/src/mcp/local/bean-metadata.ts +++ b/gitnexus/src/mcp/local/bean-metadata.ts @@ -27,6 +27,7 @@ export async function queryClassBeanMetadata( lbugPath, `${pattern} WHERE r.type = 'DECLARES' + AND r.reason STARTS WITH 'spring-bean-factory:' RETURN r.reason AS reason LIMIT 1`, { symbolId }, diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index c4e9d12a9..690c48c8f 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -19,6 +19,7 @@ import { dbIdentityChanged, } from '../../core/lbug/pool-adapter.js'; import { queryClassBeanMetadata } from './bean-metadata.js'; +import { querySpringAopMetadata } from './aop-metadata.js'; import { isValidQueryParams } from '../../core/lbug/query-params.js'; import { toDisplayLine } from './line-display.js'; import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../../core/lbug/lbug-config.js'; @@ -310,6 +311,10 @@ export const VALID_RELATION_TYPES = new Set([ // surface. 'CONDITIONAL_ON', 'DECLARES', + // Spring proxy/advice evidence (#2416). Opt-in for traversal so existing + // impact defaults do not silently widen; target enrichment still surfaces + // advised/proxied state on ordinary impact calls. + 'ADVISED_BY', ]); /** @@ -3365,16 +3370,31 @@ export class LocalBackend { const symId = sym.id; // Categorized incoming refs - const incomingRows = await executeParameterized( - repo.lbugPath, - ` + const [incomingRows, incomingAdvisedRows] = await Promise.all([ + executeParameterized( + repo.lbugPath, + ` MATCH (caller)-[r:CodeRelation]->(n {id: $symId}) WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES'] RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind LIMIT 30 `, - { symId }, - ); + { symId }, + ), + // Keep high-fan-in advice edges out of the legacy 30-row context window. + // A broad pointcut can advise hundreds of methods; sharing that LIMIT + // would make CALLS/HAS_METHOD/etc. disappear nondeterministically. + executeParameterized( + repo.lbugPath, + ` + MATCH (caller)-[r:CodeRelation {type: 'ADVISED_BY'}]->(n {id: $symId}) + RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind + LIMIT 30 + `, + { symId }, + ), + ]); + incomingRows.push(...incomingAdvisedRows); let typedPropertyRows: any[] = []; // Fix #480: Class/Interface nodes have no direct CALLS/IMPORTS edges — @@ -3493,16 +3513,28 @@ export class LocalBackend { } // Categorized outgoing refs - const outgoingRows = await executeParameterized( - repo.lbugPath, - ` + const [outgoingRows, outgoingAdvisedRows] = await Promise.all([ + executeParameterized( + repo.lbugPath, + ` MATCH (n {id: $symId})-[r:CodeRelation]->(target) WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES'] RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind LIMIT 30 `, - { symId }, - ); + { symId }, + ), + executeParameterized( + repo.lbugPath, + ` + MATCH (n {id: $symId})-[r:CodeRelation {type: 'ADVISED_BY'}]->(target) + RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind + LIMIT 30 + `, + { symId }, + ), + ]); + outgoingRows.push(...outgoingAdvisedRows); // Process participation let processRows: any[] = []; @@ -3564,6 +3596,7 @@ export class LocalBackend { (sym.name || sym[1]) as string, ); const beanMetadataPromise = queryClassBeanMetadata(repo.lbugPath, symId, epistemicSymType); + const aopMetadataPromise = querySpringAopMetadata(repo.lbugPath, symId, epistemicSymType); let methodMetadata: Record | undefined; if (isMethodLike) { @@ -3602,7 +3635,11 @@ export class LocalBackend { // dynamic dispatch are not reflected in `incoming`, so the view is a lower // bound. Additive; never suppresses a field. Resolved from the probe started // above (concurrent with methodMetadata). - const [epistemic, beanMetadata] = await Promise.all([epistemicPromise, beanMetadataPromise]); + const [epistemic, beanMetadata, aopMetadata] = await Promise.all([ + epistemicPromise, + beanMetadataPromise, + aopMetadataPromise, + ]); return { status: 'found', @@ -3616,6 +3653,7 @@ export class LocalBackend { ...(include_content && (sym.content || sym[6]) ? { content: sym.content || sym[6] } : {}), ...(methodMetadata ? { methodMetadata } : {}), ...(beanMetadata ? { bean: beanMetadata } : {}), + ...(aopMetadata ? { aop: aopMetadata } : {}), }, ...epistemic, incoming: categorize(incomingRows), @@ -5906,7 +5944,10 @@ export class LocalBackend { opts.skipEpistemic || summaryOnly ? Promise.resolve(undefined) : queryClassBeanMetadata(repo.lbugPath, symId, symType); - + const aopMetadataPromise = + opts.skipEpistemic || summaryOnly + ? Promise.resolve(undefined) + : querySpringAopMetadata(repo.lbugPath, symId, symType); const impacted: any[] = []; const visited = new Set([symId]); const pdgBridgeEvidenceById = new Map(); @@ -6388,7 +6429,11 @@ export class LocalBackend { // #1858 — await the epistemic boundary probe kicked off alongside the BFS // above. Additive: leaves impactedCount and every existing field untouched. - const [epistemic, beanMetadata] = await Promise.all([epistemicPromise, beanMetadataPromise]); + const [epistemic, beanMetadata, aopMetadata] = await Promise.all([ + epistemicPromise, + beanMetadataPromise, + aopMetadataPromise, + ]); const base = { target: { @@ -6397,6 +6442,7 @@ export class LocalBackend { type: symType, filePath: sym.filePath || sym[2], ...(beanMetadata ? { bean: beanMetadata } : {}), + ...(aopMetadata ? { aop: aopMetadata } : {}), }, direction, impactedCount: impacted.length, diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 98687a7d8..690464cbf 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -534,7 +534,7 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep type: 'array', items: { type: 'string' }, description: - 'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, METHOD_OVERRIDES, METHOD_IMPLEMENTS, ACCESSES (default: usage-based, ACCESSES excluded by default). DI edges (consumer Class or factory Method → provider Class or declaration CodeElement) require explicitly including INJECTS.', + 'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, METHOD_OVERRIDES, METHOD_IMPLEMENTS, ACCESSES (default: usage-based, ACCESSES excluded by default). DI edges require INJECTS; Spring proxy/advice edges require ADVISED_BY.', }, includeTests: { type: 'boolean', description: 'Include test files (default: false)' }, minConfidence: { diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 40a89f0f7..571ce7fa1 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -146,7 +146,10 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // v36: bound-callable graph `startLine` follows the initializer so multi-line // closure bindings join the scope channel (#2735). Warm cache would otherwise // keep serving wrapper-line startLines and drop the CALLS edge. -const SCHEMA_BUMP = 36; +// v37: Java/Kotlin capture side-channels include Spring AOP owner/advice facts +// (#2416). Warm cache entries at v36 do not carry those facts and would silently +// omit ADVISED_BY evidence. +const SCHEMA_BUMP = 37; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index afe51a1fb..896fe863f 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -668,8 +668,13 @@ export interface RepoMeta { * reuse gate is exact equality, so an index already stamped 32 would satisfy * it against a differently-shaped reverted DB. Start the next allocation at * 33 instead.) + * + * v33: Spring AOP evidence adds the Interface→CodeElement relation pair + * (#2416). LadybugDB fixes allowed endpoint pairs when the relation table is + * created, so an older index cannot persist these edges through incremental + * writeback. Force a full re-analyze. */ -export const INCREMENTAL_SCHEMA_VERSION = 32; +export const INCREMENTAL_SCHEMA_VERSION = 33; export interface IndexedRepo { repoPath: string; diff --git a/gitnexus/test/integration/lbug-core-adapter.test.ts b/gitnexus/test/integration/lbug-core-adapter.test.ts index 2de9538d5..7af37a108 100644 --- a/gitnexus/test/integration/lbug-core-adapter.test.ts +++ b/gitnexus/test/integration/lbug-core-adapter.test.ts @@ -174,6 +174,70 @@ withTestLbugDB( expect(Number((queriesLeft[0] as { cnt: number }).cnt)).toBe(1); }); + it('deleteAllAdvisedBy: removes only ADVISED_BY edges and is benign when none exist (#2416)', async () => { + const { executeQuery: coreExecuteQuery, deleteAllAdvisedBy } = + await import('../../src/core/lbug/lbug-adapter.js'); + + await expect(deleteAllAdvisedBy()).resolves.toEqual({ edgesDeleted: 0 }); + const fns = (await coreExecuteQuery('MATCH (n:Function) RETURN n.id AS id')) as Array<{ + id: string; + }>; + expect(fns.length).toBe(2); + await coreExecuteQuery( + `MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` + + `CREATE (a)-[:CodeRelation {type: 'ADVISED_BY', confidence: 0.95, reason: 'spring-aop:v1:{}', step: 0}]->(b)`, + ); + + await expect(deleteAllAdvisedBy()).resolves.toEqual({ edgesDeleted: 1 }); + const advisedLeft = await coreExecuteQuery( + `MATCH ()-[r:CodeRelation]->() WHERE r.type = 'ADVISED_BY' RETURN count(r) AS cnt`, + ); + expect(Number((advisedLeft[0] as { cnt: number }).cnt)).toBe(0); + }); + + it('deleteSpringAopEvidenceNodes: keys deletion to the owned ID namespace (#2416)', async () => { + const { executeQuery: coreExecuteQuery, deleteSpringAopEvidenceNodes } = + await import('../../src/core/lbug/lbug-adapter.js'); + + await expect(deleteSpringAopEvidenceNodes()).resolves.toEqual({ nodesDeleted: 0 }); + await coreExecuteQuery( + `CREATE (:CodeElement {id: 'CodeElement:spring-aop:test-evidence', name: 'Aop', filePath: 'A.java', startLine: 1, endLine: 1, isExported: false, content: '', description: 'ordinary text'})`, + ); + await coreExecuteQuery( + `CREATE (:CodeElement {id: 'CodeElement:ordinary-lookalike', name: 'Other', filePath: 'B.java', startLine: 1, endLine: 1, isExported: false, content: '', description: 'Spring AOP: lookalike'})`, + ); + + await expect(deleteSpringAopEvidenceNodes()).resolves.toEqual({ nodesDeleted: 1 }); + const rows = await coreExecuteQuery( + `MATCH (n:CodeElement) WHERE n.id IN ['CodeElement:spring-aop:test-evidence', 'CodeElement:ordinary-lookalike'] RETURN n.id AS id`, + ); + expect(rows).toEqual([{ id: 'CodeElement:ordinary-lookalike' }]); + await coreExecuteQuery( + `MATCH (n:CodeElement {id: 'CodeElement:ordinary-lookalike'}) DETACH DELETE n`, + ); + }); + + it('persists the Interface Spring AOP evidence relation pair (#2416)', async () => { + const { executeQuery: coreExecuteQuery } = + await import('../../src/core/lbug/lbug-adapter.js'); + await coreExecuteQuery( + `CREATE (:Interface {id: 'Interface:aop-test', name: 'AdvisedInterface', filePath: 'I.java', startLine: 1, endLine: 2, isExported: true, content: '', description: ''})`, + ); + await coreExecuteQuery( + `CREATE (:CodeElement {id: 'CodeElement:aop-interface-evidence', name: 'Transactional', filePath: 'I.java', startLine: 1, endLine: 1, isExported: false, content: '', description: 'Spring AOP evidence'})`, + ); + await coreExecuteQuery( + `MATCH (source:Interface {id: 'Interface:aop-test'}), (target:CodeElement {id: 'CodeElement:aop-interface-evidence'}) CREATE (source)-[:CodeRelation {type: 'ADVISED_BY', confidence: 1.0, reason: 'spring-aop:v1:{}', step: 0}]->(target)`, + ); + const rows = await coreExecuteQuery( + `MATCH (source)-[r:CodeRelation]->() WHERE source.id = 'Interface:aop-test' AND r.type = 'ADVISED_BY' RETURN source.id AS id`, + ); + expect(rows).toEqual([{ id: 'Interface:aop-test' }]); + await coreExecuteQuery( + `MATCH (n) WHERE n.id IN ['Interface:aop-test', 'CodeElement:aop-interface-evidence'] DETACH DELETE n`, + ); + }); + it('deleteSpringAutoConfigurationDeclarations: removes only Spring DECLARES edges (#2415)', async () => { const { executeQuery: coreExecuteQuery, deleteSpringAutoConfigurationDeclarations } = await import('../../src/core/lbug/lbug-adapter.js'); diff --git a/gitnexus/test/integration/local-backend.test.ts b/gitnexus/test/integration/local-backend.test.ts index 318490d82..ff39b3cb9 100644 --- a/gitnexus/test/integration/local-backend.test.ts +++ b/gitnexus/test/integration/local-backend.test.ts @@ -71,6 +71,7 @@ withTestLbugDB( 'HAS_METHOD', 'METHOD_OVERRIDES', 'ACCESSES', + 'ADVISED_BY', ]; const invalidTypes = ['CONTAINS', 'STEP_IN_PROCESS', 'MEMBER_OF', 'DROP_TABLE']; diff --git a/gitnexus/test/integration/spring-aop-benchmark.test.ts b/gitnexus/test/integration/spring-aop-benchmark.test.ts new file mode 100644 index 000000000..bb88d5780 --- /dev/null +++ b/gitnexus/test/integration/spring-aop-benchmark.test.ts @@ -0,0 +1,507 @@ +/** + * Spring AOP candidate-selection and Kotlin capture benchmarks (#2416). + * + * Normal CI runs deterministic work-count tripwires. Wall-clock scaling and + * mixed-language pipeline measurements stay behind the benchmark flag: + * + * GITNEXUS_BENCH=1 npx vitest run test/integration/spring-aop-benchmark.test.ts + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { GraphNode } from 'gitnexus-shared'; +import { describe, expect, it } from 'vitest'; +import { + createSpringAopCandidateIndex, + type SpringAopOwnedMethod, +} from '../../src/core/ingestion/frameworks/spring/aop-candidates.js'; +import { + decodeSpringAopReason, + parseSpringAopPointcut, + springAopPointcutMatches, + type SpringAopStaticPointcut, +} from '../../src/core/ingestion/frameworks/spring/aop.js'; +import { collectKotlinCaptureSideChannel } from '../../src/core/ingestion/languages/kotlin/capture-side-channel.js'; +import { emitKotlinScopeCaptures } from '../../src/core/ingestion/languages/kotlin/captures.js'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; + +const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1'; +const TRANSACTIONAL = 'org.springframework.transaction.annotation.Transactional'; +const CACHEABLE = 'org.springframework.cache.annotation.Cacheable'; + +interface CandidateFixture { + readonly candidates: readonly SpringAopOwnedMethod[]; + readonly methodAnnotations: ReadonlyMap>; +} + +function candidateFixture(methodCount: number): CandidateFixture { + const methodsPerOwner = 10; + if (methodCount % methodsPerOwner !== 0) { + throw new Error(`methodCount must be divisible by ${methodsPerOwner}`); + } + + const candidates: SpringAopOwnedMethod[] = []; + const methodAnnotations = new Map>(); + for (let ownerIndex = 0; ownerIndex < methodCount / methodsPerOwner; ownerIndex += 1) { + const language = ownerIndex % 2 === 0 ? 'java' : 'kotlin'; + const extension = language === 'java' ? 'java' : 'kt'; + const qualifiedName = `com.example.partition${ownerIndex % 100}.Service${ownerIndex}`; + const filePath = `src/${language}/Service${ownerIndex}.${extension}`; + const owner: GraphNode = { + id: `Class:${qualifiedName}:${extension}`, + label: 'Class', + properties: { + name: `Service${ownerIndex}`, + qualifiedName, + filePath, + language, + startLine: 1, + endLine: 40, + isExported: true, + }, + }; + + for (let methodIndex = 0; methodIndex < methodsPerOwner; methodIndex += 1) { + const name = `${methodIndex % 2 === 0 ? 'read' : 'write'}${methodIndex}`; + const method: GraphNode = { + id: `Method:${qualifiedName}.${name}:${extension}`, + label: 'Method', + properties: { + name, + qualifiedName: `${qualifiedName}.${name}`, + filePath, + language, + startLine: methodIndex + 2, + endLine: methodIndex + 2, + isExported: true, + visibility: methodIndex % 3 === 0 ? 'protected' : 'public', + parameterCount: methodIndex % 3, + }, + }; + candidates.push({ method, owner }); + + const annotations = new Set(); + if (ownerIndex % 100 === 0 && methodIndex === 0) annotations.add(TRANSACTIONAL); + if (ownerIndex % 125 === 1 && methodIndex === 1) annotations.add(CACHEABLE); + if (annotations.size > 0) methodAnnotations.set(method.id, annotations); + } + } + return { candidates, methodAnnotations }; +} + +function parsePointcut(expression: string): SpringAopStaticPointcut { + const pointcut = parseSpringAopPointcut(expression); + if (pointcut === null) throw new Error(`Expected a static pointcut: ${expression}`); + return pointcut; +} + +function matchingIds( + pointcut: SpringAopStaticPointcut, + candidates: readonly SpringAopOwnedMethod[], + methodAnnotations: ReadonlyMap>, +): string[] { + return candidates + .filter((candidate) => + springAopPointcutMatches( + pointcut, + candidate.owner, + candidate.method, + methodAnnotations.get(candidate.method.id), + ), + ) + .map((candidate) => candidate.method.id) + .sort(); +} + +function selectivePointcuts(): SpringAopStaticPointcut[] { + const partitions = [0, 7, 19, 42, 88]; + return [ + ...partitions.map((partition) => parsePointcut(`within(com.example.partition${partition}..*)`)), + ...partitions.map((partition) => + parsePointcut(`execution(public * com.example.partition${partition}..*.read*(*))`), + ), + ...partitions.map((partition) => + parsePointcut(`within(com.example.partition${partition}.Service${partition})`), + ), + parsePointcut(`@annotation(${TRANSACTIONAL})`), + parsePointcut(`@annotation(${CACHEABLE})`), + ]; +} + +describe('Spring AOP candidate-index regression tripwire (#2416)', () => { + it('preserves brute-force matches while reducing selective advice inspections by 10x', () => { + const fixture = candidateFixture(50_000); + const index = createSpringAopCandidateIndex(fixture.candidates, fixture.methodAnnotations); + const pointcuts = selectivePointcuts(); + let indexedInspections = 0; + + for (const pointcut of pointcuts) { + const selected = index.candidatesFor(pointcut); + indexedInspections += selected.length; + expect(matchingIds(pointcut, selected, fixture.methodAnnotations)).toEqual( + matchingIds(pointcut, fixture.candidates, fixture.methodAnnotations), + ); + } + + const bruteForceInspections = pointcuts.length * fixture.candidates.length; + expect(index.totalCandidates).toBe(50_000); + expect(indexedInspections).toBeLessThanOrEqual(bruteForceInspections / 10); + + const leadingWildcard = parsePointcut('within(*..Service*)'); + const broadCandidates = index.candidatesFor(leadingWildcard); + expect(broadCandidates).toHaveLength(fixture.candidates.length); + expect(matchingIds(leadingWildcard, broadCandidates, fixture.methodAnnotations)).toEqual( + matchingIds(leadingWildcard, fixture.candidates, fixture.methodAnnotations), + ); + }, 30_000); +}); + +describe('Spring AOP broad-advice budget regression tripwire (#2416)', () => { + it('bounds aggregate edge work across multiple broad advices and reports truncation', async () => { + const root = writeMixedSpringAopRepo(20); + const progressMessages: string[] = []; + try { + const result = await runPipelineFromRepo( + root, + (progress) => progressMessages.push(progress.message), + { + skipGraphPhases: true, + workerPoolSize: 1, + springAopMaxCandidateInspectionsPerAdvice: 0, + springAopMaxCandidateInspections: 0, + springAopMaxAdvisedEdgesPerAdvice: 5, + springAopMaxAdvisedEdges: 9, + }, + ); + const adviceEdges = [...result.graph.iterRelationshipsByType('ADVISED_BY')].filter( + (relationship) => decodeSpringAopReason(relationship.reason)?.kind === 'advice', + ); + + expect(adviceEdges).toHaveLength(9); + expect(progressMessages).toContain( + 'Spring AOP advice resolution truncated by configured budgets', + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 30_000); + + it('bounds aggregate candidate inspections across multiple broad advices', async () => { + const root = writeMixedSpringAopRepo(20); + const progressMessages: string[] = []; + try { + const result = await runPipelineFromRepo( + root, + (progress) => progressMessages.push(progress.message), + { + skipGraphPhases: true, + workerPoolSize: 1, + springAopMaxCandidateInspectionsPerAdvice: 4, + springAopMaxCandidateInspections: 7, + springAopMaxAdvisedEdgesPerAdvice: 0, + springAopMaxAdvisedEdges: 0, + }, + ); + const adviceEdges = [...result.graph.iterRelationshipsByType('ADVISED_BY')].filter( + (relationship) => decodeSpringAopReason(relationship.reason)?.kind === 'advice', + ); + + expect(adviceEdges).toHaveLength(7); + expect(progressMessages).toContain( + 'Spring AOP advice resolution truncated by configured budgets', + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 30_000); +}); + +function denseKotlinAopSource(classCount: number): string { + const classes = Array.from({ length: classCount }, (_, index) => { + const transactional = + index % 50 === 0 + ? ` + @Tx + fun transactional${index}() {}` + : ''; + return ` +@Noise +class Subject${index} { + @Noise + fun ordinary${index}() {} + + @OtherNoise + fun secondary${index}() {} +${transactional} +} +`; + }).join('\n'); + + return `package com.example + +import org.aspectj.lang.annotation.Aspect as AopAspect +import org.aspectj.lang.annotation.Before as AdviceBefore +import org.springframework.transaction.annotation.Transactional as Tx + +@AopAspect +class DenseAspect { + @AdviceBefore("@annotation(org.springframework.transaction.annotation.Transactional)") + fun beforeTransaction() {} +} + +${classes} +`; +} + +interface KotlinCaptureResult { + readonly classCount: number; + readonly elapsedMs: number; + readonly captureCount: number; + readonly factCount: number; + readonly annotationNames: readonly string[]; +} + +function runKotlinAopCapture(classCount: number, run: number): KotlinCaptureResult { + const filePath = `src/SpringAopBench${classCount}_${run}.kt`; + const startedAt = performance.now(); + const captures = emitKotlinScopeCaptures(denseKotlinAopSource(classCount), filePath); + const elapsedMs = performance.now() - startedAt; + const facts = collectKotlinCaptureSideChannel(filePath)?.springAopFacts ?? []; + return { + classCount, + elapsedMs, + captureCount: captures.length, + factCount: facts.length, + annotationNames: facts.flatMap((fact) => fact.annotations.map((annotation) => annotation.name)), + }; +} + +describe('Kotlin Spring AOP capture regression tripwire (#2416)', () => { + it('captures dense unrelated annotations and every Spring alias within a coarse budget', () => { + const classCount = 400; + const aliasedTransactionalCount = classCount / 50; + const budgetMs = 10_000; + + runKotlinAopCapture(4, 0); + const smaller = runKotlinAopCapture(classCount / 2, 1); + const result = runKotlinAopCapture(classCount, 1); + + expect(smaller.factCount).toBe((classCount / 2) * 3 + aliasedTransactionalCount / 2 + 2); + expect(result.factCount).toBe(classCount * 3 + aliasedTransactionalCount + 2); + expect(result.annotationNames.filter((name) => name === 'Noise')).toHaveLength(classCount * 2); + expect(result.annotationNames.filter((name) => name === 'OtherNoise')).toHaveLength(classCount); + expect(result.annotationNames.filter((name) => name === 'Tx')).toHaveLength( + aliasedTransactionalCount, + ); + expect(result.annotationNames.filter((name) => name === 'AopAspect')).toHaveLength(1); + expect(result.annotationNames.filter((name) => name === 'AdviceBefore')).toHaveLength(1); + expect(result.captureCount).toBeGreaterThan(classCount * 8); + expect(result.captureCount / smaller.captureCount).toBeGreaterThan(1.9); + expect(result.captureCount / smaller.captureCount).toBeLessThan(2.05); + expect(result.elapsedMs).toBeLessThan(budgetMs); + }, 30_000); +}); + +interface SelectorBenchResult { + readonly methods: number; + readonly buildMs: number; + readonly queryMs: number; + readonly examined: number; + readonly matches: number; +} + +function runSelectorBenchmark(methods: number): SelectorBenchResult { + const fixture = candidateFixture(methods); + const buildStartedAt = performance.now(); + const index = createSpringAopCandidateIndex(fixture.candidates, fixture.methodAnnotations); + const buildMs = performance.now() - buildStartedAt; + const pointcuts = selectivePointcuts(); + let examined = 0; + let matches = 0; + const queryStartedAt = performance.now(); + for (const pointcut of pointcuts) { + const selected = index.candidatesFor(pointcut); + examined += selected.length; + matches += matchingIds(pointcut, selected, fixture.methodAnnotations).length; + } + const queryMs = performance.now() - queryStartedAt; + return { methods, buildMs, queryMs, examined, matches }; +} + +describe.skipIf(!BENCH_ENABLED)('Spring AOP candidate-index scaling benchmark (#2416)', () => { + it('reports build/query scaling while keeping selective work proportional to candidates', () => { + const scales = [10_000, 50_000, 100_000]; + const results = scales.map((methods) => runSelectorBenchmark(methods)); + + for (const result of results) { + const bruteForceInspections = selectivePointcuts().length * result.methods; + console.log( + ` selector methods=${result.methods}: build=${result.buildMs.toFixed(1)}ms ` + + `query=${result.queryMs.toFixed(1)}ms (${result.examined} examined, ` + + `${result.matches} matches)`, + ); + expect(result.examined).toBeLessThanOrEqual(bruteForceInspections / 10); + expect(result.matches).toBeGreaterThan(0); + expect(result.buildMs + result.queryMs).toBeLessThan(10_000); + } + + const first = results[0]!; + const last = results[results.length - 1]!; + const workRatio = last.examined / first.examined; + const sizeRatio = last.methods / first.methods; + expect(workRatio).toBeGreaterThan(sizeRatio * 0.9); + expect(workRatio).toBeLessThan(sizeRatio * 1.1); + }, 60_000); +}); + +function writeFixture(root: string, relativePath: string, content: string): void { + const target = path.join(root, relativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); +} + +function javaServices(count: number): string { + return Array.from( + { length: count }, + (_, index) => ` +class JavaService${index} { + @Transactional public void transaction${index}() {} + public void read${index}() {} + public void write${index}() {} +} +`, + ).join('\n'); +} + +function kotlinServices(count: number): string { + return Array.from( + { length: count }, + (_, index) => ` +class KotlinService${index} { + @Tx fun transaction${index}() {} + fun read${index}() {} + fun write${index}() {} +} +`, + ).join('\n'); +} + +function writeMixedSpringAopRepo(serviceCount: number): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `spring-aop-bench-${serviceCount}-`)); + const javaCount = serviceCount / 2; + const kotlinCount = serviceCount - javaCount; + writeFixture( + root, + 'src/main/java/com/example/service/Services.java', + `package com.example.service; +import org.springframework.transaction.annotation.Transactional; +${javaServices(javaCount)} +`, + ); + writeFixture( + root, + 'src/main/kotlin/com/example/service/Services.kt', + `package com.example.service +import org.springframework.transaction.annotation.Transactional as Tx +${kotlinServices(kotlinCount)} +`, + ); + writeFixture( + root, + 'src/main/java/com/example/aspect/BenchmarkAspect.java', + `package com.example.aspect; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; + +@Aspect +public class BenchmarkAspect { + @Before("@annotation(org.springframework.transaction.annotation.Transactional)") + public void transactionalAdvice() {} + + @Before("within(com.example.service.KotlinService*)") + public void kotlinServiceAdvice() {} + + @Before("execution(public * com.example.service.JavaService*.read*(..))") + public void javaReadAdvice() {} +} +`, + ); + return root; +} + +interface PipelineBenchResult { + readonly services: number; + readonly elapsedMs: number; + readonly advisedBy: number; + readonly behaviorEdges: number; + readonly adviceEdges: number; + readonly transactionalAdviceEdges: number; + readonly kotlinAdviceEdges: number; + readonly javaAdviceEdges: number; +} + +async function runMixedPipelineBenchmark(serviceCount: number): Promise { + const root = writeMixedSpringAopRepo(serviceCount); + try { + const startedAt = performance.now(); + const result = await runPipelineFromRepo(root, () => {}, { + skipGraphPhases: true, + workerPoolSize: 1, + }); + const elapsedMs = performance.now() - startedAt; + const advisedBy = [...result.graph.iterRelationshipsByType('ADVISED_BY')]; + let behaviorEdges = 0; + let adviceEdges = 0; + for (const relationship of advisedBy) { + const kind = decodeSpringAopReason(relationship.reason)?.kind; + if (kind === 'behavior') behaviorEdges += 1; + if (kind === 'advice') adviceEdges += 1; + } + const adviceEdgeCount = (name: string): number => + advisedBy.filter( + (relationship) => + decodeSpringAopReason(relationship.reason)?.kind === 'advice' && + result.graph.getNode(relationship.targetId)?.properties.name === name, + ).length; + + return { + services: serviceCount, + elapsedMs, + advisedBy: advisedBy.length, + behaviorEdges, + adviceEdges, + transactionalAdviceEdges: adviceEdgeCount('transactionalAdvice'), + kotlinAdviceEdges: adviceEdgeCount('kotlinServiceAdvice'), + javaAdviceEdges: adviceEdgeCount('javaReadAdvice'), + }; + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +describe.skipIf(!BENCH_ENABLED)('mixed Java/Kotlin Spring AOP pipeline benchmark (#2416)', () => { + it('scales real behavior and advice materialization with exact ADVISED_BY counts', async () => { + const scales = [20, 40, 80]; + const results: PipelineBenchResult[] = []; + for (const services of scales) { + const result = await runMixedPipelineBenchmark(services); + results.push(result); + console.log( + ` pipeline services=${services}: ${result.elapsedMs.toFixed(1)}ms ` + + `(${result.behaviorEdges} behavior, ${result.adviceEdges} advice edges)`, + ); + } + + for (const result of results) { + const javaServiceCount = result.services / 2; + const kotlinServiceCount = result.services - javaServiceCount; + expect(result.behaviorEdges).toBe(result.services); + expect(result.transactionalAdviceEdges).toBe(result.services); + expect(result.kotlinAdviceEdges).toBe(kotlinServiceCount * 3); + expect(result.javaAdviceEdges).toBe(javaServiceCount); + expect(result.adviceEdges).toBe(result.services + kotlinServiceCount * 3 + javaServiceCount); + expect(result.advisedBy).toBe(result.behaviorEdges + result.adviceEdges); + expect(result.elapsedMs).toBeLessThan(120_000); + } + }, 300_000); +}); diff --git a/gitnexus/test/integration/spring-aop-mcp.test.ts b/gitnexus/test/integration/spring-aop-mcp.test.ts new file mode 100644 index 000000000..d93107099 --- /dev/null +++ b/gitnexus/test/integration/spring-aop-mcp.test.ts @@ -0,0 +1,457 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { querySpringAopMetadata } from '../../src/mcp/local/aop-metadata.js'; +import { listRegisteredRepos } from '../../src/storage/repo-manager.js'; +import { withTestLbugDB } from '../helpers/test-indexed-db.js'; + +vi.mock('../../src/storage/repo-manager.js', () => ({ + listRegisteredRepos: vi.fn().mockResolvedValue([]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), + findSiblingClones: vi.fn().mockResolvedValue([]), +})); + +const SERVICE_ID = 'Class:src/PaymentService.java:PaymentService'; +const ASPECT_CLASS_ID = 'Class:src/AuditAspect.java:AuditAspect'; +const NOISY_ASPECT_CLASS_ID = 'Class:src/NoisyAspect.java:NoisyAspect'; +const PAY_ID = 'Method:src/PaymentService.java:PaymentService.pay#0'; +const CLASS_LEVEL_METHOD_ID = 'Method:src/PaymentService.java:PaymentService.list#0'; +const AUDIT_ID = 'Method:src/AuditAspect.java:AuditAspect.audit#0'; +const UNKNOWN_ADVICE_ID = 'Method:src/AuditAspect.java:AuditAspect.authorize#0'; +const RESOLVED_ADVICE_ID = 'Method:src/AuditAspect.java:AuditAspect.resolvedPointcut#0'; +const HIGH_FAN_IN_ADVICE_ID = 'Method:src/AuditAspect.java:AuditAspect.hotAdvice#0'; +const NOISY_ADVICE_ID = 'Method:src/NoisyAspect.java:NoisyAspect.noisyAdvice#0'; +const NOISY_ADVISED_SOURCE_ID = 'Method:src/NoisySource.java:NoisySource.run#0'; +const NOISY_ADVICE_TARGET_ID = 'Method:src/NoisyTarget.java:NoisyTarget.advise#0'; +const FOREIGN_ADVISED_SOURCE_ID = 'Method:src/ForeignSource.java:ForeignSource.run#0'; +const FOREIGN_ADVICE_TARGET_ID = 'Method:src/ForeignTarget.java:ForeignTarget.advise#0'; +const TRUNCATED_SOURCE_ID = 'Method:src/TruncatedSource.java:TruncatedSource.run#0'; +const TRUNCATED_TARGET_ID = 'Method:src/TruncatedTarget.java:TruncatedTarget.advise#0'; +const PLAIN_ID = 'Method:src/Plain.java:Plain.run#0'; +const CLASS_BEHAVIOR_ID = `CodeElement:spring-aop:${SERVICE_ID}:transactional`; +const METHOD_BEHAVIOR_ID = `CodeElement:spring-aop:${PAY_ID}:cacheable`; +const UNKNOWN_POINTCUT_ID = `CodeElement:spring-aop:${UNKNOWN_ADVICE_ID}:pointcut`; +const RESOLVED_POINTCUT_ID = `CodeElement:spring-aop:${RESOLVED_ADVICE_ID}:pointcut`; +const UNRELATED_DECLARATION_ID = `CodeElement:spring-bean:${PLAIN_ID}`; +const ASPECT_EVIDENCE_ID = `CodeElement:spring-aop:${ASPECT_CLASS_ID}:aspect`; +const NOISY_ASPECT_EVIDENCE_ID = `CodeElement:spring-aop:${NOISY_ASPECT_CLASS_ID}:aspect`; +const NOISY_POINTCUT_ID = `CodeElement:spring-aop:${NOISY_ADVICE_ID}:pointcut`; + +const springReason = (value: object): string => `spring-aop:v1:${JSON.stringify(value)}`; + +const CLASS_BEHAVIOR_REASON = springReason({ + kind: 'behavior', + annotation: 'org.springframework.transaction.annotation.Transactional', + behavior: 'transactional', + declaredOn: 'class', + activation: 'unknown', + proxy: 'possible', +}); +const METHOD_BEHAVIOR_REASON = springReason({ + kind: 'behavior', + annotation: 'org.springframework.cache.annotation.Cacheable', + behavior: 'cacheable', + declaredOn: 'method', + activation: 'unknown', + proxy: 'possible', +}); +const ADVICE_REASON = springReason({ + kind: 'advice', + annotation: 'org.aspectj.lang.annotation.Around', + advice: 'around', + pointcut: 'execution(* com.example.PaymentService.pay(..))', + match: 'static', + activation: 'unknown', + proxy: 'possible', +}); +const UNKNOWN_POINTCUT_REASON = springReason({ + kind: 'pointcut', + annotation: 'org.aspectj.lang.annotation.Before', + pointcut: 'securedOperation()', + match: 'unresolved', + resolution: 'unknown', +}); +const RESOLVED_POINTCUT_REASON = springReason({ + kind: 'pointcut', + annotation: 'org.aspectj.lang.annotation.Pointcut', + pointcut: 'execution(* com.example.PaymentService.pay(..))', + match: 'static', + resolution: 'resolved', +}); +const ASPECT_REASON = springReason({ + kind: 'aspect', + annotation: 'org.aspectj.lang.annotation.Aspect', + activation: 'unknown', + registration: 'unknown', +}); + +const HIGH_FAN_IN_ADVISED_IDS = Array.from( + { length: 31 }, + (_, index) => `Method:src/FanInService.java:FanInService.advised${index}#0`, +); +const HIGH_FAN_IN_CALLER_IDS = Array.from( + { length: 31 }, + (_, index) => `Method:src/LegacyCaller.java:LegacyCaller.call${index}#0`, +); + +const SEED = [ + `CREATE (c:Class {id:'${SERVICE_ID}', name:'PaymentService', filePath:'src/PaymentService.java', startLine:0, endLine:20, isExported:false, content:'class PaymentService {}', description:'', frameworkAnnotations:[]})`, + `CREATE (c:Class {id:'${ASPECT_CLASS_ID}', name:'AuditAspect', filePath:'src/AuditAspect.java', startLine:0, endLine:20, isExported:false, content:'class AuditAspect {}', description:'', frameworkAnnotations:[]})`, + `CREATE (c:Class {id:'${NOISY_ASPECT_CLASS_ID}', name:'NoisyAspect', filePath:'src/NoisyAspect.java', startLine:0, endLine:20, isExported:false, content:'class NoisyAspect {}', description:'', frameworkAnnotations:[]})`, + `CREATE (m:Method {id:'${PAY_ID}', name:'pay', filePath:'src/PaymentService.java', startLine:4, endLine:8, isExported:false, content:'void pay() {}', description:'', parameterCount:0, returnType:'void'})`, + `CREATE (m:Method {id:'${CLASS_LEVEL_METHOD_ID}', name:'list', filePath:'src/PaymentService.java', startLine:10, endLine:12, isExported:false, content:'void list() {}', description:'', parameterCount:0, returnType:'void'})`, + `CREATE (m:Method {id:'${AUDIT_ID}', name:'audit', filePath:'src/AuditAspect.java', startLine:4, endLine:8, isExported:false, content:'Object audit() {}', description:'', parameterCount:0, returnType:'Object'})`, + `CREATE (m:Method {id:'${UNKNOWN_ADVICE_ID}', name:'authorize', filePath:'src/AuditAspect.java', startLine:10, endLine:12, isExported:false, content:'void authorize() {}', description:'', parameterCount:0, returnType:'void'})`, + `CREATE (m:Method {id:'${RESOLVED_ADVICE_ID}', name:'resolvedPointcut', filePath:'src/AuditAspect.java', startLine:12, endLine:13, isExported:false, content:'void resolvedPointcut() {}', description:'', parameterCount:0, returnType:'void'})`, + `CREATE (m:Method {id:'${HIGH_FAN_IN_ADVICE_ID}', name:'hotAdvice', filePath:'src/AuditAspect.java', startLine:14, endLine:16, isExported:false, content:'void hotAdvice() {}', description:'', parameterCount:0, returnType:'void'})`, + `CREATE (m:Method {id:'${NOISY_ADVICE_ID}', name:'noisyAdvice', filePath:'src/NoisyAspect.java', startLine:4, endLine:8, isExported:false, content:'void noisyAdvice() {}', description:'', parameterCount:0, returnType:'void'})`, + `CREATE (m:Method {id:'${NOISY_ADVISED_SOURCE_ID}', name:'run', filePath:'src/NoisySource.java', startLine:1, endLine:2, isExported:false, content:'void run() {}', description:'', parameterCount:0, returnType:'void'})`, + `CREATE (m:Method {id:'${NOISY_ADVICE_TARGET_ID}', name:'advise', filePath:'src/NoisyTarget.java', startLine:1, endLine:2, isExported:false, content:'void advise() {}', description:'', parameterCount:0, returnType:'void'})`, + `CREATE (m:Method {id:'${FOREIGN_ADVISED_SOURCE_ID}', name:'run', filePath:'src/ForeignSource.java', startLine:1, endLine:2, isExported:false, content:'void run() {}', description:'', parameterCount:0, returnType:'void'})`, + `CREATE (m:Method {id:'${FOREIGN_ADVICE_TARGET_ID}', name:'advise', filePath:'src/ForeignTarget.java', startLine:1, endLine:2, isExported:false, content:'void advise() {}', description:'', parameterCount:0, returnType:'void'})`, + `CREATE (m:Method {id:'${TRUNCATED_SOURCE_ID}', name:'run', filePath:'src/TruncatedSource.java', startLine:1, endLine:2, isExported:false, content:'void run() {}', description:'', parameterCount:0, returnType:'void'})`, + `CREATE (m:Method {id:'${TRUNCATED_TARGET_ID}', name:'advise', filePath:'src/TruncatedTarget.java', startLine:1, endLine:2, isExported:false, content:'void advise() {}', description:'', parameterCount:0, returnType:'void'})`, + ...HIGH_FAN_IN_ADVISED_IDS.map( + (id, index) => + `CREATE (m:Method {id:'${id}', name:'advised${index}', filePath:'src/FanInService.java', startLine:${index}, endLine:${index}, isExported:false, content:'void advised${index}() {}', description:'', parameterCount:0, returnType:'void'})`, + ), + ...HIGH_FAN_IN_CALLER_IDS.map( + (id, index) => + `CREATE (m:Method {id:'${id}', name:'call${index}', filePath:'src/LegacyCaller.java', startLine:${index}, endLine:${index}, isExported:false, content:'void call${index}() {}', description:'', parameterCount:0, returnType:'void'})`, + ), + `CREATE (m:Method {id:'${PLAIN_ID}', name:'run', filePath:'src/Plain.java', startLine:1, endLine:2, isExported:false, content:'void run() {}', description:'', parameterCount:0, returnType:'void'})`, + `CREATE (e:CodeElement {id:'${CLASS_BEHAVIOR_ID}', name:'Transactional', filePath:'src/PaymentService.java', startLine:0, endLine:0, isExported:false, content:'', description:'Spring AOP behavior evidence'})`, + `CREATE (e:CodeElement {id:'${METHOD_BEHAVIOR_ID}', name:'Cacheable', filePath:'src/PaymentService.java', startLine:4, endLine:4, isExported:false, content:'', description:'Spring AOP behavior evidence'})`, + `CREATE (e:CodeElement {id:'${UNKNOWN_POINTCUT_ID}', name:'securedOperation()', filePath:'src/AuditAspect.java', startLine:10, endLine:10, isExported:false, content:'', description:'Spring AOP unresolved pointcut evidence'})`, + `CREATE (e:CodeElement {id:'${RESOLVED_POINTCUT_ID}', name:'pay()', filePath:'src/AuditAspect.java', startLine:12, endLine:12, isExported:false, content:'', description:'Spring AOP resolved pointcut evidence'})`, + `CREATE (e:CodeElement {id:'${UNRELATED_DECLARATION_ID}', name:'plain', filePath:'src/Plain.java', startLine:1, endLine:1, isExported:false, content:'', description:'Spring Bean factory declaration'})`, + `CREATE (e:CodeElement {id:'${ASPECT_EVIDENCE_ID}', name:'Aspect', filePath:'src/AuditAspect.java', startLine:0, endLine:0, isExported:false, content:'', description:'Spring AOP aspect evidence'})`, + `CREATE (e:CodeElement {id:'${NOISY_ASPECT_EVIDENCE_ID}', name:'Aspect', filePath:'src/NoisyAspect.java', startLine:0, endLine:0, isExported:false, content:'', description:'Spring AOP aspect evidence'})`, + `CREATE (e:CodeElement {id:'${NOISY_POINTCUT_ID}', name:'securedOperation()', filePath:'src/NoisyAspect.java', startLine:4, endLine:4, isExported:false, content:'', description:'Spring AOP unresolved pointcut evidence'})`, + `MATCH (c:Class {id:'${SERVICE_ID}'}), (e:CodeElement {id:'${CLASS_BEHAVIOR_ID}'}) CREATE (c)-[:CodeRelation {type:'ADVISED_BY', confidence:1.0, reason:'${CLASS_BEHAVIOR_REASON}', step:0}]->(e)`, + `MATCH (m:Method {id:'${CLASS_LEVEL_METHOD_ID}'}), (e:CodeElement {id:'${CLASS_BEHAVIOR_ID}'}) CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:1.0, reason:'${CLASS_BEHAVIOR_REASON}', step:0}]->(e)`, + `MATCH (m:Method {id:'${PAY_ID}'}), (e:CodeElement {id:'${METHOD_BEHAVIOR_ID}'}) CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:1.0, reason:'${METHOD_BEHAVIOR_REASON}', step:0}]->(e)`, + // Duplicate evidence exercises read-side deduplication for indexes produced + // by an interrupted/retried incremental write. + `MATCH (m:Method {id:'${PAY_ID}'}), (e:CodeElement {id:'${METHOD_BEHAVIOR_ID}'}) CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:1.0, reason:'${METHOD_BEHAVIOR_REASON}', step:0}]->(e)`, + `MATCH (m:Method {id:'${PAY_ID}'}), (a:Method {id:'${AUDIT_ID}'}) CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:0.9, reason:'${ADVICE_REASON}', step:0}]->(a)`, + `MATCH (a:Method {id:'${UNKNOWN_ADVICE_ID}'}), (e:CodeElement {id:'${UNKNOWN_POINTCUT_ID}'}) CREATE (a)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'${UNKNOWN_POINTCUT_REASON}', step:0}]->(e)`, + `MATCH (a:Method {id:'${RESOLVED_ADVICE_ID}'}), (e:CodeElement {id:'${RESOLVED_POINTCUT_ID}'}) CREATE (a)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'${RESOLVED_POINTCUT_REASON}', step:0}]->(e)`, + `MATCH (c:Class {id:'${ASPECT_CLASS_ID}'}), (e:CodeElement {id:'${ASPECT_EVIDENCE_ID}'}) CREATE (c)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'${ASPECT_REASON}', step:0}]->(e)`, + `MATCH (m:Method {id:'${PLAIN_ID}'}), (e:CodeElement {id:'${UNRELATED_DECLARATION_ID}'}) CREATE (m)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'spring-bean-factory:{"names":["plain"],"namesKnown":true}', step:0}]->(e)`, + `MATCH (c:Class {id:'${NOISY_ASPECT_CLASS_ID}'}), (e:CodeElement {id:'${UNRELATED_DECLARATION_ID}'}) UNWIND range(1, 1001) AS ignored CREATE (c)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'spring-bean-factory:{"names":["noise"],"namesKnown":true}', step:ignored}]->(e)`, + `MATCH (c:Class {id:'${NOISY_ASPECT_CLASS_ID}'}), (e:CodeElement {id:'${NOISY_ASPECT_EVIDENCE_ID}'}) CREATE (c)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'${ASPECT_REASON}', step:0}]->(e)`, + `MATCH (m:Method {id:'${PLAIN_ID}'}), (e:CodeElement {id:'${NOISY_POINTCUT_ID}'}) UNWIND range(1, 1001) AS ignored CREATE (m)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'spring-bean-factory:{"names":["noise"],"namesKnown":true}', step:ignored}]->(e)`, + `MATCH (m:Method {id:'${NOISY_ADVICE_ID}'}), (e:CodeElement {id:'${NOISY_POINTCUT_ID}'}) CREATE (m)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'${UNKNOWN_POINTCUT_REASON}', step:0}]->(e)`, + `MATCH (m:Method {id:'${NOISY_ADVISED_SOURCE_ID}'}), (a:Method {id:'${NOISY_ADVICE_TARGET_ID}'}) CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:0.9, reason:'${ADVICE_REASON}', step:0}]->(a)`, + `MATCH (m:Method {id:'${NOISY_ADVISED_SOURCE_ID}'}), (a:Method {id:'${FOREIGN_ADVICE_TARGET_ID}'}) UNWIND range(1, 1001) AS ignored CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:0.1, reason:'foreign-advice', step:ignored}]->(a)`, + `MATCH (m:Method {id:'${FOREIGN_ADVISED_SOURCE_ID}'}), (a:Method {id:'${NOISY_ADVICE_TARGET_ID}'}) UNWIND range(1, 1001) AS ignored CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:0.1, reason:'foreign-advice', step:ignored}]->(a)`, + `MATCH (m:Method {id:'${TRUNCATED_SOURCE_ID}'}), (a:Method {id:'${TRUNCATED_TARGET_ID}'}) UNWIND range(1, 1001) AS item CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:0.9, reason:'${ADVICE_REASON}', step:item}]->(a)`, + ...HIGH_FAN_IN_ADVISED_IDS.map( + (id) => + `MATCH (m:Method {id:'${id}'}), (a:Method {id:'${HIGH_FAN_IN_ADVICE_ID}'}) CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:0.9, reason:'${ADVICE_REASON}', step:0}]->(a)`, + ), + ...HIGH_FAN_IN_CALLER_IDS.map( + (id) => + `MATCH (m:Method {id:'${id}'}), (a:Method {id:'${HIGH_FAN_IN_ADVICE_ID}'}) CREATE (m)-[:CodeRelation {type:'CALLS', confidence:1.0, reason:'test fixture', step:0}]->(a)`, + ), +]; + +withTestLbugDB( + 'spring-aop-mcp', + (handle) => { + let backend: LocalBackend; + + beforeAll(() => { + backend = (handle as typeof handle & { _backend: LocalBackend })._backend; + }); + + describe('Spring AOP metadata enrichment', () => { + it('normalizes declarative behavior and explicit advice for an advised method', async () => { + const metadata = await querySpringAopMetadata(handle.repoId, PAY_ID, 'Method'); + + expect(metadata).toEqual({ + framework: 'spring', + proxied: 'possible', + behaviors: [ + { + annotation: 'org.springframework.cache.annotation.Cacheable', + behavior: 'cacheable', + declaredOn: 'method', + activation: 'unknown', + evidenceId: METHOD_BEHAVIOR_ID, + }, + ], + advices: [ + { + annotation: 'org.aspectj.lang.annotation.Around', + advice: 'around', + pointcut: 'execution(* com.example.PaymentService.pay(..))', + match: 'static', + activation: 'unknown', + adviceId: AUDIT_ID, + adviceName: 'audit', + adviceFilePath: 'src/AuditAspect.java', + advisedId: PAY_ID, + advisedName: 'pay', + advisedFilePath: 'src/PaymentService.java', + }, + ], + resolvedPointcuts: [], + unresolvedPointcuts: [], + }); + }); + + it('returns the same canonical advice direction from the advice method', async () => { + const metadata = await querySpringAopMetadata(handle.repoId, AUDIT_ID, 'Method'); + + expect(metadata?.advices).toEqual([ + expect.objectContaining({ + adviceId: AUDIT_ID, + advisedId: PAY_ID, + advice: 'around', + }), + ]); + expect(metadata).not.toHaveProperty('proxied'); + }); + + it('exposes the same AOP metadata through context and impact', async () => { + const [context, adviceContext, impact, adviceImpact] = await Promise.all([ + backend.callTool('context', { uid: PAY_ID }), + backend.callTool('context', { uid: AUDIT_ID }), + backend.callTool('impact', { + target: 'pay', + direction: 'upstream', + }), + backend.callTool('impact', { + target: 'audit', + direction: 'upstream', + relationTypes: ['ADVISED_BY'], + includeTests: true, + }), + ]); + + expect(context.symbol.aop).toEqual( + expect.objectContaining({ + framework: 'spring', + proxied: 'possible', + behaviors: [expect.objectContaining({ behavior: 'cacheable' })], + advices: [expect.objectContaining({ advice: 'around', adviceId: AUDIT_ID })], + }), + ); + expect(context.outgoing.advised_by).toEqual( + expect.arrayContaining([expect.objectContaining({ uid: AUDIT_ID, name: 'audit' })]), + ); + expect(adviceContext.incoming.advised_by).toEqual( + expect.arrayContaining([expect.objectContaining({ uid: PAY_ID, name: 'pay' })]), + ); + expect(adviceContext.symbol.aop).not.toHaveProperty('proxied'); + expect(impact.target.aop).toEqual(context.symbol.aop); + expect(adviceImpact.target.aop).toEqual(adviceContext.symbol.aop); + expect(adviceImpact.byDepth[1]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: PAY_ID, + name: 'pay', + relationType: 'ADVISED_BY', + }), + ]), + ); + }); + + it('keeps legacy context relations when advice fan-in exceeds the context window', async () => { + const context = await backend.callTool('context', { uid: HIGH_FAN_IN_ADVICE_ID }); + + expect(context.incoming.calls).toHaveLength(30); + expect(context.incoming.advised_by).toHaveLength(30); + expect(context.symbol.aop.advices).toHaveLength(31); + }); + + it('supports Class and CodeElement behavior evidence', async () => { + const classMetadata = await querySpringAopMetadata(handle.repoId, SERVICE_ID, 'Class'); + const classLevelMethodMetadata = await querySpringAopMetadata( + handle.repoId, + CLASS_LEVEL_METHOD_ID, + 'Method', + ); + const evidenceMetadata = await querySpringAopMetadata( + handle.repoId, + METHOD_BEHAVIOR_ID, + 'CodeElement', + ); + + expect(classMetadata?.behaviors).toEqual([ + expect.objectContaining({ behavior: 'transactional', declaredOn: 'class' }), + ]); + expect(classMetadata?.proxied).toBe('possible'); + expect(classLevelMethodMetadata?.behaviors).toEqual([ + expect.objectContaining({ behavior: 'transactional', declaredOn: 'class' }), + ]); + expect(classLevelMethodMetadata?.proxied).toBe('possible'); + expect(evidenceMetadata?.behaviors).toEqual([ + expect.objectContaining({ behavior: 'cacheable', evidenceId: METHOD_BEHAVIOR_ID }), + ]); + expect(evidenceMetadata).not.toHaveProperty('proxied'); + }); + + it('surfaces standalone Aspect declarations without claiming proxy activation', async () => { + const [classMetadata, evidenceMetadata, context] = await Promise.all([ + querySpringAopMetadata(handle.repoId, ASPECT_CLASS_ID, 'Class'), + querySpringAopMetadata(handle.repoId, ASPECT_EVIDENCE_ID, 'CodeElement'), + backend.callTool('context', { uid: ASPECT_CLASS_ID }), + ]); + const expectedAspect = { + annotation: 'org.aspectj.lang.annotation.Aspect', + activation: 'unknown', + registration: 'unknown', + evidenceId: ASPECT_EVIDENCE_ID, + }; + + expect(classMetadata?.aspect).toEqual(expectedAspect); + expect(classMetadata).not.toHaveProperty('proxied'); + expect(evidenceMetadata?.aspect).toEqual(expectedAspect); + expect(evidenceMetadata).not.toHaveProperty('proxied'); + expect(context.symbol.aop.aspect).toEqual(expectedAspect); + expect(context.symbol.aop).not.toHaveProperty('proxied'); + }); + + it('surfaces unresolved pointcuts without guessing an advised target', async () => { + const adviceMetadata = await querySpringAopMetadata( + handle.repoId, + UNKNOWN_ADVICE_ID, + 'Method', + ); + const evidenceMetadata = await querySpringAopMetadata( + handle.repoId, + UNKNOWN_POINTCUT_ID, + 'CodeElement', + ); + + const expected = [ + { + annotation: 'org.aspectj.lang.annotation.Before', + pointcut: 'securedOperation()', + adviceId: UNKNOWN_ADVICE_ID, + adviceName: 'authorize', + adviceFilePath: 'src/AuditAspect.java', + evidenceId: UNKNOWN_POINTCUT_ID, + }, + ]; + expect(adviceMetadata).toEqual({ + framework: 'spring', + behaviors: [], + advices: [], + resolvedPointcuts: [], + unresolvedPointcuts: expected, + }); + expect(evidenceMetadata?.unresolvedPointcuts).toEqual(expected); + }); + + it('surfaces resolved standalone pointcut declarations from both endpoints', async () => { + const [adviceMetadata, evidenceMetadata] = await Promise.all([ + querySpringAopMetadata(handle.repoId, RESOLVED_ADVICE_ID, 'Method'), + querySpringAopMetadata(handle.repoId, RESOLVED_POINTCUT_ID, 'CodeElement'), + ]); + const expected = [ + { + annotation: 'org.aspectj.lang.annotation.Pointcut', + pointcut: 'execution(* com.example.PaymentService.pay(..))', + match: 'static', + resolution: 'resolved', + adviceId: RESOLVED_ADVICE_ID, + adviceName: 'resolvedPointcut', + adviceFilePath: 'src/AuditAspect.java', + evidenceId: RESOLVED_POINTCUT_ID, + }, + ]; + + expect(adviceMetadata?.resolvedPointcuts).toEqual(expected); + expect(evidenceMetadata?.resolvedPointcuts).toEqual(expected); + }); + + it('orders capped rows deterministically and reports positive truncation', async () => { + const first = await querySpringAopMetadata(handle.repoId, TRUNCATED_SOURCE_ID, 'Method'); + const second = await querySpringAopMetadata(handle.repoId, TRUNCATED_SOURCE_ID, 'Method'); + + expect(first?.truncated).toBe(true); + expect(first?.advices).toEqual([ + expect.objectContaining({ + advisedId: TRUNCATED_SOURCE_ID, + adviceId: TRUNCATED_TARGET_ID, + }), + ]); + expect(second).toEqual(first); + }); + + it('does not let unrelated DECLARES exhaust the Spring AOP query budget', async () => { + const [aspectMetadata, pointcutMetadata] = await Promise.all([ + querySpringAopMetadata(handle.repoId, NOISY_ASPECT_CLASS_ID, 'Class'), + querySpringAopMetadata(handle.repoId, NOISY_POINTCUT_ID, 'CodeElement'), + ]); + + expect(aspectMetadata?.aspect).toEqual({ + annotation: 'org.aspectj.lang.annotation.Aspect', + activation: 'unknown', + registration: 'unknown', + evidenceId: NOISY_ASPECT_EVIDENCE_ID, + }); + expect(aspectMetadata).not.toHaveProperty('truncated'); + expect(pointcutMetadata?.unresolvedPointcuts).toEqual([ + { + annotation: 'org.aspectj.lang.annotation.Before', + pointcut: 'securedOperation()', + adviceId: NOISY_ADVICE_ID, + adviceName: 'noisyAdvice', + adviceFilePath: 'src/NoisyAspect.java', + evidenceId: NOISY_POINTCUT_ID, + }, + ]); + expect(pointcutMetadata).not.toHaveProperty('truncated'); + }); + + it('does not let unrelated ADVISED_BY exhaust either AOP query direction', async () => { + const [sourceMetadata, targetMetadata] = await Promise.all([ + querySpringAopMetadata(handle.repoId, NOISY_ADVISED_SOURCE_ID, 'Method'), + querySpringAopMetadata(handle.repoId, NOISY_ADVICE_TARGET_ID, 'Method'), + ]); + + expect(sourceMetadata?.advices).toEqual([ + expect.objectContaining({ + advisedId: NOISY_ADVISED_SOURCE_ID, + adviceId: NOISY_ADVICE_TARGET_ID, + }), + ]); + expect(targetMetadata?.advices).toEqual(sourceMetadata?.advices); + expect(sourceMetadata).not.toHaveProperty('truncated'); + expect(targetMetadata).not.toHaveProperty('truncated'); + }); + + it('ignores unrelated DECLARES evidence and unsupported symbol kinds', async () => { + await expect( + querySpringAopMetadata(handle.repoId, PLAIN_ID, 'Method'), + ).resolves.toBeUndefined(); + await expect( + querySpringAopMetadata(handle.repoId, 'Function:src/plain.ts:run', 'Function'), + ).resolves.toBeUndefined(); + }); + }); + }, + { + seed: SEED, + poolAdapter: true, + afterSetup: async (handle) => { + vi.mocked(listRegisteredRepos).mockResolvedValue([ + { + name: 'test-repo', + path: '/test/repo', + storagePath: handle.tmpHandle.dbPath, + indexedAt: new Date().toISOString(), + lastCommit: 'abc123', + stats: { files: 3, nodes: 12, communities: 0, processes: 0 }, + }, + ]); + const backend = new LocalBackend(); + await backend.init(); + (handle as typeof handle & { _backend?: LocalBackend })._backend = backend; + }, + }, +); diff --git a/gitnexus/test/integration/spring-aop-pipeline.test.ts b/gitnexus/test/integration/spring-aop-pipeline.test.ts new file mode 100644 index 000000000..439451d44 --- /dev/null +++ b/gitnexus/test/integration/spring-aop-pipeline.test.ts @@ -0,0 +1,997 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { decodeSpringAopReason } from '../../src/core/ingestion/frameworks/spring/aop.js'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import { + loadParseCache, + PARSE_CACHE_VERSION, + pruneCache, + saveParseCache, + type ParseCache, +} from '../../src/storage/parse-cache.js'; +import { + getDurableParsedFileDir, + pruneAndSaveDurableParsedFileStore, +} from '../../src/storage/parsedfile-store.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; + +function writeFixture(root: string, relativePath: string, content: string): void { + const target = path.join(root, relativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); +} + +describe('Spring AOP, transaction, cache, and method-security pipeline (#2416)', () => { + let dir: string; + let result: PipelineResult; + let nodes: GraphNode[]; + let advisedBy: GraphRelationship[]; + let declarations: GraphRelationship[]; + + beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-aop-')); + writeFixture( + dir, + 'src/main/java/com/example/service/OrderService.java', + `package com.example.service; + +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.security.access.annotation.Secured; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.transaction.annotation.Transactional; + +public class OrderService { + @Transactional + public void transactionalOperation() {} + + @Cacheable("orders") + public String cachedOperation() { return "cached"; } + + @CacheEvict(cacheNames = "orders", allEntries = true) + public void evictOperation() {} + + @PreAuthorize("hasRole('ADMIN')") + public void securedOperation() {} + + @Secured("ROLE_AUDITOR") + public void legacySecuredOperation() {} + + public void plainOperation() {} +} +`, + ); + writeFixture( + dir, + 'src/main/java/com/example/service/SecuredOperations.java', + `package com.example.service; + +import org.springframework.security.access.prepost.PreAuthorize; + +public interface SecuredOperations { + @PreAuthorize("hasRole('OPERATOR')") + void interfaceSecuredOperation(); +} +`, + ); + writeFixture( + dir, + 'src/main/java/com/example/service/SecuredOperationsImpl.java', + `package com.example.service; + +public class SecuredOperationsImpl implements SecuredOperations { + @Override + public void interfaceSecuredOperation() {} +} +`, + ); + writeFixture( + dir, + 'src/main/java/com/example/service/ClassLevelService.java', + `package com.example.service; + +import org.springframework.transaction.annotation.Transactional; + +@Transactional +public class ClassLevelService { + public void inheritedTransaction() {} + private void privateHelper() {} + public static void staticHelper() {} +} +`, + ); + writeFixture( + dir, + 'src/main/java/com/example/service/TransactionalOperations.java', + `package com.example.service; + +import org.springframework.transaction.annotation.Transactional; + +@Transactional +public interface TransactionalOperations { + void interfaceInheritedTransaction(); +} +`, + ); + writeFixture( + dir, + 'src/main/java/com/example/service/TransactionalOperationsImpl.java', + `package com.example.service; + +public class TransactionalOperationsImpl implements TransactionalOperations { + @Override + public void interfaceInheritedTransaction() {} +} +`, + ); + writeFixture( + dir, + 'src/main/java/com/example/service/InheritedBehaviorService.java', + `package com.example.service; + +import org.springframework.transaction.annotation.Transactional; + +class InheritedBehaviorBase { + @Transactional + public void overriddenTransaction() {} +} + +public class InheritedBehaviorService extends InheritedBehaviorBase { + @Override + public void overriddenTransaction() {} +} +`, + ); + writeFixture( + dir, + 'src/main/java/com/example/aop/OrderAspect.java', + `package com.example.aop; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.After; +import org.aspectj.lang.annotation.AfterReturning; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; + +@Aspect +public class OrderAspect { + @Around("execution(* com.example..OrderService.*(..))") + public Object traceOrderOperations(ProceedingJoinPoint joinPoint) throws Throwable { + return joinPoint.proceed(); + } + + @Before("@annotation(org.springframework.transaction.annotation.Transactional)") + public void transactionalAnnotationAdvice() {} + + @Before("within(*Service)") + public void simpleNameWithinAdvice() {} + + @Before("execution(* *Service.kotlinCachedOperation(..))") + public void simpleNameExecutionAdvice() {} + + @Before("execution(public * com.example.service.SecuredOperations.interfaceSecuredOperation(..))") + public void publicInterfaceAdvice() {} + + @Before("within(OrderService)") + public void unresolvedSimpleTypeAdvice() {} + + @AfterReturning( + pointcut = "execution(* com.example..OrderService.cachedOperation(..))", + returning = "result") + public void cachedReturnAdvice(Object result) {} + + @Before("namedOrderOperations()") + public void unresolvedNamedAdvice() {} + + @Before("") + public void emptyPointcutAdvice() {} + + @After("execution(* com.example..OrderService.*(..)) && args(orderId)") + public void unresolvedCompoundAdvice(String orderId) {} +} +`, + ); + writeFixture( + dir, + 'src/main/kotlin/com/example/aop/KotlinOrderAspect.kt', + `package com.example.aop + +import org.aspectj.lang.annotation.Aspect +import org.aspectj.lang.annotation.Before + +@Aspect +object KotlinOrderAspect { + @Before("within(com.example.service.KotlinOrderService)") + fun traceKotlinOperations() {} + + @Before("within(com.example.service.KotlinObjectService)") + fun traceKotlinObjectOperations() {} + + @Before("""execution(* com.example..KotlinOrderService.kotlinCachedOperation(..))""") + fun rawStringPointcutAdvice() {} +} +`, + ); + writeFixture( + dir, + 'src/main/kotlin/com/example/service/KotlinOrderService.kt', + `package com.example.service + +import org.springframework.cache.annotation.CacheEvict +import org.springframework.cache.annotation.Cacheable +import org.springframework.cache.annotation.CachePut +import org.springframework.cache.annotation.Caching +import org.springframework.security.access.annotation.Secured +import org.springframework.security.access.prepost.PostAuthorize +import org.springframework.security.access.prepost.PostFilter +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.access.prepost.PreFilter +import org.springframework.transaction.annotation.Transactional + +class KotlinOrderService { + @Transactional + fun kotlinTransactionalOperation() {} + + @Cacheable("orders") + fun kotlinCachedOperation(): String = "cached" + + @CacheEvict(cacheNames = ["orders"], allEntries = true) + fun kotlinEvictOperation() {} + + @PreAuthorize("hasRole('ADMIN')") + fun kotlinSecuredOperation() {} + + @Secured("ROLE_AUDITOR") + fun kotlinLegacySecuredOperation() {} + + @CachePut("orders") + fun kotlinCachePutOperation() {} + + @Caching(cacheable = [Cacheable("orders")]) + fun kotlinCachingOperation() {} + + @PostAuthorize("returnObject != null") + fun kotlinPostAuthorizeOperation(): String = "ok" + + @PreFilter("filterObject != null") + fun kotlinPreFilterOperation(values: List) {} + + @PostFilter("filterObject != null") + fun kotlinPostFilterOperation(): List = emptyList() + + @jakarta.annotation.security.RolesAllowed("ADMIN") + fun kotlinJakartaRolesAllowedOperation() {} + + @javax.annotation.security.RolesAllowed("AUDITOR") + fun kotlinJavaxRolesAllowedOperation() {} + + @jakarta.transaction.Transactional + fun kotlinJakartaTransaction() {} + + @javax.transaction.Transactional + fun kotlinJavaxTransaction() {} + + @Transactional + suspend fun kotlinSuspendTransaction() {} + + @Transactional + fun String.kotlinExtensionTransaction() {} + + @org.springframework.transaction.annotation.Transactional + fun kotlinFullyQualifiedTransaction() {} + + @Transactional + private fun kotlinPrivateTransaction() {} +} + +@Transactional +fun kotlinTopLevelTransaction() {} +`, + ); + writeFixture( + dir, + 'src/main/kotlin/com/example/service/KotlinObjectService.kt', + `package com.example.service + +import org.springframework.transaction.annotation.Transactional + +interface KotlinObjectContract { + fun kotlinObjectInheritedTransaction() + fun kotlinObjectExplicitTransaction() +} + +@Transactional +object KotlinObjectService : KotlinObjectContract { + override fun kotlinObjectInheritedTransaction() {} + + @Transactional + override fun kotlinObjectExplicitTransaction() {} + + private fun kotlinObjectPrivateHelper() {} +} +`, + ); + writeFixture( + dir, + 'src/main/kotlin/com/example/service/KotlinTransactionalOperations.kt', + `package com.example.service + +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.transaction.annotation.Transactional + +@Transactional +interface KotlinTransactionalOperations { + fun kotlinInterfaceInheritedTransaction() + + @PreAuthorize("hasRole('KOTLIN_OPERATOR')") + fun kotlinInterfaceSecuredOperation() +} + +interface KotlinMethodAnnotatedOperations { + @Transactional + fun kotlinInterfaceExplicitTransaction() +} + +class KotlinTransactionalOperationsImpl : KotlinTransactionalOperations { + override fun kotlinInterfaceInheritedTransaction() {} + override fun kotlinInterfaceSecuredOperation() {} +} + +class KotlinMethodAnnotatedOperationsImpl : KotlinMethodAnnotatedOperations { + override fun kotlinInterfaceExplicitTransaction() {} +} + +open class KotlinBehaviorBase { + @Transactional + open fun kotlinOverriddenTransaction() {} +} + +class KotlinBehaviorService : KotlinBehaviorBase() { + override fun kotlinOverriddenTransaction() {} +} +`, + ); + writeFixture( + dir, + 'src/main/kotlin/com/example/service/KotlinCompanionService.kt', + `package com.example.service + +import org.springframework.transaction.annotation.Transactional + +class KotlinCompanionService { + companion object { + @Transactional + fun kotlinCompanionTransaction() {} + + @receiver:Transactional + fun String.kotlinReceiverTargetTransaction() {} + } +} +`, + ); + writeFixture( + dir, + 'src/main/kotlin/com/example/service/KotlinAliasedService.kt', + `package com.example.service + +import org.springframework.transaction.annotation.Transactional as Tx + +class KotlinAliasedService { + @Tx + fun kotlinAliasedTransactionalOperation() {} + + fun kotlinAliasedPlainOperation() {} +} +`, + ); + writeFixture( + dir, + 'src/main/kotlin/com/example/aop/KotlinAliasedAspect.kt', + `package com.example.aop + +import org.aspectj.lang.annotation.Aspect as AopAspect +import org.aspectj.lang.annotation.Before as AdviceBefore + +@AopAspect +object KotlinAliasedAspect { + @AdviceBefore("@annotation(org.springframework.transaction.annotation.Transactional)") + fun aliasedTransactionalAdvice() {} +} +`, + ); + writeFixture( + dir, + 'src/main/kotlin/com/example/service/KotlinWildcardService.kt', + `package com.example.service + +import org.springframework.transaction.annotation.* + +class KotlinWildcardService { + @Transactional + fun kotlinWildcardTransaction() {} +} +`, + ); + writeFixture( + dir, + 'src/main/kotlin/com/example/service/KotlinScriptService.kts', + `package com.example.service + +import org.springframework.transaction.annotation.Transactional + +class KotlinScriptService { + @Transactional + fun kotlinScriptTransaction() {} +} +`, + ); + writeFixture( + dir, + 'src/main/csharp/com/example/service/OrderService.cs', + `namespace com.example.service { + public class OrderService { + public void foreignOperation() {} + } +} +`, + ); + + result = await runPipelineFromRepo(dir, () => {}, { skipGraphPhases: false }); + nodes = [...result.graph.iterNodes()]; + advisedBy = [...result.graph.iterRelationshipsByType('ADVISED_BY')]; + declarations = [...result.graph.iterRelationshipsByType('DECLARES')]; + }, 60_000); + + afterAll(() => { + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + }); + + const nodeNamed = (name: string): GraphNode | undefined => + nodes.find((node) => node.properties.name === name); + + const relationshipTarget = (relationship: GraphRelationship): GraphNode | undefined => + result.graph.getNode(relationship.targetId); + + const methodNamedOn = (ownerName: string, methodName: string): GraphNode | undefined => { + const owner = nodeNamed(ownerName); + const ownership = [...result.graph.iterRelationshipsByType('HAS_METHOD')].find( + (relationship) => + relationship.sourceId === owner?.id && + result.graph.getNode(relationship.targetId)?.properties.name === methodName, + ); + return ownership === undefined ? undefined : result.graph.getNode(ownership.targetId); + }; + + const declarativeAdviceForNode = (source: GraphNode | undefined): GraphRelationship[] => { + if (source === undefined) return []; + return advisedBy.filter( + (relationship) => + relationship.sourceId === source.id && + relationshipTarget(relationship)?.label === 'CodeElement', + ); + }; + + const declarativeAdviceFor = (name: string): GraphRelationship[] => + declarativeAdviceForNode(nodeNamed(name)); + + const behaviorSignaturesForNode = (node: GraphNode | undefined): string[] => + declarativeAdviceForNode(node) + .flatMap((relationship) => { + const reason = decodeSpringAopReason(relationship.reason); + return reason?.kind === 'behavior' ? [`${reason.annotation}:${reason.declaredOn}`] : []; + }) + .sort(); + + const behaviorSignaturesFor = (name: string): string[] => + behaviorSignaturesForNode(nodeNamed(name)); + + it('attaches Java and Kotlin declarative behavior as explicit ADVISED_BY evidence', () => { + const methodNames = [ + 'transactionalOperation', + 'cachedOperation', + 'evictOperation', + 'securedOperation', + 'legacySecuredOperation', + 'interfaceSecuredOperation', + 'kotlinTransactionalOperation', + 'kotlinCachedOperation', + 'kotlinEvictOperation', + 'kotlinSecuredOperation', + 'kotlinLegacySecuredOperation', + 'kotlinCachePutOperation', + 'kotlinCachingOperation', + 'kotlinPostAuthorizeOperation', + 'kotlinPreFilterOperation', + 'kotlinPostFilterOperation', + 'kotlinJakartaRolesAllowedOperation', + 'kotlinJavaxRolesAllowedOperation', + 'kotlinJakartaTransaction', + 'kotlinJavaxTransaction', + 'kotlinSuspendTransaction', + 'kotlinExtensionTransaction', + 'kotlinFullyQualifiedTransaction', + ]; + + for (const methodName of methodNames) { + const edges = declarativeAdviceFor(methodName); + expect(edges, `${methodName} should retain its declarative Spring behavior`).toHaveLength(1); + expect(decodeSpringAopReason(edges[0]?.reason)?.kind).toBe('behavior'); + expect(edges.map((edge) => relationshipTarget(edge)?.label)).toEqual(['CodeElement']); + } + }); + + it('stores synthetic evidence locations in the graph zero-based line convention', () => { + const evidence = relationshipTarget(declarativeAdviceFor('transactionalOperation')[0]!); + + // @Transactional is on source line 10 in OrderService.java. + expect(evidence?.properties.startLine).toBe(9); + expect(evidence?.properties.endLine).toBe(9); + }); + + it('fans class-level behavior out only to proxy-eligible methods', () => { + expect(declarativeAdviceFor('ClassLevelService')).toHaveLength(1); + expect(declarativeAdviceFor('inheritedTransaction')).toHaveLength(1); + expect(declarativeAdviceFor('privateHelper')).toHaveLength(0); + expect(declarativeAdviceFor('staticHelper')).toHaveLength(0); + expect(declarativeAdviceFor('TransactionalOperations')).toHaveLength(1); + expect(declarativeAdviceFor('interfaceInheritedTransaction')).toHaveLength(1); + }); + + it('captures Kotlin interface class fan-out and method-declared behaviors', () => { + expect(nodeNamed('KotlinTransactionalOperations')?.label).toBe('Interface'); + expect(nodeNamed('KotlinMethodAnnotatedOperations')?.label).toBe('Interface'); + + expect(behaviorSignaturesFor('KotlinTransactionalOperations')).toEqual([ + 'org.springframework.transaction.annotation.Transactional:class', + ]); + expect(behaviorSignaturesFor('kotlinInterfaceInheritedTransaction')).toEqual([ + 'org.springframework.transaction.annotation.Transactional:class', + ]); + expect(behaviorSignaturesFor('kotlinInterfaceSecuredOperation')).toEqual([ + 'org.springframework.security.access.prepost.PreAuthorize:method', + 'org.springframework.transaction.annotation.Transactional:class', + ]); + expect(behaviorSignaturesFor('kotlinInterfaceExplicitTransaction')).toEqual([ + 'org.springframework.transaction.annotation.Transactional:method', + ]); + expect(behaviorSignaturesFor('kotlinPrivateTransaction')).toEqual([]); + expect(nodeNamed('kotlinTopLevelTransaction')?.label).toBe('Function'); + expect(behaviorSignaturesFor('kotlinTopLevelTransaction')).toEqual([]); + expect(behaviorSignaturesFor('kotlinWildcardTransaction')).toEqual([ + 'org.springframework.transaction.annotation.Transactional:method', + ]); + expect(behaviorSignaturesFor('kotlinScriptTransaction')).toEqual([ + 'org.springframework.transaction.annotation.Transactional:method', + ]); + }); + + it('covers the complete Kotlin cache, security, and transaction behavior matrix', () => { + const behaviorFor = (name: string): string | undefined => { + const [edge] = declarativeAdviceFor(name); + const reason = decodeSpringAopReason(edge?.reason); + return reason?.kind === 'behavior' ? reason.behavior : undefined; + }; + + expect({ + cachePut: behaviorFor('kotlinCachePutOperation'), + caching: behaviorFor('kotlinCachingOperation'), + postAuthorize: behaviorFor('kotlinPostAuthorizeOperation'), + preFilter: behaviorFor('kotlinPreFilterOperation'), + postFilter: behaviorFor('kotlinPostFilterOperation'), + jakartaRoles: behaviorFor('kotlinJakartaRolesAllowedOperation'), + javaxRoles: behaviorFor('kotlinJavaxRolesAllowedOperation'), + jakartaTransaction: behaviorFor('kotlinJakartaTransaction'), + javaxTransaction: behaviorFor('kotlinJavaxTransaction'), + }).toEqual({ + cachePut: 'cache-put', + caching: 'caching', + postAuthorize: 'authorization', + preFilter: 'authorization', + postFilter: 'authorization', + jakartaRoles: 'authorization', + javaxRoles: 'authorization', + jakartaTransaction: 'transactional', + javaxTransaction: 'transactional', + }); + }); + + it('propagates Java and Kotlin behavior through implementations and overrides', () => { + expect( + behaviorSignaturesForNode( + methodNamedOn('SecuredOperationsImpl', 'interfaceSecuredOperation'), + ), + ).toEqual(['org.springframework.security.access.prepost.PreAuthorize:method']); + expect( + behaviorSignaturesForNode( + methodNamedOn('TransactionalOperationsImpl', 'interfaceInheritedTransaction'), + ), + ).toEqual(['org.springframework.transaction.annotation.Transactional:class']); + expect( + behaviorSignaturesForNode(methodNamedOn('InheritedBehaviorService', 'overriddenTransaction')), + ).toEqual(['org.springframework.transaction.annotation.Transactional:method']); + expect( + behaviorSignaturesForNode( + methodNamedOn('KotlinTransactionalOperationsImpl', 'kotlinInterfaceSecuredOperation'), + ), + ).toEqual([ + 'org.springframework.security.access.prepost.PreAuthorize:method', + 'org.springframework.transaction.annotation.Transactional:class', + ]); + expect( + behaviorSignaturesForNode( + methodNamedOn('KotlinMethodAnnotatedOperationsImpl', 'kotlinInterfaceExplicitTransaction'), + ), + ).toEqual(['org.springframework.transaction.annotation.Transactional:method']); + expect( + behaviorSignaturesForNode( + methodNamedOn('KotlinBehaviorService', 'kotlinOverriddenTransaction'), + ), + ).toEqual(['org.springframework.transaction.annotation.Transactional:method']); + }); + + it('captures Kotlin companion methods as singleton behavior and fails closed on use-site targets', () => { + expect(behaviorSignaturesFor('kotlinCompanionTransaction')).toEqual([ + 'org.springframework.transaction.annotation.Transactional:method', + ]); + expect(behaviorSignaturesFor('kotlinReceiverTargetTransaction')).toEqual([]); + expect(behaviorSignaturesFor('kotlinAliasedPlainOperation')).toEqual([]); + }); + + it('matches execution(public ...) against an implicit-public Java interface method', () => { + const advice = nodeNamed('publicInterfaceAdvice'); + const targets = advisedBy + .filter((relationship) => relationship.targetId === advice?.id) + .map((relationship) => result.graph.getNode(relationship.sourceId)?.properties.name); + expect(targets).toEqual(['interfaceSecuredOperation']); + }); + + it('treats Kotlin object members as singleton instance methods for AOP', () => { + expect(behaviorSignaturesFor('KotlinObjectService')).toEqual([ + 'org.springframework.transaction.annotation.Transactional:class', + ]); + expect( + behaviorSignaturesForNode( + methodNamedOn('KotlinObjectService', 'kotlinObjectInheritedTransaction'), + ), + ).toEqual(['org.springframework.transaction.annotation.Transactional:class']); + expect( + behaviorSignaturesForNode( + methodNamedOn('KotlinObjectService', 'kotlinObjectExplicitTransaction'), + ), + ).toEqual([ + 'org.springframework.transaction.annotation.Transactional:class', + 'org.springframework.transaction.annotation.Transactional:method', + ]); + expect(behaviorSignaturesFor('kotlinObjectPrivateHelper')).toEqual([]); + + const objectAdvice = nodeNamed('traceKotlinObjectOperations'); + const advisedMethods = advisedBy + .filter((relationship) => relationship.targetId === objectAdvice?.id) + .map((relationship) => result.graph.getNode(relationship.sourceId)?.properties.name) + .sort(); + expect(advisedMethods).toEqual([ + 'kotlinObjectExplicitTransaction', + 'kotlinObjectInheritedTransaction', + ]); + }); + + it('resolves Kotlin aliases for behaviors, aspects, and advice annotations', () => { + expect(behaviorSignaturesFor('kotlinAliasedTransactionalOperation')).toEqual([ + 'org.springframework.transaction.annotation.Transactional:method', + ]); + + const aliasedAspect = nodeNamed('KotlinAliasedAspect'); + const aspectMarker = declarations.find((relationship) => { + const reason = decodeSpringAopReason(relationship.reason); + return relationship.sourceId === aliasedAspect?.id && reason?.kind === 'aspect'; + }); + expect(aspectMarker).toBeDefined(); + + const advice = nodeNamed('aliasedTransactionalAdvice'); + const advisedMethods = advisedBy + .filter((relationship) => relationship.targetId === advice?.id) + .map((relationship) => result.graph.getNode(relationship.sourceId)?.properties.name) + .sort(); + expect(advisedMethods).toEqual([ + 'kotlinAliasedTransactionalOperation', + 'kotlinCompanionTransaction', + 'kotlinExtensionTransaction', + 'kotlinFullyQualifiedTransaction', + 'kotlinInterfaceExplicitTransaction', + 'kotlinObjectExplicitTransaction', + 'kotlinOverriddenTransaction', + 'kotlinScriptTransaction', + 'kotlinSuspendTransaction', + 'kotlinTransactionalOperation', + 'kotlinWildcardTransaction', + 'overriddenTransaction', + 'transactionalOperation', + ]); + }); + + it('connects a statically understandable execution pointcut to every matching method', () => { + const advice = nodeNamed('traceOrderOperations'); + expect(advice?.label).toBe('Method'); + + const advisedMethods = advisedBy + .filter((relationship) => relationship.targetId === advice?.id) + .map((relationship) => String(result.graph.getNode(relationship.sourceId)?.properties.name)) + .sort(); + + expect(advisedMethods).toEqual([ + 'cachedOperation', + 'evictOperation', + 'legacySecuredOperation', + 'plainOperation', + 'securedOperation', + 'transactionalOperation', + ]); + + const foreignMethod = nodeNamed('foreignOperation'); + const foreignOwnership = [...result.graph.iterRelationshipsByType('HAS_METHOD')].find( + (relationship) => relationship.targetId === foreignMethod?.id, + ); + expect(foreignMethod?.label).toBe('Method'); + expect(result.graph.getNode(foreignOwnership?.sourceId ?? '')?.properties.qualifiedName).toBe( + 'com.example.service.OrderService', + ); + expect(advisedBy.some((relationship) => relationship.sourceId === foreignMethod?.id)).toBe( + false, + ); + + const kotlinAdvice = nodeNamed('traceKotlinOperations'); + const kotlinAdvisedMethods = advisedBy + .filter((relationship) => relationship.targetId === kotlinAdvice?.id) + .map((relationship) => String(result.graph.getNode(relationship.sourceId)?.properties.name)) + .sort(); + expect(kotlinAdvisedMethods).toEqual([ + 'kotlinCachePutOperation', + 'kotlinCachedOperation', + 'kotlinCachingOperation', + 'kotlinEvictOperation', + 'kotlinExtensionTransaction', + 'kotlinFullyQualifiedTransaction', + 'kotlinJakartaRolesAllowedOperation', + 'kotlinJakartaTransaction', + 'kotlinJavaxRolesAllowedOperation', + 'kotlinJavaxTransaction', + 'kotlinLegacySecuredOperation', + 'kotlinPostAuthorizeOperation', + 'kotlinPostFilterOperation', + 'kotlinPreFilterOperation', + 'kotlinSecuredOperation', + 'kotlinSuspendTransaction', + 'kotlinTransactionalOperation', + ]); + }); + + it('matches unqualified wildcard type patterns by owner simple name', () => { + const withinAdvice = nodeNamed('simpleNameWithinAdvice'); + const withinTargets = advisedBy + .filter((relationship) => relationship.targetId === withinAdvice?.id) + .map((relationship) => result.graph.getNode(relationship.sourceId)?.properties.name); + expect(withinTargets).toContain('plainOperation'); + expect(withinTargets).toContain('kotlinCachedOperation'); + expect(withinTargets).not.toContain('kotlinInterfaceInheritedTransaction'); + + const executionAdvice = nodeNamed('simpleNameExecutionAdvice'); + const executionTargets = advisedBy + .filter((relationship) => relationship.targetId === executionAdvice?.id) + .map((relationship) => result.graph.getNode(relationship.sourceId)?.properties.name); + expect(executionTargets).toEqual(['kotlinCachedOperation']); + }); + + it('@annotation matches only directly declared method annotations across Java and Kotlin', () => { + const transactionalAdvice = nodeNamed('transactionalAnnotationAdvice'); + + const advisedByTransactionalAnnotation = advisedBy + .filter((relationship) => relationship.targetId === transactionalAdvice?.id) + .map((relationship) => result.graph.getNode(relationship.sourceId)?.properties.name) + .sort(); + expect(advisedByTransactionalAnnotation).toEqual([ + 'kotlinAliasedTransactionalOperation', + 'kotlinCompanionTransaction', + 'kotlinExtensionTransaction', + 'kotlinFullyQualifiedTransaction', + 'kotlinInterfaceExplicitTransaction', + 'kotlinObjectExplicitTransaction', + 'kotlinOverriddenTransaction', + 'kotlinScriptTransaction', + 'kotlinSuspendTransaction', + 'kotlinTransactionalOperation', + 'kotlinWildcardTransaction', + 'overriddenTransaction', + 'transactionalOperation', + ]); + expect(advisedByTransactionalAnnotation).not.toContain('kotlinInterfaceInheritedTransaction'); + }); + + it('supports pointcuts with companion annotation attributes', () => { + const cachedReturnAdvice = nodeNamed('cachedReturnAdvice'); + + const advisedByCachedReturn = advisedBy + .filter((relationship) => relationship.targetId === cachedReturnAdvice?.id) + .map((relationship) => result.graph.getNode(relationship.sourceId)?.properties.name); + expect(advisedByCachedReturn).toEqual(['cachedOperation']); + }); + + it('resolves Kotlin raw-string advice pointcuts', () => { + const rawStringAdvice = nodeNamed('rawStringPointcutAdvice'); + const advisedMethods = advisedBy + .filter((relationship) => relationship.targetId === rawStringAdvice?.id) + .map((relationship) => result.graph.getNode(relationship.sourceId)?.properties.name); + + expect(advisedMethods).toEqual(['kotlinCachedOperation']); + }); + + it('retains the Aspect declaration without assuming bean registration', () => { + for (const aspectName of ['OrderAspect', 'KotlinOrderAspect']) { + const aspect = nodeNamed(aspectName); + const marker = declarations.find((relationship) => { + const reason = decodeSpringAopReason(relationship.reason); + return relationship.sourceId === aspect?.id && reason?.kind === 'aspect'; + }); + + expect(marker, `${aspectName} should retain its Aspect marker`).toBeDefined(); + expect(decodeSpringAopReason(marker?.reason)).toMatchObject({ + kind: 'aspect', + activation: 'unknown', + registration: 'unknown', + }); + } + }); + + it('preserves unknown pointcuts as evidence without guessing advised targets', () => { + for (const adviceName of [ + 'unresolvedNamedAdvice', + 'emptyPointcutAdvice', + 'unresolvedCompoundAdvice', + 'unresolvedSimpleTypeAdvice', + ]) { + const advice = nodeNamed(adviceName); + expect(advice?.label).toBe('Method'); + expect(advisedBy.some((relationship) => relationship.targetId === advice?.id)).toBe(false); + + const evidence = declarations.filter((relationship) => { + const reason = decodeSpringAopReason(relationship.reason); + return ( + relationship.sourceId === advice?.id && + relationshipTarget(relationship)?.label === 'CodeElement' && + reason?.kind === 'pointcut' && + reason.match === 'unresolved' && + reason.resolution === 'unknown' + ); + }); + expect(evidence, `${adviceName} should retain conservative pointcut evidence`).toHaveLength( + 1, + ); + } + }); +}); + +describe('Spring AOP durable warm parse cache (#2416)', () => { + it('replays identical Java/Kotlin ADVISED_BY edges without spawning workers', async () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-aop-warm-')); + const repo = path.join(temp, 'repo'); + const storage = path.join(temp, 'storage'); + try { + writeFixture( + repo, + 'src/main/java/com/example/JavaService.java', + `package com.example; + +import org.springframework.transaction.annotation.Transactional; + +public class JavaService { + @Transactional + public void javaTransaction() {} +} +`, + ); + writeFixture( + repo, + 'src/main/kotlin/com/example/KotlinAop.kt', + `package com.example + +import org.aspectj.lang.annotation.Aspect as AopAspect +import org.aspectj.lang.annotation.Before as AdviceBefore +import org.springframework.transaction.annotation.Transactional as Tx + +class KotlinService { + @Tx + fun kotlinTransaction() {} +} + +class KotlinCompanionHolder { + companion object { + @Tx + fun companionTransaction() {} + } +} + +@AopAspect +object KotlinAspect { + @AdviceBefore("""@annotation(org.springframework.transaction.annotation.Transactional)""") + fun beforeTransaction() {} +} +`, + ); + + const coldCache: ParseCache = { + version: PARSE_CACHE_VERSION, + entries: new Map(), + usedKeys: new Set(), + storagePath: storage, + onDiskKeys: new Set(), + }; + const cold = await runPipelineFromRepo(repo, () => {}, { + skipGraphPhases: true, + workerPoolSize: 1, + parseCache: coldCache, + }); + expect(cold.usedWorkerPool).toBe(true); + + pruneCache(coldCache, coldCache.usedKeys); + const savedKeys = await saveParseCache(storage, coldCache); + expect(savedKeys.length).toBeGreaterThan(0); + await pruneAndSaveDurableParsedFileStore( + getDurableParsedFileDir(storage), + PARSE_CACHE_VERSION, + new Set(savedKeys), + ); + + const warmCache = await loadParseCache(storage); + expect(warmCache.onDiskKeys).toEqual(new Set(savedKeys)); + const warm = await runPipelineFromRepo(repo, () => {}, { + skipGraphPhases: true, + workerPoolSize: 1, + parseCache: warmCache, + }); + expect(warm.usedWorkerPool).toBe(false); + + const project = (pipeline: PipelineResult) => + [...pipeline.graph.iterRelationshipsByType('ADVISED_BY')] + .flatMap((edge) => { + const reason = decodeSpringAopReason(edge.reason); + if (reason?.kind !== 'behavior' && reason?.kind !== 'advice') return []; + const source = pipeline.graph.getNode(edge.sourceId); + const target = pipeline.graph.getNode(edge.targetId); + return [ + { + id: edge.id, + sourceId: edge.sourceId, + targetId: edge.targetId, + confidence: edge.confidence, + sourceName: source?.properties.name, + sourceFilePath: source?.properties.filePath, + targetName: target?.properties.name, + targetFilePath: target?.properties.filePath, + reason, + }, + ]; + }) + .sort((left, right) => left.id.localeCompare(right.id)); + + const coldEdges = project(cold); + expect(project(warm)).toEqual(coldEdges); + expect( + coldEdges + .map((edge) => `${edge.reason.kind}:${edge.sourceName}->${edge.targetName}`) + .sort(), + ).toEqual([ + 'advice:companionTransaction->beforeTransaction', + 'advice:javaTransaction->beforeTransaction', + 'advice:kotlinTransaction->beforeTransaction', + 'behavior:companionTransaction->@Transactional', + 'behavior:javaTransaction->@Transactional', + 'behavior:kotlinTransaction->@Transactional', + ]); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } + }, 120_000); +}); diff --git a/gitnexus/test/unit/analysis-features.test.ts b/gitnexus/test/unit/analysis-features.test.ts index 2f537867d..5e77834f4 100644 --- a/gitnexus/test/unit/analysis-features.test.ts +++ b/gitnexus/test/unit/analysis-features.test.ts @@ -6,6 +6,7 @@ import { type AnalysisFeatureDescriptor, } from '../../src/core/analysis-features.js'; import { + SPRING_AOP_FEATURE, SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, } from '../../src/core/ingestion/frameworks/spring/analysis-features.js'; @@ -13,6 +14,7 @@ import { SPRING_CONFIG_BINDINGS_FEATURE } from '../../src/core/ingestion/languag const FEATURES = [ CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, + SPRING_AOP_FEATURE, SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, SPRING_CONFIG_BINDINGS_FEATURE, @@ -25,12 +27,14 @@ describe('analysis feature versions', () => { }); expect(resolveAnalysisFeatureVersions(FEATURES, ['src/App.java'])).toEqual({ 'graph.class-framework-annotations': 1, + 'spring.aop-advice': 1, 'spring.bean-inventory': 2, 'spring.conditionals-auto-configuration': 1, 'spring.config-bindings': 1, }); expect(resolveAnalysisFeatureVersions(FEATURES, ['BUILD.GRADLE.KTS'])).toEqual({ 'graph.class-framework-annotations': 1, + 'spring.aop-advice': 1, 'spring.bean-inventory': 2, 'spring.conditionals-auto-configuration': 1, }); diff --git a/gitnexus/test/unit/call-summary-schema-version.test.ts b/gitnexus/test/unit/call-summary-schema-version.test.ts index bd35a8032..95bc624d6 100644 --- a/gitnexus/test/unit/call-summary-schema-version.test.ts +++ b/gitnexus/test/unit/call-summary-schema-version.test.ts @@ -73,12 +73,12 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => { }); describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { - it('INCREMENTAL_SCHEMA_VERSION is bumped to 32 (Rust/Swift/JS-TS member-containment DDL, #2769)', () => { + it('INCREMENTAL_SCHEMA_VERSION is bumped to 33 (Spring AOP relation pairs, #2416)', () => { // Moves with every bump BY DESIGN — that is the point of pinning it. A // change that alters emitted ids or edges without bumping would otherwise // ship silently, and an existing index would keep serving the old graph // through the reuse gate below. - expect(INCREMENTAL_SCHEMA_VERSION).toBe(32); + expect(INCREMENTAL_SCHEMA_VERSION).toBe(33); }); it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => { @@ -213,7 +213,10 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { // top-up emitting one of those edges would fail the bulk COPY (or silently // drop it on the streamed path) → must NOT reuse. expect(passesReuseGate(31)).toBe(false); + // A pre-v33 (v32) index predates the Spring AOP Interface→CodeElement + // relation pair (#2416), so it cannot persist all evidence edges. + expect(passesReuseGate(32)).toBe(false); // The current stamp passes the gate (incremental top-up eligible). - expect(passesReuseGate(32)).toBe(true); + expect(passesReuseGate(33)).toBe(true); }); }); diff --git a/gitnexus/test/unit/incremental-orchestration.test.ts b/gitnexus/test/unit/incremental-orchestration.test.ts index d3773e51f..36088d387 100644 --- a/gitnexus/test/unit/incremental-orchestration.test.ts +++ b/gitnexus/test/unit/incremental-orchestration.test.ts @@ -44,9 +44,14 @@ import { } from '../helpers/embedding-seed.js'; import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE } from '../../src/core/analysis-features.js'; import { + SPRING_AOP_FEATURE, SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, } from '../../src/core/ingestion/frameworks/spring/analysis-features.js'; +import { + decodeSpringAopReason, + SPRING_AOP_EVIDENCE_ID_PREFIX, +} from '../../src/core/ingestion/frameworks/spring/aop.js'; import { SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX } from '../../src/core/ingestion/frameworks/spring/auto-configuration.js'; import { SPRING_CONFIG_BINDINGS_FEATURE } from '../../src/core/ingestion/languages/java/analysis-features.js'; @@ -107,6 +112,55 @@ async function setupKotlinSpringBeanIncrementalRepo() { return repo; } +const springAopAspectSource = (pointcut: string): string => + `package com.example;\n` + + `import org.aspectj.lang.annotation.Aspect;\n` + + `import org.aspectj.lang.annotation.Before;\n\n` + + `@Aspect public class TraceAspect {\n` + + ` @Before("${pointcut}") public void trace() {}\n` + + `}\n`; + +async function setupSpringAopIncrementalRepo() { + const repo = await createTempDir('gitnexus-incr-spring-aop-'); + const javaSrc = path.join(repo.dbPath, 'src', 'main', 'java', 'com', 'example'); + const kotlinSrc = path.join(repo.dbPath, 'src', 'main', 'kotlin', 'com', 'example'); + const resources = path.join(repo.dbPath, 'src', 'main', 'resources'); + await Promise.all([ + mkdir(javaSrc, { recursive: true }), + mkdir(kotlinSrc, { recursive: true }), + mkdir(resources, { recursive: true }), + ]); + await Promise.all([ + writeFile(path.join(repo.dbPath, '.gitignore'), '.gitnexus/\n', 'utf-8'), + writeFile( + path.join(javaSrc, 'FirstService.java'), + 'package com.example;\n\n' + + 'public class FirstService {\n' + + ' public void first() {}\n' + + '}\n', + 'utf-8', + ), + writeFile( + path.join(javaSrc, 'TraceAspect.java'), + springAopAspectSource('within(com.example.FirstService)'), + 'utf-8', + ), + writeFile( + path.join(kotlinSrc, 'KotlinService.kt'), + 'package com.example\n\n' + + 'import org.springframework.transaction.annotation.Transactional as Tx\n\n' + + 'class KotlinService {\n' + + ' @Tx fun kotlinTx() {}\n' + + '}\n', + 'utf-8', + ), + writeFile(path.join(resources, 'application.properties'), 'feature.enabled=true\n', 'utf-8'), + ]); + execSync('git init', { cwd: repo.dbPath, stdio: 'pipe' }); + gitCommitAll(repo.dbPath, 'initial spring aop fixture'); + return repo; +} + async function setupSpringBeanFactoryIncrementalRepo() { const repo = await createTempDir('gitnexus-incr-spring-bean-factory-'); const src = path.join(repo.dbPath, 'src', 'com', 'other'); @@ -236,6 +290,81 @@ async function countSpringAutoConfigurationSyntheticClasses(repoPath: string): P } } +interface SpringAopPersistedRelationship { + readonly relType: string; + readonly sourceId: string; + readonly sourceName: string; + readonly targetId: string; + readonly targetName: string; + readonly reason: string; +} + +interface SpringAopPersistedEvidence { + readonly id: string; + readonly description: string; +} + +interface SpringAopPersistedSnapshot { + readonly relationships: readonly SpringAopPersistedRelationship[]; + readonly evidence: readonly SpringAopPersistedEvidence[]; +} + +async function readSpringAopSnapshot(repoPath: string): Promise { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + const { lbugPath } = getStoragePaths(repoPath); + await adapter.initLbug(lbugPath); + try { + const relationships = (await adapter.executeQuery( + `MATCH (s)-[r:CodeRelation]->(t) ` + + `WHERE (r.type = 'ADVISED_BY' OR r.type = 'DECLARES') ` + + `AND r.reason STARTS WITH 'spring-aop:v1:' ` + + `RETURN r.type AS relType, s.id AS sourceId, s.name AS sourceName, ` + + `t.id AS targetId, t.name AS targetName, r.reason AS reason ` + + `ORDER BY relType, sourceId, targetId, reason`, + )) as SpringAopPersistedRelationship[]; + const evidence = (await adapter.executeQuery( + `MATCH (n:CodeElement) WHERE n.id STARTS WITH '${SPRING_AOP_EVIDENCE_ID_PREFIX}' ` + + `RETURN n.id AS id, n.description AS description ORDER BY id`, + )) as SpringAopPersistedEvidence[]; + return { relationships, evidence }; + } finally { + await adapter.closeLbug(); + } +} + +function assertSpringAopSnapshotShape( + snapshot: SpringAopPersistedSnapshot, + expectedAdviceSource: string, +): void { + expect(snapshot.relationships).toHaveLength(4); + expect(snapshot.evidence).toHaveLength(3); + const decoded = snapshot.relationships.map((relationship) => ({ + relationship, + reason: decodeSpringAopReason(relationship.reason), + })); + expect(decoded.map(({ reason }) => reason?.kind).sort()).toEqual([ + 'advice', + 'aspect', + 'behavior', + 'pointcut', + ]); + expect(decoded.find(({ reason }) => reason?.kind === 'behavior')?.relationship.sourceName).toBe( + 'kotlinTx', + ); + const advice = decoded.find(({ reason }) => reason?.kind === 'advice')?.relationship; + expect(advice?.sourceName).toBe(expectedAdviceSource); + expect(advice?.targetName).toBe('trace'); + expect( + new Set( + snapshot.relationships.map( + (relationship) => + `${relationship.relType}\0${relationship.sourceId}\0${relationship.targetId}\0${relationship.reason}`, + ), + ).size, + ).toBe(snapshot.relationships.length); + expect(new Set(snapshot.evidence.map(({ id }) => id)).size).toBe(snapshot.evidence.length); +} + /** Java DI fixture (#2200): `@Autowired List` + 2 implementers ⇒ exactly * 2 INJECTS edges (Consumer→FooA, Consumer→FooB). Same shapes as the * spring-di-pipeline integration fixture. */ @@ -345,6 +474,7 @@ describe('runFullAnalysis — incremental orchestration', () => { const meta = await loadMeta(storagePath); expect(meta!.analysisFeatures).toEqual({ [CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version, + [SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version, [SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version, [SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version, }); @@ -362,6 +492,7 @@ describe('runFullAnalysis — incremental orchestration', () => { expect(await readWildcardServiceAnnotations(repo.dbPath)).toEqual([SPRING_SERVICE]); expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({ [CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version, + [SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version, [SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version, [SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version, }); @@ -430,6 +561,7 @@ describe('runFullAnalysis — incremental orchestration', () => { expect(logs.join('\n')).not.toContain('Incremental:'); expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({ [CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version, + [SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version, [SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version, [SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version, }); @@ -438,6 +570,81 @@ describe('runFullAnalysis — incremental orchestration', () => { } }, 300_000); + it('replaces Spring AOP evidence across real incremental runs without duplicates', async () => { + const repo = await setupSpringAopIncrementalRepo(); + try { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + const firstSnapshot = await readSpringAopSnapshot(repo.dbPath); + assertSpringAopSnapshotShape(firstSnapshot, 'first'); + const firstPointcutEvidenceId = firstSnapshot.relationships.find( + ({ reason }) => decodeSpringAopReason(reason)?.kind === 'pointcut', + )?.targetId; + expect(firstPointcutEvidenceId).toBeDefined(); + + const aspectPath = path.join( + repo.dbPath, + 'src', + 'main', + 'java', + 'com', + 'example', + 'TraceAspect.java', + ); + await writeFile( + aspectPath, + springAopAspectSource( + '@annotation(org.springframework.transaction.annotation.Transactional)', + ), + 'utf-8', + ); + gitCommitAll(repo.dbPath, 'retarget spring advice'); + const retargetLogs: string[] = []; + const retargeted = await runFullAnalysis( + repo.dbPath, + { skipAgentsMd: true }, + { onProgress: () => {}, onLog: (message) => retargetLogs.push(message) }, + ); + expect(retargeted.alreadyUpToDate).toBeUndefined(); + expect(retargetLogs.join('\n')).toContain('Incremental: changed=1'); + expect(retargetLogs.join('\n')).not.toContain('switching to a full DB write'); + + const secondSnapshot = await readSpringAopSnapshot(repo.dbPath); + assertSpringAopSnapshotShape(secondSnapshot, 'kotlinTx'); + const secondPointcutEvidenceId = secondSnapshot.relationships.find( + ({ reason }) => decodeSpringAopReason(reason)?.kind === 'pointcut', + )?.targetId; + expect(secondPointcutEvidenceId).toBeDefined(); + expect(secondPointcutEvidenceId).not.toBe(firstPointcutEvidenceId); + expect(secondSnapshot.evidence.map(({ id }) => id)).not.toContain(firstPointcutEvidenceId); + + const propertyPath = path.join( + repo.dbPath, + 'src', + 'main', + 'resources', + 'application.properties', + ); + await writeFile(propertyPath, 'feature.enabled=false\n', 'utf-8'); + gitCommitAll(repo.dbPath, 'change unrelated resource'); + const replayLogs: string[] = []; + const replayed = await runFullAnalysis( + repo.dbPath, + { skipAgentsMd: true }, + { + onLog: (message) => replayLogs.push(message), + onProgress: () => {}, + }, + ); + expect(replayed.alreadyUpToDate).toBeUndefined(); + expect(replayLogs.join('\n')).toContain('Incremental: changed=1'); + expect(replayLogs.join('\n')).not.toContain('switching to a full DB write'); + expect(await readSpringAopSnapshot(repo.dbPath)).toEqual(secondSnapshot); + } finally { + await repo.cleanup(); + } + }, 600_000); + it('second run after a comment-only edit takes the incremental path, clears the dirty flag, and preserves graph stats exactly', async () => { const repo = await setupMiniRepo(); try { diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index c9560f43d..6d70a26fb 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -101,11 +101,9 @@ describe('fileContentHash', () => { }); describe('PARSE_CACHE_VERSION', () => { - // 35 -> 36 for the bound-callable start-line join (#2735). Updated - // deliberately after main independently took 35 for Spring side-channel - // captures (#2413), so the pin continues to catch concurrent bump collisions. - it('pins SCHEMA_BUMP to 36 so concurrent bumps cannot silently collide (#2736)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(36); + // 36 -> 37 for Java/Kotlin Spring AOP capture side-channels (#2416). + it('pins SCHEMA_BUMP to 37 so concurrent bumps cannot silently collide (#2416)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(37); }); it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => { diff --git a/gitnexus/test/unit/incremental-subgraph-extract.test.ts b/gitnexus/test/unit/incremental-subgraph-extract.test.ts index b46e86ba4..4841bba3a 100644 --- a/gitnexus/test/unit/incremental-subgraph-extract.test.ts +++ b/gitnexus/test/unit/incremental-subgraph-extract.test.ts @@ -156,6 +156,20 @@ describe('extractChangedSubgraph', () => { expect(sub.relationships.map((r) => r.id)).toEqual(['inj1']); }); + it('always includes ADVISED_BY edges even between two unchanged files (#2416)', () => { + const g = createKnowledgeGraph(); + g.addNode(makeFileNode('service:Method', '/repo/Service.java', 'Method')); + g.addNode(makeFileNode('aspect:Method', '/repo/Aspect.java', 'Method')); + g.addRelationship( + makeRel('advised1', 'service:Method', 'aspect:Method', 'ADVISED_BY', 'spring-aop:v1:{}'), + ); + g.addRelationship(makeRel('call1', 'service:Method', 'aspect:Method', 'CALLS')); + + const sub = extractChangedSubgraph(g, new Set(['/repo/AnnotationShadow.java'])); + + expect(sub.relationships.map((relationship) => relationship.id)).toEqual(['advised1']); + }); + it('always includes Spring DECLARES edges between unchanged metadata and classes (#2415)', () => { const g = createKnowledgeGraph(); g.addNode(makeFileNode('metadata:File', '/repo/META-INF/spring.factories', 'File')); @@ -191,6 +205,22 @@ describe('extractChangedSubgraph', () => { }); describe('computeEffectiveWriteSet (Finding 1)', () => { + it('does not expand through graph-wide ADVISED_BY edges rebuilt by their owner phase', () => { + const g = createKnowledgeGraph(); + g.addNode(makeFileNode('aspect:advice', '/repo/Aspect.java', 'Method')); + for (let index = 0; index < 100; index += 1) { + const serviceId = `service:${index}`; + g.addNode(makeFileNode(serviceId, `/repo/Service${index}.java`, 'Method')); + g.addRelationship( + makeRel(`advised:${index}`, serviceId, 'aspect:advice', 'ADVISED_BY', 'spring-aop:v1:{}'), + ); + } + + const effective = computeEffectiveWriteSet(g, new Set(['/repo/Aspect.java'])); + + expect([...effective]).toEqual(['/repo/Aspect.java']); + }); + it('barrel re-export — expands the writable set to the consumer file', () => { // Scenario: file C (a barrel) used to re-export from B; now re-exports // from D. File A is unchanged byte-wise but its CALLS to foo() now diff --git a/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts b/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts index 9e204631f..5b8c446f9 100644 --- a/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts +++ b/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts @@ -76,15 +76,22 @@ const FULL_ORDER = [ 'crossFile', 'scopeResolution', 'springAutoConfiguration', + 'springAop', 'pruneLocalSymbols', 'mro', + 'springAopInheritance', 'di', 'communities', 'processes', ]; const WITHOUT_GRAPH_PHASES = FULL_ORDER.filter( - (n) => n !== 'mro' && n !== 'di' && n !== 'communities' && n !== 'processes', + (n) => + n !== 'mro' && + n !== 'springAopInheritance' && + n !== 'di' && + n !== 'communities' && + n !== 'processes', ); describe('buildPhaseList parity (registry refactor, #2080)', () => { diff --git a/gitnexus/test/unit/schema.test.ts b/gitnexus/test/unit/schema.test.ts index b4c2cd04c..ae02ce2c1 100644 --- a/gitnexus/test/unit/schema.test.ts +++ b/gitnexus/test/unit/schema.test.ts @@ -116,6 +116,7 @@ describe('LadybugDB Schema', () => { it('includes Spring condition and auto-configuration edge types (#2415)', () => { expect(REL_TYPES).toContain('CONDITIONAL_ON'); expect(REL_TYPES).toContain('DECLARES'); + expect(REL_TYPES).toContain('ADVISED_BY'); expect(REL_TYPES).not.toContain('AUTO_REGISTERS'); }); }); @@ -209,8 +210,9 @@ describe('LadybugDB Schema', () => { expect(RELATION_SCHEMA).toContain('FROM BasicBlock TO BasicBlock'); }); - it('persists consumer Class injection edges to synthetic provider declarations', () => { + it('persists class-like framework edges to synthetic declarations (#2416)', () => { expect(RELATION_SCHEMA).toContain('FROM Class TO CodeElement'); + expect(RELATION_SCHEMA).toContain('FROM Interface TO CodeElement'); }); it('declares the Swift enum/property member-containment pairs (#2769)', () => { diff --git a/gitnexus/test/unit/security.test.ts b/gitnexus/test/unit/security.test.ts index 581f1b18b..bd79ae010 100644 --- a/gitnexus/test/unit/security.test.ts +++ b/gitnexus/test/unit/security.test.ts @@ -42,6 +42,8 @@ describe('VALID_RELATION_TYPES', () => { // Conditional activation and metadata declaration/discovery (#2415) 'CONDITIONAL_ON', 'DECLARES', + // Spring proxy/advice evidence (#2416) + 'ADVISED_BY', ] as const; it('contains all expected relation types', () => { diff --git a/gitnexus/test/unit/spring-annotation-arguments.test.ts b/gitnexus/test/unit/spring-annotation-arguments.test.ts index 9304aa4d3..7e6107e59 100644 --- a/gitnexus/test/unit/spring-annotation-arguments.test.ts +++ b/gitnexus/test/unit/spring-annotation-arguments.test.ts @@ -3,6 +3,7 @@ import { normalizeSpringBeanType, parseSpringAnnotationArguments, parseStaticClassLiteral, + parseStaticStringLiteral, parseStaticStringValues, } from '../../src/core/ingestion/frameworks/spring/annotation-arguments.js'; import { @@ -45,6 +46,31 @@ describe('Spring annotation static arguments', () => { expect(parseStaticClassLiteral('Object.class')).toBe(''); }); + it('parses static Kotlin raw strings and rejects raw string templates', () => { + expect(parseStaticStringLiteral('"""within(com.example.service.KotlinOrderService)"""')).toBe( + 'within(com.example.service.KotlinOrderService)', + ); + expect(parseStaticStringValues('["one", """two, three"""]')).toEqual(['one', 'two, three']); + + expect(parseStaticStringLiteral('"""bean-$name"""')).toBeNull(); + expect(parseStaticStringLiteral('"""bean-${expression}"""')).toBeNull(); + expect(parseStaticStringValues('["""static""", """$name"""]')).toBeNull(); + }); + + it('keeps commas, assignments, and ordinary quotes inside Kotlin raw arguments', () => { + expect( + parseSpringAnnotationArguments( + '@AfterReturning(pointcut = """execution(* com.example.Service.run(*,*)) && args("quoted=value")""", returning = "result")', + ), + ).toEqual([ + { + name: 'pointcut', + value: '"""execution(* com.example.Service.run(*,*)) && args("quoted=value")"""', + }, + { name: 'returning', value: '"result"' }, + ]); + }); + it('normalizes Kotlin nullability, mutable collections, projections, and bean generics', () => { expect(normalizeSpringBeanType('MutableList?')).toBe('List'); expect(normalizeSpringBeanType('com.example.Gateway')).toBe('com.example.Gateway'); diff --git a/gitnexus/test/unit/spring-aop.test.ts b/gitnexus/test/unit/spring-aop.test.ts new file mode 100644 index 000000000..93e42436c --- /dev/null +++ b/gitnexus/test/unit/spring-aop.test.ts @@ -0,0 +1,372 @@ +import type { GraphNode } from 'gitnexus-shared'; +import { describe, expect, it } from 'vitest'; +import { + decodeSpringAopReason, + encodeSpringAopReason, + isSpringAopEvidenceNode, + parseSpringAopPointcut, + springAopPointcutMatches, + type SpringAopReason, +} from '../../src/core/ingestion/frameworks/spring/aop.js'; +import { + createSpringAopCandidateIndex, + type SpringAopOwnedMethod, +} from '../../src/core/ingestion/frameworks/spring/aop-candidates.js'; + +const owner: GraphNode = { + id: 'Class:com.example.service.OrderService', + label: 'Class', + properties: { + name: 'OrderService', + qualifiedName: 'com.example.service.OrderService', + filePath: 'src/OrderService.java', + startLine: 1, + endLine: 20, + isExported: true, + }, +}; + +const method: GraphNode = { + id: 'Method:com.example.service.OrderService.run', + label: 'Method', + properties: { + name: 'run', + qualifiedName: 'com.example.service.OrderService.run', + filePath: 'src/OrderService.java', + startLine: 3, + endLine: 5, + isExported: true, + visibility: 'public', + parameterCount: 1, + }, +}; + +describe('Spring AOP static pointcuts (#2416)', () => { + it('parses the deliberately narrow execution, within, and known @annotation subset', () => { + expect(parseSpringAopPointcut('execution(public * com.example..OrderService.r*( * ))')).toEqual( + { + kind: 'execution', + ownerPattern: 'com.example..OrderService', + methodPattern: 'r*', + visibility: 'public', + parameterCount: 1, + }, + ); + expect(parseSpringAopPointcut('within(com.example..service.*)')).toEqual({ + kind: 'within', + ownerPattern: 'com.example..service.*', + }); + expect( + parseSpringAopPointcut( + '@annotation(org.springframework.transaction.annotation.Transactional)', + ), + ).toEqual({ + kind: 'annotation', + annotation: 'org.springframework.transaction.annotation.Transactional', + }); + }); + + it('matches owner, method, visibility, arity, and resolved method annotations', () => { + const execution = parseSpringAopPointcut('execution(public * com.example..OrderService.r*(*))'); + const within = parseSpringAopPointcut('within(com.example..OrderService)'); + const annotation = parseSpringAopPointcut( + '@annotation(org.springframework.transaction.annotation.Transactional)', + ); + + expect(execution && springAopPointcutMatches(execution, owner, method)).toBe(true); + expect(within && springAopPointcutMatches(within, owner, method)).toBe(true); + expect( + annotation && + springAopPointcutMatches( + annotation, + owner, + method, + new Set(['org.springframework.transaction.annotation.Transactional']), + ), + ).toBe(true); + }); + + it('treats an unmodified interface method as public for execution(public ...)', () => { + const interfaceOwner: GraphNode = { ...owner, label: 'Interface' }; + const implicitPublicMethod: GraphNode = { + ...method, + properties: { ...method.properties, visibility: 'package' }, + }; + const execution = parseSpringAopPointcut( + 'execution(public * com.example..OrderService.run(*))', + ); + + expect( + execution && springAopPointcutMatches(execution, interfaceOwner, implicitPublicMethod), + ).toBe(true); + expect(execution && springAopPointcutMatches(execution, owner, implicitPublicMethod)).toBe( + false, + ); + }); + + it('matches unqualified wildcard type patterns against the owner simple name', () => { + const within = parseSpringAopPointcut('within(*Service)'); + const execution = parseSpringAopPointcut('execution(* *Service.run(..))'); + + expect(within && springAopPointcutMatches(within, owner, method)).toBe(true); + expect(execution && springAopPointcutMatches(execution, owner, method)).toBe(true); + }); + + it.each(['within(OrderService)', 'execution(* OrderService.run(..))'])( + 'fails closed when a simple exact type needs unavailable import context: %s', + (expression) => { + expect(parseSpringAopPointcut(expression)).toBeNull(); + }, + ); + + it.each([ + 'namedPointcut()', + 'execution(* com.example..OrderService.*(..)) && args(orderId)', + 'execution(String com.example.OrderService.run(..))', + '@annotation(com.example.DynamicMarker)', + 'bean(orderService)', + ])('fails closed for unsupported or dynamic expression %s', (expression) => { + expect(parseSpringAopPointcut(expression)).toBeNull(); + }); + + it('rejects overlong input instead of truncating away a dynamic suffix', () => { + const start = 'execution(* '; + const end = '.run(..))'; + const validPrefix = `${start}${'a'.repeat(1_000 - start.length - end.length)}${end}`; + expect(validPrefix).toHaveLength(1_000); + expect(parseSpringAopPointcut(`${validPrefix} && args(value)`)).toBeNull(); + }); + + it('matches adversarial wildcard input without regex backtracking', () => { + const wildcardChain = '*a'.repeat(10); + const execution = parseSpringAopPointcut( + `execution(* com.example..OrderService.${wildcardChain}z(..))`, + ); + const within = parseSpringAopPointcut(`within(com.example.${wildcardChain}z)`); + const adversarialMethod = { + ...method, + properties: { ...method.properties, name: 'a'.repeat(24) }, + }; + const adversarialOwner = { + ...owner, + properties: { + ...owner.properties, + qualifiedName: `com.example.${'a'.repeat(24)}`, + }, + }; + + const startedAt = performance.now(); + expect(execution && springAopPointcutMatches(execution, owner, adversarialMethod)).toBe(false); + expect(within && springAopPointcutMatches(within, adversarialOwner, method)).toBe(false); + expect(performance.now() - startedAt).toBeLessThan(100); + }); +}); + +describe('Spring AOP pointcut candidate index (#2416)', () => { + it('selects only methods that directly declare an @annotation behavior', () => { + const annotated = { method, owner } satisfies SpringAopOwnedMethod; + const plain = { + owner, + method: { + ...method, + id: 'Method:com.example.service.OrderService.plain', + properties: { ...method.properties, name: 'plain' }, + }, + } satisfies SpringAopOwnedMethod; + const pointcut = parseSpringAopPointcut( + '@annotation(org.springframework.transaction.annotation.Transactional)', + ); + expect(pointcut).not.toBeNull(); + + const index = createSpringAopCandidateIndex( + [annotated, plain], + new Map([[method.id, new Set(['org.springframework.transaction.annotation.Transactional'])]]), + ); + + expect(index.candidatesFor(pointcut!)).toEqual([annotated]); + }); + + it('narrows exact and literal-prefix owner pointcuts without excluding matches', () => { + const ownedMethod = (qualifiedName: string): SpringAopOwnedMethod => { + const ownerName = qualifiedName.slice(qualifiedName.lastIndexOf('.') + 1); + return { + owner: { + ...owner, + id: `Class:${qualifiedName}`, + properties: { ...owner.properties, name: ownerName, qualifiedName }, + }, + method: { + ...method, + id: `Method:${qualifiedName}.run`, + properties: { ...method.properties, qualifiedName: `${qualifiedName}.run` }, + }, + }; + }; + const order = ownedMethod('com.example.service.OrderService'); + const payment = ownedMethod('com.example.billing.PaymentService'); + const legacy = ownedMethod('org.legacy.LegacyService'); + const candidates = [order, payment, legacy]; + const index = createSpringAopCandidateIndex(candidates, new Map()); + const exact = parseSpringAopPointcut('within(com.example.service.OrderService)'); + const prefixed = parseSpringAopPointcut('within(com.example..*Service)'); + const leadingWildcard = parseSpringAopPointcut('within(*..*Service)'); + expect(exact && index.candidatesFor(exact)).toEqual([order]); + expect(prefixed && index.candidatesFor(prefixed)).toEqual([payment, order]); + expect(leadingWildcard && index.candidatesFor(leadingWildcard)).toEqual(candidates); + }); + + it('preserves brute-force results across exact, wildcard, descendant, and annotation pointcuts', () => { + const ownedMethod = ( + qualifiedName: string, + methodName: string, + filePath: string, + ): SpringAopOwnedMethod => ({ + owner: { + ...owner, + id: `Class:${filePath}:${qualifiedName}`, + properties: { ...owner.properties, qualifiedName, filePath }, + }, + method: { + ...method, + id: `Method:${filePath}:${qualifiedName}.${methodName}`, + properties: { + ...method.properties, + name: methodName, + qualifiedName: `${qualifiedName}.${methodName}`, + filePath, + }, + }, + }); + const candidates = [ + ownedMethod('com.example.service.OrderService', 'run', 'OrderService.java'), + ownedMethod('com.example.service.OrderService', 'read', 'OrderService.kt'), + ownedMethod('com.example.billing.InvoiceService', 'run', 'InvoiceService.kt'), + ownedMethod('org.legacy.LegacyService', 'run', 'LegacyService.java'), + ]; + const transactional = 'org.springframework.transaction.annotation.Transactional'; + const methodAnnotations = new Map([[candidates[1]!.method.id, new Set([transactional])]]); + const index = createSpringAopCandidateIndex(candidates, methodAnnotations); + const expressions = [ + 'within(com.example.service.OrderService)', + 'within(com.example..*Service)', + 'within(*..*Service)', + 'execution(public * com.example..*Service.r*( * ))', + `@annotation(${transactional})`, + ]; + + for (const expression of expressions) { + const pointcut = parseSpringAopPointcut(expression); + expect(pointcut, expression).not.toBeNull(); + const matchingIds = (pool: readonly SpringAopOwnedMethod[]) => + pool + .filter((candidate) => + springAopPointcutMatches( + pointcut!, + candidate.owner, + candidate.method, + methodAnnotations.get(candidate.method.id), + ), + ) + .map((candidate) => candidate.method.id) + .sort(); + + expect(matchingIds(index.candidatesFor(pointcut!)), expression).toEqual( + matchingIds(candidates), + ); + } + }); +}); + +describe('Spring AOP persisted reason contract (#2416)', () => { + const reasons: readonly SpringAopReason[] = [ + { + kind: 'behavior', + annotation: 'org.springframework.transaction.annotation.Transactional', + behavior: 'transactional', + declaredOn: 'method', + activation: 'unknown', + proxy: 'possible', + }, + { + kind: 'behavior', + annotation: 'org.springframework.cache.annotation.Caching', + behavior: 'caching', + declaredOn: 'method', + activation: 'unknown', + proxy: 'possible', + }, + { + kind: 'advice', + annotation: 'org.aspectj.lang.annotation.Around', + advice: 'around', + pointcut: 'execution(* com.example..OrderService.*(..))', + match: 'static', + activation: 'unknown', + proxy: 'possible', + }, + { + kind: 'pointcut', + annotation: 'org.aspectj.lang.annotation.Before', + pointcut: 'namedPointcut()', + match: 'unresolved', + resolution: 'unknown', + }, + { + kind: 'pointcut', + annotation: 'org.aspectj.lang.annotation.After', + pointcut: null, + match: 'unresolved', + resolution: 'unknown', + }, + { + kind: 'aspect', + annotation: 'org.aspectj.lang.annotation.Aspect', + activation: 'unknown', + registration: 'unknown', + }, + ]; + + it.each(reasons)('round-trips the $kind reason', (reason) => { + expect(decodeSpringAopReason(encodeSpringAopReason(reason))).toEqual(reason); + }); + + it.each([ + 'spring-aop:v2:{}', + 'spring-aop:v1:not-json', + `spring-aop:v1:${JSON.stringify({ + kind: 'behavior', + annotation: 'com.example.FakeTransactional', + behavior: 'transactional', + declaredOn: 'method', + activation: 'unknown', + proxy: 'possible', + })}`, + `spring-aop:v1:${JSON.stringify({ + kind: 'pointcut', + annotation: 'org.aspectj.lang.annotation.Before', + pointcut: 'execution(* com.example.Service.run(..))', + match: 'static', + resolution: 'unknown', + })}`, + ])('rejects malformed, foreign, or internally inconsistent reason %s', (reason) => { + expect(decodeSpringAopReason(reason)).toBeUndefined(); + }); + + it('identifies owned evidence by its stable ID namespace, not mutable prose', () => { + expect( + isSpringAopEvidenceNode({ + ...method, + id: 'CodeElement:spring-aop:evidence', + label: 'CodeElement', + }), + ).toBe(true); + expect( + isSpringAopEvidenceNode({ + ...method, + id: 'CodeElement:ordinary', + label: 'CodeElement', + properties: { ...method.properties, description: 'Spring AOP: lookalike' }, + }), + ).toBe(false); + }); +}); diff --git a/gitnexus/test/unit/spring-bean-schema.test.ts b/gitnexus/test/unit/spring-bean-schema.test.ts index 07882b2eb..22ca2cd99 100644 --- a/gitnexus/test/unit/spring-bean-schema.test.ts +++ b/gitnexus/test/unit/spring-bean-schema.test.ts @@ -5,6 +5,7 @@ import { PARSE_CACHE_VERSION } from '../../src/storage/parse-cache.js'; import { INCREMENTAL_SCHEMA_VERSION } from '../../src/storage/repo-manager.js'; import { isSpringBeanCandidateSourceFile } from '../../src/core/ingestion/frameworks/spring/bean-catalog.js'; import { + SPRING_AOP_FEATURE, SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, } from '../../src/core/ingestion/frameworks/spring/analysis-features.js'; @@ -24,6 +25,7 @@ describe('Spring Bean Class persistence schema', () => { expect(parseSchemaVersion).toBeGreaterThanOrEqual(31); expect(INCREMENTAL_SCHEMA_VERSION).toBeGreaterThanOrEqual(23); expect(CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version).toBe(1); + expect(SPRING_AOP_FEATURE.version).toBe(1); expect(SPRING_BEAN_INVENTORY_FEATURE.version).toBe(2); expect(SPRING_CONDITIONALS_FEATURE.version).toBe(1); }); diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index d5326509e..d1dad6541 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -70,6 +70,7 @@ export default defineConfig({ 'test/integration/lbug-pool-stability.test.ts', 'test/integration/local-backend.test.ts', 'test/integration/local-backend-calltool.test.ts', + 'test/integration/spring-aop-mcp.test.ts', 'test/integration/search-core.test.ts', 'test/integration/search-pool.test.ts', 'test/integration/fts-description-search.test.ts', @@ -116,6 +117,7 @@ export default defineConfig({ 'test/integration/lbug-pool-stability.test.ts', 'test/integration/local-backend.test.ts', 'test/integration/local-backend-calltool.test.ts', + 'test/integration/spring-aop-mcp.test.ts', 'test/integration/search-core.test.ts', 'test/integration/search-pool.test.ts', 'test/integration/fts-description-search.test.ts', From 565287528dab408538ba75eef68ca3df3d879c5d Mon Sep 17 00:00:00 2001 From: ivangegovdve-sudo Date: Sat, 1 Aug 2026 20:31:49 +0300 Subject: [PATCH 02/13] fix(ci): stop CI Report dying silently when the tests job fails (#2728) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ci): stop CI Report dying silently when the tests job fails The "Build report" step in ci-report.yml runs under `bash --noprofile --norc -e -o pipefail`. It located its inputs with UNIT_SUMMARY=$(find "$DIR/test-reports" -name ... 2>/dev/null | head -1) `coverage-merge` in ci-tests.yml is `needs: tests` with no `if: always()`, so any failing shard skips it and the `test-reports` artifact is never uploaded. `find` then runs against a directory that does not exist and exits 1; `-o pipefail` carries that status through `| head -1`, the command substitution hands it to the assignment, and `-e` kills the step. The death is invisible: `2>/dev/null` discards find's error and the whole report is built into `$GITHUB_OUTPUT`, so the step logs nothing and just reports "Process completed with exit code 1". "Comment on PR" is then skipped, so the CI Report workflow fails and posts nothing on exactly the PRs whose tests failed — when the report is most useful. The "Coverage data unavailable" fallback already existed for this case but was unreachable, because the script died ~160 lines before it. Route the four lookups through a `find_first` helper that returns empty when the root is absent. Verified by extracting the step body and running it against both artifact layouts: with `test-reports` present the output is byte-identical to the previous script (1335 bytes), and with it absent the step now exits 0 and emits the coverage-unavailable report instead of exiting 1 with an empty $GITHUB_OUTPUT. Observed on 32 of the last 100 failed runs; correlation with the tests job's conclusion was 6/6 failure and 4/4 success in the sampled runs. Co-Authored-By: Claude Opus 5 (1M context) * fix(ci): let the prebuild assertion report a missing .node `Build prebuild` deletes `$pkgdir/prebuilds` before running prebuildify, so a run that emits nothing without failing leaves `find` searching a path that no longer exists. Under the step's `shell: bash` (`-e -o pipefail`) that `find` exits 1 and kills the step before the `test -n "$out"` guard below it — the guard written to explain exactly this case never runs, and the job dies with a bare "Process completed with exit code 1". Same shape as the `ci-report.yml` fix in this PR: a lookup that exits non-zero on an absent root pre-empts the fallback beneath it. `|| true` hands the empty result to the guard, which still fails the build, now with `::error::prebuildify produced no .node`. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Gergő Magyar Co-authored-by: Gergo Magyar --- .../workflows/build-tree-sitter-prebuilds.yml | 8 ++++++- .github/workflows/ci-report.yml | 24 +++++++++++++++---- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build-tree-sitter-prebuilds.yml b/.github/workflows/build-tree-sitter-prebuilds.yml index 06e2b4c9f..d992c5634 100644 --- a/.github/workflows/build-tree-sitter-prebuilds.yml +++ b/.github/workflows/build-tree-sitter-prebuilds.yml @@ -414,7 +414,13 @@ jobs: # prebuilds/-/.node. ( cd "$pkgdir" && npx --no-install prebuildify --napi --strip ) - out=$(find "$pkgdir/prebuilds" -name '*.node' -print -quit) + # `|| true` so the `test -n` below is the thing that reports a missing + # prebuild. `rm -rf` above deletes the directory, so a prebuildify + # run that emits nothing without failing leaves `find` searching a + # path that no longer exists — it exits 1 and `-e` would kill the step + # before the `::error::` line, which is exactly the case that line + # exists to explain. + out=$(find "$pkgdir/prebuilds" -name '*.node' -print -quit || true) test -n "$out" || { echo "::error::prebuildify produced no .node"; exit 1; } produced=$(basename "$(dirname "$out")") [ "$produced" = "$PLATFORM_ARCH" ] || { echo "::error::built $produced, expected $PLATFORM_ARCH"; exit 1; } diff --git a/.github/workflows/ci-report.yml b/.github/workflows/ci-report.yml index 6a55254ca..f1afd43b7 100644 --- a/.github/workflows/ci-report.yml +++ b/.github/workflows/ci-report.yml @@ -256,21 +256,37 @@ jobs: fi } + # ── Helper: first matching file, tolerating an absent root ── + # `coverage-merge` (ci-tests.yml) is `needs: tests` with no + # `if: always()`, so a failing shard skips it and the `test-reports` + # artifact is never uploaded. A bare `find` on the missing directory + # exits 1; `-o pipefail` carries that through `| head -1` and `-e` + # then killed this step — silently, because stderr is discarded and + # stdout is redirected to $GITHUB_OUTPUT. That skipped "Comment on + # PR" and failed the run precisely when a PR had failing tests, which + # is when the report matters most. Degrade to "" instead so the + # coverage-unavailable fallback below can do its job. + find_first() { + local root=$1 name=$2 + [ -d "$root" ] || return 0 + find "$root" -name "$name" -type f 2>/dev/null | head -1 || true + } + # ── Read coverage reports ── - UNIT_SUMMARY=$(find "$DIR/test-reports" -name "coverage-summary.json" -type f 2>/dev/null | head -1) + UNIT_SUMMARY=$(find_first "$DIR/test-reports" "coverage-summary.json") read_cov "U" "$UNIT_SUMMARY" # ── Read base branch coverage (main) ── BASE_SUMMARY="" if [ "$BASE_FOUND" = "true" ] && [ -n "$BASE_DIR" ]; then - BASE_SUMMARY=$(find "$BASE_DIR/base" -name "coverage-summary.json" -type f 2>/dev/null | head -1) + BASE_SUMMARY=$(find_first "$BASE_DIR/base" "coverage-summary.json") fi read_cov "B" "$BASE_SUMMARY" # ── Locate test results ── - RESULTS_FILE=$(find "$DIR/test-reports" -name "test-results.json" -type f 2>/dev/null | head -1) - WEB_RESULTS_FILE=$(find "$DIR/test-reports" -name "web-test-results.json" -type f 2>/dev/null | head -1) + RESULTS_FILE=$(find_first "$DIR/test-reports" "test-results.json") + WEB_RESULTS_FILE=$(find_first "$DIR/test-reports" "web-test-results.json") sum_results() { local file=$1 From a6aae8142d08990ddbf6afa20ed6b5b1ba56fdc4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:00:21 +0000 Subject: [PATCH 03/13] chore(deps)(deps): bump brace-expansion from 5.0.7 to 5.0.9 in /gitnexus (#2786) Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 5.0.7 to 5.0.9. - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.7...v5.0.9) --- updated-dependencies: - dependency-name: brace-expansion dependency-version: 5.0.9 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus/package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 6ecb49d33..98555cd48 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -2333,15 +2333,15 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/busboy": { From d268f351d3228dda3e25433c213cb3b305246a86 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Sat, 1 Aug 2026 20:24:06 +0100 Subject: [PATCH 04/13] fix(group): preserve manifest-only impact crossings (#2784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(group): preserve manifest-only impact crossings Keep proven manifest cross-repo hits when the far endpoint has no concrete graph symbol, avoiding a guaranteed failed UID fan-out. * fix(group): verify manifest-only neighbor repos Keep manifest-only crossings from bypassing neighbor repository resolution so unavailable repos still surface as truncated fan-out. * fix(group): distinguish boundary-only impact crossings Keep manifest-only boundaries visible without treating unattempted fan-out as completed impact or escalating risk, and cover service scope, deduplication, and real bridge persistence. --------- Co-authored-by: Gergő Magyar --- gitnexus/src/cli/group.ts | 9 +- gitnexus/src/core/group/cross-impact.ts | 64 +++- gitnexus/src/core/group/types.ts | 6 + gitnexus/src/mcp/tools.ts | 2 +- .../manifest-synthetic-impact-lbug.test.ts | 142 ++++++++ .../group/manifest-synthetic-impact.test.ts | 323 ++++++++++++++++++ gitnexus/vitest.config.ts | 2 + 7 files changed, 539 insertions(+), 9 deletions(-) create mode 100644 gitnexus/test/integration/group/manifest-synthetic-impact-lbug.test.ts create mode 100644 gitnexus/test/unit/group/manifest-synthetic-impact.test.ts diff --git a/gitnexus/src/cli/group.ts b/gitnexus/src/cli/group.ts index 0053099b7..9fa583c3b 100644 --- a/gitnexus/src/cli/group.ts +++ b/gitnexus/src/cli/group.ts @@ -250,10 +250,17 @@ export function registerGroupCommands(program: Command): void { } else { const summary = (raw as { summary?: Record })?.summary; const risk = (raw as { risk?: string })?.risk; + const boundaryOnly = + ( + raw as { + cross?: Array<{ fanout_status?: string }>; + } + )?.cross?.filter((entry) => entry.fanout_status === 'not_attempted').length ?? 0; console.log(`Group impact for "${name}" (${String(opts.repo)}): risk=${risk ?? '?'}`); if (summary) { + const boundaryNote = boundaryOnly > 0 ? ` (${boundaryOnly} boundary-only)` : ''; console.log( - ` direct=${summary.direct ?? 0} processes=${summary.processes_affected ?? 0} cross=${summary.cross_repo_hits ?? 0}`, + ` direct=${summary.direct ?? 0} processes=${summary.processes_affected ?? 0} cross=${summary.cross_repo_hits ?? 0}${boundaryNote}`, ); } } diff --git a/gitnexus/src/core/group/cross-impact.ts b/gitnexus/src/core/group/cross-impact.ts index 70c0af506..930ecea25 100644 --- a/gitnexus/src/core/group/cross-impact.ts +++ b/gitnexus/src/core/group/cross-impact.ts @@ -349,14 +349,31 @@ function extractProcessNames(impact: unknown): string[] { // No behavior change — `'UNKNOWN'` was already handled correctly at the // `(localRisk === 'LOW' || localRisk === 'UNKNOWN')` branch below. export function mergeRisk(localRisk: string, cross: CrossRepoImpact[]): string { - const highConf = cross.some((c) => c.contract.confidence >= 0.85); + const traversed = cross.filter((c) => c.fanout_status !== 'not_attempted'); + const highConf = traversed.some((c) => c.contract.confidence >= 0.85); if (localRisk === 'CRITICAL') return 'CRITICAL'; - if (cross.length >= 3) return 'CRITICAL'; + if (traversed.length >= 3) return 'CRITICAL'; if (highConf) return 'HIGH'; - if (cross.length > 0 && (localRisk === 'LOW' || localRisk === 'UNKNOWN')) return 'MEDIUM'; + if (traversed.length > 0 && (localRisk === 'LOW' || localRisk === 'UNKNOWN')) return 'MEDIUM'; return localRisk; } +function addCrossImpact(cross: CrossRepoImpact[], candidate: CrossRepoImpact): void { + const sameBoundary = (entry: CrossRepoImpact): boolean => + entry.repo_path === candidate.repo_path && entry.contract.id === candidate.contract.id; + + if (candidate.fanout_status === 'not_attempted') { + if (cross.some(sameBoundary)) return; + } else { + const boundaryOnlyIndex = cross.findIndex( + (entry) => sameBoundary(entry) && entry.fanout_status === 'not_attempted', + ); + if (boundaryOnlyIndex >= 0) cross.splice(boundaryOnlyIndex, 1); + } + + cross.push(candidate); +} + export async function ensureBridgeReady( groupDir: string, ): Promise<{ handle: BridgeHandle } | { error: string }> { @@ -587,7 +604,12 @@ export async function runGroupImpact( const seen = new Set(); for (const n of neighbors) { - if (servicePrefix && !fileMatchesServicePrefix(n.neighborFilePath, servicePrefix)) { + const manifestOnly = n.neighborUid.startsWith('manifest::'); + if ( + servicePrefix && + !manifestOnly && + !fileMatchesServicePrefix(n.neighborFilePath, servicePrefix) + ) { continue; } if (!repoInSubgroup(n.neighborRepo, subgroup)) { @@ -604,8 +626,7 @@ export async function runGroupImpact( if (seen.has(key)) continue; seen.add(key); - const remainingMs = deadline - Date.now(); - if (remainingMs <= 0) { + if (!manifestOnly && deadline - Date.now() <= 0) { truncatedRepos.push(n.neighborRepo); continue; } @@ -621,6 +642,35 @@ export async function runGroupImpact( continue; } + // A manifest link can prove the repository boundary even when its far + // endpoint has no graph symbol of its own (for example, a string-based + // RPC dispatch). Those endpoints deliberately use a deterministic + // `manifest::...` UID. Calling impactByUid with that synthetic value can + // never succeed because it is not a node in the verified neighbor + // repository; dropping the crossing here made `group sync` report the + // manifest link while `group impact` silently returned cross=0 (#2722). + // + // Preserve the proven crossing with an empty local fan-out. Real UIDs + // still take the normal impactByUid path below, where failures remain + // truncations rather than false successful traversals. + if (manifestOnly) { + addCrossImpact(cross, { + repo: regName, + repo_path: n.neighborRepo, + contract: { + id: n.contractId, + type: n.contractType as ContractType, + match_type: 'manifest', + confidence: n.confidence, + }, + by_depth: {}, + affected_processes: [], + fanout_status: 'not_attempted', + }); + continue; + } + + const remainingMs = deadline - Date.now(); // Phase-2 hardening: race each impactByUid against a per-call // timeout derived from the remaining budget. Without this wrap a // single hung neighbor would pin the request past the clamped @@ -644,7 +694,7 @@ export async function runGroupImpact( continue; } - cross.push({ + addCrossImpact(cross, { repo: regName, repo_path: n.neighborRepo, contract: { diff --git a/gitnexus/src/core/group/types.ts b/gitnexus/src/core/group/types.ts index 42f6df368..629c50a99 100644 --- a/gitnexus/src/core/group/types.ts +++ b/gitnexus/src/core/group/types.ts @@ -177,6 +177,12 @@ export interface CrossRepoImpact { }; by_depth: Record; affected_processes: string[]; + /** + * Present when the bridge proves a repository boundary but the far endpoint + * has no graph symbol, so local fan-out cannot be attempted. Omitted for + * completed fan-out to preserve the existing serialized result shape. + */ + fanout_status?: 'not_attempted'; } export interface OutOfScopeLink { diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 690464cbf..b48de9b8d 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -463,7 +463,7 @@ Handles disambiguation: when multiple symbols share the target name, returns ran EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, METHOD_OVERRIDES, METHOD_IMPLEMENTS, ACCESSES Confidence: 1.0 = certain, <0.8 = fuzzy match -GROUP MODE: set "repo" to "@" for cross-repo impact anchored at the default member (lexicographically first key in group.yaml "repos"), or "@/" to choose the member (same path keys as in group.yaml). Phase-1 walk runs in that member; cross-boundary fan-out uses the group bridge. +GROUP MODE: set "repo" to "@" for cross-repo impact anchored at the default member (lexicographically first key in group.yaml "repos"), or "@/" to choose the member (same path keys as in group.yaml). Phase-1 walk runs in that member; cross-boundary fan-out uses the group bridge. A cross entry with fanout_status:"not_attempted" proves the declared repository boundary, but its far endpoint has no graph symbol; do not interpret empty by_depth or affected_processes on that entry as a completed zero-impact walk. SERVICE: optional monorepo path prefix (case-sensitive path segments). When "repo" starts with "@", scopes the local impact walk and cross-repo symbol paths to files under that prefix; ignored for a normal indexed repo name.`, annotations: READ_ONLY_TOOL_ANNOTATIONS, diff --git a/gitnexus/test/integration/group/manifest-synthetic-impact-lbug.test.ts b/gitnexus/test/integration/group/manifest-synthetic-impact-lbug.test.ts new file mode 100644 index 000000000..1127a7c85 --- /dev/null +++ b/gitnexus/test/integration/group/manifest-synthetic-impact-lbug.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fsp from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { runGroupImpact } from '../../../src/core/group/cross-impact.js'; +import { closeAllCachedBridges, writeBridge } from '../../../src/core/group/bridge-db.js'; +import type { GroupToolPort } from '../../../src/core/group/service.js'; +import type { CrossLink, StoredContract } from '../../../src/core/group/types.js'; + +// LadybugDB does not reliably release the writer handle before an immediate +// read-only reopen on Windows; the existing real-bridge tests use the same +// platform guard. Linux CI executes this regression against the real DB. +const itRealBridge = process.platform === 'win32' ? it.skip : it; + +describe('manifest-only group impact through a real bridge', () => { + let home: string; + let groupDir: string; + + beforeEach(async () => { + home = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-manifest-impact-lbug-')); + groupDir = path.join(home, 'groups', 'waveful'); + await fsp.mkdir(groupDir, { recursive: true }); + await fsp.writeFile( + path.join(groupDir, 'group.yaml'), + `version: 1 +name: waveful +description: "" +repos: + backend: backend-registry + app: app-registry +links: [] +packages: {} +detect: + http: false + grpc: false + thrift: false + topics: false + shared_libs: false + embedding_fallback: false +matching: + bm25_threshold: 0.7 + embedding_threshold: 0.65 + max_candidates_per_step: 3 +`, + 'utf8', + ); + }); + + afterEach(async () => { + await closeAllCachedBridges(); + await fsp.rm(home, { recursive: true, force: true }); + }); + + itRealBridge( + 'round-trips a synthetic uid and reports the boundary without attempting fan-out', + async () => { + const contractId = 'custom::executeAddDynamicLinkMS'; + const providerUid = 'Function:src/functions.ts:executeAddDynamicLinkMS'; + const syntheticUid = `manifest::app::${contractId}`; + const provider: StoredContract = { + repo: 'backend', + role: 'provider', + contractId, + type: 'custom', + symbolUid: providerUid, + symbolRef: { filePath: 'src/functions.ts', name: 'executeAddDynamicLinkMS' }, + symbolName: 'executeAddDynamicLinkMS', + confidence: 1, + meta: {}, + }; + const consumer: StoredContract = { + repo: 'app', + role: 'consumer', + contractId, + type: 'custom', + symbolUid: syntheticUid, + symbolRef: { filePath: '', name: contractId }, + symbolName: contractId, + confidence: 1, + meta: { source: 'manifest' }, + }; + const link: CrossLink = { + from: { repo: 'app', symbolUid: syntheticUid, symbolRef: consumer.symbolRef }, + to: { repo: 'backend', symbolUid: providerUid, symbolRef: provider.symbolRef }, + type: 'custom', + contractId, + matchType: 'manifest', + confidence: 1, + }; + const report = await writeBridge(groupDir, { + contracts: [provider, consumer], + crossLinks: [link], + repoSnapshots: {}, + missingRepos: [], + }); + expect(report.linksInserted).toBe(1); + + const impactByUid = vi.fn(async () => null); + const port: GroupToolPort = { + resolveRepo: vi.fn(async (name: string) => ({ + id: name, + name, + repoPath: name, + storagePath: path.join(home, name), + })), + impact: vi.fn(async () => ({ + target: { id: providerUid, filePath: 'src/functions.ts' }, + byDepth: {}, + summary: { direct: 0, processes_affected: 0, modules_affected: 0 }, + risk: 'LOW', + })), + impactByUid, + query: vi.fn(), + context: vi.fn(), + }; + + const result = await runGroupImpact( + { port, gitnexusDir: home }, + { + name: 'waveful', + repo: 'backend', + target: 'executeAddDynamicLinkMS', + direction: 'upstream', + }, + ); + + if ('error' in result) throw new Error(result.error); + + expect(result.cross).toEqual([ + expect.objectContaining({ + repo_path: 'app', + contract: expect.objectContaining({ id: contractId, match_type: 'manifest' }), + fanout_status: 'not_attempted', + }), + ]); + expect(result.summary.cross_repo_hits).toBe(1); + expect(result.risk).toBe('LOW'); + expect(result.truncated).toBe(false); + expect(impactByUid).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/gitnexus/test/unit/group/manifest-synthetic-impact.test.ts b/gitnexus/test/unit/group/manifest-synthetic-impact.test.ts new file mode 100644 index 000000000..91caf3429 --- /dev/null +++ b/gitnexus/test/unit/group/manifest-synthetic-impact.test.ts @@ -0,0 +1,323 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fsp from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import type { BridgeHandle } from '../../../src/core/group/types.js'; +import type { GroupToolPort } from '../../../src/core/group/service.js'; + +const bridgeHandle = { + _db: {}, + _conn: {}, + groupDir: '', + _readOnly: true, +} as BridgeHandle; + +const bridgeRows = vi.hoisted(() => ({ + value: [] as Array>, +})); + +vi.mock('../../../src/core/group/bridge-db.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readBridgeMeta: vi.fn(async () => ({ version: 1, generatedAt: '', missingRepos: [] })), + getCachedBridgeReadOnly: vi.fn(async () => bridgeHandle), + queryBridge: vi.fn(async () => bridgeRows.value), + closeBridgeDb: vi.fn(async () => undefined), + }; +}); + +const { runGroupImpact } = await import('../../../src/core/group/cross-impact.js'); + +describe('group impact through manifest-only endpoints', () => { + let home: string; + + beforeEach(async () => { + bridgeRows.value = [ + { + neighborRepo: 'app', + neighborUid: 'manifest::app::custom::executeAddDynamicLinkMS', + neighborFilePath: '', + matchType: 'manifest', + confidence: 1, + contractId: 'custom::executeAddDynamicLinkMS', + contractType: 'custom', + }, + ]; + home = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-manifest-impact-')); + const groupDir = path.join(home, 'groups', 'waveful'); + await fsp.mkdir(groupDir, { recursive: true }); + await fsp.writeFile( + path.join(groupDir, 'group.yaml'), + `version: 1 +name: waveful +description: "" +repos: + backend: backend-registry + app: app-registry +links: [] +packages: {} +detect: + http: false + grpc: false + thrift: false + topics: false + shared_libs: false + embedding_fallback: false +matching: + bm25_threshold: 0.7 + embedding_threshold: 0.65 + max_candidates_per_step: 3 +`, + 'utf8', + ); + await fsp.writeFile(path.join(groupDir, 'bridge.lbug'), ''); + }); + + afterEach(async () => { + await fsp.rm(home, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + function makePort(impactByUid: GroupToolPort['impactByUid']): GroupToolPort { + return { + resolveRepo: vi.fn(async (name: string) => ({ + id: name, + name, + repoPath: name, + storagePath: path.join(home, name), + })), + impact: vi.fn(async () => ({ + target: { + id: 'Function:src/functions.ts:executeAddDynamicLinkMS', + filePath: 'src/functions.ts', + }, + byDepth: {}, + summary: { direct: 0, processes_affected: 0, modules_affected: 0 }, + risk: 'LOW', + })), + impactByUid, + query: vi.fn(), + context: vi.fn(), + }; + } + + it('reports a proven manifest crossing when the far endpoint has only a synthetic UID', async () => { + const impactByUid = vi.fn(async () => null); + const resolveRepo = vi.fn(async (name: string) => ({ + id: name, + name, + repoPath: name, + storagePath: path.join(home, name), + })); + const port: GroupToolPort = { + resolveRepo, + impact: vi.fn(async () => ({ + target: { + id: 'Function:src/functions.ts:executeAddDynamicLinkMS', + filePath: 'src/functions.ts', + }, + byDepth: {}, + summary: { direct: 0, processes_affected: 0, modules_affected: 0 }, + risk: 'LOW', + })), + impactByUid, + query: vi.fn(), + context: vi.fn(), + }; + + const result = await runGroupImpact( + { port, gitnexusDir: home }, + { + name: 'waveful', + repo: 'backend', + target: 'executeAddDynamicLinkMS', + direction: 'upstream', + }, + ); + + expect('error' in result).toBe(false); + if ('error' in result) return; + + expect(result.summary.cross_repo_hits).toBe(1); + expect(result.cross).toEqual([ + expect.objectContaining({ + repo: 'app-registry', + repo_path: 'app', + contract: expect.objectContaining({ + id: 'custom::executeAddDynamicLinkMS', + match_type: 'manifest', + confidence: 1, + }), + by_depth: {}, + affected_processes: [], + fanout_status: 'not_attempted', + }), + ]); + expect(result.truncated).toBe(false); + expect(result.risk).toBe('LOW'); + expect(resolveRepo).toHaveBeenCalledWith('app-registry'); + expect(impactByUid).not.toHaveBeenCalled(); + }); + + it('keeps a boundary-only crossing visible when its service scope is unknown', async () => { + const impactByUid = vi.fn(async () => null); + const result = await runGroupImpact( + { port: makePort(impactByUid), gitnexusDir: home }, + { + name: 'waveful', + repo: 'backend', + target: 'executeAddDynamicLinkMS', + direction: 'upstream', + service: 'src', + }, + ); + + expect('error' in result).toBe(false); + if ('error' in result) return; + + expect(result.summary.cross_repo_hits).toBe(1); + expect(result.cross[0]).toMatchObject({ + repo_path: 'app', + fanout_status: 'not_attempted', + }); + expect(result.risk).toBe('LOW'); + expect(impactByUid).not.toHaveBeenCalled(); + }); + + it('labels a synthetic endpoint as manifest even when exact matching emitted the link', async () => { + bridgeRows.value[0].matchType = 'exact'; + const result = await runGroupImpact( + { port: makePort(vi.fn(async () => null)), gitnexusDir: home }, + { + name: 'waveful', + repo: 'backend', + target: 'executeAddDynamicLinkMS', + direction: 'upstream', + }, + ); + + expect('error' in result).toBe(false); + if ('error' in result) return; + + expect(result.cross[0].contract.match_type).toBe('manifest'); + expect(result.cross[0].fanout_status).toBe('not_attempted'); + }); + + it('prefers completed fan-out over a duplicate manifest-only boundary', async () => { + bridgeRows.value.push({ + neighborRepo: 'app', + neighborUid: 'Function:src/handler.ts:executeAddDynamicLinkMS', + neighborFilePath: 'src/handler.ts', + matchType: 'exact', + confidence: 1, + contractId: 'custom::executeAddDynamicLinkMS', + contractType: 'custom', + }); + const impactByUid = vi.fn(async () => ({ + byDepth: { + 1: [{ id: 'Function:src/caller.ts:callDynamicLink' }], + }, + affected_processes: [{ name: 'dynamic-link-flow' }], + })); + const result = await runGroupImpact( + { port: makePort(impactByUid), gitnexusDir: home }, + { + name: 'waveful', + repo: 'backend', + target: 'executeAddDynamicLinkMS', + direction: 'upstream', + }, + ); + + expect('error' in result).toBe(false); + if ('error' in result) return; + + expect(impactByUid).toHaveBeenCalledTimes(1); + expect(result.cross).toHaveLength(1); + expect(result.cross[0]).toMatchObject({ + repo_path: 'app', + contract: { id: 'custom::executeAddDynamicLinkMS', match_type: 'exact' }, + by_depth: { + 1: [{ id: 'Function:src/caller.ts:callDynamicLink' }], + }, + affected_processes: ['dynamic-link-flow'], + }); + expect(result.cross[0].fanout_status).toBeUndefined(); + expect(result.risk).toBe('HIGH'); + }); + + it('does not escalate risk from multiple boundary-only crossings', async () => { + bridgeRows.value = [1, 2, 3].map((suffix) => ({ + neighborRepo: 'app', + neighborUid: `manifest::app::custom::dynamic-${suffix}`, + neighborFilePath: '', + matchType: 'manifest', + confidence: 1, + contractId: `custom::dynamic-${suffix}`, + contractType: 'custom', + })); + const result = await runGroupImpact( + { port: makePort(vi.fn(async () => null)), gitnexusDir: home }, + { + name: 'waveful', + repo: 'backend', + target: 'executeAddDynamicLinkMS', + direction: 'upstream', + }, + ); + + expect('error' in result).toBe(false); + if ('error' in result) return; + + expect(result.cross).toHaveLength(3); + expect(result.cross.every((entry) => entry.fanout_status === 'not_attempted')).toBe(true); + expect(result.risk).toBe('LOW'); + }); + + it('keeps an unavailable synthetic neighbor truncated instead of reporting a hit', async () => { + const impactByUid = vi.fn(async () => null); + const port: GroupToolPort = { + resolveRepo: vi.fn(async (name) => { + if (name === 'app-registry') throw new Error('repository unavailable'); + return { + id: name, + name, + repoPath: name, + storagePath: path.join(home, name), + }; + }), + impact: vi.fn(async () => ({ + target: { + id: 'Function:src/functions.ts:executeAddDynamicLinkMS', + filePath: 'src/functions.ts', + }, + byDepth: {}, + summary: { direct: 0, processes_affected: 0, modules_affected: 0 }, + risk: 'LOW', + })), + impactByUid, + query: vi.fn(), + context: vi.fn(), + }; + + const result = await runGroupImpact( + { port, gitnexusDir: home }, + { + name: 'waveful', + repo: 'backend', + target: 'executeAddDynamicLinkMS', + direction: 'upstream', + }, + ); + + expect('error' in result).toBe(false); + if ('error' in result) return; + + expect(result.cross).toEqual([]); + expect(result.summary.cross_repo_hits).toBe(0); + expect(result.truncated).toBe(true); + expect(result.truncatedRepos).toEqual(['app']); + expect(impactByUid).not.toHaveBeenCalled(); + }); +}); diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index d1dad6541..026da9a57 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -91,6 +91,7 @@ export default defineConfig({ 'test/integration/lbug-non-ascii-path.test.ts', 'test/integration/lbug-conn-serialization.test.ts', 'test/integration/group/manifest-resolve-symbol-2325.test.ts', + 'test/integration/group/manifest-synthetic-impact-lbug.test.ts', 'test/integration/group/http-route-resolve-symbol.test.ts', 'test/integration/fts-stemmer-sweep.test.ts', 'test/integration/lbug-multiwriter-deadlock.test.ts', @@ -138,6 +139,7 @@ export default defineConfig({ 'test/integration/lbug-non-ascii-path.test.ts', 'test/integration/lbug-conn-serialization.test.ts', 'test/integration/group/manifest-resolve-symbol-2325.test.ts', + 'test/integration/group/manifest-synthetic-impact-lbug.test.ts', 'test/integration/group/http-route-resolve-symbol.test.ts', 'test/integration/skills-e2e.test.ts', 'test/integration/fts-extension-e2e.test.ts', From 639eb04b311124dd411eb96b81d9b77286051365 Mon Sep 17 00:00:00 2001 From: Karl Lehenbauer Date: Sat, 1 Aug 2026 15:10:58 -0500 Subject: [PATCH 05/13] fix(swift): preprocess indented conditional directives so class bodies survive parsing (#2771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(swift): preprocess indented conditional directives so class bodies survive parsing * fix(swift): make conditional-directive blanking comment-, string- and brace-aware (#2771) Addresses the review findings on PR #2771. The transform fired unconditionally, which turned valid Swift into parse errors while missing the most common shape it was written for. - The blank/keep decision now consults `blockCommentDepth`, so ` #endif */` — the result of commenting out a conditional block — keeps its comment terminator. Previously `hasError` went raw=false -> preprocessed=true and the rest of the file was swallowed. - The decision keys on the scanner's brace depth instead of indentation. A column-0 `#if` inside a class body is blanked (6 of 7 body shapes previously still lost the enclosing declaration) and an indented file-scope directive is not — matching what the doc comment already claimed. Bare-CR line endings, NBSP/ideographic indentation and a leading BOM are recognized too. - A group is blanked only when every branch is brace-balanced. An `#if`/`#else` that splits a declaration header leaves one unmatched `{` once both branches survive, which collapsed five top-level nodes into one and gave unrelated types fabricated `NetworkClient.` qualified names. Such a group now degrades to the pre-fix behavior. - Multiline strings honour `\"""` escapes, and a plain `"""` closes even when a `#` follows it, so the scanner no longer wedges in string state and silently stops blanking for the rest of the file. - The pound run is counted once per position and skipped. It was quadratic: 10.6s for one 64k-`#` line, well inside the 512 KB walker limit. - Extended regex literals (`#/.../#`) no longer open a phantom block comment. - Directive-free files return early, matching `stripUeMacros`. Worker parity: `emitSwiftScopeCaptures` and `emitCppScopeCaptures` re-apply their provider's `preprocessSource` on the parse-cache-miss path — Dart already did this — and the embedding parse in `ensureAndParse` applies the hook as well. Before this the worker and the scope-capture/embedding halves analyzed different programs, turning a consistent degradation into cold-run/warm-run non-determinism. A new parity test pins the equivalence for every provider that defines the hook. SCHEMA_BUMP 37 -> 38: this changes parse semantics, the chunk key hashes raw on-disk bytes, and `preprocessSource` runs after the key is computed — so a same-package-version warm cache would replay pre-fix Swift results verbatim, including across `--force`. Co-Authored-By: Claude Opus 5 (1M context) * refactor(ingestion): apply preprocessSource once in the scope bridge (#2771) Follow-up cleanup on the review fixes. The previous commit re-applied each provider's `preprocessSource` inside `emitSwiftScopeCaptures` and `emitCppScopeCaptures`, mirroring what Dart already did — three copies of the same rule, and a contract that asked every future emitter to remember it. `extractParsedFile` is the single funnel every `emitScopeCaptures` caller passes through (parse worker, scope-resolution run, Vue script extraction), and it already receives the provider. Applying the hook there on the cache-miss path covers all three languages and every future one, names no language in shared code, and drops Dart's unconditional transform on the cache-hit path. Verified the three emitters use `sourceText` for nothing but the parse, so the substitution is output-identical — which the parity test asserts directly. Also from the cleanup pass: - the parity test derives its language list from the provider registry, so a new provider adopting the hook fails until it adds a fixture - `ensureAndParse` resolves the provider from the language it already computed, instead of a second extension table (`getProviderForFile`) - the preprocessor returns `sourceText` unchanged when no group was blanked, which is the common case for files whose only directives are top-level - `split(/(\r\n|\n|\r)/)` replaces the hand-rolled line splitter, and the per-group brace bookkeeping is two scalars instead of an array - the hint regex is derived from the line regex so the two cannot drift - unit assertions compare the WHOLE preprocessed file against the expected blanking, replacing per-line spot checks; the pipeline tests share one `runFixture` helper and `getNodesForFile` in the resolver test helpers - `LanguageProvider.preprocessSource` documents the real call sites and says plainly that the set is not closed — `populateRangeBindings` still hands language helpers raw text Co-Authored-By: Claude Opus 5 (1M context) * chore(autofix): apply prettier + eslint fixes via /autofix command --------- Co-authored-by: Gergő Magyar Co-authored-by: Gergo Magyar Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- gitnexus/src/core/embeddings/ast-utils.ts | 11 +- .../src/core/ingestion/language-provider.ts | 14 +- .../core/ingestion/languages/cpp/captures.ts | 9 +- .../core/ingestion/languages/dart/captures.ts | 5 +- .../src/core/ingestion/languages/swift.ts | 2 + .../ingestion/languages/swift/captures.ts | 11 +- .../swift/conditional-directive-preprocess.ts | 283 +++++++++++++++ .../core/ingestion/scope-extractor-bridge.ts | 11 +- gitnexus/src/storage/parse-cache.ts | 7 +- .../doc-comment-description-e2e.test.ts | 45 +++ .../test/integration/resolvers/helpers.ts | 22 ++ .../swift-conditional-directive.test.ts | 119 +++++++ .../integration/tree-sitter-languages.test.ts | 178 +++++++++- gitnexus/test/unit/ast-utils.test.ts | 5 +- gitnexus/test/unit/chunker.test.ts | 5 +- gitnexus/test/unit/embedding-chunking.test.ts | 5 +- .../test/unit/incremental-parse-cache.test.ts | 6 +- .../unit/preprocess-source-parity.test.ts | 87 +++++ ...t-conditional-directive-preprocess.test.ts | 334 ++++++++++++++++++ 19 files changed, 1141 insertions(+), 18 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/swift/conditional-directive-preprocess.ts create mode 100644 gitnexus/test/integration/swift-conditional-directive.test.ts create mode 100644 gitnexus/test/unit/preprocess-source-parity.test.ts create mode 100644 gitnexus/test/unit/swift-conditional-directive-preprocess.test.ts diff --git a/gitnexus/src/core/embeddings/ast-utils.ts b/gitnexus/src/core/embeddings/ast-utils.ts index e4976d71e..194f47313 100644 --- a/gitnexus/src/core/embeddings/ast-utils.ts +++ b/gitnexus/src/core/embeddings/ast-utils.ts @@ -11,6 +11,7 @@ import { resolveLanguageKey, } from '../tree-sitter/parser-loader.js'; import { parseSourceSafe } from '../tree-sitter/safe-parse.js'; +import { getProvider } from '../ingestion/languages/index.js'; const parserCache = new Map(); @@ -30,7 +31,15 @@ export const ensureAndParse = async (content: string, filePath: string): Promise parserCache.set(parserKey, parserInstance); } - return parseSourceSafe(parserInstance, content); + // Same text the ingestion worker parses — otherwise a provider whose + // `preprocessSource` repairs a declaration (Swift conditional directives, + // C++ UE macros, Dart extension types) would leave embeddings looking at an + // error-recovered tree. Resolved from `language` so the transform and the + // parser always come from the same provider. Length-preserving, so node + // offsets still index `content`. + const parseContent = getProvider(language).preprocessSource?.(content, filePath) ?? content; + + return parseSourceSafe(parserInstance, parseContent); }; const FUNCTION_LIKE_TYPES = new Set([ diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index c4ba324ba..329faf42f 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -114,9 +114,17 @@ interface LanguageProviderConfig { * The current C++ UE-macro preprocessor relies on the practical fact that * UE reflection macros and module-export tokens are ASCII-only. * - * Must be a pure function — same input always yields the same output. Called - * once per file, on every code path that re-parses (parsing-processor, import - * processor, heritage processor, call processor, parse worker). + * Must be a pure function — same input always yields the same output, and + * re-applying it to its own output changes nothing. + * + * Applied by the parse worker (`parse-worker.ts`), by `extractParsedFile` + * (`scope-extractor-bridge.ts`) on the parse-cache-miss path, and by the + * embedding parse (`embeddings/ast-utils.ts`, which does not go through the + * bridge). Any *new* path that re-parses a file must apply it too, or the two + * halves of the pipeline analyze different programs — and note the set is not + * closed today: language-owned re-parse helpers reached through other + * provider hooks (e.g. `populateRangeBindings`) still see raw text. + * `test/unit/preprocess-source-parity.test.ts` pins the bridge equivalence. * * Default: undefined (no preprocessing — `file.content` is parsed verbatim). */ diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index a13cba09d..ce1ed009c 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -8,6 +8,7 @@ import { import { getCppParser, getCppScopeQuery } from './query.js'; import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; +import { stripUeMacros } from '../../cpp-ue-preprocessor.js'; import { normalizeQualifiedName } from '../../utils/qualified-name.js'; import { splitCppInclude, splitCppUsingDecl } from './import-decomposer.js'; import { @@ -159,8 +160,12 @@ export function emitCppScopeCaptures( ): readonly CaptureMatch[] { let tree = cachedTree as ReturnType['parse']> | undefined; if (tree === undefined) { - tree = parseSourceSafe(getCppParser(), sourceText, undefined, { - bufferSize: getTreeSitterBufferSize(sourceText), + // Idempotent re-application: `extractParsedFile` already preprocesses, but + // direct emitter callers (benchmarks, capture goldens) must see the same + // program the pipeline does. + const parseText = stripUeMacros(sourceText); + tree = parseSourceSafe(getCppParser(), parseText, undefined, { + bufferSize: getTreeSitterBufferSize(parseText), }); } diff --git a/gitnexus/src/core/ingestion/languages/dart/captures.ts b/gitnexus/src/core/ingestion/languages/dart/captures.ts index 3157c0841..c3a09b91b 100644 --- a/gitnexus/src/core/ingestion/languages/dart/captures.ts +++ b/gitnexus/src/core/ingestion/languages/dart/captures.ts @@ -39,13 +39,13 @@ import { computeDartArityMetadata } from './arity-metadata.js'; import { synthesizeDartReceiverBinding } from './receiver-binding.js'; import { synthesizeDartSignatureBindings } from './signature-bindings.js'; import { getDartParser, getDartScopeQuery } from './query.js'; +import { preprocessDartExtensionTypes } from './extension-type-preprocess.js'; import { recordCacheHit, recordCacheMiss } from './cache-stats.js'; import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { encodeMarker } from '../../utils/heritage-marker.js'; import { DART_BUILT_INS } from './built-ins.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; -import { preprocessDartExtensionTypes } from './extension-type-preprocess.js'; import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; const FUNCTION_DECL_TAGS = [ @@ -113,6 +113,9 @@ export function emitDartScopeCaptures( _filePath: string, cachedTree?: unknown, ): readonly CaptureMatch[] { + // Idempotent re-application: `extractParsedFile` already preprocesses, but + // direct emitter callers (benchmarks, capture goldens) must see the same + // program the pipeline does. const parseText = preprocessDartExtensionTypes(sourceText); let tree: Parser.Tree; if (cachedTree !== undefined && cachedTree !== null) { diff --git a/gitnexus/src/core/ingestion/languages/swift.ts b/gitnexus/src/core/ingestion/languages/swift.ts index d19a8fb52..1ae31679d 100644 --- a/gitnexus/src/core/ingestion/languages/swift.ts +++ b/gitnexus/src/core/ingestion/languages/swift.ts @@ -37,6 +37,7 @@ import { swiftMergeBindings, swiftArityCompatibility, } from './swift/index.js'; +import { preprocessSwiftConditionalDirectives } from './swift/conditional-directive-preprocess.js'; /** Swift init/deinit declarations have special names and Constructor label. */ const swiftExtractFunctionName = ( @@ -178,6 +179,7 @@ const BUILT_INS: ReadonlySet = new Set([ export const swiftProvider = defineLanguage({ id: SupportedLanguages.Swift, extensions: ['.swift'], + preprocessSource: preprocessSwiftConditionalDirectives, entryPointPatterns: [ /^viewDidLoad$/, /^viewWillAppear$/, diff --git a/gitnexus/src/core/ingestion/languages/swift/captures.ts b/gitnexus/src/core/ingestion/languages/swift/captures.ts index b9b111454..e3d398695 100644 --- a/gitnexus/src/core/ingestion/languages/swift/captures.ts +++ b/gitnexus/src/core/ingestion/languages/swift/captures.ts @@ -46,6 +46,7 @@ import { computeSwiftArityMetadata } from './arity-metadata.js'; import { synthesizeSwiftReceiverBinding } from './receiver-binding.js'; import { synthesizeSwiftSignatureBindings } from './signature-bindings.js'; import { getSwiftParser, getSwiftScopeQuery } from './query.js'; +import { preprocessSwiftConditionalDirectives } from './conditional-directive-preprocess.js'; import { recordCacheHit, recordCacheMiss } from './cache-stats.js'; import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; @@ -89,10 +90,16 @@ export function emitSwiftScopeCaptures( cachedTree?: unknown, ): readonly CaptureMatch[] { // Reuse the parse phase's cached Tree when available; otherwise parse. + // `extractParsedFile` already applies `preprocessSource` on this path, but + // this emitter is also called directly (benchmarks, capture goldens, the + // scope-capture tripwire), and those callers must see the same program the + // pipeline does. The transform is idempotent, so applying it twice is a + // no-op; it is length-preserving, so offsets still index `sourceText`. let tree = cachedTree as ReturnType['parse']> | undefined; if (tree === undefined) { - tree = parseSourceSafe(getSwiftParser(), sourceText, undefined, { - bufferSize: getTreeSitterBufferSize(sourceText), + const parseText = preprocessSwiftConditionalDirectives(sourceText); + tree = parseSourceSafe(getSwiftParser(), parseText, undefined, { + bufferSize: getTreeSitterBufferSize(parseText), }); recordCacheMiss(); } else { diff --git a/gitnexus/src/core/ingestion/languages/swift/conditional-directive-preprocess.ts b/gitnexus/src/core/ingestion/languages/swift/conditional-directive-preprocess.ts new file mode 100644 index 000000000..78ea5aa14 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/swift/conditional-directive-preprocess.ts @@ -0,0 +1,283 @@ +/** + * A conditional-compilation directive occupying a whole line. + * + * `[^\S\r\n]` (horizontal whitespace) rather than `[ \t]` so NBSP / + * ideographic-space indentation and a leading BOM are recognized too. Leading + * whitespace is *not* required: nesting is decided from the scanner's brace + * depth, not from indentation (a column-0 `#if` inside a class body is exactly + * the shape that loses its enclosing declaration). + */ +const SWIFT_CONDITIONAL_DIRECTIVE_RE = /^[^\S\r\n]*#(if|elseif|else|endif)\b[^\r\n]*$/; + +/** + * Cheap whole-file precondition, mirroring `stripUeMacros`'s `HAS_UE_HINT`. + * Derived from the line pattern so the two can never drift apart. + */ +const HAS_CONDITIONAL_DIRECTIVE_HINT = new RegExp(SWIFT_CONDITIONAL_DIRECTIVE_RE.source, 'm'); + +interface SwiftPreprocessScanState { + blockCommentDepth: number; + multilineStringPounds: number | null; + /** Net `{` minus `}` seen in code position. May go negative on broken source. */ + braceDepth: number; +} + +/** + * An `#if` … `#endif` run. Blanking is decided per group, so a group is either + * fully blanked or fully preserved — never half-erased. + */ +interface DirectiveGroup { + braceDepthAtStart: number; + /** Indices into the alternating `parts` array of this group's directive lines. */ + directiveLines: number[]; + /** Net brace delta of every branch so far — what the parser sees once all survive. */ + totalDelta: number; + /** False as soon as one branch is not brace-balanced on its own. */ + allBranchesBalanced: boolean; + currentBranchDelta: number; +} + +function hasTripleQuoteAt(line: string, index: number): boolean { + return line.startsWith('"""', index); +} + +/** + * Length of the multiline terminator at `index`, or 0. + * + * A plain `"""` string always closes at `"""`, whatever follows it — an + * adjacent `#` is the next token, not part of the delimiter. A raw `#"""` + * string closes at `"""` followed by *at least* its own pound count. + */ +function matchingMultilineCloseLength(line: string, index: number, poundCount: number): number { + if (!hasTripleQuoteAt(line, index)) return 0; + if (poundCount === 0) return 3; + return line.startsWith('#'.repeat(poundCount), index + 3) ? 3 + poundCount : 0; +} + +function skipRegularString(line: string, startIndex: number, rawPoundCount: number): number { + const endPounds = '#'.repeat(rawPoundCount); + let index = startIndex + (rawPoundCount > 0 ? rawPoundCount + 1 : 1); + + while (index < line.length) { + if (line[index] === '"') { + if (rawPoundCount === 0) return index + 1; + if (line.startsWith(endPounds, index + 1)) return index + 1 + rawPoundCount; + } + if (rawPoundCount === 0 && line[index] === '\\') index++; + index++; + } + + return line.length; +} + +/** + * Skip an extended regex literal (`#/…/#`), whose body may legally contain + * `/*` — which would otherwise open a block comment that never closes. + * `slashIndex` points at the `/` that follows the opening pound run. + */ +function skipExtendedRegexLiteral(line: string, slashIndex: number, poundCount: number): number { + const closePounds = '#'.repeat(poundCount); + let index = slashIndex + 1; + + while (index < line.length) { + if (line[index] === '\\') { + index += 2; + continue; + } + if (line[index] === '/' && line.startsWith(closePounds, index + 1)) { + return index + 1 + poundCount; + } + index++; + } + + return line.length; +} + +function scanSwiftLine(line: string, state: SwiftPreprocessScanState): void { + let index = 0; + + while (index < line.length) { + if (state.blockCommentDepth > 0) { + if (line.startsWith('/*', index)) { + state.blockCommentDepth++; + index += 2; + } else if (line.startsWith('*/', index)) { + state.blockCommentDepth--; + index += 2; + } else { + index++; + } + continue; + } + + if (state.multilineStringPounds !== null) { + const closeLength = matchingMultilineCloseLength(line, index, state.multilineStringPounds); + if (closeLength > 0) { + state.multilineStringPounds = null; + index += closeLength; + continue; + } + // Non-raw multiline strings honour backslash escapes, so `\"""` is string + // data and not a terminator. Raw strings escape with `\#`, so a bare + // backslash there is literal. + index += state.multilineStringPounds === 0 && line[index] === '\\' ? 2 : 1; + continue; + } + + if (line.startsWith('//', index)) return; + if (line.startsWith('/*', index)) { + state.blockCommentDepth = 1; + index += 2; + continue; + } + + if (line[index] === '#') { + // Count the pound run exactly once and always advance past it, so a long + // run of bare `#` stays linear instead of being re-walked per character. + let poundCount = 1; + while (line[index + poundCount] === '#') poundCount++; + const afterPounds = index + poundCount; + + if (hasTripleQuoteAt(line, afterPounds)) { + state.multilineStringPounds = poundCount; + index = afterPounds + 3; + } else if (line[afterPounds] === '"') { + index = skipRegularString(line, index, poundCount); + } else if (line[afterPounds] === '/') { + index = skipExtendedRegexLiteral(line, afterPounds, poundCount); + } else { + index = afterPounds; + } + continue; + } + + if (hasTripleQuoteAt(line, index)) { + state.multilineStringPounds = 0; + index += 3; + continue; + } + if (line[index] === '"') { + index = skipRegularString(line, index, 0); + continue; + } + + if (line[index] === '{') state.braceDepth++; + else if (line[index] === '}') state.braceDepth--; + index++; + } +} + +/** + * Blank nested Swift conditional-compilation directives before parsing. + * + * tree-sitter-swift 0.7.1 does not admit `directive` as a `class_body` child + * (`vendor/tree-sitter-swift/src/node-types.json`), so error recovery can + * discard the enclosing declaration. Replacing only the directive text with + * spaces preserves `.length`, line endings, and every declaration offset. + * + * ponytail: delete this file (and the `preprocessSource` wiring in `swift.ts`) + * once a vendored tree-sitter-swift release includes upstream PR #583 ("Allow + * #if/#elseif/#else/#endif directives inside type bodies"); see also upstream + * issue #298 and PR #599. `.github/vendored-grammars.json` has no `"hold"` on + * swift, so the bump bot will land that release on its own. + * + * A directive group is blanked only when **all** of these hold: + * + * - it opens at `braceDepth > 0` — nesting, not indentation, is what the + * grammar rejects; top-level directives are valid source-file members and + * are left intact + * - it is not inside a multiline string literal or a block comment — blanking + * a line that carries a block-comment terminator would un-terminate the + * comment and swallow the rest of the file, and blanking inside a literal + * would rewrite program data + * - every branch is brace-balanced. A group that splits a declaration header + * (`#if` … `func f() async {` … `#else` … `func f() {` … `#endif`) leaves + * one unmatched `{` once both branches survive, which re-parents every + * later top-level declaration. Such a group degrades to the pre-fix + * behavior instead. + * + * Known residuals, all of which degrade to "directive left in place" rather + * than to corrupted output: string interpolation is not parsed with full Swift + * expression fidelity, so a nested multiline string inside an interpolation can + * confuse the scanner; a bare `/…/` regex literal containing `/*` opens a + * phantom block comment; and a multiline extended regex literal is treated as + * ending at its first line. + * + * Byte length is *not* preserved when a blanked directive line carries + * non-ASCII trailing text (` #if os(iOS) // 日本語 🔥` is 23 UTF-16 code units + * either way, but shrinks from 31 UTF-8 bytes to 23). C++ sidesteps this + * because UE macro tokens are ASCII-only; a Swift directive line can carry any + * trailing comment. Per the `LanguageProvider.preprocessSource` contract this is + * safe only while no consumer slices the original UTF-8 bytes by `startIndex`: + * node-tree-sitter reports UTF-16 code-unit indices, and every in-process + * consumer slices the JS string, whose `.length` *is* preserved. + * + * Must stay pure and idempotent — see `LanguageProvider.preprocessSource`. + */ +export function preprocessSwiftConditionalDirectives(sourceText: string): string { + if (!HAS_CONDITIONAL_DIRECTIVE_HINT.test(sourceText)) return sourceText; + + // Alternating [line, terminator, line, terminator, …, line]; joining it back + // reproduces the input byte for byte, including bare-`\r` endings. + const parts = sourceText.split(/(\r\n|\n|\r)/); + const state: SwiftPreprocessScanState = { + blockCommentDepth: 0, + multilineStringPounds: null, + braceDepth: 0, + }; + const openGroups: DirectiveGroup[] = []; + let blankedAny = false; + + for (let index = 0; index < parts.length; index += 2) { + const text = parts[index]!; + const insideLiteralOrComment = + state.multilineStringPounds !== null || state.blockCommentDepth > 0; + const match = insideLiteralOrComment ? null : SWIFT_CONDITIONAL_DIRECTIVE_RE.exec(text); + + const braceDepthAtStart = state.braceDepth; + scanSwiftLine(text, state); + const braceDelta = state.braceDepth - braceDepthAtStart; + + const openGroup = openGroups[openGroups.length - 1]; + if (match === null) { + if (openGroup !== undefined) openGroup.currentBranchDelta += braceDelta; + continue; + } + + // Directive lines are blanked as a unit, so their own braces (if any) never + // reach the parser and must not count toward a branch's balance. + if (match[1] === 'if') { + openGroups.push({ + braceDepthAtStart, + directiveLines: [index], + totalDelta: 0, + allBranchesBalanced: true, + currentBranchDelta: 0, + }); + continue; + } + // A `#else`/`#endif` with no open `#if` is malformed source — leave it be. + if (openGroup === undefined) continue; + + openGroup.directiveLines.push(index); + openGroup.totalDelta += openGroup.currentBranchDelta; + openGroup.allBranchesBalanced &&= openGroup.currentBranchDelta === 0; + openGroup.currentBranchDelta = 0; + if (match[1] !== 'endif') continue; + + openGroups.pop(); + const parentGroup = openGroups[openGroups.length - 1]; + // Every branch survives preprocessing, so the enclosing branch sees the sum + // — not one branch's delta. + if (parentGroup !== undefined) parentGroup.currentBranchDelta += openGroup.totalDelta; + + if (openGroup.braceDepthAtStart > 0 && openGroup.allBranchesBalanced) { + // Safe to mutate in place: every line of this group has already been + // scanned, and the decision never reopens. + for (const line of openGroup.directiveLines) parts[line] = ' '.repeat(parts[line]!.length); + blankedAny = true; + } + } + + return blankedAny ? parts.join('') : sourceText; +} diff --git a/gitnexus/src/core/ingestion/scope-extractor-bridge.ts b/gitnexus/src/core/ingestion/scope-extractor-bridge.ts index 626f83728..b87fa3b19 100644 --- a/gitnexus/src/core/ingestion/scope-extractor-bridge.ts +++ b/gitnexus/src/core/ingestion/scope-extractor-bridge.ts @@ -49,7 +49,16 @@ export function extractParsedFile( if (provider.emitScopeCaptures === undefined) return undefined; if (sourceText.trim().length === 0) return undefined; try { - const captures = provider.emitScopeCaptures(sourceText, filePath, cachedTree, { sourceKind }); + // A provider that rewrites source before parsing must see the same text + // here that the parse worker fed tree-sitter, or the two halves of the + // pipeline analyze different programs. Only the cache-miss path re-parses; + // with a cached tree the emitter ignores the text. The transform is + // length-preserving, so every offset still indexes the original. + const parseText = + cachedTree === undefined + ? (provider.preprocessSource?.(sourceText, filePath) ?? sourceText) + : sourceText; + const captures = provider.emitScopeCaptures(parseText, filePath, cachedTree, { sourceKind }); return extractScope(captures, filePath, provider); } catch (err) { const message = `scope extraction failed for ${filePath}: ${ diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 571ce7fa1..635285d44 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -149,7 +149,12 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // v37: Java/Kotlin capture side-channels include Spring AOP owner/advice facts // (#2416). Warm cache entries at v36 do not carry those facts and would silently // omit ADVISED_BY evidence. -const SCHEMA_BUMP = 37; +// v38: Swift nested conditional-compilation directives are blanked before the +// parse (#2771), so a class body that previously error-recovered away now +// survives. The chunk key hashes raw on-disk bytes and `preprocessSource` runs +// after it is computed, so unchanged Swift files would otherwise replay their +// pre-fix `ParseWorkerResult` verbatim — including across `--force`. +const SCHEMA_BUMP = 38; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/integration/doc-comment-description-e2e.test.ts b/gitnexus/test/integration/doc-comment-description-e2e.test.ts index 41911be67..9424ae32e 100644 --- a/gitnexus/test/integration/doc-comment-description-e2e.test.ts +++ b/gitnexus/test/integration/doc-comment-description-e2e.test.ts @@ -12,6 +12,8 @@ import os from 'node:os'; import path from 'node:path'; import { runPipelineFromRepo } from './resolvers/helpers.js'; import type { PipelineResult } from '../../src/types/pipeline.js'; +import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; function createTsRepo(): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'doc-desc-e2e-')); @@ -32,6 +34,27 @@ function createTsRepo(): string { return dir; } +function createSwiftRepo(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'doc-desc-swift-e2e-')); + fs.writeFileSync( + path.join(dir, 'Widget.swift'), + [ + 'class Widget {', + ' /**', + ' Renders the widget, marker SWIFTDOC.', + ' Example:', + ' #if os(iOS)', + ' useUIKit()', + ' #endif', + ' */', + ' func render() {}', + '}', + '', + ].join('\n'), + ); + return dir; +} + describe('doc-comment description end-to-end (issue #2270)', () => { it('surfaces an exported function JSDoc as its node description through the pipeline', async () => { const result: PipelineResult = await runPipelineFromRepo(createTsRepo(), () => {}, { @@ -48,4 +71,26 @@ describe('doc-comment description end-to-end (issue #2270)', () => { expect(result.usedWorkerPool).toBe(true); expect(descriptions.get('Function:computeBalance')).toContain('EXPORTEDDOC'); }); + + // Swift preprocessing blanks nested conditional directives before parsing; + // doing that inside a doc comment would delete lines from `description` (#2771). + it.skipIf(!isLanguageAvailable(SupportedLanguages.Swift))( + 'keeps directive lines inside a Swift doc comment in the node description', + async () => { + const result: PipelineResult = await runPipelineFromRepo(createSwiftRepo(), () => {}, { + skipGraphPhases: true, + workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, + workerPoolSize: 2, + }); + + const descriptions = new Map(); + result.graph.forEachNode((node) => { + descriptions.set(`${node.label}:${node.properties.name}`, node.properties.description); + }); + + expect(descriptions.get('Function:render')).toBe( + 'Renders the widget, marker SWIFTDOC. Example: #if os(iOS) useUIKit() #endif', + ); + }, + ); }); diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index bafde0f2f..02528c34d 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -95,6 +95,28 @@ export function getNodesByLabel(result: PipelineResult, label: string): string[] return names.sort(); } +/** + * Every node a single fixture file contributed, as sorted `Label:name` and + * `Label:qualifiedName` strings — for exact `toEqual` assertions on the whole + * file's output rather than spot checks. + */ +export function getNodesForFile( + result: PipelineResult, + filePathSuffix: string, +): { names: string[]; labelled: string[]; qualified: string[] } { + const names: string[] = []; + const labelled: string[] = []; + const qualified: string[] = []; + result.graph.forEachNode((n) => { + if (!String(n.properties.filePath ?? '').endsWith(filePathSuffix)) return; + const name = n.properties.name; + names.push(name); + labelled.push(`${n.label}:${name}`); + qualified.push(`${n.label}:${String(n.properties.qualifiedName ?? name)}`); + }); + return { names: names.sort(), labelled: labelled.sort(), qualified: qualified.sort() }; +} + export function edgeSet(edges: Array<{ source: string; target: string }>): string[] { return edges.map((e) => `${e.source} → ${e.target}`).sort(); } diff --git a/gitnexus/test/integration/swift-conditional-directive.test.ts b/gitnexus/test/integration/swift-conditional-directive.test.ts new file mode 100644 index 000000000..1ab4fd534 --- /dev/null +++ b/gitnexus/test/integration/swift-conditional-directive.test.ts @@ -0,0 +1,119 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; +import { getNodesForFile } from './resolvers/helpers.js'; +import { preprocessSwiftConditionalDirectives } from '../../src/core/ingestion/languages/swift/conditional-directive-preprocess.js'; + +const swiftFixture = `class Outer { + enum A { case x } + #if os(iOS) + enum B { case y } + #endif +} +`; + +const swiftMultilineStringFixture = `class StringHolder { + let payload = """ + #if string-data + #elseif more-string-data + #else + #endif + """ + #if REAL_DIRECTIVE + func afterString() {} + #endif +} +`; + +const swiftColumnZeroFixture = `class ColumnZero { + enum A { case x } +#if os(iOS) + enum B { case y } +#endif +} +`; + +const swiftHeaderSplitFixture = `class NetworkClient { + #if swift(>=5.5) + func fetch() async { + #else + func fetch() { + #endif + perform() + } +} + +struct SessionStore {} +`; + +const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift); +const scratchDirs: string[] = []; + +/** Analyze a one-file Swift repo through the real worker pool. */ +async function runFixture(prefix: string, source: string) { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + scratchDirs.push(repo); + fs.writeFileSync(path.join(repo, 'Fixture.swift'), source, 'utf8'); + + const result = await runPipelineFromRepo(repo, () => {}, { workerPoolSize: 1 }); + return getNodesForFile(result, 'Fixture.swift'); +} + +describe.skipIf(!swiftAvailable)('Swift conditional-directive pipeline regression', () => { + afterAll(() => { + for (const scratchDir of scratchDirs) fs.rmSync(scratchDir, { recursive: true, force: true }); + }); + + it('keeps Outer and both nested declarations in the real worker pipeline', async () => { + const { names } = await runFixture('gitnexus-swift-directive-', swiftFixture); + + expect(names).toEqual(['A', 'B', 'Fixture.swift', 'Outer', 'x', 'y']); + }, 60000); + + it('keeps a column-zero directive inside a class body from discarding the class', async () => { + const { names } = await runFixture('gitnexus-swift-column-zero-', swiftColumnZeroFixture); + + expect(names).toEqual(['A', 'B', 'ColumnZero', 'Fixture.swift', 'x', 'y']); + }, 60000); + + it('keeps later top-level types out of a class whose header is split across branches', async () => { + // Blanking an unbalanced group re-parents unrelated declarations, which + // shows up as a fabricated `NetworkClient.` qualified-name prefix. + expect(preprocessSwiftConditionalDirectives(swiftHeaderSplitFixture)).toBe( + swiftHeaderSplitFixture, + ); + + const { qualified } = await runFixture('gitnexus-swift-header-split-', swiftHeaderSplitFixture); + + expect(qualified).toEqual([ + 'Class:NetworkClient', + 'File:Fixture.swift', + 'Function:fetch', + 'Function:fetch', + 'Struct:SessionStore', + ]); + }, 60000); + + it('preserves a multiline string property while blanking a real directive between strings', async () => { + const rewritten = preprocessSwiftConditionalDirectives(swiftMultilineStringFixture); + const opening = swiftMultilineStringFixture.indexOf('"""') + 3; + const closing = swiftMultilineStringFixture.indexOf('"""', opening); + expect([opening, closing]).toEqual([40, 105]); + expect(rewritten.slice(opening, closing)).toBe( + swiftMultilineStringFixture.slice(opening, closing), + ); + + const { labelled } = await runFixture('gitnexus-swift-string-', swiftMultilineStringFixture); + + expect(labelled).toEqual([ + 'Class:StringHolder', + 'File:Fixture.swift', + 'Function:afterString', + 'Property:payload', + ]); + }, 60000); +}); diff --git a/gitnexus/test/integration/tree-sitter-languages.test.ts b/gitnexus/test/integration/tree-sitter-languages.test.ts index 54cb332ca..22194d6f4 100644 --- a/gitnexus/test/integration/tree-sitter-languages.test.ts +++ b/gitnexus/test/integration/tree-sitter-languages.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect, beforeAll } from 'vitest'; import fs from 'fs'; import path from 'path'; -import { loadParser, loadLanguage } from '../../src/core/tree-sitter/parser-loader.js'; +import { + loadParser, + loadLanguage, + isLanguageAvailable, +} from '../../src/core/tree-sitter/parser-loader.js'; import { SupportedLanguages, getLanguageFromFilename } from 'gitnexus-shared'; import { getProvider } from '../../src/core/ingestion/languages/index.js'; import Parser from 'tree-sitter'; @@ -493,6 +497,178 @@ describe('Tree-sitter multi-language parsing', () => { expect(defs.length).toBeGreaterThan(0); }); + // Grammar-load failures are expected on the platform-sensitive matrix, so + // skip rather than hard-fail — same guard the sibling tests apply inline. + describe.skipIf(!isLanguageAvailable(SupportedLanguages.Swift))( + 'conditional-compilation directives', + () => { + const provider = getProvider(SupportedLanguages.Swift); + const preprocess = (content: string): string => + provider.preprocessSource?.(content, 'Fixture.swift') ?? content; + + beforeAll(async () => {}); + + it('captures a class whose body contains indented conditional directives after preprocessing', () => { + const content = [ + 'class Outer {', + ' enum A { case x }', + ' #if os(iOS)', + ' enum B { case y }', + ' #endif', + '}', + ].join('\n'); + const { tree, matches } = parseAndQuery( + parser, + preprocess(content), + provider.treeSitterQueries, + ); + const defs = extractDefinitions(matches); + + expect(tree.rootNode.hasError).toBe(false); + expect(defs).toEqual([ + { type: 'definition.class', name: 'Outer' }, + { type: 'definition.enum', name: 'A' }, + { type: 'definition.property', name: 'x' }, + { type: 'definition.enum', name: 'B' }, + { type: 'definition.property', name: 'y' }, + ]); + }); + + it('captures a class whose body contains column-zero conditional directives', () => { + const content = [ + 'class Outer {', + ' enum A { case x }', + '#if os(iOS)', + ' enum B { case y }', + '#endif', + '}', + ].join('\n'); + const { tree, matches } = parseAndQuery( + parser, + preprocess(content), + provider.treeSitterQueries, + ); + const defs = extractDefinitions(matches); + + expect(tree.rootNode.hasError).toBe(false); + expect(defs).toEqual([ + { type: 'definition.class', name: 'Outer' }, + { type: 'definition.enum', name: 'A' }, + { type: 'definition.property', name: 'x' }, + { type: 'definition.enum', name: 'B' }, + { type: 'definition.property', name: 'y' }, + ]); + }); + + it('leaves top-level conditional directives intact while capturing their declarations', () => { + const content = [ + '#if os(iOS)', + 'struct PlatformValue {', + ' let value: Int = 1', + '}', + '#else', + 'struct PlatformValue {', + ' let value: Int = 2', + '}', + '#endif', + ].join('\n'); + const parseContent = preprocess(content); + const { tree, matches } = parseAndQuery(parser, parseContent, provider.treeSitterQueries); + const defs = extractDefinitions(matches); + + expect(tree.rootNode.hasError).toBe(false); + expect(parseContent).toBe(content); + expect(defs).toEqual([ + { type: 'definition.struct', name: 'PlatformValue' }, + { type: 'definition.property', name: 'value' }, + { type: 'definition.struct', name: 'PlatformValue' }, + { type: 'definition.property', name: 'value' }, + ]); + }); + + it('keeps source that comments out a conditional block parseable', () => { + const content = [ + 'class Foo {', + ' /* temporarily disabled:', + ' #if DEBUG', + ' func f() {}', + ' #endif */', + ' func g() {}', + '}', + ].join('\n'); + const parseContent = preprocess(content); + const { tree, matches } = parseAndQuery(parser, parseContent, provider.treeSitterQueries); + const defs = extractDefinitions(matches); + + // Erasing the comment terminator would swallow `g()` and the rest. + expect(parseContent).toBe(content); + expect(tree.rootNode.hasError).toBe(false); + expect(defs).toEqual([ + { type: 'definition.class', name: 'Foo' }, + { type: 'definition.function', name: 'g' }, + ]); + }); + + it('does not re-parent later declarations when branches split a declaration header', () => { + const content = [ + 'class NetworkClient {', + ' #if swift(>=5.5)', + ' func fetch() async {', + ' #else', + ' func fetch() {', + ' #endif', + ' perform()', + ' }', + '}', + 'struct SessionStore {}', + 'enum Unrelated { case a }', + ].join('\n'); + const parseContent = preprocess(content); + const { tree } = parseAndQuery(parser, parseContent, provider.treeSitterQueries); + const topLevelTypes = tree.rootNode.namedChildren.map((child) => child.type); + + // Blanking both markers would leave `NetworkClient` unterminated and + // collapse every later top-level declaration into it (5 nodes -> 1). + // `struct`/`enum` both surface as `class_declaration` in this grammar. + expect(parseContent).toBe(content); + expect(topLevelTypes).toEqual([ + 'class_declaration', + 'directive', + 'function_declaration', + 'class_declaration', + 'class_declaration', + ]); + }); + + it('keeps a declaration from every branch once the directives are blanked', () => { + const content = [ + 'class Themed {', + ' #if os(iOS)', + ' func accent(alpha: Int) {}', + ' #else', + ' func accent() {}', + ' #endif', + '}', + ].join('\n'); + const { tree, matches } = parseAndQuery( + parser, + preprocess(content), + provider.treeSitterQueries, + ); + const defs = extractDefinitions(matches); + + // Branch selection is not modelled: mutually exclusive declarations + // both reach the graph. Pinned so changing it shows up as a diff. + expect(tree.rootNode.hasError).toBe(false); + expect(defs).toEqual([ + { type: 'definition.class', name: 'Themed' }, + { type: 'definition.function', name: 'accent' }, + { type: 'definition.function', name: 'accent' }, + ]); + }); + }, + ); + it('gracefully handles missing tree-sitter-swift', async () => { // If Swift is NOT available, loadLanguage should throw // If it IS available, this test just passes diff --git a/gitnexus/test/unit/ast-utils.test.ts b/gitnexus/test/unit/ast-utils.test.ts index 481ebc740..47a4affe1 100644 --- a/gitnexus/test/unit/ast-utils.test.ts +++ b/gitnexus/test/unit/ast-utils.test.ts @@ -21,7 +21,10 @@ vi.mock('../../src/core/tree-sitter/safe-parse.js', async () => { return buildSafeParseMock(parseSourceSafeSpy); }); -vi.mock('gitnexus-shared', () => ({ +// Partial mock: `ast-utils` now resolves the LanguageProvider registry to apply +// `preprocessSource`, and that graph needs the real shared exports (#2771). +vi.mock('gitnexus-shared', async (importOriginal) => ({ + ...(await importOriginal()), getLanguageFromFilename, })); diff --git a/gitnexus/test/unit/chunker.test.ts b/gitnexus/test/unit/chunker.test.ts index 91bda726a..2191bc059 100644 --- a/gitnexus/test/unit/chunker.test.ts +++ b/gitnexus/test/unit/chunker.test.ts @@ -22,7 +22,10 @@ vi.mock('../../src/core/tree-sitter/parser-loader.js', () => ({ ), })); -vi.mock('gitnexus-shared', () => ({ +// Partial mock: `ast-utils` now resolves the LanguageProvider registry to apply +// `preprocessSource`, and that graph needs the real shared exports (#2771). +vi.mock('gitnexus-shared', async (importOriginal) => ({ + ...(await importOriginal()), getLanguageFromFilename, })); diff --git a/gitnexus/test/unit/embedding-chunking.test.ts b/gitnexus/test/unit/embedding-chunking.test.ts index cd7103319..4dc0410af 100644 --- a/gitnexus/test/unit/embedding-chunking.test.ts +++ b/gitnexus/test/unit/embedding-chunking.test.ts @@ -22,7 +22,10 @@ const { getLanguageFromFilename } = vi.hoisted(() => ({ getLanguageFromFilename: vi.fn().mockReturnValue('typescript'), })); -vi.mock('gitnexus-shared', () => ({ +// Partial mock: `ast-utils` now resolves the LanguageProvider registry to apply +// `preprocessSource`, and that graph needs the real shared exports (#2771). +vi.mock('gitnexus-shared', async (importOriginal) => ({ + ...(await importOriginal()), getLanguageFromFilename, })); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 6d70a26fb..26578c8a5 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -101,9 +101,9 @@ describe('fileContentHash', () => { }); describe('PARSE_CACHE_VERSION', () => { - // 36 -> 37 for Java/Kotlin Spring AOP capture side-channels (#2416). - it('pins SCHEMA_BUMP to 37 so concurrent bumps cannot silently collide (#2416)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(37); + // 37 -> 38 for the Swift conditional-directive parse-semantics change (#2771). + it('pins SCHEMA_BUMP to 38 so concurrent bumps cannot silently collide (#2771)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(38); }); it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => { diff --git a/gitnexus/test/unit/preprocess-source-parity.test.ts b/gitnexus/test/unit/preprocess-source-parity.test.ts new file mode 100644 index 000000000..6c3d04e6e --- /dev/null +++ b/gitnexus/test/unit/preprocess-source-parity.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { providers, getProvider } from '../../src/core/ingestion/languages/index.js'; +import { extractParsedFile } from '../../src/core/ingestion/scope-extractor-bridge.js'; +import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js'; +import { ensureAndParse } from '../../src/core/embeddings/ast-utils.js'; + +/** + * Every provider that defines `preprocessSource` must produce the same + * `ParsedFile` whether it is handed raw source or already-preprocessed source. + * + * The parse worker applies the hook, but `emitScopeCaptures` re-parses on a + * parse-cache miss and the embedding pipeline parses independently — so unless + * those paths see the same transform the halves of the pipeline analyze + * different programs and the graph depends on whether the run was warm (#2771). + * + * Fixtures are keyed by language and cross-checked against the registry, so a + * new provider adopting the hook fails here until it adds one. + */ +const FIXTURES: Partial> = { + [SupportedLanguages.Swift]: { + filePath: 'Fixture.swift', + source: [ + 'class Outer {', + ' enum A { case x }', + ' #if os(iOS)', + ' enum B { case y }', + ' #endif', + '}', + '', + ].join('\n'), + }, + [SupportedLanguages.CPlusPlus]: { + filePath: 'Actor.cpp', + source: [ + 'UCLASS()', + 'class MYGAME_API AGameActor : public AActor {', + ' GENERATED_BODY()', + 'public:', + ' UPROPERTY(EditAnywhere) int Health;', + ' UFUNCTION(BlueprintCallable) void Tick(float DeltaTime) { Health = 1; }', + '};', + '', + ].join('\n'), + }, + [SupportedLanguages.Dart]: { + filePath: 'meters.dart', + source: ['extension type Meters(int value) {', ' int get raw => value;', '}', ''].join('\n'), + }, +}; + +const languagesWithHook = Object.entries(providers) + .filter(([, provider]) => provider.preprocessSource !== undefined) + .map(([language]) => language) + .sort(); + +describe('LanguageProvider.preprocessSource parity', () => { + it('has a fixture for every provider defining the hook', () => { + expect(Object.keys(FIXTURES).sort()).toEqual(languagesWithHook); + }); + + describe.each(languagesWithHook)('%s', (language) => { + const provider = getProvider(language as SupportedLanguages); + const { filePath, source } = FIXTURES[language as SupportedLanguages]!; + + describe.skipIf(!isLanguageAvailable(language as SupportedLanguages))( + 'with the grammar', + () => { + it('extracts the same ParsedFile from raw and preprocessed source', () => { + const preprocessed = provider.preprocessSource!(source, filePath); + + expect(preprocessed).not.toBe(source); + expect(preprocessed).toHaveLength(source.length); + expect(extractParsedFile(provider, source, filePath, () => {})).toEqual( + extractParsedFile(provider, preprocessed, filePath, () => {}), + ); + }); + + it('parses the preprocessed text on the embedding path too', async () => { + const tree = await ensureAndParse(source, filePath); + + expect(tree.rootNode.hasError).toBe(false); + }); + }, + ); + }); +}); diff --git a/gitnexus/test/unit/swift-conditional-directive-preprocess.test.ts b/gitnexus/test/unit/swift-conditional-directive-preprocess.test.ts new file mode 100644 index 000000000..4e9bc4c8c --- /dev/null +++ b/gitnexus/test/unit/swift-conditional-directive-preprocess.test.ts @@ -0,0 +1,334 @@ +import { describe, expect, it } from 'vitest'; +import { preprocessSwiftConditionalDirectives } from '../../src/core/ingestion/languages/swift/conditional-directive-preprocess.js'; + +/** + * Assert the WHOLE output: `source` with exactly `blankedLines` replaced by + * spaces of the same width. Widths come from the source line, so a wrong-length + * blank fails, and an unexpected blank anywhere else fails too. + */ +function expectBlanked(source: string, blankedLines: readonly number[], separator = '\n'): void { + const lines = source.split(separator); + + expect(preprocessSwiftConditionalDirectives(source).split(separator)).toEqual( + lines.map((line, index) => (blankedLines.includes(index) ? ' '.repeat(line.length) : line)), + ); +} + +describe('Swift conditional-directive preprocessing', () => { + it('blanks every directive of a nested group and leaves the top-level one alone', () => { + const source = [ + '#if os(macOS)', + 'class TopLevel {}', + '#endif', + 'class Outer {', + ' #if os(iOS) // platform branch', + ' enum A { case x }', + '\t#elseif DEBUG && canImport(UIKit) // fallback', + ' enum B { case y }', + ' #else', + ' enum C { case z }', + ' #endif // end branch', + '}', + ].join('\n'); + + expectBlanked(source, [4, 6, 8, 10]); + }); + + it('preserves JavaScript string length and newline count', () => { + const source = '#if DEBUG\nclass Outer {\n #else\n}\n#endif\n'; + const rewritten = preprocessSwiftConditionalDirectives(source); + + expect(rewritten).toHaveLength(source.length); + expect(rewritten.match(/\n/g)?.length ?? 0).toBe(source.match(/\n/g)?.length ?? 0); + expectBlanked(source, []); + }); + + it('returns directive-free Swift source unchanged', () => { + const source = 'class Plain {\n var value: Int = 0\n func read() -> Int { value }\n}\n'; + + expect(preprocessSwiftConditionalDirectives(source)).toBe(source); + }); + + it('preserves CRLF line endings and offsets', () => { + const source = 'class Outer {\r\n\t#if os(iOS)\r\n\tenum A { case x }\r\n\t#endif\r\n}\r\n'; + const rewritten = preprocessSwiftConditionalDirectives(source); + + expect(rewritten).toHaveLength(source.length); + expect(rewritten.indexOf('enum A')).toBe(source.indexOf('enum A')); + expectBlanked(source, [1, 3], '\r\n'); + }); + + it('treats a bare carriage return as a line terminator', () => { + const source = 'class Outer {\r #if os(iOS)\r enum A { case x }\r #endif\r}\r'; + + expect(preprocessSwiftConditionalDirectives(source)).toHaveLength(source.length); + expectBlanked(source, [1, 3], '\r'); + }); + + it('leaves regular and raw multiline string interiors byte-identical', () => { + const source = [ + 'struct Strings {', + ' let regular = """', + ' #if os(iOS)', + ' #elseif DEBUG', + ' #else', + ' #endif', + ' """', + ' #if REAL_DIRECTIVE', + ' let between = true', + ' #endif', + ' let raw = #"""', + ' #if raw(iOS)', + ' #elseif raw(DEBUG)', + ' #else', + ' #endif', + ' """#', + ' let doubleRaw = ##"""', + ' #if double-raw-string-data', + ' #endif', + ' """##', + '}', + ].join('\n'); + + expectBlanked(source, [7, 9]); + }); + + it('does not let an unterminated multiline string blank later lines', () => { + const source = ['let text = """', ' #if this-is-string-data', ' still string data'].join( + '\n', + ); + + expect(preprocessSwiftConditionalDirectives(source)).toBe(source); + }); + + it('leaves non-conditional hash directives untouched', () => { + const source = [ + 'class Directives {', + ' #warning("warning")', + ' #error("error")', + ' #available(iOS 17, *)', + ' #selector(getter: Directives.value)', + ' #if DEBUG', + ' #endif', + '}', + ].join('\n'); + + expectBlanked(source, [5, 6]); + }); + + it('keeps nested block comments out of string state and never blanks inside them', () => { + const source = [ + '/*', + ' #if in-comment', + ' /* nested comment */', + ' #endif', + '*/', + 'let text = """', + ' #if in-string', + '"""', + ].join('\n'); + + expectBlanked(source, []); + }); + + it('keeps a block-comment terminator that shares its line with a directive', () => { + const source = [ + 'class Foo {', + ' /* temporarily disabled:', + ' #if DEBUG', + ' func f() {}', + ' #endif */', + ' func g() {}', + '}', + ].join('\n'); + + // Blanking ` #endif */` would un-terminate the comment and swallow `g()`. + expect(preprocessSwiftConditionalDirectives(source)).toBe(source); + }); + + it('blanks a column-zero directive nested inside a class body', () => { + const source = [ + 'class Outer {', + ' enum A { case x }', + '#if os(iOS)', + ' enum B { case y }', + '#endif', + '}', + ].join('\n'); + + expectBlanked(source, [2, 4]); + }); + + it('leaves an indented directive that is still at file scope intact', () => { + const source = [' #if DEBUG', ' struct Debugged {}', ' #endif', ''].join('\n'); + + expect(preprocessSwiftConditionalDirectives(source)).toBe(source); + }); + + it('recognizes non-ASCII indentation and a leading byte-order mark', () => { + const nbspSource = [ + 'class Outer {', + ' #if os(iOS)', + ' enum A { case x }', + ' #endif', + '}', + ].join('\n'); + const bomSource = `class Outer {\n #if os(iOS)\n enum A { case x }\n #endif\n}\n`; + + expectBlanked(nbspSource, [1, 3]); + expectBlanked(bomSource, [1, 3]); + }); + + it('refuses to blank a group whose branches split a declaration header', () => { + const source = [ + 'class NetworkClient {', + ' #if swift(>=5.5)', + ' func fetch() async {', + ' #else', + ' func fetch() {', + ' #endif', + ' perform()', + ' }', + '}', + 'struct SessionStore {}', + ].join('\n'); + + // Both branch bodies open a brace and only one closes; blanking would leave + // `NetworkClient` unterminated and re-parent `SessionStore` under it. + expect(preprocessSwiftConditionalDirectives(source)).toBe(source); + }); + + it('blanks nested balanced groups at every level', () => { + const source = [ + 'class Outer {', + ' #if os(iOS)', + ' func inner() {', + ' #if DEBUG', + ' log()', + ' #endif', + ' }', + ' #endif', + '}', + ].join('\n'); + + expectBlanked(source, [1, 3, 5, 7]); + }); + + it('lets an unbalanced nested group also block its enclosing group', () => { + const source = [ + 'class Outer {', + ' #if os(iOS)', + ' func inner() {', + ' #if DEBUG', + ' if x {', + ' #else', + ' if y {', + ' #endif', + ' log()', + ' }', + ' }', + ' #endif', + '}', + ].join('\n'); + + // Both `if` branches survive blanking, so the enclosing branch is +1 too. + // Conservative propagation degrades the whole nest to pre-fix behavior. + expect(preprocessSwiftConditionalDirectives(source)).toBe(source); + }); + + it('does not wedge on an escaped triple quote inside a multiline string', () => { + const source = [ + 'class Outer {', + ' let text = """', + ' escaped \\""" still string data', + ' #if in-string', + ' """', + ' #if REAL_DIRECTIVE', + ' func after() {}', + ' #endif', + '}', + ].join('\n'); + + expectBlanked(source, [5, 7]); + }); + + it('closes a plain multiline string whose terminator is followed by a pound', () => { + const source = [ + 'class Outer {', + ' let text = """', + ' body', + ' """#hashAfterClose', + ' #if REAL_DIRECTIVE', + ' func after() {}', + ' #endif', + '}', + ].join('\n'); + + expectBlanked(source, [4, 6]); + }); + + it('closes a raw multiline string terminated by extra pounds', () => { + const source = [ + 'class Outer {', + ' let raw = #"""', + ' body', + ' """##', + ' #if REAL_DIRECTIVE', + ' func after() {}', + ' #endif', + '}', + ].join('\n'); + + expectBlanked(source, [4, 6]); + }); + + it('does not let an extended regex literal open a phantom block comment', () => { + const source = [ + 'class Outer {', + ' let pattern = #/a/*b/#', + ' #if REAL_DIRECTIVE', + ' func after() {}', + ' #endif', + '}', + ].join('\n'); + + expectBlanked(source, [2, 4]); + }); + + it('stays linear on a long run of bare pound signs', () => { + // The pre-fix scanner re-walked the whole run at every index: ~10.6s for + // n=64000. The bound is a per-test timeout rather than a measured + // elapsed-time assertion; the fixed scanner runs this in ~1ms. + const source = `class Outer {\n let s = ${'#'.repeat(64000)}\n #if REAL_DIRECTIVE\n func after() {}\n #endif\n}\n`; + + expectBlanked(source, [2, 4]); + }, 2000); + + it('preserves JavaScript length on a directive carrying non-ASCII comment text', () => { + const source = ['class Outer {', ' #if os(iOS) // 日本語 🔥', ' #endif', '}'].join('\n'); + const rewritten = preprocessSwiftConditionalDirectives(source); + + // UTF-16 length is preserved; UTF-8 byte length is not (56 -> 48). + // Documented as safe because no consumer slices the original bytes by + // `startIndex` — node-tree-sitter reports UTF-16 code-unit indices. + expect(rewritten).toHaveLength(source.length); + expect(Buffer.byteLength(source, 'utf8')).toBe(56); + expect(Buffer.byteLength(rewritten, 'utf8')).toBe(48); + expectBlanked(source, [1, 2]); + }); + + it('is idempotent', () => { + const source = ['class Outer {', ' #if os(iOS)', ' enum A { case x }', ' #endif', '}'].join( + '\n', + ); + const once = preprocessSwiftConditionalDirectives(source); + + expect(preprocessSwiftConditionalDirectives(once)).toBe(once); + }); + + it('leaves an unmatched directive untouched', () => { + const source = ['class Outer {', ' #endif', ' #if NEVER_CLOSED', '}'].join('\n'); + + expect(preprocessSwiftConditionalDirectives(source)).toBe(source); + }); +}); From 911151e2304f298a995fcc69c738ad2c6db9393a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 1 Aug 2026 22:42:18 +0100 Subject: [PATCH 06/13] fix(resolution): resolve Go pointer-receiver calls, and report the program boundary instead of hedging (#2766) (#2782) --- .github/workflows/ci-tests.yml | 16 +- ARCHITECTURE.md | 11 +- .../src/scope-resolution/reference-site.ts | 38 +- gitnexus-shared/src/scope-resolution/types.ts | 48 +- .../python-scope/baseline-fingerprint.txt | 2 +- .../bench/receiver-resolution/BASELINE.md | 594 ++++++++- .../bench/receiver-resolution/baseline.json | 208 ++- .../bench/receiver-resolution/measure.mjs | 1166 ++++++++++++++++- gitnexus/bench/scope-capture/baselines.json | 65 +- .../core/ingestion/languages/cpp/interpret.ts | 77 +- .../ingestion/languages/cpp/scope-resolver.ts | 30 + .../languages/csharp/accessor-unwrap.ts | 33 +- .../languages/csharp/scope-resolver.ts | 4 +- .../core/ingestion/languages/go/captures.ts | 72 + .../core/ingestion/languages/go/interpret.ts | 20 +- .../ingestion/languages/go/scope-resolver.ts | 40 + gitnexus/src/core/ingestion/languages/java.ts | 71 + .../languages/kotlin/scope-resolver.ts | 11 + .../languages/python/scope-resolver.ts | 12 + .../languages/rust/scope-resolver.ts | 11 + .../languages/typescript/scope-resolver.ts | 15 +- .../src/core/ingestion/scope-extractor.ts | 57 +- .../contract/scope-resolver.ts | 105 +- .../scope-resolution/graph-bridge/edges.ts | 54 +- .../passes/compound-receiver.ts | 229 +++- .../passes/receiver-bound-calls.ts | 194 ++- .../scope-resolution/pipeline/run.ts | 4 + .../scope-resolution/resolution-outcome.ts | 115 ++ .../scope-resolution/scope/walkers.ts | 129 +- .../scope-resolution/unresolved-receivers.ts | 110 +- .../core/ingestion/type-extractors/shared.ts | 22 + .../src/core/ingestion/utils/call-analysis.ts | 142 +- .../ingestion/utils/callable-flow-captures.ts | 5 +- .../ingestion/utils/receiver-chain-codec.ts | 65 +- .../core/ingestion/workers/parse-worker.ts | 5 +- gitnexus/src/mcp/local/local-backend.ts | 144 +- gitnexus/src/mcp/tools.ts | 18 + gitnexus/src/storage/parse-cache.ts | 22 +- gitnexus/src/storage/repo-manager.ts | 26 +- .../expected-captures.json | 16 +- .../go-captures-golden/expected-captures.json | 70 +- .../go-pointer-receiver-field-chain/go.mod | 3 + .../handlers/handler.go | 111 ++ .../repository/repo.go | 17 + .../expected-captures.json | 8 +- .../expected-captures.json | 20 +- .../expected-captures.json | 12 +- .../expected-captures.json | 10 +- .../expected-captures.json | 2 +- gitnexus/test/helpers/scope-model.ts | 76 ++ .../impact-epistemic-lower-bound.test.ts | 19 + .../test/integration/resolvers/go.test.ts | 139 ++ .../test/integration/resolvers/helpers.ts | 18 + .../test/integration/resolvers/java.test.ts | 73 +- .../test/integration/resolvers/python.test.ts | 9 +- .../typescript-esm-js-extension.test.ts | 15 +- .../resolvers/typescript-value-refs.test.ts | 9 +- .../integration/resolvers/typescript.test.ts | 44 +- .../unit/call-summary-schema-version.test.ts | 15 +- .../test/unit/incremental-parse-cache.test.ts | 19 +- gitnexus/test/unit/noise-filter.test.ts | 59 +- gitnexus/test/unit/parsedfile-store.test.ts | 19 +- .../test/unit/receiver-chain-codec.test.ts | 86 +- .../scope-resolution/cpp/cpp-captures.test.ts | 54 + .../go/go-captures-smoke.test.ts | 35 + .../receiver-chain-fold.test.ts | 291 +++- .../scope-resolution/scope-extractor.test.ts | 40 + .../unresolved-receivers.test.ts | 364 ++++- .../unit/ts-receiver-chain-capture.test.ts | 25 +- 69 files changed, 5182 insertions(+), 486 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/go-pointer-receiver-field-chain/go.mod create mode 100644 gitnexus/test/fixtures/lang-resolution/go-pointer-receiver-field-chain/handlers/handler.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-pointer-receiver-field-chain/repository/repo.go create mode 100644 gitnexus/test/helpers/scope-model.ts diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 18ac7fd89..b4fbb3d56 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -509,9 +509,19 @@ jobs: # recorder gates on the receiver's punctuation, not on what the # reference is, so property reads would inflate it by ~20%. The shape # arm asserts the state of each receiver spelling by EDGE PRESENCE, - # which is the only arm that can see the shapes the recorder is blind - # to (`?.`, explicit type args, `repos[0]`): those emit no edge AND no - # drop, so fixing them moves the count by zero. + # which is the only arm that can see shapes the recorder is blind to: + # they emit no edge AND no drop, so fixing them moves the count by zero. + # + # `repos[0]` is no longer among them (#2766): Case 0's gate now accepts + # a minted receiver chain instead of testing the receiver's punctuation, + # so subscript receivers record a drop and ARE countable. 13 shapes moved + # INVISIBLE -> VISIBLE that way. `?.` and explicit type args remain + # invisible on some languages, so the shape arm still earns its keep. + # + # The check is EXACT-MATCH, which is strictly stronger than a ratchet: + # the count cannot rise without a deliberate rebaseline, and the + # rebaseline path demands the movement be explained. No separate + # drop-ratchet gate is needed on top of this. run: node --import tsx bench/receiver-resolution/measure.mjs --check working-directory: gitnexus diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c8063f848..42160627e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -234,6 +234,14 @@ Property-key dispatch remains a separate conservative fallback. Its per-key fan- Standalone (regex-based) providers such as COBOL participate via `ScopeResolver.scopeResolutionEdgeMode: 'callable-flow-only'`: `runScopeResolution` runs for them, but every ordinary emission path — heritage, interface implementations, receiver-bound, free-call fallback, reference/import edges, post-resolution hooks — is gated off, so their legacy phase (e.g. `cobolPhase`) remains the sole owner of structural edges and the callable solver's `CALLS` are purely additive. A callable-flow-only provider whose files emitted no callable facts exits early, before finalize, keeping the opt-in proportional to source scanning. +### Receiver chains and the drop census (#2766) + +A compound receiver (`svc.getUser().address.save()`) is captured as a compact string on `ReferenceSite.receiverChain`. `utils/receiver-chain-codec.ts` is the ONE encoder/decoder — capture emitters, the scope-resolution fold, and the durable ParsedFile store all import it rather than hand-rolling the format. + +Wire format is **v2**: `2|||…`, one-character version prefix, then base-first steps, each a one-character kind sigil plus the member name (`c` = call, `f` = field). `a` (await) and `i` (index) are **name-free** and encode as a bare sigil — an awaited call's name already lives on its `c` step, and a subscript key is a value, not a lookup-able identifier. The version went 1 → 2 when those two kinds were added, and a decoder REFUSES a foreign version rather than decoding the prefix it understands: a chain missing its await/index hop decodes cleanly as a different, shorter chain and would type the receiver against the wrong member. The format is unescaped (`|` and `~` cannot occur in an identifier), so an unencodable name is refused rather than escaped, and the payload is capped at `MAX_RECEIVER_CHAIN_BYTES` / `MAX_CHAIN_DEPTH` steps. Because these strings live in the incremental parse cache and the durable ParsedFile store, a format change requires a `PARSE_CACHE_VERSION` schema bump — a stale cache would otherwise replay v1 chains this build discards. + +Receivers the resolver could not type are not silently dropped. Each records a `ResolutionOutcome` (`scope-resolution/resolution-outcome.ts`) carrying the receiver's *shape* (`classifyReceiverShape`: `chain-call` / `chain-field` / `chain-mixed` / `chain-unwrap` / `no-chain` — the bench censuses these) and its *origin* (`in-program` / `external` / `unknown`). `scope-resolution/unresolved-receivers.ts` aggregates them per member name into the index-persisted `unresolvedReceiverMembers` summary, keeping in-program and external counts under separate keys. Only in-program drops make a count short: an external-rooted call (`System.out.println`, `fetch(...)`) has no in-graph node an edge could have reached, so it is reported but does not hedge. `impact` / `context` read that summary and publish `epistemic: 'exact' | 'lower-bound'`, prose `boundaries`, and the machine-readable `causes` split (`EpistemicCauses` in `mcp/local/local-backend.ts`). + ### Optional CFG/PDG emission (`--pdg`, #2081–#2086) On a `--pdg` run the parse worker builds a per-function control-flow graph from the tree-sitter AST (`LanguageProvider.cfgVisitor`; TypeScript/JavaScript today) and serializes it onto `ParsedFile.cfgSideChannel` as plain data. Scope-resolution then emits the program-dependence layers from that side-channel **inside Phase 4 of `runScopeResolution`, while the disk-backed ParsedFile store is still live** — the only window where the worker-built CFGs are loaded (the store is cleared right after the phase returns). A standalone post-`mro` phase would read an empty store, so the emit deliberately lives in-phase, mirroring the `applyCaptureSideChannel` pattern. The opt-in is off by default (graph byte-identical), folded into the parse-cache key (a pdg-off warm cache is never reused on a `--pdg` run), and each layer is bounded by a per-function edge cap that logs any dropped edges. All layers are `BasicBlock → BasicBlock` edges in the single `CodeRelation` table, keyed by `type`; there is **no** `Function → BasicBlock` edge — the symbol↔block join is reconstructed from the BasicBlock id prefix + line span. The layers build on each other: @@ -263,7 +271,8 @@ Single interface a language implements to plug into the pipeline. Contract fully | `importEdgeReason` | Confidence-tier string for IMPORTS edge reason field | | `propagatesReturnTypesAcrossImports?` | Opt out of cross-file return-type propagation (default on) | | `fieldFallbackOnMethodLookup?` | Statically-typed languages turn this OFF — the heuristic over-connects (default on) | -| `unwrapCollectionAccessor?` | Property-style collection views (`data.Values` on Dictionary-like receivers) — default off | +| `elementTypeOf?` | `(containerType, via: {kind:'index'} \| {kind:'accessor',name}) → elementType \| undefined` — element type of a container, reached by subscript (`repos[0]`) or by a property-style collection view (`data.Values`). ONE hook for both routes (it replaced the split `unwrapCollectionAccessor` / `unwrapCollectionElement`, where implementing one silently answered nothing for the other). Consulted only where the source actually performed the access — never as a general type-name normalizer | +| `stripTypePreservingDecoration?` | `(typeName) → strippedName \| undefined` — strip ONE layer of TYPE-PRESERVING decoration (pointer, reference, `const`, nullable, borrow, sigil) so a receiver declared `*Host` still finds the `Host` binding (#2766). Never a container: unwrapping `Repo[]` here would fold `repos.find(x)` to `Repo.find` — that is `elementTypeOf`'s job, and only after a real subscript. Consulted only after every undecorated lookup fails, and only by receiver-chain base/step resolution — default off | | `collapseMemberCallsByCallerTarget?` | One CALLS edge per (caller, target) instead of per-site — default off | | `populateNamespaceSiblings?` | Cross-file implicit visibility (compiler-implicit namespace sharing) — default off; ctx carries `treeCache` | | `hoistTypeBindingsToModule?` | Walk up to Module scope when looking up a method's return-type typeBinding — default off; enable only when bindings are stored at module level | diff --git a/gitnexus-shared/src/scope-resolution/reference-site.ts b/gitnexus-shared/src/scope-resolution/reference-site.ts index e40e0a511..e8a58e1b2 100644 --- a/gitnexus-shared/src/scope-resolution/reference-site.ts +++ b/gitnexus-shared/src/scope-resolution/reference-site.ts @@ -138,6 +138,30 @@ export interface ReferenceSite { * majority of sites — the field costs nothing where it is not needed. */ readonly receiverChain?: string; + /** + * This site sits in CALLEE position: it is the expression being invoked by an + * enclosing call, not a value the program otherwise consumes. Only ever set on + * `kind: 'read'` sites, and only by languages whose member-read capture also + * matches the callee of a member call (`obj.f()` yields both a `call` site on + * `f` and a `read` site on `obj.f`). + * + * It is a POSITION FACT, not a decision. Whether that read is redundant + * depends on what the tail resolves to, which the capture layer cannot know: + * + * - tail is a METHOD → the read duplicates the call's own edge and must be + * suppressed (an `ACCESSES → m` beside a `CALLS → m` + * at the same position is a phantom). + * - tail is a FIELD → the read is GENUINE. `h.dep.Work()` where + * `Work func() error` selects a func-typed field and + * then calls the value it holds; deleting the read + * erases the only evidence that the field was used + * (callback/hook structs, hand-rolled mocks). + * + * The suppression is therefore applied at edge emission, where the resolved + * target's kind is known — see `tryEmitEdge`. Absent on every site that is not + * in callee position, so nothing changes for languages that never set it. + */ + readonly inCalleePosition?: boolean; } /** @@ -153,4 +177,16 @@ export interface ReferenceSite { * (`extractMixedChain`) walks a tree-sitter AST and so must stay in the * analyzer, but the shape it yields crosses into resolution. */ -export type MixedChainStep = { kind: 'field' | 'call'; name: string }; +/** + * One hop in a receiver chain. + * + * `field` and `call` carry the member name they reach. `await` and `index` are + * NAME-FREE: the call step already holds the method name for an awaited call, + * and a subscript has no member name at all — an index expression's key is a + * value, not an identifier the resolver could look up. The codec encodes them + * as a bare sigil and rejects any trailing characters, so the encoder's + * non-empty-name guard stays live for exactly the two kinds it was written for. + */ +export type MixedChainStep = + | { kind: 'field' | 'call'; name: string } + | { kind: 'await' | 'index'; name?: undefined }; diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index dbefe60d7..dcd074400 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -264,8 +264,24 @@ export type ParsedImport = export interface ParsedTypeBinding { /** The name being bound (parameter name, `self`, assignment LHS, …). */ readonly boundName: string; - /** The raw type name as written in source (`'User'`, `'models.User'`, …). */ + /** The type name AFTER this provider's normalization (`'User'`, + * `'models.User'`, …) — see `TypeRef.rawName`. */ readonly rawTypeName: string; + /** + * Optional override for `TypeRef.declaredSpelling`, for a grammar that does + * not keep the whole written type under `@type-binding.type`. + * + * The scope extractor derives the spelling from that capture by default, + * which is right for every language whose type node spans the annotation. + * C++ is the exception: `User* repos` parses with the `*` on the DECLARATOR, + * so the type capture is a bare `User` and the container-ness the index step + * needs is nowhere in the captures the extractor reads. A provider that can + * reconstruct it exactly sets it here. + * + * Leave undefined otherwise — the extractor's derivation is preferred to a + * per-language reimplementation of it. + */ + readonly declaredSpelling?: string; readonly source: TypeRef['source']; } @@ -370,8 +386,36 @@ export interface BindingRef { * re-exports, and nested modules. Generics deferred to V2 via `typeArgs`. */ export interface TypeRef { - /** The name as written in source (e.g., `'User'`, `'models.User'`, `'List'`). */ + /** + * The type name AFTER the language's capture-time normalization — NOT + * necessarily what the source says. Every provider's `interpretTypeBinding` + * reduces the annotation before it gets here: TypeScript runs + * `stripGeneric` + `stripArraySuffix` to a FIXED POINT (`User[][]` → `User`), + * Go's `normalizeGoTypeName` drops `[]` and `map[K]`, C#/Python/Kotlin/Rust + * strip their single-arg collection wrappers. What survives is the name a + * class lookup can use (`'User'`, `'models.User'`, `'List'`). + * + * A consumer that needs the CONTAINER, not the element, must read + * `declaredSpelling` — see below. + */ readonly rawName: string; + /** + * The annotation exactly as written, kept ONLY when `rawName` is not it. + * + * `rawName` alone cannot distinguish `repos: User[]` (a container the capture + * layer already reduced, so the position IS the element) from `grid: Grid` + * (an ordinary class the source happened to subscript). Both arrive as a bare + * class name that resolves. An index step reading only `rawName` therefore had + * no choice but to guess, and guessing "already reduced" typed `grid[0]` as + * `Grid` — a confidently WRONG owner for the next member. + * + * Absent when the provider's normalization was a no-op (nothing was lost, so + * `rawName` is already the written spelling), and absent for TypeRefs + * synthesized outside the capture path (a `this` receiver binding, a + * propagated return type). Consumers must treat absence as "no container + * evidence" and decline, never as "not a container". + */ + readonly declaredSpelling?: string; /** Anchor for resolving `rawName` — the scope where the annotation/inference was written. */ readonly declaredAtScope: ScopeId; readonly source: diff --git a/gitnexus/bench/python-scope/baseline-fingerprint.txt b/gitnexus/bench/python-scope/baseline-fingerprint.txt index b27ff7a41..49a146016 100644 --- a/gitnexus/bench/python-scope/baseline-fingerprint.txt +++ b/gitnexus/bench/python-scope/baseline-fingerprint.txt @@ -1 +1 @@ -b57c5479f158c41a6328fa8d61c234aaec88fc41780252d2fbd7baca21491aa2 +a0da3e7c00f603e4bdad91a376b3fc181577a73c2ca1719ab7449d3463c671e0 diff --git a/gitnexus/bench/receiver-resolution/BASELINE.md b/gitnexus/bench/receiver-resolution/BASELINE.md index 49ac4979b..f612258bf 100644 --- a/gitnexus/bench/receiver-resolution/BASELINE.md +++ b/gitnexus/bench/receiver-resolution/BASELINE.md @@ -1,5 +1,430 @@ # Receiver-resolution baseline +> **`baseline.json` is the source of truth for every number.** It is what +> `measure.mjs --check` enforces byte-exactly. This file is a lab notebook: +> each section records what was measured AT THAT UNIT and why it changed the +> plan. A figure here that disagrees with `baseline.json` is a superseded +> snapshot, not a live claim — sections carry a snapshot marker where that has +> already happened. Never quote a count from this file into code, a gate, or a +> commit message; read it from `baseline.json`. + +## Receiver ORIGIN — three quarters of the hedge was the program boundary + +The drop count was measuring two different things and reporting both as +uncertainty. Dumping all 102 call drops with source context settles it: + +| Origin | Count | Is anything lost? | +| ------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `external` | **44** | **No.** `System.out.println`, `fetch(...)`, `os.environ.setdefault`, `document.body.appendChild`, `.stream()`. The callee is not in the graph — there is no node an edge could point at. | +| `in-program` | 36 | Yes in principle — but see below. | +| `unknown` | 22 | Yes. Casts, ternaries, `globalThis.x ??= []`, and everything the classifier will not guess about. | + +> **These numbers moved once, in review, and the movement is the point.** They +> were first measured as 76 / 20 / 6, when `external` was the FALLTHROUGH: any +> base whose type did not resolve was called external. Review reproduced two +> triggers where that published `epistemic: 'exact'` over a real in-program loss +> — a Go pointer receiver (`*Host`, whose lookup was missing the decoration +> stripper) and any base with no type binding at all, including this branch's own +> `droppedCall(svc)` fixture. `external` is now a POSITIVE determination via +> `LanguageProvider.isBuiltInName`, and everything unproven is `unknown`, which +> still hedges. So `external` fell 76 -> 44 and the difference went to +> `in-program` (+16, the drops that really were ours) and `unknown` (+16, the +> drops we decline to characterize). Total call drops is unchanged at 102 — +> this is re-bucketing, not resolution. +> +> A controlled A/B over the Java built-in set (off vs on, same tree) reads +> 7/36/59 vs 44/36/22: `in-program` is byte-identical across the toggle, so +> naming built-ins reclassified nothing the index can demonstrate is ours. + +**A compiler resolves `System.out.println` against the JDK.** Lacking the JDK, +the honest statement is _"this call leaves the analyzed program"_ — not _"this +analysis is incomplete"_. Those are different epistemic states, and collapsing +them is what made `impact` report a lower bound on essentially every real +codebase, which is what teaches readers to ignore the signal. + +`ResolutionOutcome.receiverOrigin` now records which one applies, and +`summarizeUnresolvedReceivers` skips `external`. `unknown` still counts — +assuming a completeness we cannot demonstrate is the unsafe direction. + +### How origin is decided + +By the receiver base's **declared type**, not its name. A first cut asked +whether the base was a local, which marked `inputs.stream()` in-program: +`inputs` is a local, but its type `List` is JDK, so the target is +external. Asking whether the base's _type_ is one this index contains moved 28 +sites to the correct bucket. + +### What the remaining in-program drops actually are + +Mostly **not** product defects. `user.Address.Save()` resolves cleanly in +isolation — the `csharp-deep-field-chain` fixture alone emits both expected +edges with **zero** drops. It drops in the count arm only because the corpus is +~200 independent mini-projects in one directory and **55 files define +`Address`**, so the resolver correctly declines on ambiguity rather than picking +one. That is right behaviour measured on an unrepresentative corpus. + +The genuinely untypeable population is the `unknown` bucket — and those are the real +targets for type resolution, because a cast _gives_ you the type +(`((Box) obj).open()`) and a ternary needs a join of its branch types. +They were previously invisible under the stdlib calls the old fallthrough swept +into `external`. + +`callDropsByOrigin` is now part of the gated projection, so this split cannot +drift silently. + +--- + +## Phantom callee read sites — a duplicate-edge bug the U8 test missed + +Go's `@reference.read` pattern matches **every** `selector_expression`, with no +call-position exclusion. So `h.dep.Work()` minted **three** reference sites: + +| site | kind | name | what it is | +| ---- | ------ | ------ | ------------------------------------------------------------- | +| S1 | `call` | `Work` | the member call | +| S2 | `read` | `Work` | **phantom** — the callee `h.dep.Work`, already captured by S1 | +| S3 | `read` | `dep` | the genuine field read | + +S2 resolved through `findOwnedMember`, which prefers methods over fields, and +emitted an `ACCESSES` edge to the **method** duplicating S1's `CALLS` edge at the +same position. + +**The U8 assertion passed by accident.** It asserted `RunSamePackage → Work` was +absent from `ACCESSES`, and it was — but only because that row has a _pointer_ +receiver whose text-cascade head lookup failed for an unrelated reason. The +value-receiver twin was emitting the bad edge the whole time: + +``` +ACCESSES RunFromValueReceiver -> DoWork:Method <- phantom, shipped +ACCESSES RunLocal -> DoWork:Method <- phantom, shipped +``` + +First fix, at capture: drop the match outright, on the rule _"a selector in +function position is never a read."_ **That rule is false, and review caught +it.** In Go a func-typed struct field IS read and then called indirectly — +`h.dep.Work()` where `Work func() error` — and `isCalleeOfMemberCall` cannot +tell a method from a func-valued field, because the AST shape is identical. +Dropping at capture therefore deleted the only `ACCESSES` evidence for callback +structs, hook structs and hand-rolled mocks (`mock.DoFunc`, `opts.OnEvent`). + +Second fix, and the one that shipped: **split the decision across the two layers +that each hold half of it.** Capture records the POSITION as a fact +(`@reference.callee-position` → `ReferenceSite.inCalleePosition`) — only the AST +knows it, and it is gone by resolution time. Emit makes the DECISION from the +resolved target's kind — only resolution knows whether the tail is a method or a +field, and it may be declared in another package. Neither layer can answer alone. +The suppression is language-neutral in `graph-bridge/edges.ts` and keys on the +canonical `CALL_TARGET_TYPES`, so `Macro` and `Delegate` targets are covered too. + +A method _value_ (`f := h.dep.Work`) is not in function position and is +untouched. The assertion is backed by an exact-set check over the whole fixture — +now carrying target KINDS, so it catches both a new phantom and a deleted +genuine read. + +### What the numbers say + +- `callDrops` **unchanged at 102** — no call was lost, in either fix. +- `read` drops went **27 → 22** under the capture-time drop, then **22 → 27** + again once the marker replaced it. The round trip is the finding: those five + sites are genuine field reads, and the first fix was scoring their deletion as + an improvement. +- `totalDropsAllKinds` **124 → 129**, the same five sites. +- One drop reclassified `chain-field` → `chain-unwrap`. The phantom and the real + call share a site key, so the phantom's field-shaped chain was previously the + one recorded. The census now describes the actual dropped call. + +Caught by three review agents dispatched at the A1 regression; the phantom was +the mechanism, not the global-normalization story the first revert note asserted. +The func-field regression it introduced was then caught by two more, on the +tri-review of #2782 — which is the argument for the exact-set-with-kinds +assertion over the targeted one that passed by accident the first time. + +--- + +## U9 (part 2) — no drop ratchet is needed; the gate is already stronger + +The plan's R10 set a ZERO supported-shape drop target, and review correctly +found that it contradicts R12: a site whose normalized name matches more than +one class MUST decline, a decline records a drop, and simple names collide +routinely in large Go and Java codebases. The proposed fix was a ratchet — the +count may not rise above the value measured after the last unit. + +Neither is needed. `measure.mjs --check` already asserts **exact match** against +the committed baseline, which is strictly stronger than a ratchet: the count +cannot rise _or_ fall without a deliberate `--update-baseline`, and that path +prints an instruction to explain the movement in the commit message. A ratchet +would be a weakening. + +So R10 as written (zero) was wrong, and the ratchet proposed to repair it is +redundant. The existing gate stands, now also covering `callDropsByShape` since +the shape census joined the gated projection. + +**Deferred and NOT done: the `impact` risk-cutoff recalibration.** Review flagged +that added edges push symbols toward the absolute cutoffs (`directCount >= 30`, +`impacted.length >= 200`), so edits read HIGHER risk without being more +dangerous, and agents warning on HIGH/CRITICAL escalate more often. That is real, +but measuring it honestly needs a before/after risk distribution over a corpus +large enough for those thresholds to bind — the committed fixtures are nowhere +near 200 impacted symbols. Recording it as owed rather than inventing a number +from fixtures that cannot exercise the cutoffs. + +--- + +## U6 — the depth cap does NOT limit resolution. Measured, not raised. + +The premise was that a chain deeper than `MAX_CHAIN_DEPTH` (3) is discarded +whole rather than truncated, so a 4-hop builder chain "contributes nothing at +all". The first half is true; the second is not. + +`fourHopChain` was added to the TypeScript corpus as a declared extra +specifically to make the question answerable — without a chain longer than the +cap, raising the cap measures nothing: + +```ts +root.getSvc().getUser().address.getCity().save(); +// ^step1 ^step2 ^step3 ^step4 receiver of `save` = 4 steps +``` + +| Cap | Chain minted? | Cell state | +| --- | ---------------------------------------------------- | ------------ | +| 3 | **none** (confirmed by probing the emitter directly) | **RESOLVES** | +| 4 | `2\|root\|cgetSvc\|cgetUser\|faddress\|cgetCity` | RESOLVES | + +The site resolves at BOTH depths. At 3 it resolves through the text cascade, +which owns the fallback path and runs to its own +`COMPOUND_RECEIVER_MAX_DEPTH` of 8. + +**So the cap bounds which chains are typed structurally, not which calls +resolve.** Raising it moves work from the cascade to the fold without changing a +single edge — measured across the whole matrix: totals identical at 3 and 4, +`callDrops` 102 at both. + +Left at 3. The fixture is committed so the next person to reach for this number +inherits the measurement instead of the intuition. + +What DID need fixing: `unwrapTransparentReceiver` shared `MAX_CHAIN_DEPTH` as +its iteration bound. The two answer unrelated questions — how many chain hops do +we type, versus how many redundant parens might someone write — so raising the +chain cap would have silently widened the paren peel as a side effect. That +coupling got worse when the await/subscript work added a peel call at loop +entry. Now `MAX_TRANSPARENT_WRAPPER_DEPTH`, its own constant. + +--- + +## U9 — the epistemic hedge has TWO producers, and only one is a defect + +`impact` reports `epistemic: 'lower-bound'` for two independent reasons that were +previously indistinguishable in the output: + +| Cause | Unit | What it means | Is it a defect? | +| ------------------ | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `receiverTyping` | call sites | Call sites dropped because the analyzer could not type the receiver | **Yes** — a resolver gap. This is the population this whole series targets. | +| `dispatchBoundary` | symbols | The symbol sits behind an interface with real consumers or 2+ implementations; the number is the implementations plus interface-level consumers behind it | **No** — callers binding through DI or dynamic dispatch are genuinely untraceable statically. A compiler refuses here too. | +| `externalBoundary` | call sites | The call left the indexed program (`System.out.println`, `fetch(...)`) | **No**, and not even a shortfall — there is no in-graph node an edge could have reached. An `epistemic: 'exact'` result can carry it. | + +Both collapsed into one enum plus prose, so a consumer — especially a coding +agent gating its own edits on the result — could tell THAT a count was short but +not WHY, and could not branch on the difference. Worse, it made "the hedge should +stop appearing" unfalsifiable: with no way to see which producer fired, there was +no way to check whether fixing receiver typing had done anything. + +`impact` and `context` now carry a structured `causes: { receiverTyping, +dispatchBoundary, externalBoundary }` alongside the prose. Every field counts +MISSING THINGS, never notes: there is one note per symbol name (and one per +boundary node) but each reports N of something, so counting notes published `1` +next to prose reading "2 call sites", and a consumer branching on the number +would have read a different magnitude than the human reading the text. The same +rule applies to `dispatchBoundary`, which counts the implementations plus +interface-level consumers behind the boundary rather than the boundary sentences +— one sentence can describe an interface with 40 implementations. Its unit is +SYMBOLS rather than call sites because per-site multiplicity is not retained on +those edges (consumers are counted `DISTINCT`, and +`collapseMemberCallsByCallerTarget` languages emit one CALLS edge per +caller/target pair); the units are stated per field on `EpistemicCauses` so a +consumer knows which it is holding. + +**Only the `receiverTyping` producer is addressed by this series.** The dispatch +boundary is untouched and will keep firing for interface-dispatched symbols — +which is correct. Any claim that the hedge has "stopped appearing" has to be read +per-producer, and that is now possible. + +Measured on the #2766 reproduction: `WithTx` went from `impactedCount: 0` with a +`lower-bound` hedge to `impactedCount: 1` with `epistemic: exact`. The hedge is +gone there because its cause is gone, not because it was suppressed. + +--- + +## U10 — recorded drops, censused by receiver shape + +`ResolutionOutcome`'s suppressed variant now carries `receiverShape`, set by the +emitting case from the site's ENCODED CHAIN — the compact string the capture +emitters mint by walking the real AST. Never re-derived from the source line: +doing that would mean regex-classifying the number that gates this work, the +same textual-shape dispatch the structural-receiver line exists to remove. +Diagnostic only, so the persisted `RepoMeta.unresolvedReceiverMembers` artifact +is unchanged. + +Census of the call drops on the committed fixture corpus, **as measured at U10** +— it predates the phantom-read fix documented above, which reclassified one drop +`chain-field` → `chain-unwrap`. `callDropsByShape` in `baseline.json` is current: + +| Shape | Count | Share | +| ------------------------------------------------------------- | ----- | ----- | +| `chain-field` — every step a field (`h.repo.save()`) | 60 | 59% | +| `chain-call` — every step a call (`svc.getUser().save()`) | 27 | 27% | +| `no-chain` — no chain minted; the walk found no nameable base | 12 | 12% | +| `chain-mixed` — interleaved (`svc.getUser().addr.save()`) | 2 | 2% | + +Two decisions come out of it. + +**The `.java` bucket is not one defect.** Its 49 call drops split 30 field-chain +/ 14 call-chain / 5 no-chain, so the open question of whether Java's largest- +single-bucket status hides a single cause is answered: it does not. It is the +same population as everywhere else, just more of it. + +**Field-receiver chains are where the remaining value is.** At 59% of the U10 +census they dominate, and they are precisely the shape U1 fixed for Go. The same +defect class in java, csharp, cpp, php, py and rust is the largest addressable +population the count arm can see. (This paragraph used to quote a per-extension × +per-shape split from the U10 run. `baseline.json` carries `callDropsByExtension` +and `callDropsByShape` but not their cross-product, so that split has to be +re-derived from a fresh run rather than read off the committed baseline.) + +**What this census CANNOT justify.** Await-wrapped and subscript receivers barely +appear, because the committed fixture corpus contains almost no such sites — not +because they are rare in real code. At U10 `indexElement` was a gap in every +language in the shape arm, so U5's population was real but structurally invisible +to the count arm. (It no longer is uniform — the subscript route resolves in +several languages now; read the current per-language state from `indexElement` in +`baseline.json`, not from this paragraph.) The durable point: any decision to fund +or drop U4 and U5 has to be read off the SHAPE arm, because reading it off this +census confuses "absent from these fixtures" with "does not happen". + +## U2 — shape matrix expanded to a canonical axis + +The shape arm was three languages with an ad-hoc shape list each. It is now a +**canonical 10-shape axis** (`SHAPE_IDS`) that every language must answer for, +with two states added so a hole cannot masquerade as a measurement: + +- `N/A` — the grammar does not admit this spelling. **A reason is required.** An + omitted cell and a genuinely inapplicable cell look identical in a diff + otherwise, which is how coverage rots. +- `GRAMMAR-UNAVAILABLE` — the parser could not be loaded, so nothing was + measured. Neither passes nor fails the gate, and `drift` skips it on **both** + sides so the gate cannot fail for the environment it ran in. `tree-sitter-dart`, + `-kotlin` and `-swift` are vendored _optional_ grammars: absent when a run sets + `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1`, and soft-failing when no vendored prebuild + matches the host (the set covers darwin/linux arm64+x64 and win32-arm64 — a + win32-x64 or musl host has none). **All 14 load on a glibc linux-x64 host, so + this state has no producer in the committed baseline** — it guards the + skip-flag and unsupported-host cases rather than a condition seen here. + +`assertMatrixComplete` throws when a language omits a cell, declares an unknown +id, or writes an `N/A` with no reason. Languages may declare `extraShapeIds` for +diagnostics the canonical axis cannot express (PHP's annotated/unannotated +return-type pair, C++'s pointer/value base pair) — an extra must be declared, so +it stays a deliberate diagnostic rather than a typo'd canonical id. + +**Vue and COBOL** are language-level `N/A` rows: their emitters never call +`synthesizeReceiverChainCapture`, so there is nothing to measure — but the +language axis now obeys the same no-omitted-cells rule as the shape axis. + +### What the first expanded run found + +Three results that redirected the plan they were built to serve. **Snapshot: the +first U2 run, before any of the fixes below landed** — these cells state the +problem, and several have since flipped (`baseline.json` is current): + +**Go — the root cause, isolated to one cell.** Three rows vary receiver +decoration and field decoration independently: + +| Cell | Receiver | Field | State | +| ----------------------- | ----------- | ----------- | --------------- | +| `fieldReceiverCall` | value | value | RESOLVES | +| `decoratedFieldType` | value | **pointer** | RESOLVES | +| `decoratedReceiverBase` | **pointer** | value | **VISIBLE-GAP** | + +Only the pointer _receiver_ fails. Go already normalizes field type bindings +through `normalizeGoTypeName`, so the step lookup is sound and the defect is +entirely the base — `synthesizeGoReceiverBinding` stores `typeNode.text` raw, so +`func (h *Host)` binds `h` to the literal `*Host`, which +`findClassBindingInScope` cannot resolve. + +**PHP — the sigil hypothesis is dead.** The two rows differ only in whether the +called method declares a return type: + +| Cell | Return type | State | +| --------------------------------------------- | ------------- | ------------- | +| `arrowCallChain` — `$svc->getUser()->save()` | unannotated | INVISIBLE-GAP | +| `plainChain` — `$svc->getUserTyped()->save()` | **annotated** | **RESOLVES** | + +Same chain, same `->`, same base. PHP chains resolve when the return type is +declared; the `$` sigil is not involved. `decoratedFieldType` (`?User $repo`) +also resolves, so PHP nullable field types already work. + +**C++ — the base already resolves, but `this->` field receivers do not.** +`pointerArrowChain` and `valueDotChain` both RESOLVE, so a decorated C++ base is +not a gap. But `this->repo.save()` and `this->repo->save()` are both +INVISIBLE-GAP — a distinct defect, not a decoration one. + +**Rust — the decorated receiver is NOT a gap.** `&mut self` resolves, so Go is +the only language whose method receiver decoration defeats the lookup. Rust's +gap is the field: `Box` is INVISIBLE-GAP. + +### The decoration cells, across all 14 + +The rows U1 exists to fix. Everything else is a different defect. **Snapshot: as +measured at U2, i.e. BEFORE U1 landed** — it is the statement of the problem, not +of the current state. Go's `decoratedReceiverBase` and TypeScript's +`decoratedFieldType` have since moved; `baseline.json` has the live cells. + +| Language | `decoratedReceiverBase` | `decoratedFieldType` | +| ------------------------- | ------------------------- | ---------------------------------- | +| go | **VISIBLE-GAP** (`*Host`) | RESOLVES | +| rust | RESOLVES (`&mut self`) | **INVISIBLE-GAP** (`Box`) | +| typescript | N/A | **INVISIBLE-GAP** (`User \| null`) | +| csharp | N/A | **VISIBLE-GAP** (`User?`) | +| swift | N/A | **INVISIBLE-GAP** (`User?`) | +| cpp | N/A | **INVISIBLE-GAP** (`User*`) | +| python, php, kotlin, dart | N/A | RESOLVES | +| java, c, javascript, ruby | N/A | N/A | + +So U1's measured scope is **Go's receiver base**, plus the field-type gap in +**Rust, TypeScript, C#, Swift and C++** — and _not_ PHP, Python, Kotlin, Dart or +Java, whose decoration handling already works or does not exist. Five of the +seven hooks the plan speculatively listed were aimed at languages that need +none; three languages that do need one were not on the list at all. + +### Other gaps this run surfaced, not in the plan + +- **Swift resolves almost nothing.** `plainChain`, `plainDeepChain`, + `optionalChain` and `nonNullAssert` are all INVISIBLE-GAP, while + `fieldReceiverCall` resolves. Chained receivers are essentially unsupported. +- **Ruby chains are VISIBLE-GAPs** (`plainChain`, `plainDeepChain`, + `optionalChain`) and `fieldReceiverCall` on `@repo` is INVISIBLE. +- **C++ `this->` field receivers** are INVISIBLE-GAP in both the value and + pointer form. +- **C# has four gaps** beyond the field one: `optionalChain`, `nonNullAssert`, + `awaitParen`, `explicitTypeArgs`. +- **Dart `await` already resolves** — the only language where `awaitParen` is + green, which makes it the reference for U4's unwrap direction. +- **`indexElement` was INVISIBLE-GAP in all 14** at U2 — uniform, and exactly what + U5 targets. (Superseded: several languages resolve it now; see `baseline.json`.) + +### Coverage status + +All 14 languages measured, plus `vue` and `cobol` as language-level `N/A` rows. +The cell tally recorded at U2 was 164 cells / 42 RESOLVES / 22 VISIBLE-GAP / 31 +INVISIBLE-GAP / 69 N/A / 0 GRAMMAR-UNAVAILABLE — a snapshot, superseded by every +unit since (the axis also gained TypeScript's declared `fourHopChain` extra). +Count the states off `baseline.json` rather than quoting this line. + +The **count arm did not move when the shape axis was expanded** — shape fixtures +are built in temp directories and never touch the committed corpus, so expanding +the shape axis moves the shape arm only. + +--- + > **Updated after U10** (structural receiver typing wired into Case 0). Three > TypeScript shapes flipped to `RESOLVES` — `svc?.getUser().save()`, > `svc!.getUser().save()`, `svc.getTyped().save()` — and the call-drop @@ -11,12 +436,18 @@ > change as "no improvement" and stopped the series. Nothing regressed: no edge > was lost and no new drop appeared. > -> Still gaps after U10, both genuine: -> - `(await svc.getUserAsync()).save()` — `extractMixedChain` reaches -> `await …`, which is not a chain node, so no chain is minted. Remains a -> VISIBLE-GAP and is now the call-kind fixture in the drop-recorder test. -> - `repos[0].save()` — Case 0's punctuation gate never fires for a subscript -> receiver, so it stays INVISIBLE. +> Two gaps remained open **at U10**, both genuine at the time: +> +> - `(await svc.getUserAsync()).save()` — `extractMixedChain` reached `await …`, +> which is not a chain node, so no chain was minted. It was a VISIBLE-GAP and is +> the call-kind fixture in the drop-recorder test. +> - `repos[0].save()` — Case 0's punctuation gate never fired for a subscript +> receiver, so it was INVISIBLE. +> +> Both were subsequently closed for TypeScript by the `await`/`index` step kinds +> (wire format v2) and by Case 0's third gate arm, which admits any site carrying +> a minted chain regardless of receiver punctuation. Per-language state is in +> `baseline.json` — `awaitParen` and `indexElement`. > > The tables below are the pre-U10 measurement, kept as the reference point. @@ -27,15 +458,15 @@ A/B produced by reverting ONLY the fold wiring (`compound-receiver.ts` + emission — and therefore the persisted bytes — is identical in both arms and the delta isolates the fold. Build + both caches wiped before every run (KTD4). -| Metric | Control | Treatment | Δ | Threshold | Verdict | -|---|---|---|---|---|---| -| scope-resolution wall-clock, median of 3 | 25470.0 ms | 25687.9 ms | +0.86% | ≤ +3% | **PASS** | -| wall-clock, slowest of 3 | 25520.0 ms | 25832.6 ms | +1.22% | ≤ +5% p95 | **PASS** | -| serialized bytes per emitting site | — | **35.2 B** | — | ≤ 48 B | **PASS** | -| persisted store growth | 1 234 600 B | 1 235 340 B | **+0.0599%** | ≤ 3% | **PASS** | -| retained chain payload | — | 740 B | — | ≤ 6 MB | **PASS** | -| call drops (no regression) | 99 | 99 | 0 | no new drops | **PASS** | -| peak RSS | — | — | — | ≤ +2% | **NOT RESOLVABLE** | +| Metric | Control | Treatment | Δ | Threshold | Verdict | +| ---------------------------------------- | ----------- | ----------- | ------------ | ------------ | ------------------ | +| scope-resolution wall-clock, median of 3 | 25470.0 ms | 25687.9 ms | +0.86% | ≤ +3% | **PASS** | +| wall-clock, slowest of 3 | 25520.0 ms | 25832.6 ms | +1.22% | ≤ +5% p95 | **PASS** | +| serialized bytes per emitting site | — | **35.2 B** | — | ≤ 48 B | **PASS** | +| persisted store growth | 1 234 600 B | 1 235 340 B | **+0.0599%** | ≤ 3% | **PASS** | +| retained chain payload | — | 740 B | — | ≤ 6 MB | **PASS** | +| call drops (no regression) | 99 | 99 | 0 | no new drops | **PASS** | +| peak RSS | — | — | — | ≤ +2% | **NOT RESOLVABLE** | **The 35.2 B result confirms KTD7 by measurement rather than by assertion.** The 48-byte threshold was set deliberately so the object encoding (~71 B predicted) @@ -43,7 +474,7 @@ fails and the compact string (~35 B predicted) passes. Measured: 35.2 B, including the JSON key and quotes. The encoding decision is now evidence-backed. **Peak RSS: the threshold is below this instrument's resolution, so it is -reported as unresolvable rather than as a pass or a fail.** Three *independent* +reported as unresolvable rather than as a pass or a fail.** Three _independent_ treatment runs with the code held constant gave 414.9 / 436.6 / 436.9 MB — a 5.3% spread, wider than the ±2% being tested. (An earlier pair of 3-reps-in-one- process runs read 536 vs 551 MB and looked like a +2.77% regression; that was @@ -82,26 +513,32 @@ Two consecutive runs were byte-identical, not merely within noise. ## Count arm — `test/fixtures/lang-resolution` -| Metric | Value | -|---|---| -| **Call drops (the gate number)** | **99** | -| Total drops, all site kinds | 124 | -| Split by site kind | `call: 99`, `read: 25` | +**Snapshot: the U7-era measurement (commit `f87b2cbe`), kept as the reference +point for the A/B above.** The gate enforces `countArm` in `baseline.json`, which +has moved since — read the live call-drop number, site-kind split, and +per-extension breakdown from there. -Call drops by extension: +| Metric | Value at U7 | +| -------------------------------- | ---------------------- | +| **Call drops (the gate number)** | **99** | +| Total drops, all site kinds | 124 | +| Split by site kind | `call: 99`, `read: 25` | -| ext | n | ext | n | ext | n | -|---|---|---|---|---|---| -| `.java` | 49 | `.py` | 5 | `.rs` | 3 | -| `.cs` | 8 | `.go` | 5 | `.kt` | 3 | -| `.ts` | 7 | `.cpp` | 5 | `.rb` | 2 | -| `.tsx` | 6 | `.php` | 4 | `.js` | 1 | -| | | | | `.swift` | 1 | +Call drops by extension, at U7: -**Why the split matters (KTD6 defect 1, now measured).** 25 of the 124 drops — 20% — -are property *reads*, not lost calls. Case 0's recorder gates on the receiver's +| ext | n | ext | n | ext | n | +| ------- | --- | ------ | --- | -------- | --- | +| `.java` | 49 | `.py` | 5 | `.rs` | 3 | +| `.cs` | 8 | `.go` | 5 | `.kt` | 3 | +| `.ts` | 7 | `.cpp` | 5 | `.rb` | 2 | +| `.tsx` | 6 | `.php` | 4 | `.js` | 1 | +| | | | | `.swift` | 1 | + +**Why the split matters (KTD6 defect 1, now measured).** About a fifth of the +drops are property _reads_, not lost calls (25 of 124 at U7; `bySiteKind` in +`baseline.json` is current). Case 0's recorder gates on the receiver's punctuation, not on what the reference is, so `d.source.kind` lands in the same -bucket as a dropped method call. Gating on the unsplit 124 would have measured a +bucket as a dropped method call. Gating on the unsplit total would have measured a population one fifth of which this work does not target. ## Shape arm @@ -110,19 +547,24 @@ population one fifth of which this work does not target. name-keyed fallback onto a same-named member reads as `RESOLVES`, so a shape whose receiver has no well-defined type is not a usable control. -| Language | Shape | State | siteKind | -|---|---|---|---| -| TypeScript | `svc.getUser().save()` | RESOLVES | — | -| TypeScript | `svc.getUser().address.save()` | RESOLVES | — | -| TypeScript | `svc?.getUser().save()` | **INVISIBLE-GAP** | — | -| TypeScript | `svc!.getUser().save()` | VISIBLE-GAP | `call` | -| TypeScript | `(await svc.getUserAsync()).save()` | VISIBLE-GAP | `call` | -| TypeScript | `svc.getTyped().save()` | **INVISIBLE-GAP** | — | -| TypeScript | `repos[0].save()` | **INVISIBLE-GAP** | — | -| PHP | `$svc->getUser()->save()` | VISIBLE-GAP | `call` | -| PHP | `$this->repo->save()` (typed property) | RESOLVES | — | -| C++ | `svc->getUser()->save()` | **INVISIBLE-GAP** | — | -| C++ | `svc2.getUser()->save()` | RESOLVES | — | +**Snapshot: the pre-U10 measurement over three languages**, kept because it is the +evidence that the shape arm moves when the count arm does not. Superseded twice — +by U8's rollout table above and by the canonical shape axis in `baseline.json`. +The three TypeScript rows marked as gaps here (`?.`, `!`, ``) all resolve now. + +| Language | Shape | State at pre-U10 | siteKind | +| ---------- | -------------------------------------- | ----------------- | -------- | +| TypeScript | `svc.getUser().save()` | RESOLVES | — | +| TypeScript | `svc.getUser().address.save()` | RESOLVES | — | +| TypeScript | `svc?.getUser().save()` | **INVISIBLE-GAP** | — | +| TypeScript | `svc!.getUser().save()` | VISIBLE-GAP | `call` | +| TypeScript | `(await svc.getUserAsync()).save()` | VISIBLE-GAP | `call` | +| TypeScript | `svc.getTyped().save()` | **INVISIBLE-GAP** | — | +| TypeScript | `repos[0].save()` | **INVISIBLE-GAP** | — | +| PHP | `$svc->getUser()->save()` | VISIBLE-GAP | `call` | +| PHP | `$this->repo->save()` (typed property) | RESOLVES | — | +| C++ | `svc->getUser()->save()` | **INVISIBLE-GAP** | — | +| C++ | `svc2.getUser()->save()` | RESOLVES | — | ## Corrections to the plan, forced by measurement @@ -142,11 +584,11 @@ receiver has no well-defined type is not a usable control. `@reference.call.member`, `@reference.name`, and crucially `@reference.receiver`: - | Shape | `@reference.receiver` | - |---|---| - | `svc?.getUser().save()` | `svc?.getUser()` | + | Shape | `@reference.receiver` | + | ----------------------------- | ---------------------- | + | `svc?.getUser().save()` | `svc?.getUser()` | | `svc.getTyped().save()` | `svc.getTyped()` | - | `repos[0].save()` | `repos[0]` | + | `repos[0].save()` | `repos[0]` | So a `ReferenceSite` exists for every one of them, and hanging a `receiverChain` field on `ReferenceSite` is a viable carrier for all of them. @@ -155,7 +597,7 @@ receiver has no well-defined type is not a usable control. The drop suppression is therefore downstream of capture. For `repos[0]` the cause is known and matches the plan: the receiver has neither `.` nor `(`, so Case 0's gate never fires. For `?.` and `` the receiver text satisfies the - gate, so Case 0 *does* run and one of two things happens — the site was marked + gate, so Case 0 _does_ run and one of two things happens — the site was marked in `handledSites` by another case, or `resolveCompoundReceiverClass` returned a class on which the member was then not found, leaving `compoundReceiverUnresolved` false. Those are materially different defects and @@ -163,13 +605,13 @@ receiver has no well-defined type is not a usable control. establish, since the second would mean the recorder under-reports by mis-attribution rather than by a gate. - *(An earlier revision of this file asserted that these shapes produce no + _(An earlier revision of this file asserted that these shapes produce no reference site at all. That was inferred from edge-and-drop absence and is - disproven by the capture dump above.)* + disproven by the capture dump above.)_ 3. **KTD6 defect 2 overstates the PHP blindness.** The claim is that Case 0's C-family punctuation test means PHP `->` receivers "never record a drop at - all". Measured, `$svc->getUser()->save()` *is* recorded, because its receiver + all". Measured, `$svc->getUser()->save()` _is_ recorded, because its receiver text `$svc->getUser()` contains `(` and satisfies the gate. And the plan's own example, `$this->repo->save()`, does not need recording — with a typed property it resolves. The genuine PHP gap is the call chain, and it is already visible. @@ -182,12 +624,26 @@ receiver has no well-defined type is not a usable control. ## Known blind spots Every count here is a lower bound on a known-biased population, and any later delta -must be read against the same bias. +must be read against the same bias. Kept in sync with `KNOWN_BLIND` in +`measure.mjs`, which prints these on every run. -- Case 0's gate is a C-family punctuation test (`.` or `(`), so a receiver that is a - plain property path (`$this->repo`, `a::b`) never reaches the recorder. -- `repos[0].save()` has neither `.` nor `(` in its receiver — same result. -- `?.` and explicit type arguments produce no reference site at all. +- Case 0 is reached by a receiver-TEXT punctuation test (`.` or `(`) **or** by a + minted receiver chain. A receiver spelled without that punctuation — a subscript + `repos[0]`, a PHP `->` / `::` property path — therefore reaches the recorder only + where its emitter mints a chain. Where no chain is minted, the call still + vanishes with the instrument blind to it. +- A drop is recorded only while `compoundReceiverUnresolved` stays true. When the + cascade TYPES the receiver but then finds no member on it, the flag is false and + no drop is recorded even though no edge was emitted. So an absent drop is not + evidence a site resolved — the recorder can under-report by mis-attribution, not + only by a gate. (This is what moved PHP's `arrowCallChain` from VISIBLE-GAP to + INVISIBLE-GAP when its fixture parameter was typed; see U8 below.) +- Retracted, and left here because it was quoted for several units: the earlier + claim that `?.` and explicit type arguments _"produce no reference site at all"_. + They do — the capture dump under "Corrections to the plan" §2 shows a full call + match with `@reference.receiver` for all three of `svc?.getUser()`, + `svc.getTyped()` and `repos[0]`. The absence was of an EDGE and of a DROP, + never of a site. ## U8 — per-language rollout @@ -200,15 +656,15 @@ match, an absent receiver, or a chain with no nameable base all leave the match untouched — so inserting the call before every `out.push(grouped)` is safe even in the emitters that have three or four such paths. -| Language | Shape | Before | After | -|---|---|---|---| -| TypeScript | `svc?.getUser().save()` | INVISIBLE-GAP | **RESOLVES** | -| TypeScript | `svc!.getUser().save()` | VISIBLE-GAP | **RESOLVES** | -| TypeScript | `svc.getTyped().save()` | INVISIBLE-GAP | **RESOLVES** | -| C++ | `svc->getUser()->save()` | INVISIBLE-GAP | **RESOLVES** | -| C++ | `svc2.getUser()->save()` (control) | RESOLVES | RESOLVES | -| PHP | `$svc->getUser()->save()` | VISIBLE-GAP | INVISIBLE-GAP | -| PHP | `$this->repo->save()` (control) | RESOLVES | RESOLVES | +| Language | Shape | Before | After | +| ---------- | ---------------------------------- | ------------- | ------------- | +| TypeScript | `svc?.getUser().save()` | INVISIBLE-GAP | **RESOLVES** | +| TypeScript | `svc!.getUser().save()` | VISIBLE-GAP | **RESOLVES** | +| TypeScript | `svc.getTyped().save()` | INVISIBLE-GAP | **RESOLVES** | +| C++ | `svc->getUser()->save()` | INVISIBLE-GAP | **RESOLVES** | +| C++ | `svc2.getUser()->save()` (control) | RESOLVES | RESOLVES | +| PHP | `$svc->getUser()->save()` | VISIBLE-GAP | INVISIBLE-GAP | +| PHP | `$this->repo->save()` (control) | RESOLVES | RESOLVES | The C++ row is the one the plan flagged as having **no fixture anywhere** — `cpp-chain-call/` uses the value `.` form, which already worked. It now has one, @@ -225,6 +681,10 @@ the emitter: name=save chain=1|$svc|cgetUser recv=$svc.getUser() ``` +The leading `1` is the **v1** wire prefix current when this dump was taken; the +codec is at v2 now (`2|$svc|cgetUser`), and a v2 decoder refuses a v1 payload by +design — do not copy this literal into a fixture. + The chain is minted correctly. The residual is that the fold's base, `$svc`, does not bind in the PHP resolver, so the fold returns `undefined` and the site falls through to the text cascade. That is PHP binding-key work, not a diff --git a/gitnexus/bench/receiver-resolution/baseline.json b/gitnexus/bench/receiver-resolution/baseline.json index 904bc658b..31d2933ef 100644 --- a/gitnexus/bench/receiver-resolution/baseline.json +++ b/gitnexus/bench/receiver-resolution/baseline.json @@ -1,28 +1,208 @@ { "shapeArm": { + "vue": { + "plainChain": "N/A", + "plainDeepChain": "N/A", + "optionalChain": "N/A", + "nonNullAssert": "N/A", + "awaitParen": "N/A", + "explicitTypeArgs": "N/A", + "indexElement": "N/A", + "fieldReceiverCall": "N/A", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "N/A" + }, + "cobol": { + "plainChain": "N/A", + "plainDeepChain": "N/A", + "optionalChain": "N/A", + "nonNullAssert": "N/A", + "awaitParen": "N/A", + "explicitTypeArgs": "N/A", + "indexElement": "N/A", + "fieldReceiverCall": "N/A", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "N/A" + }, "typescript": { "plainChain": "RESOLVES", "plainDeepChain": "RESOLVES", "optionalChain": "RESOLVES", "nonNullAssert": "RESOLVES", - "awaitParen": "VISIBLE-GAP", + "awaitParen": "RESOLVES", "explicitTypeArgs": "RESOLVES", - "indexElement": "INVISIBLE-GAP" + "indexElement": "RESOLVES", + "fourHopChain": "RESOLVES", + "fieldReceiverCall": "RESOLVES", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "RESOLVES" }, "php": { "arrowCallChain": "INVISIBLE-GAP", - "arrowPropertyPath": "RESOLVES" + "arrowPropertyPath": "RESOLVES", + "plainChain": "RESOLVES", + "plainDeepChain": "INVISIBLE-GAP", + "optionalChain": "INVISIBLE-GAP", + "nonNullAssert": "N/A", + "awaitParen": "N/A", + "explicitTypeArgs": "N/A", + "indexElement": "VISIBLE-GAP", + "fieldReceiverCall": "N/A", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "RESOLVES" }, "cpp": { "pointerArrowChain": "RESOLVES", - "valueDotChain": "RESOLVES" + "valueDotChain": "RESOLVES", + "plainChain": "N/A", + "plainDeepChain": "RESOLVES", + "optionalChain": "N/A", + "nonNullAssert": "N/A", + "awaitParen": "N/A", + "explicitTypeArgs": "VISIBLE-GAP", + "indexElement": "RESOLVES", + "fieldReceiverCall": "INVISIBLE-GAP", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "INVISIBLE-GAP" + }, + "go": { + "plainChain": "RESOLVES", + "plainDeepChain": "RESOLVES", + "optionalChain": "N/A", + "nonNullAssert": "N/A", + "awaitParen": "N/A", + "explicitTypeArgs": "N/A", + "indexElement": "RESOLVES", + "fieldReceiverCall": "RESOLVES", + "decoratedReceiverBase": "RESOLVES", + "decoratedFieldType": "RESOLVES" + }, + "javascript": { + "plainChain": "VISIBLE-GAP", + "plainDeepChain": "VISIBLE-GAP", + "optionalChain": "VISIBLE-GAP", + "nonNullAssert": "N/A", + "awaitParen": "VISIBLE-GAP", + "explicitTypeArgs": "N/A", + "indexElement": "VISIBLE-GAP", + "fieldReceiverCall": "RESOLVES", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "N/A" + }, + "python": { + "plainChain": "RESOLVES", + "plainDeepChain": "RESOLVES", + "optionalChain": "N/A", + "nonNullAssert": "N/A", + "awaitParen": "RESOLVES", + "explicitTypeArgs": "N/A", + "indexElement": "RESOLVES", + "fieldReceiverCall": "RESOLVES", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "RESOLVES" + }, + "java": { + "plainChain": "RESOLVES", + "plainDeepChain": "RESOLVES", + "optionalChain": "N/A", + "nonNullAssert": "N/A", + "awaitParen": "N/A", + "explicitTypeArgs": "RESOLVES", + "indexElement": "VISIBLE-GAP", + "fieldReceiverCall": "RESOLVES", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "N/A" + }, + "csharp": { + "plainChain": "RESOLVES", + "plainDeepChain": "RESOLVES", + "optionalChain": "INVISIBLE-GAP", + "nonNullAssert": "VISIBLE-GAP", + "awaitParen": "RESOLVES", + "explicitTypeArgs": "VISIBLE-GAP", + "indexElement": "VISIBLE-GAP", + "fieldReceiverCall": "RESOLVES", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "VISIBLE-GAP" + }, + "ruby": { + "plainChain": "VISIBLE-GAP", + "plainDeepChain": "VISIBLE-GAP", + "optionalChain": "VISIBLE-GAP", + "nonNullAssert": "N/A", + "awaitParen": "N/A", + "explicitTypeArgs": "N/A", + "indexElement": "INVISIBLE-GAP", + "fieldReceiverCall": "INVISIBLE-GAP", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "N/A" + }, + "rust": { + "plainChain": "RESOLVES", + "plainDeepChain": "RESOLVES", + "optionalChain": "N/A", + "nonNullAssert": "N/A", + "awaitParen": "N/A", + "explicitTypeArgs": "INVISIBLE-GAP", + "indexElement": "RESOLVES", + "fieldReceiverCall": "RESOLVES", + "decoratedReceiverBase": "RESOLVES", + "decoratedFieldType": "INVISIBLE-GAP" + }, + "c": { + "plainChain": "N/A", + "plainDeepChain": "VISIBLE-GAP", + "optionalChain": "N/A", + "nonNullAssert": "N/A", + "awaitParen": "N/A", + "explicitTypeArgs": "N/A", + "indexElement": "VISIBLE-GAP", + "fieldReceiverCall": "INVISIBLE-GAP", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "N/A" + }, + "kotlin": { + "plainChain": "RESOLVES", + "plainDeepChain": "RESOLVES", + "optionalChain": "RESOLVES", + "nonNullAssert": "VISIBLE-GAP", + "awaitParen": "RESOLVES", + "explicitTypeArgs": "RESOLVES", + "indexElement": "RESOLVES", + "fieldReceiverCall": "RESOLVES", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "RESOLVES" + }, + "swift": { + "plainChain": "INVISIBLE-GAP", + "plainDeepChain": "INVISIBLE-GAP", + "optionalChain": "INVISIBLE-GAP", + "nonNullAssert": "INVISIBLE-GAP", + "awaitParen": "VISIBLE-GAP", + "explicitTypeArgs": "N/A", + "indexElement": "INVISIBLE-GAP", + "fieldReceiverCall": "RESOLVES", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "INVISIBLE-GAP" + }, + "dart": { + "plainChain": "VISIBLE-GAP", + "plainDeepChain": "VISIBLE-GAP", + "optionalChain": "VISIBLE-GAP", + "nonNullAssert": "VISIBLE-GAP", + "awaitParen": "RESOLVES", + "explicitTypeArgs": "N/A", + "indexElement": "INVISIBLE-GAP", + "fieldReceiverCall": "RESOLVES", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "RESOLVES" } }, "countArm": { - "callDrops": 101, - "totalDropsAllKinds": 128, + "callDrops": 102, + "totalDropsAllKinds": 129, "bySiteKind": { - "call": 101, + "call": 102, "read": 27 }, "callDropsByExtension": { @@ -34,11 +214,23 @@ ".py": 5, ".go": 5, ".php": 4, + ".kt": 4, ".rs": 3, - ".kt": 3, ".rb": 2, ".js": 1, ".swift": 1 + }, + "callDropsByShape": { + "chain-field": 60, + "chain-call": 27, + "no-chain": 12, + "chain-mixed": 2, + "chain-unwrap": 1 + }, + "callDropsByOrigin": { + "external": 44, + "in-program": 36, + "unknown": 22 } } } diff --git a/gitnexus/bench/receiver-resolution/measure.mjs b/gitnexus/bench/receiver-resolution/measure.mjs index 7a279ea87..8cfb8e9f5 100644 --- a/gitnexus/bench/receiver-resolution/measure.mjs +++ b/gitnexus/bench/receiver-resolution/measure.mjs @@ -7,8 +7,9 @@ * * TWO ARMS, because neither one alone is trustworthy: * - * 1. SHAPE ARM (`--shapes`, default). A fixed corpus of receiver spellings, - * each classified by what the graph actually contains: + * 1. SHAPE ARM (`--shapes`, default). A fixed matrix of receiver spellings — + * every SHAPE_ID against every language — each cell classified by what the + * graph actually contains: * * RESOLVES edge emitted. A test written against this shape starts * green and proves nothing. Note this states only that an @@ -20,13 +21,35 @@ * Measurable by the count arm below. * INVISIBLE-GAP no edge, and NO drop was recorded. The call is lost and * the instrument cannot see it. + * N/A the language's grammar does not admit this spelling. + * Carries a REQUIRED reason — an omitted cell and a + * genuinely inapplicable one look identical in a diff + * otherwise, and that is how coverage silently rots. + * GRAMMAR- the language's parser could not be loaded, so nothing + * UNAVAILABLE about it was measured. Neither passes nor fails the + * gate. Dart, Kotlin and Swift are vendored OPTIONAL + * grammars: they are skipped entirely by + * GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1, and they soft-fail + * when no vendored prebuild matches the host (the set + * covers darwin/linux arm64+x64 and win32-arm64 — a + * win32-x64 or musl host has none). Without this state an + * unloaded grammar emits no edges and every one of its + * cells reads as a resolution gap indistinguishable from a + * real regression. + * NOTE: all 14 load on a glibc linux-x64 host, so this + * state has no producer in the committed baseline. It + * guards the skip-flag and unsupported-host cases. * - * The third state is why this arm exists. Case 0's recorder is reached only - * when the receiver text contains `.` or `(` AND the capture layer produced - * a reference site at all. Measured on this corpus, `svc?.getUser().save()`, - * `svc.getTyped().save()` and `repos[0].save()` are all INVISIBLE — - * so fixing them moves the count arm by exactly zero. Gating solely on a - * drop count would read a working fix as "no improvement". + * INVISIBLE-GAP is why this arm exists, and the series proved it: at the + * time this arm was built `svc?.getUser().save()`, `svc.getTyped().save()` + * and `repos[0].save()` were all INVISIBLE, so fixing them moved the count + * arm by exactly zero and a gate reading only the drop count would have + * scored a working change as "no improvement". They resolve for TypeScript + * now; the current per-shape state is `baseline.json`, never this comment. + * + * COMPLETENESS IS ENFORCED: every language must declare a cell for every + * SHAPE_ID, and every N/A must carry a reason. A missing cell fails the run + * rather than being silently skipped. * * 2. COUNT ARM (`--corpus `). Runs the real pipeline over a repo and * reports drops SPLIT BY SITE KIND. The gate number is `call` only: the @@ -38,10 +61,16 @@ * is a lower bound on a KNOWN-BIASED population and any delta measured later * must be read against the same bias: * - * - Case 0's gate is a C-family punctuation test, so PHP `$this->repo->save()` - * and `::` receivers never record a drop. - * - `repos[0].save()` has neither `.` nor `(` in its receiver, same result. - * - `?.` and explicit type arguments produce no reference site to begin with. + * - Case 0 is reached by a receiver-TEXT punctuation test (`.` or `(`) OR by a + * minted receiver chain, so a receiver spelled without that punctuation — a + * subscript, PHP `->`/`::` — records a drop only where its emitter mints a + * chain. Where none is minted the call still vanishes unrecorded. + * - A drop is recorded only while `compoundReceiverUnresolved` stays true: if + * the cascade types the receiver but finds no member, no drop is recorded + * even though no edge was emitted, so an absent drop is not proof of + * resolution. (Superseded claim, kept because it was quoted for several + * units: `?.` and explicit type arguments DO produce a reference site — + * what was absent was the edge and the drop, never the site.) * * MEASUREMENT HYGIENE — a run that skips this is void: * @@ -74,21 +103,65 @@ const DEFAULT_CORPUS = path.resolve(__dirname, '..', '..', 'test', 'fixtures', ' // Shape corpus // --------------------------------------------------------------------------- +/** + * The canonical shape axis. Every language corpus must declare a cell for every + * id here — a body to measure, or `na` with a reason. `assertMatrixComplete` + * enforces it, so adding a shape forces every language to answer for it. + */ +const SHAPE_IDS = [ + 'plainChain', + 'plainDeepChain', + 'optionalChain', + 'nonNullAssert', + 'awaitParen', + 'explicitTypeArgs', + 'indexElement', + 'fieldReceiverCall', + 'decoratedReceiverBase', + 'decoratedFieldType', +]; + +/** + * Languages that carry no receiver-chain emission at all, recorded as + * language-level rows so the language axis obeys the same no-omitted-cells rule + * as the shape axis. Their capture emitters do not call + * `synthesizeReceiverChainCapture`, so there is nothing to measure — but an + * absent row and an inapplicable row must not look alike in a diff. + */ +const NA_LANGUAGES = { + vue: 'SFC templates delegate to the TypeScript/JavaScript emitters; no receiver chain is minted for the .vue file itself.', + cobol: 'No method-call receiver syntax; the emitter mints no receiver chains.', +}; + /** * Each entry is one receiver spelling. `entry` is the function that contains * it; `member` is the method it should reach. Classification asks only two * questions of the result — is there a CALLS edge from `entry` to `member`, * and was a drop recorded on that line — so it never guesses from an id shape. + * + * An entry with `na` declares the shape inapplicable to this language and is + * never compiled into the fixture. */ const CORPORA = [ { lang: 'typescript', ext: '.ts', + // `fourHopChain` exists to make MAX_CHAIN_DEPTH answerable. Without a chain + // longer than the cap, raising the cap measures nothing and the question + // "what does depth N buy?" has no instrument behind it. + extraShapeIds: ['fourHopChain'], support: { - 'models.ts': `export class Address { + 'models.ts': `export class City { save(): void {} } +export class Address { + save(): void {} + getCity(): City { + return new City(); + } +} + export class User { name: string = ''; address: Address = new Address(); @@ -129,29 +202,99 @@ export class Service { }, { id: 'explicitTypeArgs', member: 'save', body: 'svc.getTyped().save();', note: 'PF4' }, { id: 'indexElement', member: 'save', body: 'repos[0].save();', note: 'PF5' }, + { + // FOUR STEPS in the receiver of `save`: getSvc, getUser, address, + // getCity. One more than the pre-U6 cap of 3, so the chain is DISCARDED + // WHOLE at depth 3 and types end to end at depth 4 — the whole basis + // for choosing the number. (An earlier version of this fixture had only + // three steps and therefore resolved at both depths, measuring nothing.) + id: 'fourHopChain', + member: 'save', + body: 'root.getSvc().getUser().address.getCity().save();', + raw: `class Root { + getSvc(): Service { + return new Service(); + } +} + +export function fourHopChain(root: Root): void { + root.getSvc().getUser().address.getCity().save(); +} +`, + }, + { + id: 'fieldReceiverCall', + member: 'save', + body: 'this.repo.save();', + raw: `class TsHolder { + repo: User = new User(); + fieldReceiverCall(): void { + this.repo.save(); + } +} +`, + note: 'control — undecorated field receiver', + }, + { + id: 'decoratedReceiverBase', + na: 'TypeScript `this` carries no type decoration — there is no pointer/reference spelling on a method receiver.', + }, + { + id: 'decoratedFieldType', + member: 'save', + body: 'this.repo!.save();', + raw: `class TsNullableHolder { + repo: User | null = null; + decoratedFieldType(): void { + this.repo!.save(); + } +} +`, + note: 'union-with-null field type', + }, ], }, { lang: 'php', ext: '.php', + // `arrowCallChain` / `arrowPropertyPath` discriminate an unannotated return + // type from a property-path receiver — neither is expressible on the + // canonical axis, and both are load-bearing for the PHP diagnosis. + extraShapeIds: ['arrowCallChain', 'arrowPropertyPath'], support: { 'models.php': ` `function ${entry}(Service $svc) {\n ${body}\n}\n`, + // that is really a fixture defect. `$repos` is typed as an array for the + // subscript row for the same reason. + wrap: (entry, body) => `function ${entry}(Service $svc, array $repos) {\n ${body}\n}\n`, shapes: [ { id: 'arrowCallChain', @@ -177,27 +320,84 @@ class Service { `, note: 'PF6-control — property-path receiver, no `.` and no `(`', }, + { + id: 'plainChain', + member: 'save', + body: '$svc->getUserTyped()->save();', + note: 'discriminator for arrowCallChain — same chain, ANNOTATED return type', + }, + { + id: 'plainDeepChain', + member: 'save', + body: '$svc->getUser()->address->save();', + note: 'deep arrow chain', + }, + { id: 'optionalChain', member: 'save', body: '$svc?->getUser()->save();', note: 'nullsafe' }, + { + id: 'nonNullAssert', + na: 'PHP has no non-null assertion operator.', + }, + { id: 'awaitParen', na: 'PHP has no await expression in the core language.' }, + { + id: 'explicitTypeArgs', + na: 'PHP has no generic type-argument syntax at a call site.', + }, + { id: 'indexElement', member: 'save', body: '$repos[0]->save();', note: 'array subscript' }, + { + id: 'fieldReceiverCall', + na: 'Covered by `arrowPropertyPath` above — the same undecorated field-receiver shape.', + }, + { + id: 'decoratedReceiverBase', + na: 'PHP `$this` carries no type decoration on a method receiver.', + }, + { + id: 'decoratedFieldType', + member: 'save', + body: '$this->repo->save();', + raw: `class NullableHolder { + public ?User $repo; + public function decoratedFieldType() { + $this->repo->save(); + } +} +`, + note: 'nullable field type', + }, ], }, { lang: 'cpp', ext: '.cpp', + // `pointerArrowChain` / `valueDotChain` discriminate a `->` base from a `.` + // base on the same chain — the pair is why PF7 was attributable to the base + // rather than to C++ chaining in general. + extraShapeIds: ['pointerArrowChain', 'valueDotChain'], support: { 'models.h': `#pragma once +class Address { +public: + void save(); +}; + class User { public: + Address addr; void save(); }; class Service { public: User* getUser(); + template + T* getTyped(); }; `, }, header: `#include "models.h"\n`, - wrap: (entry, body) => `void ${entry}(Service* svc, Service svc2) {\n ${body}\n}\n`, + wrap: (entry, body) => + `void ${entry}(Service* svc, Service svc2, User* repos) {\n ${body}\n}\n`, shapes: [ { id: 'pointerArrowChain', @@ -215,10 +415,812 @@ public: body: 'svc2.getUser()->save();', note: 'PF7-control — value `.` base resolves', }, + { + id: 'plainChain', + na: 'C++ spells a chained call through `->` or `.`; both are measured above as pointerArrowChain and valueDotChain.', + }, + { id: 'plainDeepChain', member: 'save', body: 'svc->getUser()->addr.save();' }, + { id: 'optionalChain', na: 'C++ has no optional-chaining operator.' }, + { id: 'nonNullAssert', na: 'C++ has no non-null assertion operator.' }, + { + id: 'awaitParen', + na: 'co_await is a coroutine feature the emitter does not model as an async container.', + }, + { id: 'explicitTypeArgs', member: 'save', body: 'svc->getTyped()->save();' }, + { id: 'indexElement', member: 'save', body: 'repos[0].save();' }, + { + id: 'fieldReceiverCall', + member: 'save', + body: 'this->repo.save();', + raw: `struct CppHolder { + User repo; + void fieldReceiverCall() { + this->repo.save(); + } +}; +`, + note: 'control — undecorated field receiver', + }, + { + id: 'decoratedReceiverBase', + na: 'C++ `this` is implicitly a pointer with no written decoration; the pointer base is measured by pointerArrowChain.', + }, + { + id: 'decoratedFieldType', + member: 'save', + body: 'this->repo->save();', + raw: `struct CppPtrHolder { + User* repo; + void decoratedFieldType() { + this->repo->save(); + } +}; +`, + note: 'pointer field type', + }, + ], + }, + { + // Go is the language whose root cause this plan proves: a method with a + // POINTER receiver binds its receiver to the literal string `*Host`, which + // findClassBindingInScope cannot resolve. The three field-receiver rows + // below isolate that — they vary receiver decoration and field decoration + // independently, so a gap can be attributed to one or the other rather than + // to "Go field receivers" as a whole. + lang: 'go', + ext: '.go', + support: { + 'models.go': `package main + +type Address struct{} + +func (a *Address) Save() {} + +type User struct { + Address *Address +} + +func (u *User) Save() {} + +type Service struct{} + +func (s *Service) GetUser() *User { return &User{} } + +type Tgt struct{} + +func (t *Tgt) PtrM() {} + +func (t Tgt) ValM() {} +`, + }, + header: `package main\n`, + wrap: (entry, body) => `func ${entry}(svc *Service, repos []*User) {\n\t${body}\n}\n`, + shapes: [ + { id: 'plainChain', member: 'Save', body: 'svc.GetUser().Save()', note: 'control' }, + { + id: 'plainDeepChain', + member: 'Save', + body: 'svc.GetUser().Address.Save()', + note: 'control', + }, + { id: 'optionalChain', na: 'Go has no optional-chaining operator.' }, + { id: 'nonNullAssert', na: 'Go has no non-null assertion operator.' }, + { id: 'awaitParen', na: 'Go has no await expression; concurrency is channel-based.' }, + { + id: 'explicitTypeArgs', + na: 'Go methods cannot declare type parameters, so there is no call-site type-argument spelling on a method.', + }, + { id: 'indexElement', member: 'Save', body: 'repos[0].Save()', note: 'slice subscript' }, + { + // VALUE receiver + VALUE field. Neither side carries decoration, so this + // is the control that isolates the two rows below. + id: 'fieldReceiverCall', + member: 'ValM', + body: 'h.f.ValM()', + raw: `type HostVV struct{ f Tgt } + +func (h HostVV) fieldReceiverCall() { + h.f.ValM() +} +`, + note: 'control — value receiver, value field', + }, + { + // POINTER receiver + VALUE field. The receiver binds as `*HostPV` and + // the class lookup misses every branch (no dot, so the dotted-tail + // fallback never fires). THE root cause this plan fixes. + id: 'decoratedReceiverBase', + member: 'ValM', + body: 'h.f.ValM()', + raw: `type HostPV struct{ f Tgt } + +func (h *HostPV) decoratedReceiverBase() { + h.f.ValM() +} +`, + note: 'pointer receiver, value field — isolates the BASE', + }, + { + // VALUE receiver + POINTER field. Go already normalizes field type + // bindings through normalizeGoTypeName, so this resolves today and + // proves the step lookup is NOT the defect. + id: 'decoratedFieldType', + member: 'PtrM', + body: 'h.f.PtrM()', + raw: `type HostVP struct{ f *Tgt } + +func (h HostVP) decoratedFieldType() { + h.f.PtrM() +} +`, + note: 'value receiver, pointer field — isolates the STEP', + }, + ], + }, + { + lang: 'javascript', + ext: '.js', + support: { + 'models.js': `export class Address { + save() {} +} + +export class User { + constructor() { + this.address = new Address(); + } + save() {} +} + +export class Service { + getUser() { + return new User(); + } + async getUserAsync() { + return new User(); + } +} +`, + }, + header: `import { Service, User } from './models.js';\n`, + wrap: (entry, body) => `export async function ${entry}(svc, repos) {\n ${body}\n}\n`, + shapes: [ + { id: 'plainChain', member: 'save', body: 'svc.getUser().save();' }, + { id: 'plainDeepChain', member: 'save', body: 'svc.getUser().address.save();' }, + { id: 'optionalChain', member: 'save', body: 'svc?.getUser().save();' }, + { id: 'nonNullAssert', na: 'JavaScript has no non-null assertion operator.' }, + { id: 'awaitParen', member: 'save', body: '(await svc.getUserAsync()).save();' }, + { id: 'explicitTypeArgs', na: 'JavaScript has no type-argument syntax.' }, + { id: 'indexElement', member: 'save', body: 'repos[0].save();' }, + { + id: 'fieldReceiverCall', + member: 'save', + body: 'this.repo.save();', + raw: `class JsHolder { + constructor() { + this.repo = new User(); + } + fieldReceiverCall() { + this.repo.save(); + } +} +`, + }, + { + id: 'decoratedReceiverBase', + na: 'JavaScript method receivers carry no type decoration.', + }, + { + id: 'decoratedFieldType', + na: 'JavaScript fields carry no declared type, so there is no decoration to strip.', + }, + ], + }, + { + lang: 'python', + ext: '.py', + support: { + 'models.py': `class Address: + def save(self) -> None: + pass + + +class User: + def __init__(self) -> None: + self.address: Address = Address() + + def save(self) -> None: + pass + + +class Service: + def get_user(self) -> User: + return User() + + async def get_user_async(self) -> User: + return User() +`, + }, + header: `from typing import List, Optional\nfrom models import Address, Service, User\n`, + wrap: (entry, body) => `def ${entry}(svc: Service, repos: List[User]) -> None:\n ${body}\n`, + shapes: [ + { id: 'plainChain', member: 'save', body: 'svc.get_user().save()' }, + { id: 'plainDeepChain', member: 'save', body: 'svc.get_user().address.save()' }, + { id: 'optionalChain', na: 'Python has no optional-chaining operator.' }, + { id: 'nonNullAssert', na: 'Python has no non-null assertion operator.' }, + { + id: 'awaitParen', + member: 'save', + body: '(await svc.get_user_async()).save()', + raw: `async def awaitParen(svc: Service) -> None: + (await svc.get_user_async()).save() +`, + note: 'annotated coroutine — the declared return type IS the awaited type', + }, + { id: 'explicitTypeArgs', na: 'Python has no call-site type-argument syntax.' }, + { id: 'indexElement', member: 'save', body: 'repos[0].save()' }, + { + id: 'fieldReceiverCall', + member: 'save', + body: 'self.repo.save()', + raw: `class PyHolder: + def __init__(self) -> None: + self.repo: User = User() + + def fieldReceiverCall(self) -> None: + self.repo.save() +`, + }, + { id: 'decoratedReceiverBase', na: 'Python `self` carries no type decoration.' }, + { + id: 'decoratedFieldType', + member: 'save', + body: 'self.repo.save()', + raw: `class PyOptionalHolder: + def __init__(self) -> None: + self.repo: Optional[User] = None + + def decoratedFieldType(self) -> None: + self.repo.save() +`, + note: 'Optional[...] field annotation', + }, + ], + }, + { + lang: 'java', + ext: '.java', + support: { + 'Address.java': `public class Address { + public void save() {} +} +`, + 'User.java': `public class User { + public Address address; + public void save() {} +} +`, + 'Service.java': `import java.util.List; + +public class Service { + public User getUser() { return new User(); } + public User getTyped() { return new User(); } +} +`, + }, + header: `import java.util.List;\n`, + // Java has no free functions, so each shape gets its own wrapper class. The + // METHOD is named for the shape, which is what classification matches on. + wrap: (entry, body) => + `class Cap_${entry} {\n void ${entry}(Service svc, User[] repos) {\n ${body}\n }\n}\n`, + shapes: [ + { id: 'plainChain', member: 'save', body: 'svc.getUser().save();' }, + { id: 'plainDeepChain', member: 'save', body: 'svc.getUser().address.save();' }, + { id: 'optionalChain', na: 'Java has no optional-chaining operator.' }, + { id: 'nonNullAssert', na: 'Java has no non-null assertion operator.' }, + { id: 'awaitParen', na: 'Java has no await expression in the core language.' }, + { id: 'explicitTypeArgs', member: 'save', body: 'svc.getTyped().save();' }, + { id: 'indexElement', member: 'save', body: 'repos[0].save();' }, + { + id: 'fieldReceiverCall', + member: 'save', + body: 'this.repo.save();', + raw: `class JavaHolder { + User repo; + void fieldReceiverCall() { + this.repo.save(); + } +} +`, + }, + { id: 'decoratedReceiverBase', na: 'Java `this` carries no type decoration.' }, + { + id: 'decoratedFieldType', + na: "Java's only field-type decorations are containers (arrays, generics), which change the member set and are measured by indexElement rather than by a type-preserving strip.", + }, + ], + }, + { + lang: 'csharp', + ext: '.cs', + support: { + 'Models.cs': `using System.Threading.Tasks; + +public class Address { + public void Save() {} +} + +public class User { + public Address Address; + public void Save() {} +} + +public class Service { + public User GetUser() { return new User(); } + public async Task GetUserAsync() { return new User(); } + public User GetTyped() { return new User(); } +} +`, + }, + header: `using System.Threading.Tasks;\n`, + wrap: (entry, body) => + `class Cap_${entry} {\n async Task ${entry}(Service svc, User[] repos) {\n ${body}\n }\n}\n`, + shapes: [ + { id: 'plainChain', member: 'Save', body: 'svc.GetUser().Save();' }, + { id: 'plainDeepChain', member: 'Save', body: 'svc.GetUser().Address.Save();' }, + { id: 'optionalChain', member: 'Save', body: 'svc?.GetUser().Save();' }, + { id: 'nonNullAssert', member: 'Save', body: 'svc!.GetUser().Save();' }, + { id: 'awaitParen', member: 'Save', body: '(await svc.GetUserAsync()).Save();' }, + { id: 'explicitTypeArgs', member: 'Save', body: 'svc.GetTyped().Save();' }, + { id: 'indexElement', member: 'Save', body: 'repos[0].Save();' }, + { + id: 'fieldReceiverCall', + member: 'Save', + body: 'this.repo.Save();', + raw: `class CsHolder { + User repo; + void fieldReceiverCall() { + this.repo.Save(); + } +} +`, + }, + { id: 'decoratedReceiverBase', na: 'C# `this` carries no type decoration.' }, + { + id: 'decoratedFieldType', + member: 'Save', + body: 'this.repo.Save();', + raw: `class CsNullableHolder { + User? repo; + void decoratedFieldType() { + this.repo.Save(); + } +} +`, + note: 'nullable reference-type field', + }, + ], + }, + { + lang: 'ruby', + ext: '.rb', + support: { + 'models.rb': `class Address + def save; end +end + +class User + attr_reader :address + def initialize + @address = Address.new + end + def save; end +end + +class Service + def get_user + User.new + end +end +`, + }, + header: `require_relative 'models'\n`, + wrap: (entry, body) => `def ${entry}(svc, repos)\n ${body}\nend\n`, + shapes: [ + { id: 'plainChain', member: 'save', body: 'svc.get_user.save' }, + { id: 'plainDeepChain', member: 'save', body: 'svc.get_user.address.save' }, + { id: 'optionalChain', member: 'save', body: 'svc&.get_user.save', note: 'safe navigation' }, + { id: 'nonNullAssert', na: 'Ruby has no non-null assertion operator.' }, + { id: 'awaitParen', na: 'Ruby has no await expression in the core language.' }, + { id: 'explicitTypeArgs', na: 'Ruby has no type-argument syntax.' }, + { id: 'indexElement', member: 'save', body: 'repos[0].save' }, + { + id: 'fieldReceiverCall', + member: 'save', + body: '@repo.save', + raw: `class RbHolder + def initialize + @repo = User.new + end + + def fieldReceiverCall + @repo.save + end +end +`, + }, + { id: 'decoratedReceiverBase', na: 'Ruby method receivers carry no type decoration.' }, + { + id: 'decoratedFieldType', + na: 'Ruby instance variables carry no declared type, so there is no decoration to strip.', + }, + ], + }, + { + lang: 'rust', + ext: '.rs', + support: { + 'models.rs': `pub struct Address; + +impl Address { + pub fn save(&self) {} +} + +pub struct User { + pub address: Address, +} + +impl User { + pub fn save(&self) {} +} + +pub struct Service; + +impl Service { + pub fn get_user(&self) -> User { + User { address: Address } + } + pub fn get_typed(&self) -> User { + User { address: Address } + } +} +`, + }, + header: `mod models;\nuse models::{Address, Service, User};\n`, + wrap: (entry, body) => `fn ${entry}(svc: &Service, repos: &Vec) {\n ${body}\n}\n`, + shapes: [ + { id: 'plainChain', member: 'save', body: 'svc.get_user().save();' }, + { id: 'plainDeepChain', member: 'save', body: 'svc.get_user().address.save();' }, + { + id: 'optionalChain', + na: 'Rust `?` is error propagation, not optional chaining on a receiver.', + }, + { id: 'nonNullAssert', na: 'Rust has no non-null assertion operator.' }, + { + id: 'awaitParen', + na: 'Rust `async fn` declares the AWAITED type; `.await` is postfix and mints no wrapper the emitter models as an async container.', + }, + { id: 'explicitTypeArgs', member: 'save', body: 'svc.get_typed::().save();' }, + { id: 'indexElement', member: 'save', body: 'repos[0].save();' }, + { + id: 'fieldReceiverCall', + member: 'save', + body: 'self.repo.save();', + raw: `struct RsHolder { + repo: User, +} + +impl RsHolder { + fn fieldReceiverCall(&self) { + self.repo.save(); + } +} +`, + }, + { + // Rust is the one language besides Go whose method receiver carries + // written decoration: `&self` / `&mut self` rather than a bare `self`. + id: 'decoratedReceiverBase', + member: 'save', + body: 'self.repo.save();', + raw: `struct RsMutHolder { + repo: User, +} + +impl RsMutHolder { + fn decoratedReceiverBase(&mut self) { + self.repo.save(); + } +} +`, + note: '&mut self receiver', + }, + { + id: 'decoratedFieldType', + member: 'save', + body: 'self.repo.save();', + raw: `struct RsBoxHolder { + repo: Box, +} + +impl RsBoxHolder { + fn decoratedFieldType(&self) { + self.repo.save(); + } +} +`, + note: 'Box — deref-transparent smart pointer', + }, + ], + }, + { + lang: 'c', + ext: '.c', + support: { + 'models.h': `#pragma once + +typedef struct Address { + void (*save)(void); +} Address; + +typedef struct User { + Address* address; + void (*save)(void); +} User; +`, + }, + header: `#include "models.h"\n`, + wrap: (entry, body) => `void ${entry}(User* u, User* repos) {\n ${body}\n}\n`, + // C has no method dispatch. The only receiver-shaped construct is a call + // through a struct function-pointer field, so the chain shapes that assume a + // method on a returned value are inapplicable rather than gaps. + shapes: [ + { + id: 'plainChain', + na: 'C has no method call on a returned value; dispatch is through struct function-pointer fields only.', + }, + { id: 'plainDeepChain', member: 'save', body: 'u->address->save();' }, + { id: 'optionalChain', na: 'C has no optional-chaining operator.' }, + { id: 'nonNullAssert', na: 'C has no non-null assertion operator.' }, + { id: 'awaitParen', na: 'C has no await expression.' }, + { id: 'explicitTypeArgs', na: 'C has no generics.' }, + { id: 'indexElement', member: 'save', body: 'repos[0].save();' }, + { id: 'fieldReceiverCall', member: 'save', body: 'u->save();' }, + { + id: 'decoratedReceiverBase', + na: 'C has no method receiver, so there is no receiver decoration.', + }, + { + id: 'decoratedFieldType', + na: 'A C struct field is a function pointer, not a typed receiver whose decoration could be stripped.', + }, + ], + }, + { + // Kotlin, Swift and Dart are vendored OPTIONAL grammars shipping only + // darwin-arm64 prebuilds. On any other host these rows come back + // GRAMMAR-UNAVAILABLE and are skipped by the gate in both directions, so the + // cells below are the contract for a host that CAN load them rather than a + // measurement this machine produced. + lang: 'kotlin', + ext: '.kt', + support: { + 'models.kt': `class Address { + fun save() {} +} + +class User { + val address: Address = Address() + fun save() {} +} + +class Service { + fun getUser(): User = User() + suspend fun getUserAsync(): User = User() + fun getTyped(): User = User() +} +`, + }, + header: '', + wrap: (entry, body) => + `suspend fun ${entry}(svc: Service, repos: List) {\n ${body}\n}\n`, + shapes: [ + { id: 'plainChain', member: 'save', body: 'svc.getUser().save()' }, + { id: 'plainDeepChain', member: 'save', body: 'svc.getUser().address.save()' }, + { id: 'optionalChain', member: 'save', body: 'svc?.getUser()?.save()' }, + { id: 'nonNullAssert', member: 'save', body: 'svc!!.getUser().save()' }, + { id: 'awaitParen', member: 'save', body: '(svc.getUserAsync()).save()' }, + { id: 'explicitTypeArgs', member: 'save', body: 'svc.getTyped().save()' }, + { id: 'indexElement', member: 'save', body: 'repos[0].save()' }, + { + id: 'fieldReceiverCall', + member: 'save', + body: 'this.repo.save()', + raw: `class KtHolder { + val repo: User = User() + fun fieldReceiverCall() { + this.repo.save() + } +} +`, + }, + { id: 'decoratedReceiverBase', na: 'Kotlin `this` carries no type decoration.' }, + { + id: 'decoratedFieldType', + member: 'save', + body: 'this.repo?.save()', + raw: `class KtNullableHolder { + val repo: User? = null + fun decoratedFieldType() { + this.repo?.save() + } +} +`, + note: 'nullable field type', + }, + ], + }, + { + lang: 'swift', + ext: '.swift', + support: { + 'models.swift': `class Address { + func save() {} +} + +class User { + var address: Address = Address() + func save() {} +} + +class Service { + func getUser() -> User { return User() } + func getUserAsync() async -> User { return User() } +} +`, + }, + header: '', + wrap: (entry, body) => `func ${entry}(svc: Service, repos: [User]) async {\n ${body}\n}\n`, + shapes: [ + { id: 'plainChain', member: 'save', body: 'svc.getUser().save()' }, + { id: 'plainDeepChain', member: 'save', body: 'svc.getUser().address.save()' }, + { id: 'optionalChain', member: 'save', body: 'svc.getUser().address?.save()' }, + { id: 'nonNullAssert', member: 'save', body: 'svc.getUser().address!.save()' }, + { id: 'awaitParen', member: 'save', body: '(await svc.getUserAsync()).save()' }, + { + id: 'explicitTypeArgs', + na: 'Swift infers generic parameters; there is no call-site type-argument spelling on a method.', + }, + { id: 'indexElement', member: 'save', body: 'repos[0].save()' }, + { + id: 'fieldReceiverCall', + member: 'save', + body: 'self.repo.save()', + raw: `class SwHolder { + var repo: User = User() + func fieldReceiverCall() { + self.repo.save() + } +} +`, + }, + { id: 'decoratedReceiverBase', na: 'Swift `self` carries no type decoration.' }, + { + id: 'decoratedFieldType', + member: 'save', + body: 'self.repo?.save()', + raw: `class SwOptionalHolder { + var repo: User? = nil + func decoratedFieldType() { + self.repo?.save() + } +} +`, + note: 'optional field type', + }, + ], + }, + { + lang: 'dart', + ext: '.dart', + support: { + 'models.dart': `class Address { + void save() {} +} + +class User { + Address address = Address(); + void save() {} +} + +class Service { + User getUser() => User(); + Future getUserAsync() async => User(); +} +`, + }, + header: `import 'models.dart';\n`, + wrap: (entry, body) => + `Future ${entry}(Service svc, List repos) async {\n ${body}\n}\n`, + shapes: [ + { id: 'plainChain', member: 'save', body: 'svc.getUser().save();' }, + { id: 'plainDeepChain', member: 'save', body: 'svc.getUser().address.save();' }, + { id: 'optionalChain', member: 'save', body: 'svc.getUser().address?.save();' }, + { id: 'nonNullAssert', member: 'save', body: 'svc.getUser().address!.save();' }, + { id: 'awaitParen', member: 'save', body: '(await svc.getUserAsync()).save();' }, + { id: 'explicitTypeArgs', na: 'Dart infers generic parameters at a method call site.' }, + { id: 'indexElement', member: 'save', body: 'repos[0].save();' }, + { + id: 'fieldReceiverCall', + member: 'save', + body: 'this.repo.save();', + raw: `class DartHolder { + User repo = User(); + void fieldReceiverCall() { + this.repo.save(); + } +} +`, + }, + { id: 'decoratedReceiverBase', na: 'Dart `this` carries no type decoration.' }, + { + id: 'decoratedFieldType', + member: 'save', + body: 'this.repo?.save();', + raw: `class DartNullableHolder { + User? repo; + void decoratedFieldType() { + this.repo?.save(); + } +} +`, + note: 'nullable field type', + }, ], }, ]; +/** + * Every corpus declares a cell for every SHAPE_ID, every `na` carries a reason, + * and no id is declared twice or unknown. Throws rather than warning: a matrix + * with a hole reports as if the hole were measured, which is the exact failure + * the N/A-with-reason rule exists to prevent. + */ +function assertMatrixComplete(corpora) { + const problems = []; + for (const corpus of corpora) { + // A language may carry EXTRA cells beyond the canonical axis when they + // discriminate something the axis cannot — PHP's annotated/unannotated + // return-type pair, C++'s pointer/value base pair. They must be declared, so + // an extra stays a deliberate diagnostic rather than a typo'd canonical id. + const known = new Set([...SHAPE_IDS, ...(corpus.extraShapeIds ?? [])]); + const seen = new Set(); + for (const shape of corpus.shapes) { + if (!known.has(shape.id)) { + problems.push( + `${corpus.lang}: shape id "${shape.id}" is neither canonical nor in extraShapeIds`, + ); + } + if (seen.has(shape.id)) problems.push(`${corpus.lang}: duplicate shape id "${shape.id}"`); + seen.add(shape.id); + if (shape.na !== undefined && String(shape.na).trim() === '') { + problems.push(`${corpus.lang}.${shape.id}: N/A with no reason`); + } + if (shape.na === undefined && shape.body === undefined) { + problems.push(`${corpus.lang}.${shape.id}: neither a body nor an N/A reason`); + } + } + for (const id of SHAPE_IDS) { + if (!seen.has(id)) problems.push(`${corpus.lang}: missing cell for "${id}"`); + } + } + if (problems.length > 0) { + throw new Error( + `[receiver-resolution] shape matrix is incomplete:\n ${problems.join('\n ')}`, + ); + } +} + function classify(corpus, result) { const calls = []; for (const rel of result.graph.iterRelationships()) { @@ -233,6 +1235,9 @@ function classify(corpus, result) { ); return corpus.shapes.map((shape) => { + if (shape.na !== undefined) { + return { shape: null, id: shape.id, note: shape.na, state: 'N/A', siteKind: null }; + } const hasEdge = calls.some((call) => call.from === shape.id && call.to === shape.member); // A drop belongs to this shape when it names the shape's member and sits on // the shape's own line — matched on the generated source, not on an id. @@ -249,8 +1254,48 @@ function classify(corpus, result) { }); } +/** Every cell for a language whose parser could not be loaded. Neither passes + * nor fails the gate — an unbuilt optional grammar is a fact about the host, + * not a resolution result. */ +function grammarUnavailableRow(corpus, reason) { + return corpus.shapes.map((shape) => ({ + shape: shape.body ?? null, + id: shape.id, + note: reason, + state: 'GRAMMAR-UNAVAILABLE', + siteKind: null, + })); +} + +/** A parse failure caused by a missing vendored grammar, as opposed to a real + * pipeline error. The vendored loader throws with the grammar package name, and + * a language whose grammar never loaded produces a graph with no nodes at all. */ +function isGrammarLoadFailure(error) { + const text = `${error?.message ?? ''}`; + return ( + text.includes('tree-sitter-') && + (text.includes('Cannot find module') || + text.includes('No native build') || + text.includes('not supported on this platform') || + text.includes('prebuild')) + ); +} + async function runShapeArm() { + assertMatrixComplete(CORPORA); const results = []; + for (const [lang, reason] of Object.entries(NA_LANGUAGES)) { + results.push({ + language: lang, + shapes: SHAPE_IDS.map((id) => ({ + shape: null, + id, + note: reason, + state: 'N/A', + siteKind: null, + })), + }); + } for (const corpus of CORPORA) { const root = fs.mkdtempSync(path.join(os.tmpdir(), `gn-recv-${corpus.lang}-`)); try { @@ -263,6 +1308,8 @@ async function runShapeArm() { const lineOfShape = new Map(); let text = corpus.header; for (const shape of corpus.shapes) { + // N/A cells are declarations about the language, not code to measure. + if (shape.na !== undefined) continue; // A shape whose receiver needs surrounding structure (a class with a // property, say) supplies `raw`; everything else is wrapped in a plain // function. Either way the statement itself is `body`, and its offset @@ -275,7 +1322,27 @@ async function runShapeArm() { } fs.writeFileSync(path.join(root, `main${corpus.ext}`), text, 'utf8'); - const result = await runPipelineFromRepo(root, () => {}); + let result; + try { + result = await runPipelineFromRepo(root, () => {}); + } catch (error) { + if (!isGrammarLoadFailure(error)) throw error; + results.push({ + language: corpus.lang, + shapes: grammarUnavailableRow(corpus, `parser failed to load: ${error.message}`), + }); + continue; + } + // A grammar that soft-fails produces no parse and therefore no nodes. + // Reporting that as ten resolution gaps would be indistinguishable from a + // real regression, so it gets its own state. + if ([...result.graph.iterNodes()].length === 0) { + results.push({ + language: corpus.lang, + shapes: grammarUnavailableRow(corpus, 'parser produced no nodes — grammar unavailable'), + }); + continue; + } // Attach the owning shape to each drop by line before classifying. for (const outcome of result.resolutionOutcomes ?? []) { for (const [id, line] of lineOfShape) { @@ -303,12 +1370,32 @@ async function runCountArm(repoPath) { const byKind = new Map(); const byExtension = new Map(); const callDropsByExtension = new Map(); + // Shape census over the CALL drops only, so the answer to "which kind of + // receiver are we losing?" is not diluted by property reads and writes. + const callDropsByShape = new Map(); + // THE number that matters now. A drop whose receiver is rooted outside the + // indexed program (`System.out.println`, `fetch(...)`) reaches code with no + // node to point an edge at — nothing was lost. Only `in-program` and + // `unknown` drops represent a real resolver gap, and only those make an + // `impact` count a lower bound. Measured on this corpus: 76 external, 20 + // in-program, 6 unknown — i.e. three quarters of the raw count was the + // program boundary rather than uncertainty about the program. + const callDropsByOrigin = new Map(); + const callDropsByShapeAndExt = new Map(); for (const drop of drops) { const kind = drop.siteKind ?? '<>'; byKind.set(kind, (byKind.get(kind) ?? 0) + 1); const ext = path.extname(drop.filePath); byExtension.set(ext, (byExtension.get(ext) ?? 0) + 1); - if (kind === 'call') callDropsByExtension.set(ext, (callDropsByExtension.get(ext) ?? 0) + 1); + if (kind === 'call') { + callDropsByExtension.set(ext, (callDropsByExtension.get(ext) ?? 0) + 1); + const shape = drop.receiverShape ?? '<>'; + callDropsByShape.set(shape, (callDropsByShape.get(shape) ?? 0) + 1); + const origin = drop.receiverOrigin ?? '<>'; + callDropsByOrigin.set(origin, (callDropsByOrigin.get(origin) ?? 0) + 1); + const pair = `${ext} ${shape}`; + callDropsByShapeAndExt.set(pair, (callDropsByShapeAndExt.get(pair) ?? 0) + 1); + } } const sortDesc = (map) => Object.fromEntries([...map.entries()].sort((a, b) => b[1] - a[1])); @@ -319,6 +1406,9 @@ async function runCountArm(repoPath) { totalDropsAllKinds: drops.length, bySiteKind: sortDesc(byKind), callDropsByExtension: sortDesc(callDropsByExtension), + callDropsByShape: sortDesc(callDropsByShape), + callDropsByOrigin: sortDesc(callDropsByOrigin), + callDropsByShapeAndExtension: sortDesc(callDropsByShapeAndExt), allDropsByExtension: sortDesc(byExtension), }; } @@ -399,9 +1489,8 @@ async function runPerfArm(repoPath, reps) { // --------------------------------------------------------------------------- const KNOWN_BLIND = [ - "Case 0's gate is a C-family punctuation test (`.` or `(`), so PHP `->` and `::` receivers record no drop.", - '`repos[0].save()` has neither `.` nor `(` in its receiver — same result.', - '`?.` and explicit type arguments produce no reference site at all, so no drop is recorded.', + 'Case 0 is reached by a receiver-TEXT punctuation test (`.` or `(`) OR by a minted receiver chain. A receiver spelled without that punctuation — a subscript, PHP `->`/`::` — therefore records a drop only where its emitter mints a chain; where none is minted the call still vanishes with the recorder blind to it.', + 'A drop is recorded only while `compoundReceiverUnresolved` stays true. When the cascade TYPES the receiver but then finds no member on it, the flag is false and no drop is recorded even though no edge was emitted — so an absent drop is not evidence that a site resolved.', 'Every count is therefore a LOWER BOUND on a known-biased population. A later delta must be read against the same bias.', ]; @@ -434,11 +1523,22 @@ function projection(output) { totalDropsAllKinds: output.countArm.totalDropsAllKinds, bySiteKind: output.countArm.bySiteKind, callDropsByExtension: output.countArm.callDropsByExtension, + callDropsByShape: output.countArm.callDropsByShape, + callDropsByOrigin: output.countArm.callDropsByOrigin, }, }; } -/** Every leaf whose value differs, as `dotted.path: expected -> actual`. */ +/** Every leaf whose value differs, as `dotted.path: expected -> actual`. + * + * `GRAMMAR-UNAVAILABLE` is skipped on EITHER side. That state is a fact about + * the host, not about the code: the optional grammars are absent when a run + * sets `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` or when no vendored prebuild matches + * the platform, so the same commit legitimately measures those cells on one + * runner and cannot on another. Gating on them would fail CI for the + * environment it ran in rather than for a regression — the opposite of what the + * state was added to prevent. Skipping both directions is what makes "neither + * passes nor fails the gate" true. */ function drift(expected, actual, prefix = '') { const out = []; const keys = new Set([...Object.keys(expected ?? {}), ...Object.keys(actual ?? {})]); @@ -446,6 +1546,7 @@ function drift(expected, actual, prefix = '') { const want = expected?.[key]; const got = actual?.[key]; const at = prefix === '' ? key : `${prefix}.${key}`; + if (want === 'GRAMMAR-UNAVAILABLE' || got === 'GRAMMAR-UNAVAILABLE') continue; if (want !== null && typeof want === 'object') out.push(...drift(want, got ?? {}, at)); else if (want !== got) out.push(`${at}: ${JSON.stringify(want)} -> ${JSON.stringify(got)}`); } @@ -455,11 +1556,26 @@ function drift(expected, actual, prefix = '') { const args = process.argv.slice(2); const corpusIndex = args.indexOf('--corpus'); const check = args.includes('--check'); +const updateBaseline = args.includes('--update-baseline'); +// `--update-baseline` defaults the corpus for the same reason `--check` does: +// the two must measure the SAME thing or the gate can never be satisfied. It +// previously did not, so a bare `--update-baseline` skipped the count arm and +// then crashed dereferencing `output.countArm` in `projection` — which is the +// command the `--check` failure message tells you to run. Writing a baseline +// with no count arm would have been worse: the gate would go green while +// silently measuring nothing. const corpusPath = - corpusIndex === -1 ? (check ? DEFAULT_CORPUS : undefined) : path.resolve(args[corpusIndex + 1]); + corpusIndex === -1 + ? check || updateBaseline + ? DEFAULT_CORPUS + : undefined + : path.resolve(args[corpusIndex + 1]); const output = { knownBlind: KNOWN_BLIND }; -if (corpusPath === undefined || check || args.includes('--shapes')) { +// `--update-baseline` needs BOTH arms, because `projection` writes both and +// `--check` compares both. Running one arm would write a baseline missing the +// other, and the next `--check` would then crash rather than fail a comparison. +if (corpusPath === undefined || check || updateBaseline || args.includes('--shapes')) { output.shapeArm = await runShapeArm(); } if (corpusPath !== undefined) { @@ -471,7 +1587,7 @@ if (perfIndex !== -1) { output.perfArm = await runPerfArm(corpusPath ?? DEFAULT_CORPUS, Number.isFinite(reps) ? reps : 3); } -if (args.includes('--update-baseline')) { +if (updateBaseline) { fs.writeFileSync(BASELINE_PATH, `${JSON.stringify(projection(output), null, 2)}\n`, 'utf8'); console.error(`[receiver-resolution] wrote ${BASELINE_PATH}`); } else if (check) { diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index d0ef387ca..8b712832c 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -1,12 +1,17 @@ { "_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/-* fixture corpus + a 20-entity synthetic source (correctness gate; re-baseline intentionally on a legitimate capture change). scaling_budget = max allowed (t800/t250)/(800/250); ~1.0 is linear, ~3.2 is quadratic. The synthetic source is now HERITAGE-BEARING for every language (each Entity extends/implements/embeds/uses-trait/conforms-to a shared base) so the #1951 @reference.inherits synth is gated at scale, not just the base capture loop. All languages thread the tree-sitter captured node instead of re-deriving it with findNodeAtRange(tree.rootNode,...) per match, so all are linear (go #1915, python #1918, ruby/php/rust/csharp #1951, java #1956).", "go": { - "fingerprint": "5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb", + "fingerprint": "e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3", "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 \u2014 the tag is added to existing call matches, never a new match \u2014 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." }, "cobol": { "fingerprint": "d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e", @@ -25,7 +30,7 @@ "_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": { - "fingerprint": "7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5", + "fingerprint": "856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature/cv metadata. Prior dde874d2c30bda9f634f9799281a66de800cad9f76cf65e7c31839e2ae9da9ff -> 57860dd2a8d4b06c6d2dd0d854c08b781faee3da8f2b6c42ba0c68a9f70e5ccb; scaling 1.090 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C++ overload-aware function/reference/member-pointer flow facts with invocation/constructor-result suppression. Prior 3a503a1513e7eede3f7a223dcce0896c06d15bdfa920445224c9025848c0d710 -> dde874d2c30bda9f634f9799281a66de800cad9f76cf65e7c31839e2ae9da9ff; scaling 1.034 < 1.5.", @@ -37,20 +42,22 @@ "_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).", "_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_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_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." }, "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.", - "fingerprint": "8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855", + "fingerprint": "476d98a7cc659951c315d63319c8077bbcf0e5f3ec12d32ed773992a1f3a2adc", "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 f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a -> 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1; scaling 1.061 < 1.5.", "_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_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." }, "rust": { - "fingerprint": "83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c", + "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_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.", @@ -61,35 +68,39 @@ "_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_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_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." }, "php": { - "fingerprint": "3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28", + "fingerprint": "b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c", "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 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_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." }, "ruby": { - "fingerprint": "fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83", + "fingerprint": "1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57", "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 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.", "_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_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." }, "swift": { - "fingerprint": "a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b", + "fingerprint": "2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7", "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_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." }, "dart": { "fingerprint": "ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73", @@ -102,7 +113,7 @@ "_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." }, "java": { - "fingerprint": "310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee", + "fingerprint": "a9943355e945e03ddb87c800f4cc1f62b3d04feefb3ec64c258d8e0bb3b3fcd9", "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.", @@ -114,16 +125,18 @@ "_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_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." }, "java-local-types": { - "fingerprint": "3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236", + "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_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." }, "typescript": { - "fingerprint": "9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc", + "fingerprint": "cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff", "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.", @@ -133,10 +146,11 @@ "_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_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." }, "javascript": { - "fingerprint": "83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc", + "fingerprint": "806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594", "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 b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3 -> 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b; scaling 1.050 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior 917a9cd975ba035bdad71fdb70cd72eeddec58c25797e5a1addfa6172808a55c -> b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3; scaling 1.093 < 1.5.", @@ -146,10 +160,11 @@ "_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_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." }, "kotlin": { - "fingerprint": "d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1", + "fingerprint": "efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2", "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 bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12 -> e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1; scaling 1.090 < 1.5.", "_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.", @@ -159,6 +174,8 @@ "_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_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." } } diff --git a/gitnexus/src/core/ingestion/languages/cpp/interpret.ts b/gitnexus/src/core/ingestion/languages/cpp/interpret.ts index b330a3fc0..1627232c5 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/interpret.ts @@ -77,7 +77,76 @@ export function interpretCppTypeBinding(captures: CaptureMatch): ParsedTypeBindi source = 'annotation'; } - return { boundName: name, rawTypeName: normalizeCppTypeName(type), source }; + const declaredSpelling = cppPointerSpelling(captures, type, name); + return declaredSpelling === undefined + ? { boundName: name, rawTypeName: normalizeCppTypeName(type), source } + : { boundName: name, rawTypeName: normalizeCppTypeName(type), declaredSpelling, source }; +} + +/** Anchors whose capture spans a whole declaration, so the declarator — and + * with it the pointer — is inside the captured text. */ +const CPP_WHOLE_DECLARATION_ANCHORS = [ + '@type-binding.parameter', + '@type-binding.annotation', + '@type-binding.assignment', +] as const; + +const squeezeWhitespace = (text: string): string => text.replace(/\s+/g, ''); + +/** + * `User* repos` — the written spelling, when tree-sitter-cpp hangs the pointer + * off the DECLARATOR rather than the type. + * + * `@type-binding.type` is a bare `User` there, so the binding records `User` and + * nothing downstream can tell `repos[0]` (pointer subscript, element `User`) + * from `grid[0]` on a class with `operator[]` (element: whatever the operator + * returns). The index step needs that distinction and declines without it, so + * the pointer is reconstructed here — at the capture layer, from the anchor, + * which spans the whole declaration. + * + * EXACT SHAPE ONLY: the declaration must be precisely ` * ` once + * whitespace is removed. `const T*`, `T**`, an array declarator, a reference, + * a function pointer — none match, and none get a spelling, so the index step + * declines for them. A loose match would hand back container evidence that is + * not there and re-mint the confidently wrong edge this exists to prevent. + */ +function cppPointerSpelling( + captures: CaptureMatch, + typeText: string, + nameText: string, +): string | undefined { + // The target below always contains a literal `*`, so an anchor whose text has + // none can never equal it. Testing that FIRST is the whole optimisation: this + // runs on every C++ type binding and the overwhelming majority declare no + // pointer, so the squeezes (three `replace` passes over the declaration, the + // type and the name) are skipped entirely for them. `target` is built lazily + // on the first star-bearing anchor for the same reason. + let target: string | undefined; + for (const anchor of CPP_WHOLE_DECLARATION_ANCHORS) { + const text = captures[anchor]?.text; + if (text === undefined || !text.includes('*')) continue; + if (target === undefined) { + const type = squeezeWhitespace(typeText); + const name = squeezeWhitespace(nameText); + if (type.length === 0 || name.length === 0) return undefined; + target = `${type}*${name}`; + } + if (squeezeWhitespace(text) === target) return `${typeText.trim()}*`; + } + return undefined; +} + +/** Declaration specifiers that carry no type identity. Held in ONE place so + * every consumer agrees on the keyword list — the resolver's element-type + * hook strips exactly the same set before deciding whether a spelling is a + * pointer, and a silently diverging copy there would make the two disagree + * about what `const T*` is. */ +const CPP_SPECIFIER_RE = + /\b(const|volatile|restrict|static|extern|inline|mutable|constexpr|consteval)\b/g; + +/** Remove C++ declaration specifiers from `text` and trim the result. */ +export function stripCppSpecifiers(text: string): string { + return text.replace(CPP_SPECIFIER_RE, '').trim(); } /** @@ -90,11 +159,7 @@ export function interpretCppTypeBinding(captures: CaptureMatch): ParsedTypeBindi * by base-name fallback in resolveClassBindingForName. */ export function normalizeCppTypeName(text: string): string { - let t = text.trim(); - // Strip const, volatile, restrict, static, extern, inline, mutable, constexpr - t = t - .replace(/\b(const|volatile|restrict|static|extern|inline|mutable|constexpr|consteval)\b/g, '') - .trim(); + let t = stripCppSpecifiers(text); // Strip pointer stars while (t.endsWith('*')) t = t.slice(0, -1).trim(); while (t.startsWith('*')) t = t.slice(1).trim(); diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index a1b891b32..13f21183e 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -9,6 +9,7 @@ import { tagNamespacePrefixes, } from '../../scope-resolution/scope/walkers.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { extractElementTypeFromString } from '../../type-extractors/shared.js'; import { cppProvider } from '../c-cpp.js'; import { cppArityCompatibility } from './arity.js'; import { CPP_CONVERSION_ONLY_ARG_TYPE_PREFIXES, cppConversionRank } from './conversion-rank.js'; @@ -46,6 +47,12 @@ import { clearCppMemberLookupState, resolveCppReceiverMember, } from './member-lookup.js'; +import { stripCppSpecifiers } from './interpret.js'; + +/** A pointee worth binding: a bare identifier, not `T**`, `T[]`, `A::B` or a + * template spelling. Hoisted — a literal here would mint a fresh RegExp on + * every subscript the resolver folds. */ +const CPP_SIMPLE_POINTEE_RE = /^[A-Za-z_]\w*$/; /** * Per-pass memo of the augmented `#include`-resolution file set @@ -247,6 +254,29 @@ export const cppScopeResolver: ScopeResolver = { return mro.includes(lhsDef.nodeId); }, + // Subscript route only — C++ collection views are method calls. + // + // Two container spellings reach a subscript. A POINTER is one of them: `p[i]` + // on a `User*` is pointer arithmetic yielding a `User`, so the trailing `*` is + // peeled here rather than by `stripTypePreservingDecoration` (C++ declares + // none) — and peeling it at a bare class lookup instead would be wrong, since + // that is the route by which `p->m()` must keep seeing `User`'s own members. + // The other is a template container (`std::vector`), left to the shared + // extractor. + // + // Fed the annotation AS WRITTEN, since `normalizeCppTypeName` strips both the + // star and the array brackets at capture. `undefined` = "not a container", so + // an `operator[]`-bearing class does not fold `grid[0]` onto `Grid`. + elementTypeOf: (containerType, via) => { + if (via.kind !== 'index') return undefined; + const t = stripCppSpecifiers(containerType); + if (t.endsWith('*')) { + const pointee = t.slice(0, -1).trim(); + return CPP_SIMPLE_POINTEE_RE.test(pointee) ? pointee : undefined; + } + return extractElementTypeFromString(t); + }, + // C++ is statically typed — disable field fallback heuristic fieldFallbackOnMethodLookup: false, // C++ needs return type propagation across #include boundaries diff --git a/gitnexus/src/core/ingestion/languages/csharp/accessor-unwrap.ts b/gitnexus/src/core/ingestion/languages/csharp/accessor-unwrap.ts index d0ea89789..bca732185 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/accessor-unwrap.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/accessor-unwrap.ts @@ -1,16 +1,18 @@ +import type { ElementAccessRoute } from '../../scope-resolution/contract/scope-resolver.js'; +import { extractElementTypeFromString } from '../../type-extractors/shared.js'; + /** - * C# collection-accessor unwrapping. + * C# container element-type unwrapping. * * When the compound-receiver resolver encounters a trailing * `.Values` / `.Keys` on a dotted member-access chain, it calls the - * provider's `unwrapCollectionAccessor` hook to find the element + * provider's `elementTypeOf` hook to find the element * type. This module supplies the C# implementation — recognizing * Dictionary-family generics and returning the value or key type. * - * Other languages (Python, Java, TypeScript) use method-call syntax - * for the same access (`.values()` / `.keys()`), which the compound- - * receiver's call-expression branch already handles; they leave this - * hook undefined. + * Other languages (Python, Java, TypeScript) use method-call syntax for the + * same access (`.values()` / `.keys()`), which the compound-receiver's + * call-expression branch already handles; they answer only the `index` route. */ /** Extract (K, V) from `Dictionary` / `IDictionary` / @@ -46,12 +48,19 @@ function extractDictionaryArgs(rawName: string): { key: string; value: string } * receiver / accessor combination we don't recognize, letting the * compound-receiver pass fall through to the regular field walk. */ -export function unwrapCsharpCollectionAccessor( - receiverType: string, - accessor: string, +export function unwrapCsharpElementType( + containerType: string, + via: ElementAccessRoute, ): string | undefined { - if (accessor !== 'Values' && accessor !== 'Keys') return undefined; - const args = extractDictionaryArgs(receiverType); + const args = extractDictionaryArgs(containerType); + // Subscript on a dictionary yields the VALUE type — `dict["k"]` is a V, never + // a K. Previously this route returned nothing at all for C#, because the + // dictionary parse below was reachable only from the accessor hook. + if (via.kind === 'index') { + if (args !== undefined) return args.value; + return extractElementTypeFromString(containerType); + } + if (via.name !== 'Values' && via.name !== 'Keys') return undefined; if (args === undefined) return undefined; - return accessor === 'Values' ? args.value : args.key; + return via.name === 'Values' ? args.value : args.key; } diff --git a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts index 469d52363..eb1d3b10d 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts @@ -21,7 +21,7 @@ import { } from './index.js'; import { populateCsharpNamespaceSiblings } from './namespace-siblings.js'; import { loadCsharpResolutionConfig, type CsharpResolutionConfig } from './resolution-config.js'; -import { unwrapCsharpCollectionAccessor } from './accessor-unwrap.js'; +import { unwrapCsharpElementType } from './accessor-unwrap.js'; const csharpScopeResolver: ScopeResolver = { // Construction is keyword-prefixed: `new Service(db).doWork()` (#2708). @@ -89,7 +89,7 @@ const csharpScopeResolver: ScopeResolver = { // `data.Values` / `data.Keys` on Dictionary-like receivers unwrap // to the value / key element type. Other languages use method-call // syntax for the same access and leave this hook undefined. - unwrapCollectionAccessor: unwrapCsharpCollectionAccessor, + elementTypeOf: unwrapCsharpElementType, // C# matches legacy DAG by collapsing member-call CALLS edges to // `(caller, target)` — multiple `g.Greet(...)` sites from Main diff --git a/gitnexus/src/core/ingestion/languages/go/captures.ts b/gitnexus/src/core/ingestion/languages/go/captures.ts index ffc624c87..baff677af 100644 --- a/gitnexus/src/core/ingestion/languages/go/captures.ts +++ b/gitnexus/src/core/ingestion/languages/go/captures.ts @@ -16,6 +16,31 @@ import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; +/** Range for a capture that marks no source span. */ +const ZERO_RANGE = Object.freeze({ startLine: 0, startCol: 0, endLine: 0, endCol: 0 }); + +/** + * The callee-position marker, allocated once for the whole process. + * + * This capture is a PRESENCE FLAG, not a payload: `scope-extractor.ts` reads it + * only as `match['@reference.callee-position'] !== undefined`, and the tag is + * listed in `KNOWN_SUB_TAGS`, so `anchorCaptureFor` never considers its range + * when picking a reference anchor. Nothing else in the pipeline touches it. + * (Its range used to duplicate `@reference.read`'s exactly, and that capture is + * still emitted, so the one place that scans every capture's range — the + * synthetic Module-scope span in `scope-extractor.ts` — sees the same maximum.) + * + * Minting it per site from the read node was therefore pure cost, and not a + * cheap one: `node.text` is an `input.substring` behind N-API index marshals, + * and `startPosition` / `endPosition` are two more round-trips each. A frozen + * singleton carries exactly the same information — its presence — for free. + */ +const CALLEE_POSITION_MARKER: Capture = Object.freeze({ + name: '@reference.callee-position', + range: ZERO_RANGE, + text: '', +}); + const GO_CALLABLE_CAPTURE_OPTIONS = { functionNodeTypes: new Set(['function_declaration', 'method_declaration', 'func_literal']), callNodeTypes: new Set(['call_expression']), @@ -45,6 +70,25 @@ const GO_CALLABLE_CAPTURE_OPTIONS = { }, } as const; +/** + * Is this `selector_expression` in CALLEE position — the `function` child of an + * enclosing `call_expression` — rather than free-standing? + * + * `h.dep.Work` in `h.dep.Work()` is in callee position; `f := h.dep.Work` (a + * method value, or a func-typed field value) is not. + * + * Callee position alone does NOT make the selector a non-read. Go dispatches + * `h.dep.Work()` through a func-typed struct field just as readily as through a + * method, and this predicate cannot tell the two apart — `call_expression` has + * the same shape either way, and the tail's declaration may live in another + * package. See {@link emitGoScopeCaptures} for what is done with the answer. + */ +function isCalleeOfMemberCall(node: SyntaxNode): boolean { + const parent = node.parent; + if (parent === null || parent.type !== 'call_expression') return false; + return parent.childForFieldName('function')?.id === node.id; +} + export function emitGoScopeCaptures( sourceText: string, _filePath: string, @@ -183,6 +227,34 @@ export function emitGoScopeCaptures( } } + // Mark — do NOT drop — the read site on a member call's callee. + // + // The `@reference.read` pattern matches every `selector_expression`, so + // `h.dep.Work()` yields THREE sites: the call on `Work`, a read on the inner + // `h.dep` (the genuine field read), and a read on the OUTER `h.dep.Work`. + // + // That third site is a phantom ONLY when `Work` is a method: it then + // resolves through `findOwnedMember` (which prefers methods over fields) and + // emits an ACCESSES edge to the METHOD duplicating the CALLS edge at the + // same position — `v.impl.DoWork()` emitting both `CALLS -> DoWork` and + // `ACCESSES -> DoWork`. + // + // When `Work` is a FUNC-TYPED STRUCT FIELD (`Work func() error` — callback + // structs, hook structs, hand-rolled mocks) the very same syntax is a + // genuine field read followed by an indirect call through the value it + // holds, and that read is the field's only ACCESSES evidence. Dropping the + // site here deleted it (#2782 review). + // + // Method-vs-field is not knowable at capture: `call_expression` has the same + // shape either way and the tail's declaration may live in another package. + // So the capture layer records the POSITION and edge emission — which knows + // the resolved target's kind — decides. See `ReferenceSite.inCalleePosition` + // and `tryEmitEdge`. + const readNode = nodeMap['@reference.read']; + if (readNode !== undefined && isCalleeOfMemberCall(readNode)) { + grouped['@reference.callee-position'] = CALLEE_POSITION_MARKER; + } + // Structural receiver chain for a call whose receiver is itself an // expression, so resolution can type it by folding over structure // instead of re-parsing the receiver's source text. Self-gating: a diff --git a/gitnexus/src/core/ingestion/languages/go/interpret.ts b/gitnexus/src/core/ingestion/languages/go/interpret.ts index c2711a27a..d3a9d6113 100644 --- a/gitnexus/src/core/ingestion/languages/go/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/go/interpret.ts @@ -79,11 +79,23 @@ export function interpretGoTypeBinding(captures: CaptureMatch): ParsedTypeBindin return { boundName: name, rawTypeName: normalizedType, source }; } +/** Shallow `map[K]V` spelling match — the key is anything up to the first `]`, + * so a nested map key (`map[map[a]b]V`) is deliberately NOT recognised. Held + * in one place so the capture-side normalizers and the resolver's element-type + * hook cannot drift on what counts as a map spelling. */ +const GO_MAP_VALUE_RE = /^map\[[^\]]+\]\s*(.+)$/; + +/** `map[K]V` → `V`, trimmed. `undefined` when `text` is not a map spelling. */ +export function goMapValueType(text: string): string | undefined { + const match = GO_MAP_VALUE_RE.exec(text); + return match === null ? undefined : match[1].trim(); +} + export function normalizeGoTypeName(text: string): string { let t = text.trim(); t = stripGoOuterTypePrefixes(t); - const mapMatch = t.match(/^map\[[^\]]+\]\s*(.+)$/); - if (mapMatch) t = mapMatch[1].trim(); + const mapValue = goMapValueType(t); + if (mapValue !== undefined) t = mapValue; t = stripGoOuterTypePrefixes(t.replace(/^(?:<-)?chan(?:<-)?\s+/, '')); if (t.startsWith('func(')) { const retMatch = t.match(/^func\([^)]*\)\s*(.*)$/); @@ -111,8 +123,8 @@ export function normalizeGoReturnType(text: string): string { t = t.slice(1, closeIdx).trim(); } t = stripGoOuterTypePrefixes(t); - const mapMatch = t.match(/^map\[[^\]]+\]\s*(.+)$/); - if (mapMatch) t = mapMatch[1].trim(); + const mapValue = goMapValueType(t); + if (mapValue !== undefined) t = mapValue; t = stripGoOuterTypePrefixes(t.replace(/^(?:<-)?chan(?:<-)?\s+/, '')); if (t.startsWith('func(')) { const retMatch = t.match(/^func\([^)]*\)\s*(.*)$/); diff --git a/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts index e55595a63..ff891d079 100644 --- a/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts @@ -15,6 +15,11 @@ import { import { detectGoInterfaceImplementations } from './interface-impls.js'; import { populateGoRangeBindings } from './range-binding.js'; import { expandGoWildcardNames } from './expand-wildcards.js'; +import { goMapValueType } from './interpret.js'; + +/** Slice `[]T` and array `[N]T` / `[...]T` → the element spelling. Hoisted — + * a literal inside the hook would mint a fresh RegExp per folded subscript. */ +const GO_SLICE_ELEMENT_RE = /^\[[^\]]*\]\s*(.+)$/; export const goScopeResolver: ScopeResolver = { language: SupportedLanguages.Go, @@ -33,6 +38,41 @@ export const goScopeResolver: ScopeResolver = { arityCompatibility: (callsite, def) => goArityCompatibility(def, callsite), + // Only `*` — a pointer leaves the method set reachable by selector unchanged, + // so `*Host` and `Host` name the same class for receiver typing. `[]` and + // `map[…]` are deliberately NOT stripped here: they are containers whose + // member set differs from the element's, and unwrapping them belongs to the + // index step that consumed a subscript. (Field bindings never reach this + // anyway — `normalizeGoTypeName` already strips them at capture. The one + // binding that arrives decorated is the receiver self-binding, kept raw on + // purpose for `method-owners.ts`.) + stripTypePreservingDecoration: (typeName) => + typeName.startsWith('*') ? typeName.slice(1).trim() : undefined, + + // The subscript counterpart of the stripper above, and the reason the two are + // separate hooks: `*` is safe to strip at any class lookup, `[]` / `map[K]` + // only where the source actually indexed. Answers the `index` route only — + // Go has no property-style collection view. + // + // Reads the AS-WRITTEN spelling (`[]*User`), not the capture-normalized name: + // `normalizeGoTypeName` already collapsed that to `User`, which is exactly the + // ambiguity that made an unanswered index step fold onto the container. The + // element is returned STILL DECORATED (`*User`) — the index step looks it up + // through `stripTypePreservingDecoration` above, so the pointer resolves. + elementTypeOf: (containerType, via) => { + if (via.kind !== 'index') return undefined; + const t = containerType.trim(); + // `map[K]V` — a subscript yields V. Literally the same matcher + // `normalizeGoTypeName` uses, so the two cannot disagree on what a map + // spelling is. + const mapValue = goMapValueType(t); + if (mapValue !== undefined) return mapValue; + // Slice `[]T` and array `[N]T` / `[...]T`. + const sliceMatch = GO_SLICE_ELEMENT_RE.exec(t); + if (sliceMatch !== null) return sliceMatch[1]!.trim(); + return undefined; + }, + buildMro: (graph, parsedFiles, nodeLookup) => buildMro(graph, parsedFiles, nodeLookup, defaultLinearize), diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index d55e4a63b..047a87791 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -43,6 +43,76 @@ import { resolveJavaImportTarget, } from './java/index.js'; +/** + * Java names the platform owns, matched against a BARE IDENTIFIER — a dropped + * receiver's chain base (`System` in `System.out.println(...)`) or the bare + * spelling of that base's declared type (`String raw` ⇒ `raw.trim()`). This set + * is the ONLY positive evidence `classifyReceiverOrigin` has that a lost edge + * pointed OUTSIDE the analyzed program; without it every Java drop hedges + * `impact()` down to `epistemic: 'lower-bound'` (#2744). + * + * TYPE names only — deliberately no method names, unlike `csharp.ts`. The same + * hook also gates `type-env.ts`'s return-type inference and the #2545 free-call + * shadow guard, both keyed on the CALLEE name; Java method names are camelCase + * and collide constantly with user code (`format`, `add`, `get`, `run`, `apply`), + * so listing them would silently suppress real in-program resolutions. Receiver + * bases are what this pass actually asks about, and those are types. + * + * Inclusion rule, applied to every entry below: a name earns a place only when + * (a) it plausibly appears as a receiver base or bare declared type, and (b) an + * application defining its OWN type by that name is implausible. Rule (b) is the + * hard gate. A name listed here can never be reported as in-program from the + * fallthrough arm, so a wrong entry silently erases a real uncertainty signal, + * whereas a missing one only costs a hedge — the failure is asymmetric, so this + * set under-includes on purpose. + * + * Deliberately ABSENT, each for rule (b) — all are ordinary domain nouns an + * application really does declare, and `Map`/`Set`/`Collection` are the worst + * case because a same-package Java type needs no import to shadow them: + * `Map`, `Set`, `Collection`, `Stream`, `Number`, `Record`, `Error`. + * (`Record` and `Error` also fail rule (a): `java.lang.Record` has no callable + * static surface and application code never receives a bare `Error`.) `Void` + * is absent on rule (a) alone — no `Void` instance exists to be a receiver. + * `List` IS included: unlike `Map`, a hand-rolled `List` would fight the + * near-universal `java.util.List` import, and it is the highest-value declared + * receiver type in the language. + */ +const BUILT_INS: ReadonlySet = new Set([ + // java.lang — implicitly imported, so these appear unqualified everywhere. + 'System', + 'String', + 'Integer', + 'Long', + 'Double', + 'Boolean', + 'Character', + 'Byte', + 'Short', + 'Float', + 'Object', + 'Math', + 'Thread', + 'Runtime', + 'Class', + 'StringBuilder', + 'StringBuffer', + 'Exception', + 'RuntimeException', + 'Throwable', + 'Iterable', + 'Comparable', + 'Runnable', + 'Enum', + // java.util — an explicit import, but an unresolvable one: the import target + // is outside the workspace, so it produces no in-program binding and the base + // still reaches this set (verified against real scope extraction, not assumed). + 'Optional', + 'List', + 'Arrays', + 'Collections', + 'Objects', +]); + const orderJavaSameNameTypeCandidates = ({ callSiteFilePath, candidates, @@ -122,6 +192,7 @@ export const javaProvider = defineLanguage({ // ── Javadoc → description (issue #2270) ── descriptionExtractor: createLeadingDocDescriptionExtractor(), + builtInNames: BUILT_INS, // ── RFC #909 Ring 3: scope-based resolution hooks ── emitScopeCaptures: emitJavaScopeCaptures, diff --git a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts index f2a7c9d21..af101af3c 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts @@ -5,6 +5,7 @@ import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolv import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js'; import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; import { isClassLike } from '../../scope-resolution/scope/walkers.js'; +import { indexOnlyElementType } from '../../type-extractors/shared.js'; import { kotlinProvider } from '../kotlin.js'; import { kotlinArityCompatibility, @@ -117,6 +118,16 @@ export const kotlinScopeResolver: ScopeResolver = { isSuperReceiver: (text) => text.trim() === 'super', + // Subscript route only — Kotlin's collection views (`.values`, `.keys`) are + // properties on the stdlib types, resolved by the ordinary member walk rather + // than by unwrapping a generic here. + // + // Fed the annotation AS WRITTEN (`List`, `Map`), which + // `normalizeKotlinType` had already reduced to `User`. `undefined` means "not + // a container", so an `operator fun get` class does not fold `cache[k]` onto + // `Cache` itself. + elementTypeOf: indexOnlyElementType, + isStaticOnly: isKotlinStaticOnly, fieldFallbackOnMethodLookup: false, diff --git a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts index 4aecc28b4..12b20ded8 100644 --- a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts @@ -17,6 +17,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { indexOnlyElementType } from '../../type-extractors/shared.js'; import { pythonProvider } from '../python.js'; import { isPythonImportedModule, @@ -75,6 +76,17 @@ const pythonScopeResolver: ScopeResolver = { isSuperReceiver: (text) => /^super\s*\(/.test(text), + // Subscript route only — Python spells collection views as method calls + // (`.values()`), which the compound resolver's call branch already handles. + // + // The hook receives the annotation AS WRITTEN (`List[User]`, `Dict[str, User]`), + // not the name `interpret.ts`'s `stripGeneric` reduced it to, so `undefined` + // here means "this spelling is not a container" — which is what stops + // `cfg['k'].run()` on a `__getitem__`-bearing class from folding onto the + // class itself. Answering the route at all is what keeps `repos[0].save()` + // resolving. + elementTypeOf: indexOnlyElementType, + // Python is dynamically typed — field-fallback heuristic on, return- // type propagation across imports on. Both default to true; listed // explicitly here for documentation. diff --git a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts index da9221420..5fd0f1570 100644 --- a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts @@ -2,6 +2,7 @@ import type { ParsedFile } from 'gitnexus-shared'; import { SupportedLanguages } from 'gitnexus-shared'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { indexOnlyElementType } from '../../type-extractors/shared.js'; import { rustProvider } from '../rust.js'; import { rustArityCompatibility, rustMergeBindings, resolveRustImportTarget } from './index.js'; import { populateRustOwners } from './method-owners.js'; @@ -165,6 +166,16 @@ export const rustScopeResolver: ScopeResolver = { isSuperReceiver: () => false, + // Subscript route only — Rust has no property-style collection view; `.iter()` + // / `.values()` are method calls the compound resolver's call branch handles. + // + // Fed the annotation AS WRITTEN (`&Vec`, `HashMap`), which + // `normalizeRustTypeName` had already collapsed to `User` by the time the fold + // saw it. Returning `undefined` is the answer "not a container" and makes the + // index step decline rather than fold onto the receiver's own class — an + // `Index`-impl type would otherwise take its own members as the element's. + elementTypeOf: indexOnlyElementType, + populateRangeBindings: populateRustRangeBindings, fieldFallbackOnMethodLookup: false, diff --git a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts index fa8050bfa..fe7a30da4 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts @@ -23,6 +23,7 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe import { typescriptProvider } from '../typescript.js'; import { loadTsconfigPaths, type TsconfigPaths } from '../../language-config.js'; import { buildSuffixIndex, type SuffixIndex } from '../../import-resolvers/utils.js'; +import { indexOnlyElementType } from '../../type-extractors/shared.js'; import { typescriptArityCompatibility, typescriptMergeBindings, @@ -106,6 +107,12 @@ function makeTsResolveImportTarget(): ScopeResolver['resolveImportTarget'] { } const typescriptScopeResolver: ScopeResolver = { + // One hook, both routes. TypeScript exposes collection views as METHOD calls + // (`.values()`), which the call-expression branch already handles, so the + // accessor route yields nothing here — but it is now the same hook rather + // than a second one left undefined. + elementTypeOf: indexOnlyElementType, + // Construction is keyword-prefixed: `new Service(db).doWork()` (#2708). constructionSyntax: { keyword: 'new' }, language: SupportedLanguages.TypeScript, @@ -155,10 +162,10 @@ const typescriptScopeResolver: ScopeResolver = { fieldFallbackOnMethodLookup: false, propagatesReturnTypesAcrossImports: true, - // TypeScript uses `.values()` / `.keys()` method-call syntax for - // collection views -- no property-style accessors like C#'s - // `Dictionary.Values`. Leave `unwrapCollectionAccessor` - // undefined and let the regular member-call branch handle them. + // TypeScript uses `.values()` / `.keys()` method-call syntax for collection + // views -- no property-style accessors like C#'s `Dictionary.Values` -- + // so `elementTypeOf` answers only the `index` route and lets the regular + // member-call branch handle the rest. // // `collapseMemberCallsByCallerTarget` left undefined (= false) -- // TypeScript legacy DAG emits one edge per call site, so diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index a8f55edc3..231d37c97 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -911,6 +911,13 @@ function pass3CollectImports( // ─── Pass 4: collect type bindings ───────────────────────────────────────── +/** Cap on the retained as-written annotation. Real container spellings are a + * handful of characters; a multi-line mapped/conditional type is neither a + * container any `elementTypeOf` parses nor worth keeping one copy of per + * binding on a kernel-scale repo. Over the cap the spelling is dropped, which + * makes an index step decline — the safe direction. */ +const MAX_DECLARED_SPELLING_LENGTH = 256; + function pass4CollectTypeBindings( matches: readonly CaptureMatch[], drafts: readonly ScopeDraft[], @@ -953,11 +960,41 @@ function pass4CollectTypeBindings( provider.bindingScopeFor?.(match, draftToScope(innermost), scopeTree) ?? autoHostedId; const host = draftById.get(hostId) ?? innermost; - const typeRef: TypeRef = { - rawName: parsed.rawTypeName, - declaredAtScope: host.id, - source: parsed.source, - }; + // The annotation as the source wrote it, kept only when the provider's + // interpretation is not already it. `interpretTypeBinding` normalizes + // container spellings away (`User[]` → `User`, `List[User]` → `User`, + // `[]*User` → `User`), which makes a reduced container indistinguishable + // from a class of the same name — and an index step folding on that + // ambiguity typed `grid[0]` as `Grid`. Read at the one place the + // distinction matters; see `TypeRef.declaredSpelling`. + // + // Read from the capture rather than from `ParsedTypeBinding` deliberately: + // `@type-binding.type` is the shared anchor EVERY provider already reads to + // build `rawTypeName`, so nothing has to be threaded through fourteen + // interpreters (and none can forget to). + // A provider may override when its grammar keeps part of the written type + // outside `@type-binding.type` (C++ hangs `*` on the declarator). + const writtenType = (parsed.declaredSpelling ?? match['@type-binding.type']?.text)?.trim(); + const declaredSpelling = + writtenType !== undefined && + writtenType.length > 0 && + writtenType.length <= MAX_DECLARED_SPELLING_LENGTH && + writtenType !== parsed.rawTypeName + ? writtenType + : undefined; + const typeRef: TypeRef = + declaredSpelling === undefined + ? { + rawName: parsed.rawTypeName, + declaredAtScope: host.id, + source: parsed.source, + } + : { + rawName: parsed.rawTypeName, + declaredSpelling, + declaredAtScope: host.id, + source: parsed.source, + }; // Prefer stronger sources when multiple matches fire for the same // bound name in the same scope. Example: `u: User = find()` matches // both the annotation and constructor-inferred patterns; the explicit @@ -1105,6 +1142,14 @@ function pass5CollectReferences( // that logs nothing. const receiverChain = extractReceiverChain(match); + // Callee-position marker: a member-read capture that is actually the callee + // of an enclosing call (`obj.f` in `obj.f()`). Recorded, not acted on — + // whether the read is a phantom or a genuine func-typed-field read depends + // on the resolved tail's kind, which only edge emission knows. Emitted by + // 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; + const site: ReferenceSite = { name: nameCap.text, atRange: anchor.range, @@ -1122,6 +1167,7 @@ function pass5CollectReferences( ...(argumentTypes !== undefined ? { argumentTypes } : {}), ...(argumentTypeClasses !== undefined ? { argumentTypeClasses } : {}), ...(receiverChain !== undefined ? { receiverChain } : {}), + ...(inCalleePosition ? { inCalleePosition: true } : {}), }; referenceSites.push(site); } @@ -1522,6 +1568,7 @@ const KNOWN_SUB_TAGS: ReadonlySet = new Set([ '@reference.name', '@reference.qualified-name', '@reference.property-key', + '@reference.callee-position', '@reference.receiver', '@reference.operator', '@reference.arity', diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 174190464..aa0641367 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -15,7 +15,7 @@ * - propagatesReturnTypesAcrossImports (default true) * - fieldFallbackOnMethodLookup (default true — turn OFF for * statically-typed languages; the heuristic over-connects) - * - unwrapCollectionAccessor — property-style collection views + * - elementTypeOf — container element type, by subscript or accessor * - collapseMemberCallsByCallerTarget — one edge per caller/target * - populateNamespaceSiblings — cross-file implicit visibility * - hoistTypeBindingsToModule — enable ONLY when method return @@ -268,6 +268,7 @@ * `docs/plans/2026-04-20-001-refactor-emit-pipeline-generalization-plan.md`. */ +import type { DecorationStripper } from '../scope/walkers.js'; import type { BindingRef, Callsite, @@ -313,6 +314,11 @@ export interface ImportResolutionContext { * `RegistryProviders.constraintCompatibility`'s third parameter. */ export type { ConstraintContext } from 'gitnexus-shared'; +/** How a container's element was reached in the source. */ +export type ElementAccessRoute = + | { readonly kind: 'index' } + | { readonly kind: 'accessor'; readonly name: string }; + export interface ScopeResolver { /** Identity for telemetry + per-language flag check. */ readonly language: SupportedLanguages; @@ -708,22 +714,56 @@ export interface ScopeResolver { readonly fieldFallbackOnMethodLookup?: boolean; /** - * Unwrap a property-style collection accessor on a typed receiver - * to its element type. Called by `resolveCompoundReceiverClass` - * when walking dotted member-access chains of the form - * `receiver.Accessor`. The provider returns the element type's - * simple name, or `undefined` when the accessor doesn't unwrap — - * in which case the regular field-walk resumes. + * Element type of a container, reached either by a subscript (`repos[0]`) or + * by a property-style collection view (`dict.Values`). Returns the element + * type's simple name, or `undefined` when the container does not unwrap by + * that route — in which case the caller resumes its normal walk. * - * Use this only for languages that expose collection views as - * properties rather than method calls; languages whose collection - * views are `.values()` / `.keys()` method calls leave this - * undefined and let the normal call-expression branch handle them. + * ONE hook for both routes, deliberately. They were previously two + * (`unwrapCollectionAccessor` for the property route, `unwrapCollectionElement` + * for the subscript route), which meant a language implementing one silently + * got nothing for the other: C# parsed `Dictionary` for `.Values` but + * returned nothing for `list[0]`, and TypeScript did the reverse. Two entries + * answering one question, each accreting an implementation per language. + * + * `via` carries the route so a provider can distinguish them where it matters + * (a `Dictionary` yields its VALUE type by subscript but either type by + * accessor name); a provider that does not care can ignore it. + * + * Consulted ONLY where the source actually performed the access. It is NOT a + * general type-name normalizer: unwrapping a container at a bare class lookup + * would let `repos.find(x)` fold to `Repo.find`, because a container's member + * set is not its element's. For the same reason it is deliberately separate + * from `stripTypePreservingDecoration`: a pointer or a nullable leaves the + * member set unchanged and is safe to strip at the lookup; a container is not. + * + * ## The `index` route is REQUIRED, and `undefined` means "not a container" + * + * A language that leaves the `index` route unanswered gets NO index folding — + * the structural fold declines the step rather than passing the position + * through. That is not a default worth softening. `undefined` used to mean + * "fall back to identity", on the theory that a capture layer which already + * reduced the container (Go's `normalizeGoTypeName`, C#/TypeScript's + * `stripGeneric`) leaves nothing to unwrap. The theory holds for a container; + * it is false for an ordinary class the source happened to subscript, and + * `rawName` cannot tell those apart — `repos: User[]` and `grid: Grid` both + * arrive as a bare resolvable class name. Identity there typed `grid[0].run()` + * as `Grid.run` and `t[0].Render()` as `Table.Render`: a wrong owner, which + * this pipeline ranks strictly below a missing edge. + * + * The hook is therefore handed `TypeRef.declaredSpelling` — the annotation AS + * WRITTEN, retained by the scope extractor precisely because capture-time + * normalization destroys it — falling back to `rawName` only when nothing was + * normalized away. So a provider sees `User[]`, `List[User]`, `[]*User` or + * `Dictionary`, never the post-reduction `User`, and answering + * `undefined` for a spelling it does not recognize as a container is an + * ANSWER, not an absence. + * + * Implementations may return a still-DECORATED element name (Go's `[]*User` + * yields `*User`): the index step looks the element up through + * `stripTypePreservingDecoration`, so the pointer resolves. */ - readonly unwrapCollectionAccessor?: ( - receiverType: string, - accessor: string, - ) => string | undefined; + readonly elementTypeOf?: (containerType: string, via: ElementAccessRoute) => string | undefined; /** * Collapse member-call CALLS edges by `(caller, target)` rather @@ -1114,6 +1154,39 @@ export interface ScopeResolver { */ readonly hoistTypeBindingsToModule?: boolean; + /** + * Strip ONE layer of type-preserving decoration off a declared type name, + * or return `undefined` when there is nothing left to strip. + * + * Exists because a declared type is stored as written. Go's + * `synthesizeGoReceiverBinding` keeps `typeNode.text`, so a pointer-receiver + * method binds its receiver to the literal `*Host` — which matches no class + * binding, so receiver-chain resolution declines at the base and every + * `h.field.method()` in the dominant Go idiom loses its `CALLS` edge (#2766). + * The stored binding is deliberately left decorated (`method-owners.ts` + * consumes `*T` vs `T` to model Go's value and pointer method sets), so the + * normalization belongs at LOOKUP, never as a rewrite of the binding. + * + * TYPE-PRESERVING ONLY. Pointer, reference, `const`, nullable, borrow, + * deref-transparent smart pointer and sigil all leave the member set + * unchanged. A CONTAINER — array, slice, map, `Option` — does not: stripping + * one here would type `repos: Repo[]` as `Repo` and let `repos.find(x)` fold + * to `Repo.find`, a confident wrong edge the ambiguity gate cannot catch + * because `Repo` binds uniquely. Containers are unwrapped only by an index + * step that consumed a subscript. + * + * Consulted ONLY after every undecorated lookup has failed, and only by + * receiver-chain base and step resolution — the shared class lookup keeps + * exact-name behaviour for its other ~two dozen callers, several of which are + * shaped `findClassBindingInScope(...) ?? otherResolver(...)` and would have + * their fallback suppressed by a global widening. + * + * Leave undefined for languages whose declared types carry no type-preserving + * decoration. Measured: only Go needs it for a receiver base; Rust, C#, Swift, + * TypeScript and C++ need it for field types. + */ + readonly stripTypePreservingDecoration?: DecorationStripper; + /** * Whether the compound-receiver resolver should strip C-style cast * expressions from receiver-position text before resolving it — @@ -1140,7 +1213,7 @@ export interface ScopeResolver { * * A second opting language must extend the classifier grammar or * convert this toggle into a per-language classifier hook (the - * `unwrapCollectionAccessor` pattern) — do not flip this flag for + * `elementTypeOf` pattern) — do not flip this flag for * another language as-is. * * Known non-goal: the compound-receiver options built from this diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/edges.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/edges.ts index f7f246f26..1bf72b6ef 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/edges.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/edges.ts @@ -15,9 +15,10 @@ * language-agnostic — no language needs to change it. */ -import type { Reference, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { NodeLabel, Reference, ScopeId, SymbolDefinition } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../../../graph/types.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { CALL_TARGET_TYPES } from '../../model/symbol-table.js'; import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js'; import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js'; import type { CalleeIdSink } from './callee-id-sink.js'; @@ -70,6 +71,41 @@ export function mapReferenceKindToEdgeType( } } +/** + * Is this read site a PHANTOM — a duplicate of the call happening beside it? + * + * Some languages' member-read capture also matches the callee of a member call, + * so `obj.f()` produces a `call` site on `f` AND a `read` site on `obj.f` at the + * same position (Go's `@reference.read` matches every `selector_expression`). + * The capture layer marks that second site `inCalleePosition` rather than + * dropping it, because the two cases it covers are indistinguishable there: + * + * - tail resolves to a METHOD → phantom. The ACCESSES edge duplicates the + * CALLS edge emitted for the same source position; suppress it. + * - tail resolves to a FIELD → genuine. `h.dep.Work()` where + * `Work func() error` reads a func-typed struct field and calls the value + * it holds. That read is the field's only ACCESSES evidence — keep it. + * + * Only the resolved target's kind separates them, which is why this decision + * lives at emission and not in any language's capture code. Sites without the + * marker are untouched, so a genuine method VALUE (`f := obj.method`) — never in + * callee position — keeps its read. + * + * "Invoked rather than read" is `CALL_TARGET_TYPES` — the canonical callable + * target set (`FREE_CALLABLE_TYPES` ∪ Method/Constructor), not a local copy. A + * hand-rolled `{Function, Method, Constructor}` silently omits `Macro` (C/C++) + * and `Delegate` (C#), the two labels that set exists to add, and drops the + * `satisfies` guard in `symbol-table.ts` that compile-enforces every free + * callable label against `LABEL_BEHAVIOR`. + */ +function isPhantomCalleeRead( + site: { readonly kind: string; readonly inCalleePosition?: boolean }, + targetDef: { readonly type: NodeLabel }, +): boolean { + if (site.kind !== 'read' || site.inCalleePosition !== true) return false; + return CALL_TARGET_TYPES.has(targetDef.type); +} + /** * Resolve caller + target to graph ids and emit the edge. Returns true * if the edge was emitted (not deduped, not skipped). @@ -87,6 +123,9 @@ export function tryEmitEdge( readonly inScope: ScopeId; readonly atRange: { startLine: number; startCol: number }; readonly kind: string; + /** See {@link isPhantomCalleeRead}. Set by the extractor from the + * language's `@reference.callee-position` marker; absent otherwise. */ + readonly inCalleePosition?: boolean; }, targetDef: SymbolDefinition, reason: string, @@ -95,6 +134,10 @@ export function tryEmitEdge( collapseByCallerTarget = false, calleeCapture?: CalleeIdCaptureCtx, ): boolean { + // A read that only exists because it is the callee of the call beside it, and + // whose tail resolved to a callable, is that call restated as an access — + // checked first because it needs no id resolution. + if (isPhantomCalleeRead(site, targetDef)) return false; // Inheritance edges are emitted directly by `preEmitInheritanceEdges` (which // owns the enclosing-class caller and the EXTENDS-vs-IMPLEMENTS type), so this // generic bridge derives caller + edge type purely from the site. @@ -152,6 +195,15 @@ export function tryEmitEdge( * * All other invariants of `tryEmitEdge` apply: dedup key shape, collapse * flag honoring, edge-type mapping, caller-id resolution. + * + * ONE deliberate exception: the {@link isPhantomCalleeRead} suppression is not + * applied here, because it keys on the target's node label and this entry point + * is handed an id rather than a def. Reachable only for a `read` site marked + * `inCalleePosition` whose receiver typed as an object-literal VALUE — a + * JS/TS-shaped registration. No language that sets the marker resolves through + * this bridge today (verified for Go, whose func-valued struct-literal fields + * resolve through the owned-member path instead). A language adding the marker + * must re-check this path. */ export function tryEmitEdgeWithExplicitTargetId( graph: KnowledgeGraph, diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index 6b77da5dd..39f98b467 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -21,12 +21,13 @@ */ import type { ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared'; -import type { ScopeResolver } from '../contract/scope-resolver.js'; +import type { ElementAccessRoute, ScopeResolver } from '../contract/scope-resolver.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import type { WorkspaceResolutionIndex } from '../workspace-index.js'; import { stripTemplateArguments } from '../../utils/template-arguments.js'; import type { DecodedReceiverChain } from '../../utils/receiver-chain-codec.js'; import { decodeReceiverChain } from '../../utils/receiver-chain-codec.js'; +import type { DecorationStripper } from '../scope/walkers.js'; import { findClassBindingInScope, findEnclosingClassDef, @@ -77,14 +78,13 @@ interface ResolveCompoundReceiverOptions { * class, walk its fields and try the lookup on each field's class. * Phase-9C "unified fixpoint" — Python-shaped heuristic. */ readonly fieldFallback?: boolean; - /** Language-specific accessor unwrap — `data.Values` on a - * Dictionary-typed receiver yields V (C#), etc. Returns the - * element type's simple name, or `undefined` to let the regular - * field-walk handle the access. */ - readonly unwrapCollectionAccessor?: ( - receiverType: string, - accessor: string, - ) => string | undefined; + /** Container -> element, by subscript (`repos[0]`) or accessor (`data.Values` + * on a `Dictionary` yields V). Returns the element type's simple name, + * or `undefined`. See the `ScopeResolver` field of the same name for why the + * two routes share one hook, and for why `undefined` on the `index` route + * means "not a container" — a step that gets it DECLINES, so a language must + * answer that route to get index folding at all. */ + readonly elementTypeOf?: (containerType: string, via: ElementAccessRoute) => string | undefined; /** Walk up from the class scope to ancestor (Module) scopes when * looking up a method's return-type typeBinding. Only enable for * languages that hoist return-type bindings to Module scope (C#); @@ -121,6 +121,13 @@ interface ResolveCompoundReceiverOptions { * (`const Config = make(1); Config.db.query()` emitted `entry → Database.query`), * the exact wrong-edge failure this work exists to avoid. */ readonly strictBaseBinding?: boolean; + /** Per-language type-preserving decoration stripper, from the `ScopeResolver` + * contract. Passed to the class lookup at the base and step sites so a + * decorated declared type (`*Host`) resolves to its class. Absent for + * languages whose declared types carry no such decoration, and never applied + * by the shared lookup's other callers — see the contract's own note on why + * this is opt-in rather than global. */ + readonly stripTypePreservingDecoration?: DecorationStripper; } /** Is this hop the language's construction selector applied to the class @@ -276,35 +283,77 @@ function resolveConstructionExpressionClass( } /** - * One step of the fold: the type of `memberName` on `owner`, or `undefined`. + * One position in a fold: the class the chain has reached, PLUS the declared + * type text that produced it. * - * A method's return type and a field's declared type both live in the owning - * class scope's `typeBindings`, keyed by name, so a `call` step and a `field` - * step are the same lookup — the step's `kind` carries no resolution - * difference, only intent. Walks the MRO so an inherited member resolves. + * The declared type is carried for two reasons, and the index step is its only + * reader. First, it is the CONTAINER evidence that step demands: capture + * normalizes `repos: User[]` down to `User`, so the resolved class alone cannot + * tell a reduced container from a class the source merely subscripted, and + * folding on that ambiguity typed `grid[0].run()` as `Grid.run`. Second, a + * declared type that named NO class is still a usable position — + * `Promise` and `[]Repo` match nothing in the workspace — because a later + * step may unwrap it; keeping only the class would strand those shapes at the + * step that produced them. * - * Deliberately does NOT consult the field fallback that - * `resolveCompoundReceiverClass` offers. That fallback iterates every field of - * the owner and re-resolves each one's class looking for a same-named member — - * O(fields x depth x names) per step, the shape behind the 128 GB blowup in - * #1871 — and it answers a DIFFERENT question ("does any field's type have a - * member of this name?"), which is a guess. Structure exists precisely so this - * does not have to guess. + * `undefined` when the position was reached by a route that has no declared type + * to report (a static class receiver, say). A step needing one declines rather + * than guessing. */ +interface FoldState { + /** + * Absent when the position's declared type named no class — `Promise` + * and `[]Repo` name nothing in the workspace. ONE signal, not two: an earlier + * version carried a separate `unresolvedDeclaredType` flag alongside a `def` + * holding the PREVIOUS position, which no path ever read. Two sources of + * truth for one fact, and the dead `def` read as intentional. + * + * Only an unwrapping step (await, index) can advance from an absent `def`; + * every other step declines, because folding on against the previous class + * would look the next member up on the wrong owner. + */ + readonly def: SymbolDefinition | undefined; + /** + * The declared type AS WRITTEN — the spelling when capture normalized one + * away (`TypeRef.declaredSpelling`), else `rawName` (they are the same string + * when nothing was normalized). Only the index step reads it, and only the + * as-written form separates `repos: User[]` from `grid: Grid`: both reduce to + * a bare, resolvable class name. + */ + readonly declaredType?: string; + readonly declaredAtScope?: ScopeId; +} + function typeOfMemberOnClass( owner: SymbolDefinition, memberName: string, scopes: ScopeResolutionIndexes, index: WorkspaceResolutionIndex, options: ResolveCompoundReceiverOptions, -): SymbolDefinition | undefined { +): FoldState | undefined { const classScopeByDefId = index.classScopeByDefId; const ownerChain = [owner.nodeId, ...scopes.methodDispatch.mroFor(owner.nodeId)]; for (const ownerId of ownerChain) { const classScope = classScopeByDefId.get(ownerId); const memberType = classScope?.typeBindings.get(memberName); if (memberType !== undefined) { - return findClassBindingInScope(memberType.declaredAtScope, memberType.rawName, scopes); + const def = findClassBindingInScope( + memberType.declaredAtScope, + memberType.rawName, + scopes, + options.stripTypePreservingDecoration, + ); + // The declared type is reported even when it resolved to no class: + // `Promise` and `[]Repo` name nothing in the workspace, and an + // await or index step unwrapping them is exactly how they become + // resolvable. Returning `undefined` here would strand those shapes. A + // `def` that stayed absent is reported as absent, NOT as the previous + // owner, so nothing may fold an ordinary member off this position. + return { + def, + declaredType: memberType.declaredSpelling ?? memberType.rawName, + declaredAtScope: memberType.declaredAtScope, + }; } // Languages whose binding-scope hook hoists a method's return-type binding // out of the class body and onto an ancestor (Module) scope keep NOTHING in @@ -320,7 +369,26 @@ function typeOfMemberOnClass( if (curScope === undefined) break; const hoisted = curScope.typeBindings.get(memberName); if (hoisted !== undefined) { - return findClassBindingInScope(hoisted.declaredAtScope, hoisted.rawName, scopes); + const def = findClassBindingInScope( + hoisted.declaredAtScope, + hoisted.rawName, + scopes, + // Same stripper the primary branch above passes. Omitting it here + // meant a decorated declared type (`*Host`) resolved on one branch + // and not the other, for the same member of the same class. + options.stripTypePreservingDecoration, + ); + // Identical to the primary branch: a declared type that named no + // class is still a usable position when the next step unwraps it. + // Returning `undefined` here made `svc.getMap()['k'].run()` decline + // while byte-identical `byId['k'].run()` resolved, purely because ten + // languages route return-type bindings through this hoisted branch + // and the other through the class scope. + return { + def, + declaredType: hoisted.declaredSpelling ?? hoisted.rawName, + declaredAtScope: hoisted.declaredAtScope, + }; } curId = curScope.parent; } @@ -362,15 +430,92 @@ export function foldReceiverChain( // `receiverChain` is dropped before resolving the base: it describes the // whole receiver, and handing it back to the resolver would re-enter this // fold on the base and never terminate. - let current = resolveCompoundReceiverClass(chain.baseReceiverName, inScope, scopes, index, { + const baseDef = resolveCompoundReceiverClass(chain.baseReceiverName, inScope, scopes, index, { ...options, fieldFallback: false, receiverChain: undefined, strictBaseBinding: true, }); - if (current === undefined) return undefined; + // The base's own declared type is carried too, so a chain whose FIRST step is + // an unwrap (`repos[0].save()` — index applied directly to the base) has the + // container spelling available. Without it that shape declines at step 1. + // Looked up ONLY when something will read it: the base failed to resolve (so + // an unwrap step is the last chance), or an index step will unwrap the + // container. `resolveCompoundReceiverClass` already walked the scope chain for + // this same name above, so doing it unconditionally duplicated that walk on + // every fold — and the U10 census says 86% of chains are pure field/call and + // never read it. + const needsBaseDeclaredType = + baseDef === undefined || chain.steps.some((step) => step.kind === 'index'); + const baseBinding = needsBaseDeclaredType + ? findReceiverTypeBinding(inScope, chain.baseReceiverName, scopes) + : undefined; + + // A base whose declared type names no class is NOT automatically a dead end: + // `repos: User[]` binds to the literal `User[]`, which matches no class + // because a container is not one. That position is still usable IF the next + // step unwraps it — which is exactly what an index step does. Carrying it + // forward with the marker lets that step recover; every other step kind + // declines on the marker, so nothing folds against a phantom owner. + if (baseDef === undefined && baseBinding === undefined) return undefined; + let current: FoldState = { + def: baseDef, + declaredType: baseBinding?.declaredSpelling ?? baseBinding?.rawName, + declaredAtScope: baseBinding?.declaredAtScope, + }; for (const step of chain.steps) { + // A position whose declared type named no class can only be advanced by an + // unwrapping step. Folding an ordinary member off it would look the member + // up on the PREVIOUS class — a wrong owner, silently. + // + // `await` IS identity, and soundly so: every language whose capture layer + // reduces the wrapper has ALREADY done it by the time a binding reaches the + // fold (TypeScript strips `Promise`, C# strips `Task`), and awaiting a + // value that is NOT a thenable yields that same value — so both regimes land + // on the identical answer and there is nothing to distinguish. + if (step.kind === 'await') continue; + if (step.kind === 'index') { + // An index step is NOT identity, and that asymmetry with `await` above is + // the whole point. `await` on a non-promise is a no-op; a subscript on a + // non-container is not — it yields the element of an indexer whose type + // is a different class entirely. + // + // The NORMALIZED type name cannot tell the two regimes apart: capture + // reduces `repos: User[]` to `User`, so a reduced container and a class + // the source merely subscripted (`grid: Grid` where `Grid` declares an + // index signature) both arrive as a bare, resolvable class name. Falling + // back to identity there kept `current` on the CONTAINER and looked the + // next member up on it — `grid[0].run()` → `Grid.run`, `t[0].Render()` → + // `Table.Render`. A confidently wrong owner, which is strictly worse than + // no edge and invisible to a bench that scores edge PRESENCE. + // + // So the step demands positive evidence instead: `declaredType`, the + // AS-WRITTEN spelling (`TypeRef.declaredSpelling`, preserved precisely + // because `rawName` threw it away), handed to the language's + // `elementTypeOf`. A provider that does not recognize the spelling as a + // container is answering "not a container", and the only sound move is to + // decline — a language must therefore answer the `index` route to get + // index folding at all. + const declared = current.declaredType; + const element = + declared === undefined ? undefined : options.elementTypeOf?.(declared, { kind: 'index' }); + if (element === undefined) return undefined; + const scopeForLookup = current.declaredAtScope ?? inScope; + const elementClass = findClassBindingInScope( + scopeForLookup, + element, + scopes, + options.stripTypePreservingDecoration, + ); + if (elementClass === undefined) return undefined; + current = { + def: elementClass, + declaredType: element, + declaredAtScope: scopeForLookup, + }; + continue; + } // Construction is NOT an ordinary member lookup. `Factory.new` on a class // constant denotes an instance of Factory, and the cascade already encodes // that (`isConstructionSelectorHop`) along with the class-constant test that @@ -381,13 +526,23 @@ export function foldReceiverChain( // first. That turned a correct edge into a WRONG one (Ruby // `Factory.new.run` → `Product.run`), which is the failure mode this whole // line of work exists to avoid. Decline and let the cascade answer. + // + // Placed AFTER the name-free continues above, deliberately. When it sat + // first, `options.constructionSyntax?.selector === step.name` compared + // `undefined === undefined` for every await/index step in a language with no + // construction selector, vetoing the entire fold before it ran — which is + // why those receivers minted a chain, fired the gate, and produced no edge. + // Position, not a guard, is what makes that unreachable: only named steps + // get here. if (options.constructionSyntax?.selector === step.name) return undefined; - - const next = typeOfMemberOnClass(current, step.name, scopes, index, options); + if (current.def === undefined) return undefined; + const next = typeOfMemberOnClass(current.def, step.name, scopes, index, options); if (next === undefined) return undefined; current = next; } - return current; + // A chain that ended without a class returns undefined naturally — no + // separate guard, because `def` IS the signal. + return current.def; } export function resolveCompoundReceiverClass( @@ -480,7 +635,12 @@ export function resolveCompoundReceiverClass( return findClassBindingInScope(rhsTb.declaredAtScope, arg, scopes); } - const viaTb = findClassBindingInScope(tb.declaredAtScope, tb.rawName, scopes); + const viaTb = findClassBindingInScope( + tb.declaredAtScope, + tb.rawName, + scopes, + options.stripTypePreservingDecoration, + ); if (viaTb !== undefined) return viaTb; // Member-alias / call-result shapes store the RHS path on rawName @@ -714,7 +874,7 @@ export function resolveCompoundReceiverClass( // the final segment and unwraps the receiver's generic, return // the element class directly. Resolved before the field-walk // because Dictionary-family types aren't local class defs. - if (options.unwrapCollectionAccessor !== undefined && parts.length >= 2) { + if (options.elementTypeOf !== undefined && parts.length >= 2) { const last = parts[parts.length - 1]; const headInner = parts[0]; if (last === undefined || headInner === undefined) return undefined; @@ -742,7 +902,12 @@ export function resolveCompoundReceiverClass( prefixType = cur; } if (prefixType !== undefined) { - const elemName = options.unwrapCollectionAccessor(prefixType.rawName, last); + // `rawName`, not `declaredSpelling`, and deliberately: the accessor route + // only fires on a multi-arg container (`Dictionary`), which every + // provider's capture-time normalization leaves ALONE — so the two are the + // same string here, and reading the spelling would change nothing except + // to widen an unmeasured surface. + const elemName = options.elementTypeOf(prefixType.rawName, { kind: 'accessor', name: last }); if (elemName !== undefined) { return findClassBindingInScope(prefixType.declaredAtScope, elemName, scopes); } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 038ad2437..e75aa0045 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -61,6 +61,7 @@ import { findReceiverTypeBinding, findValueBindingInScope, isClassLike, + type DecorationStripper, } from '../scope/walkers.js'; import { tryEmitEdge, @@ -82,6 +83,10 @@ import type { ResolutionOutcomeRecorder, ResolutionSuppressionReason, } from '../resolution-outcome.js'; +import { classifyReceiverShape } from '../resolution-outcome.js'; +import type { ReceiverOrigin } from '../resolution-outcome.js'; +import { decodeReceiverChain } from '../../utils/receiver-chain-codec.js'; +import type { DecodedReceiverChain } from '../../utils/receiver-chain-codec.js'; /** Subset of `ScopeResolver` consumed by this pass. Accepting the * subset rather than the full provider keeps tests and partial @@ -92,10 +97,11 @@ type ReceiverBoundProviderSubset = Pick< | 'isSuperReceiverInContext' | 'fieldFallbackOnMethodLookup' | 'collapseMemberCallsByCallerTarget' - | 'unwrapCollectionAccessor' + | 'elementTypeOf' | 'hoistTypeBindingsToModule' | 'stripReceiverCastExpressions' | 'constructionSyntax' + | 'stripTypePreservingDecoration' | 'resolveQualifiedReceiverMember' | 'resolveReceiverMember' | 'resolveThisViaEnclosingClass' @@ -113,8 +119,17 @@ function resolveClassBindingForName( scopeId: string, rawClassName: string, scopes: ScopeResolutionIndexes, + /** + * OPT-IN, and deliberately not passed by the emitting cases. `findClass + * BindingInScope`'s own docstring explains why the stripper is opt-in: a name + * that previously bound nothing starts binding, which SUPPRESSES the + * `?? otherResolver(...)` fallbacks several callers rely on. Case 4 therefore + * keeps exact-name behaviour and only `classifyReceiverOrigin` — which emits + * no edge and can only change a diagnostic label — passes it. + */ + stripDecoration?: DecorationStripper, ): SymbolDefinition | undefined { - const direct = findClassBindingInScope(scopeId, rawClassName, scopes); + const direct = findClassBindingInScope(scopeId, rawClassName, scopes, stripDecoration); if (direct !== undefined) return direct; if (!rawClassName.includes('<')) return undefined; @@ -128,7 +143,7 @@ function resolveClassBindingForName( // default to [] before checking `.length`. const qnameIds = scopes.qualifiedNames.get(baseName) ?? []; if (qnameIds.length === 0) { - return findClassBindingInScope(scopeId, baseName, scopes); + return findClassBindingInScope(scopeId, baseName, scopes, stripDecoration); } const matches: SymbolDefinition[] = []; for (const id of qnameIds) { @@ -149,7 +164,123 @@ function resolveClassBindingForName( // safety in non-ODR or mixed-language edge cases. } - return findClassBindingInScope(scopeId, baseName, scopes); + return findClassBindingInScope(scopeId, baseName, scopes, stripDecoration); +} + +/** A bare, undecorated identifier and nothing else — see {@link isBareTypeName}. */ +const BARE_TYPE_NAME_RE = /^[A-Za-z_$][\w$]*$/; + +/** + * A type name a built-in test may be asked about: a bare, undecorated + * identifier and nothing else. + * + * `Promise`, `[]Repo` and `Option` all name a built-in CONTAINER + * whose ELEMENT is very often in-program, and an await/index/unwrap step is + * exactly how a receiver chain reaches that element. Answering "external" + * because the outer spelling matched a built-in would relabel a real in-program + * drop, which is the failure this whole function exists to stop. A decorated or + * dotted spelling is likewise not a built-in name, it merely contains one. + */ +function isBareTypeName(rawName: string): boolean { + return BARE_TYPE_NAME_RE.test(rawName); +} + +/** + * Is this dropped receiver rooted inside the analyzed program? + * + * Asks of the receiver's BASE — the leftmost name the chain hangs off — what + * this index can DEMONSTRATE. Three answers, and the asymmetry between them is + * the whole point: + * + * - `in-program` — the base's declared type resolves here, or the base itself is + * a class, a qualified name, or a value this program declares. A real edge was + * lost; the hedge must fire. + * - `external` — POSITIVE evidence that the target is outside: the language + * itself names the base (or its bare declared type) a built-in. `console.log`, + * `fetch(...)`, `JSON.stringify` reach code no index contains, so there is no + * node an edge could have pointed at and nothing was lost. + * - `unknown` — everything else. An absence of evidence is NOT evidence of + * externality: an unannotated parameter (`function f(svc) { svc.a().b(); }`) + * is recorded nowhere in the scope model at all, and calling that "external" + * published `epistemic: 'exact'` over a genuinely missing in-program caller — + * strictly worse than hedging, because it is a confident wrong answer rather + * than an admitted gap. `unknown` counts WITH `in-program` in + * `summarizeUnresolvedReceivers`, which is the safe direction. + * + * Uses the AST-derived chain base when one was minted, and falls back to the + * head of the receiver text otherwise — never a regex over the source line. + * + * Exported for the unit tests that pin the three-way split; the pass is its only + * production caller. + */ +export function classifyReceiverOrigin( + decoded: DecodedReceiverChain | undefined, + inScope: string, + receiverName: string, + scopes: ScopeResolutionIndexes, + options: { + /** The language's type-preserving decoration stripper. Without it a Go + * pointer receiver — `func (h *Host)` binds `h` to the literal `*Host` — + * resolves to no class and the whole method body's drops were reported as + * external. Same hook the three receiver-chain lookups in + * `compound-receiver.ts` already receive. */ + readonly stripTypePreservingDecoration?: DecorationStripper; + /** `LanguageProvider.isBuiltInName`, threaded through the pass options the + * same way `emitFreeCallFallback` receives it. THE only source of positive + * external evidence available here; languages that declare no built-in set + * simply never produce an `external` verdict, which is the safe default. */ + readonly isBuiltInName?: (name: string) => boolean; + } = {}, +): ReceiverOrigin { + // The chain's base is authoritative. Without one, take the head of the + // receiver text up to the first member/call punctuation. + const base = decoded?.baseReceiverName ?? /^[A-Za-z_$][\w$]*/.exec(receiverName)?.[0]; + if (base === undefined || base.length === 0) return 'unknown'; + const strip = options.stripTypePreservingDecoration; + const isBuiltIn = options.isBuiltInName; + + // The base's declared TYPE, when it has one, is the strongest signal about + // where the member lives: `inputs.stream()` has an in-program base bound to + // `List`, whose `stream` is in the JDK. + const binding = findReceiverTypeBinding(inScope, base, scopes); + if (binding !== undefined) { + // `resolveClassBindingForName`, not a bare lookup: it also strips template + // arguments, so an in-program generic base (`Box b; b.open()`) + // resolves instead of being mislabelled and dropped from the hedge. + if ( + resolveClassBindingForName(binding.declaredAtScope, binding.rawName, scopes, strip) !== + undefined + ) { + return 'in-program'; + } + // The declared type is not one this index contains. That is only proof of + // externality when the language itself names it — otherwise the type merely + // failed to resolve (an alias, an inferred callable, a generic parameter), + // and we fall through to ask what the index knows about the base itself. + if (isBareTypeName(binding.rawName) && isBuiltIn?.(binding.rawName) === true) { + return 'external'; + } + } + // Anything else this index knows by that name (namespace, module, free fn). + // O(1), so it goes ahead of the scope-chain walks below: all three checks are + // arms of the same `in-program` disjunction and none has a side effect, so + // answering from the index first is free and changes no verdict. + if (scopes.qualifiedNames.has(base)) return 'in-program'; + // A type the program declares, used as a static receiver. + if (findClassBindingInScope(inScope, base, scopes, strip) !== undefined) return 'in-program'; + // A VALUE the program declares — an object-literal service, a local whose + // initializer we could not type (`const loc = makeIt(); loc.getUser().save()`), + // a field. The type channel had nothing usable to say about these, but the + // program demonstrably declares the name, so the lost edge is in-program and + // failing to type it is a resolver defect. This is the channel Case 5 already + // dispatches on; consulting it here keeps the diagnostic honest about the + // same population. + if (findValueBindingInScope(inScope, base, scopes) !== undefined) return 'in-program'; + + // Positive external evidence, and the only kind reachable from this pass. + if (isBuiltIn?.(base) === true) return 'external'; + + return 'unknown'; } export function emitReceiverBoundCalls( @@ -168,6 +299,14 @@ export function emitReceiverBoundCalls( * `undefined` ⇒ zero overhead, byte-identity (R4). Per-file capture * contexts are built from this + `parsed.filePath` in the loop. */ readonly calleeIdSink?: CalleeIdSink; + /** `LanguageProvider.isBuiltInName`. Passed through the options bag rather + * than widened into `ReceiverBoundProviderSubset`, mirroring how + * `emitFreeCallFallback` receives the same hook — the subset exists to keep + * test providers small, and this pass reads nothing else off the language + * provider. Consumed ONLY by `classifyReceiverOrigin`, so leaving it unset + * degrades a drop's label to `unknown` (the safe direction) and changes no + * edge. */ + readonly isBuiltInName?: (name: string) => boolean; } = {}, ): number { let emitted = 0; @@ -180,10 +319,17 @@ export function emitReceiverBoundCalls( const hoistTypeBindingsToModule = provider.hoistTypeBindingsToModule === true; const compoundOpts = { fieldFallback, - unwrapCollectionAccessor: provider.unwrapCollectionAccessor, + elementTypeOf: provider.elementTypeOf, hoistTypeBindingsToModule, stripReceiverCastExpressions: provider.stripReceiverCastExpressions === true, constructionSyntax: provider.constructionSyntax, + stripTypePreservingDecoration: provider.stripTypePreservingDecoration, + }; + // Loop-invariant: both hooks come off the pass arguments, so the options bag + // for `classifyReceiverOrigin` is built once here rather than per dropped site. + const receiverOriginOpts = { + stripTypePreservingDecoration: provider.stripTypePreservingDecoration, + isBuiltInName: options.isBuiltInName, }; // Build an interface → implementors map from IMPLEMENTS edges. @@ -402,7 +548,23 @@ export function emitReceiverBoundCalls( // the end of the site loop, not here — a later case may still resolve // the site, and only a site that survives every case is a real drop. let compoundReceiverUnresolved = false; - if (receiverName.includes('.') || receiverName.includes('(')) { + // The punctuation test is a C-family heuristic and it is the reason + // `repos[0].save()` is INVISIBLE in all 14 languages: a subscript receiver + // contains neither `.` nor `(`, so this case never fired, the fold was + // never consulted, and no drop was recorded either — the call vanished + // with the instrument blind to it. PHP `->` and `::` receivers are lost + // the same way. + // + // A minted receiver chain is the STRUCTURAL answer to the same question: + // the capture layer walked the real AST and found the receiver is an + // expression, whatever punctuation it happens to be spelled with. Trusting + // that instead of the text is the substitution this whole line of work + // exists to make. + if ( + receiverName.includes('.') || + receiverName.includes('(') || + site.receiverChain !== undefined + ) { const currentClass = resolveCompoundReceiverClass( receiverName, site.inScope, @@ -1341,6 +1503,10 @@ export function emitReceiverBoundCalls( // dropped site (its callee is unknown by definition, so the drop cannot // be attributed to any target symbol). if (compoundReceiverUnresolved && !handledSites.has(siteKey)) { + // Decoded once: both the shape census and the origin classifier read the + // same chain, and this is inside the drop guard so a resolved site pays + // nothing. + const decodedChain = decodeReceiverChain(site.receiverChain); options.recordResolutionOutcome?.({ kind: 'suppressed', reason: 'receiver-unresolved', @@ -1354,6 +1520,22 @@ export function emitReceiverBoundCalls( // recorded here too. Carry the kind so a consumer can separate a // dropped CALL from a dropped property access. siteKind: site.kind, + // Structural, from the AST-derived chain the emitter minted — never + // re-derived from the source line. + // `decodeReceiverChain` opens with a non-string guard, so the + // undefined case needs no ternary here. + receiverShape: classifyReceiverShape(decodedChain), + // Whether anything was actually lost. An external target has no node + // to point at, so its absence is completeness, not uncertainty — but + // ONLY a positive built-in match may say so. Everything the index + // cannot demonstrate stays `unknown` and keeps hedging. + receiverOrigin: classifyReceiverOrigin( + decodedChain, + site.inScope, + receiverName, + scopes, + receiverOriginOpts, + ), }); } } diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index aa6268d52..e11ae7c9a 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -810,6 +810,10 @@ export function runScopeResolution( { recordResolutionOutcome, calleeIdSink: calleeIdAccumulator, + // The pass's only source of positive EXTERNAL evidence for a dropped + // receiver (`console.log`, `fetch(...)`). Same hook, same spelling as + // the `emitFreeCallFallback` wiring below. + isBuiltInName: provider.languageProvider.isBuiltInName, }, ); const unresolvedReceiverExtras = diff --git a/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts b/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts index 07b9d3083..a0e5e0919 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts @@ -1,4 +1,5 @@ import type { Range, ReferenceKind } from 'gitnexus-shared'; +import type { DecodedReceiverChain } from '../utils/receiver-chain-codec.js'; export type ResolutionSuppressionReason = | 'adl-ordinary-lookup-blocked' @@ -61,6 +62,120 @@ export type ResolutionOutcome = * persisted `RepoMeta.unresolvedReceiverMembers` artifact is unchanged. */ readonly siteKind?: ReferenceKind; + /** + * Structural shape of the receiver whose type could not be established. + * + * Derived from the site's ENCODED RECEIVER CHAIN — the compact string the + * capture emitters mint by walking the real AST — never from the source + * line. Re-deriving a shape textually would mean regex-classifying the + * number that gates this work, which is exactly the textual-shape dispatch + * the structural-receiver line of work exists to remove. + * + * Lets a consumer ask "which KIND of receiver are we losing?" instead of + * only "how many". Without it, `callDropsByExtension` is the finest + * available split and a language's bucket says nothing about whether the + * cause is one defect or five. + * + * NOTE ON COVERAGE: only drops that REACH the recorder carry a shape, and + * Case 0's gate fires on receiver punctuation, so shapes that mint no + * reference site at all (`?.`, explicit type args, subscript) are absent + * from this breakdown entirely — they are the INVISIBLE-GAP population the + * bench shape arm exists to see. A shape census here is a census of the + * VISIBLE drops, not of all lost calls. + * + * Diagnostic only. `summarizeUnresolvedReceivers` ignores it, so the + * persisted `RepoMeta.unresolvedReceiverMembers` artifact is unchanged. + */ + readonly receiverShape?: ReceiverShape; + /** + * Whether the receiver is rooted INSIDE the analyzed program. + * + * The single most important distinction a static-analysis tool can make + * about a call it did not resolve, and the one this codebase previously + * collapsed: + * + * - `in-program` — the receiver's base is a local, parameter, field or a + * type this index knows. Failing to type it is a RESOLVER DEFECT: a real + * caller exists in the graph and was lost. + * - `external` — the base is rooted in code this index does not contain + * (`System.out.println`, `fetch(...)`, `os.environ.setdefault`). There is + * NO node to point an edge at, so nothing was lost. A compiler resolves + * these against the JDK / BCL / lib.d.ts; without those, the honest + * answer is "outside the program", not "unknown". + * + * Only `in-program` makes an `impact` count a lower bound. Reporting an + * external target as uncertainty is what made the hedge fire on nearly + * every real codebase and taught readers to ignore it. + */ + readonly receiverOrigin?: ReceiverOrigin; }; +/** + * How a dropped receiver was spelled, structurally. + * + * - `chain-call` every recorded step is a call — `svc.getUser().save()` + * - `chain-field` every recorded step is a field — `h.repo.save()` + * - `chain-mixed` the chain interleaves both — `svc.getUser().addr.save()` + * - `chain-unwrap` the chain contains an `await` or `index` step — the shapes + * this work exists to expose. They were previously counted as + * FIELDS, because the classifier's parameter widened `kind` to + * `string` and they fell into its `else`. + * - `no-chain` the site carried no chain, so the receiver was a compound + * expression the capture walk could not reduce to a nameable + * base (it stopped early, or the base was unencodable) + */ + +export type ReceiverShape = + | 'chain-call' + | 'chain-field' + | 'chain-mixed' + | 'chain-unwrap' + | 'no-chain'; + +/** + * Is the receiver rooted inside the analyzed program, or outside it? + * + * `unknown` is for a site carrying no usable base at all — neither a chain nor + * an explicit receiver — where the question cannot be asked. It is treated as + * `in-program` for hedging purposes, because assuming completeness we cannot + * demonstrate is the unsafe direction. + */ +export type ReceiverOrigin = 'in-program' | 'external' | 'unknown'; + +/** Classify a dropped receiver from its encoded chain. `undefined` chain ⇒ + * `no-chain`; an undecodable one is also `no-chain`, since what we know about + * it is exactly that no usable structure survived. + * + * Takes `DecodedReceiverChain` rather than a structural duck-type: widening + * `kind` to `string` let `await` and `index` fall into an `else` branch and be + * counted as FIELDS, so the two shapes this work exists to expose were + * censused as `chain-field`. The discriminated union makes a new step kind a + * compile error instead of a silent bucket. */ +export function classifyReceiverShape(decoded: DecodedReceiverChain | undefined): ReceiverShape { + if (decoded === undefined || decoded.steps.length === 0) return 'no-chain'; + let calls = 0; + let fields = 0; + let unwraps = 0; + for (const step of decoded.steps) { + switch (step.kind) { + case 'call': + calls++; + break; + case 'field': + fields++; + break; + case 'await': + case 'index': + unwraps++; + break; + } + } + // An unwrap step dominates: a chain containing one fails for reasons a pure + // field or call chain does not, so folding it into either bucket would + // misattribute the population a fix has to target. + if (unwraps > 0) return 'chain-unwrap'; + if (calls > 0 && fields > 0) return 'chain-mixed'; + return calls > 0 ? 'chain-call' : 'chain-field'; +} + export type ResolutionOutcomeRecorder = (outcome: ResolutionOutcome) => void; diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index e250f88a5..b4b8aeab9 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -321,10 +321,67 @@ export function moduleScopeIdOf( * * Without (2) we'd miss every cross-file class-receiver call. */ +/** + * Every class-like definition visible for `name`, from the scope chain AND the + * qualified-name index, deduped by `nodeId`. + * + * Exists because `walkScopeChain` returns the FIRST match and cannot report a + * collision, so a caller that widens what a name can match (the decoration + * normalizer below) has no way to tell "one answer" from "picked the nearest of + * several". Mirrors `findAllCallableBindingsInScope`, which solved the same + * problem for callables. + */ +export function findAllClassBindingsInScope( + startScope: ScopeId, + name: string, + scopes: ScopeResolutionIndexes, +): readonly SymbolDefinition[] { + const inScope = findAllBindingsInScope(startScope, name, scopes, (def) => isClassLike(def.type)); + // The scope chain wins outright when it binds the name: an inner binding + // shadows anything the qualified-name index would contribute. + if (inScope.length > 0) return inScope; + + const byNodeId = new Map(); + for (const id of scopes.qualifiedNames.get(name)) { + const def = scopes.defs.get(id); + if (def !== undefined && isClassLike(def.type)) byNodeId.set(def.nodeId, def); + } + return [...byNodeId.values()]; +} + +/** + * Strip one layer of type-preserving decoration off a declared type name, or + * `undefined` when there is nothing left to strip. Supplied per language through + * the `ScopeResolver` contract; the core never names a language (AGENTS.md R6). + * + * TYPE-PRESERVING only — pointer, reference, `const`, nullable, borrow, + * deref-transparent smart pointer, sigil. A CONTAINER (array, slice, map, + * `Option`) changes the member set, so stripping one here would type + * `repos: Repo[]` as `Repo` and let `repos.find(x)` fold to `Repo.find`. Those + * are unwrapped only by an index step that consumed a subscript. + */ +export type DecorationStripper = (typeName: string) => string | undefined; + +/** Bounded so a pathological stripper cannot spin. Real decoration nests + * shallowly (`*[]T`, `const T&`); three layers is generous. */ +const MAX_DECORATION_LAYERS = 3; + export function findClassBindingInScope( startScope: ScopeId, receiverName: string, scopes: ScopeResolutionIndexes, + /** + * OPT-IN. When supplied, a name that binds nothing is retried with decoration + * stripped one layer at a time, and each retry must resolve to exactly ONE + * class-like definition or it declines. + * + * Opt-in rather than global because roughly two dozen call sites use the shape + * `findClassBindingInScope(...) ?? otherResolver(...)`: turning a former + * `undefined` into a hit SUPPRESSES the fallback that used to answer, which + * would retarget inheritance edges and bypass generic-specialization + * selection. Only receiver-chain base and step resolution passes this. + */ + stripDecoration?: DecorationStripper, ): SymbolDefinition | undefined { const local = walkScopeChain(startScope, receiverName, scopes, (def) => isClassLike(def.type)); if (local !== undefined) return local; @@ -350,6 +407,25 @@ export function findClassBindingInScope( } } } + + // Decoration fallback (opt-in). Every branch above works on the name exactly + // as written; only when none of them bound anything do we consider that the + // name may be a decorated spelling of one that would. + if (stripDecoration !== undefined) { + let current = receiverName; + for (let layer = 0; layer < MAX_DECORATION_LAYERS; layer++) { + const stripped = stripDecoration(current); + if (stripped === undefined || stripped === current || stripped.length === 0) break; + current = stripped; + const candidates = findAllClassBindingsInScope(startScope, current, scopes); + // Exactly one, or decline. Two same-named classes reachable from here mean + // the decoration was carrying the only disambiguating information, and + // picking the nearest would mint a confident wrong edge — the failure this + // whole line of work exists to avoid. A missing edge is recoverable. + if (candidates.length === 1) return candidates[0]; + if (candidates.length > 1) return undefined; + } + } return undefined; } @@ -787,10 +863,28 @@ export function findAllCallableBindingCandidatesInScope( * `findCallableBindingInScope`: once any callable binding is found in a * scope, outer scopes are not consulted. */ -export function findAllCallableBindingsInScope( +/** + * Every definition visible for `name` at the NEAREST scope that binds it, + * filtered by `predicate` and deduped by `nodeId`. + * + * THE shared "collect all at the nearest binding scope" walk. `walkScopeChain` + * answers the first-match question; this answers the how-many question, which is + * what a caller needs before it can decline on ambiguity. + * + * Stops at the first scope that binds the name at all: an inner binding SHADOWS + * an outer one, so continuing would report a shadowed outer definition as a + * competing candidate and decline a name that is unambiguous at this point. + * + * Returns `[]` on a cycle or a missing scope. That is deliberate and matters: + * an earlier copy of this walk `break`-ed instead and fell through to a + * qualified-name fallback, so the same malformed input produced a different + * answer depending on which copy the caller happened to reach. + */ +function findAllBindingsInScope( startScope: ScopeId, - callableName: string, + name: string, scopes: ScopeResolutionIndexes, + predicate: (def: SymbolDefinition) => boolean, ): readonly SymbolDefinition[] { let currentId: ScopeId | null = startScope; const visited = new Set(); @@ -805,24 +899,16 @@ export function findAllCallableBindingsInScope( if (scope.kind !== 'Object') { const out: SymbolDefinition[] = []; const seen = new Set(); - const pushCallable = (def: SymbolDefinition): void => { - if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') return; + const push = (def: SymbolDefinition): void => { + if (!predicate(def)) return; if (seen.has(def.nodeId)) return; seen.add(def.nodeId); out.push(def); }; - const localBindings = scope.bindings.get(callableName); - if (localBindings !== undefined) { - for (const b of localBindings) { - pushCallable(b.def); - } - } - - const importedBindings = lookupBindingsAt(currentId, callableName, scopes); - for (const b of importedBindings) { - pushCallable(b.def); - } + // Local first: a binding in this scope shadows an imported one. + for (const b of scope.bindings.get(name) ?? []) push(b.def); + for (const b of lookupBindingsAt(currentId, name, scopes)) push(b.def); if (out.length > 0) return out; } @@ -831,6 +917,19 @@ export function findAllCallableBindingsInScope( return []; } +export function findAllCallableBindingsInScope( + startScope: ScopeId, + callableName: string, + scopes: ScopeResolutionIndexes, +): readonly SymbolDefinition[] { + return findAllBindingsInScope( + startScope, + callableName, + scopes, + (def) => def.type === 'Function' || def.type === 'Method' || def.type === 'Constructor', + ); +} + /** * ISO C++ `[basic.lookup.unqual]` §7: ADL is suppressed when ordinary * unqualified lookup finds: diff --git a/gitnexus/src/core/ingestion/scope-resolution/unresolved-receivers.ts b/gitnexus/src/core/ingestion/scope-resolution/unresolved-receivers.ts index db6cfb03b..11e7d7f61 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/unresolved-receivers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/unresolved-receivers.ts @@ -31,6 +31,58 @@ export interface UnresolvedReceiverSummary { /** Distinct member names beyond the cap, omitted from `counts`. Absent when * nothing was dropped from the map. */ readonly omittedNames?: number; + /** + * Call sites dropped whose receiver was rooted OUTSIDE the indexed program, + * by member name — `System.out.println`, `fetch(...)`, `os.environ.*`. + * + * Kept SEPARATE rather than filtered away. These do not make a count a lower + * bound (there is no in-graph node an edge could have reached), but erasing + * them at summary time would leave the persisted artifact unable to + * distinguish "clean index" from "76 drops we judged external" — with no + * audit path and no way back without a re-index. That is the same collapse + * `EpistemicCauses` exists to undo, and the judgement being recorded here is a + * heuristic, so it must stay reversible. + */ + readonly externalCounts?: Readonly>; + /** Total external-rooted call sites, including any beyond the cap. */ + readonly externalSites?: number; + /** + * Distinct member names beyond the cap, omitted from `externalCounts`. Absent + * when nothing was dropped from that map. + * + * The exact twin of `omittedNames`, and it exists for the same reason. Past + * the cap `lookupExternalCallCount` returns `undefined` for a truncated name, + * which is indistinguishable from "this member had no external drops" — so a + * symbol with real boundary evidence reads as having none. One map carrying a + * truncation marker and the other silently losing entries also made the + * persisted artifact self-contradictory: `externalSites` would exceed the sum + * of `externalCounts` with nothing to explain the difference. + */ + readonly externalOmittedNames?: number; +} + +/** + * Rank a name→count map and cap it at {@link MAX_UNRESOLVED_RECEIVER_MEMBERS}. + * + * Highest count first, name as a tiebreak so the persisted map is stable across + * runs — an unstable ordering would churn the metadata file (and its diff) on + * every analyze for no behavioural reason. ONE comparator, shared by the + * in-program and external maps: two hand-copied comparators that must stay + * identical or the artifact churns on one map and not the other is exactly the + * drift this contract cannot tolerate. + * + * `omitted` is the number of distinct names past the cap, so the caller can + * report truncation rather than silently losing entries. + */ +function rankAndCap(counts: Map): { + kept: [string, number][]; + omitted: number; +} { + const ranked = [...counts.entries()].sort( + ([aName, aCount], [bName, bCount]) => bCount - aCount || aName.localeCompare(bName), + ); + const kept = ranked.slice(0, MAX_UNRESOLVED_RECEIVER_MEMBERS); + return { kept, omitted: ranked.length - kept.length }; } /** @@ -43,7 +95,9 @@ export function summarizeUnresolvedReceivers( outcomes: readonly ResolutionOutcome[], ): UnresolvedReceiverSummary | undefined { const counts = new Map(); + const externalCounts = new Map(); let totalSites = 0; + let externalSites = 0; for (const outcome of outcomes) { if (outcome.kind !== 'suppressed' || outcome.reason !== 'receiver-unresolved') continue; if (outcome.name.length === 0) continue; @@ -56,24 +110,43 @@ export function summarizeUnresolvedReceivers( // A missing `siteKind` counts as a call: the only emitter always sets it, and // erring toward `lower-bound` is the safe direction for an epistemic signal. if (outcome.siteKind !== undefined && outcome.siteKind !== 'call') continue; + // Routed, not discarded. External-rooted drops (`console.log(...)`, + // `fetch(...)`) reach code this index does not contain, so there is no node + // an edge could have pointed at and nothing was lost — they must not hedge. + // But they stay in the artifact under their own key so the split is + // auditable and reversible. + // + // `external` is a POSITIVE determination made by `classifyReceiverOrigin` + // from a language built-in match, never a fallthrough: a receiver the + // classifier could not place lands in `unknown`, which counts here WITH + // `in-program`, because assuming a completeness we cannot demonstrate is + // the unsafe direction. + if (outcome.receiverOrigin === 'external') { + externalSites++; + externalCounts.set(outcome.name, (externalCounts.get(outcome.name) ?? 0) + 1); + continue; + } totalSites++; counts.set(outcome.name, (counts.get(outcome.name) ?? 0) + 1); } - if (totalSites === 0) return undefined; + // An index whose only drops were external-rooted still reports the split, so + // "nothing was lost" is distinguishable from "nothing was measured". + if (totalSites === 0 && externalSites === 0) return undefined; - // Highest count first, name as a tiebreak so the persisted map is stable - // across runs — an unstable ordering would churn the metadata file (and its - // diff) on every analyze for no behavioural reason. - const ranked = [...counts.entries()].sort( - ([aName, aCount], [bName, bCount]) => bCount - aCount || aName.localeCompare(bName), - ); - const kept = ranked.slice(0, MAX_UNRESOLVED_RECEIVER_MEMBERS); - const omittedNames = ranked.length - kept.length; + const { kept, omitted: omittedNames } = rankAndCap(counts); + const { kept: externalKept, omitted: externalOmittedNames } = rankAndCap(externalCounts); return { counts: Object.fromEntries(kept), totalSites, ...(omittedNames > 0 ? { omittedNames } : {}), + ...(externalSites > 0 + ? { + externalCounts: Object.fromEntries(externalKept), + externalSites, + ...(externalOmittedNames > 0 ? { externalOmittedNames } : {}), + } + : {}), }; } @@ -102,3 +175,22 @@ export function lookupUnresolvedCallCount( if (typeof sites !== 'number' || !Number.isFinite(sites) || sites <= 0) return undefined; return sites; } + +/** + * Look up the EXTERNAL-rooted dropped-call count for a member name. + * + * Companion to `lookupUnresolvedCallCount`, and prototype-safe for the same + * reason: the map is revived from JSON, so `constructor` / `toString` and + * friends would otherwise return a function. + */ +export function lookupExternalCallCount( + summary: UnresolvedReceiverSummary | undefined, + symName: string, +): number | undefined { + const counts = summary?.externalCounts; + if (counts === undefined || symName.length === 0) return undefined; + if (!Object.hasOwn(counts, symName)) return undefined; + const sites = counts[symName]; + if (typeof sites !== 'number' || !Number.isFinite(sites) || sites <= 0) return undefined; + return sites; +} diff --git a/gitnexus/src/core/ingestion/type-extractors/shared.ts b/gitnexus/src/core/ingestion/type-extractors/shared.ts index 17cb962cd..1da3ff12e 100644 --- a/gitnexus/src/core/ingestion/type-extractors/shared.ts +++ b/gitnexus/src/core/ingestion/type-extractors/shared.ts @@ -1,3 +1,4 @@ +import type { ElementAccessRoute } from '../scope-resolution/contract/scope-resolver.js'; import type { SyntaxNode } from '../utils/ast-helpers.js'; /** Which type argument to extract from a multi-arg generic container. @@ -698,6 +699,27 @@ export function extractElementTypeFromString( return undefined; } +/** + * `elementTypeOf` for a language with no property-style collection view: + * answer the subscript route from the written spelling, decline the accessor + * route. + * + * Languages whose collection views are spelled as method calls (`.values()`, + * `.iter()`) never reach the accessor route — the compound resolver's + * call-expression branch handles those — so the whole hook is this one + * decision. Declining (returning `undefined`) is the answer "this spelling is + * not a container", which is what stops an index-overloading class from + * folding `x[k].m()` onto its own members. + * + * Languages that DO expose a collection view as a property (C#'s `.Values`) + * need a bespoke body instead. + */ +export const indexOnlyElementType = ( + containerType: string, + via: ElementAccessRoute, +): string | undefined => + via.kind === 'index' ? extractElementTypeFromString(containerType) : undefined; + // ── Return type text helpers ───────────────────────────────────────────── // extractReturnTypeName works on raw return-type text already stored in // SymbolDefinition (e.g. "User", "Promise", "User | null", "*User"). diff --git a/gitnexus/src/core/ingestion/utils/call-analysis.ts b/gitnexus/src/core/ingestion/utils/call-analysis.ts index ea43911d9..86be8b8cd 100644 --- a/gitnexus/src/core/ingestion/utils/call-analysis.ts +++ b/gitnexus/src/core/ingestion/utils/call-analysis.ts @@ -2,6 +2,7 @@ import type { MixedChainStep } from 'gitnexus-shared'; import type { SyntaxNode } from './ast-helpers.js'; import { CALL_ARGUMENT_LIST_TYPES } from './ast-helpers.js'; +import { subscriptBase } from './callable-flow-captures.js'; /** Node types representing call expressions across supported languages. */ export const CALL_EXPRESSION_TYPES = new Set([ @@ -16,6 +17,24 @@ export const CALL_EXPRESSION_TYPES = new Set([ /** * Hard limit on chain depth to prevent runaway recursion. * For `a.b().c().d()`, the chain has depth 2 (b and c before d). + * + * A chain deeper than this is DISCARDED WHOLE, not truncated: + * `extractMixedChain` returns an undefined base and the encoder refuses to mint + * a partial chain, because a base-side prefix decodes cleanly as a shorter, + * complete-looking chain and would type the receiver against the wrong member. + * Correct, but it means a builder chain one hop too long contributes nothing at + * all rather than degrading. + * + * DELIBERATELY NOT RAISED. Measured (see bench/receiver-resolution/BASELINE.md, + * `fourHopChain`): a 4-step chain mints NOTHING at this cap — confirmed by + * probing the emitter directly — and the site still RESOLVES, because the text + * cascade that owns the fallback path runs to `COMPOUND_RECEIVER_MAX_DEPTH` (8) + * and answers where the structural fold declined. + * + * So the cap bounds which chains are typed STRUCTURALLY, not which calls + * resolve. Raising it moves work from the cascade to the fold without changing + * any edge, and the fixture that proves it is committed so the next person to + * reach for this number has the measurement rather than the intuition. */ export const MAX_CHAIN_DEPTH = 3; @@ -353,6 +372,51 @@ export const extractReceiverNode = (nameNode: SyntaxNode): SyntaxNode | undefine // ── Chained-call extraction ─────────────────────────────────────────────── /** Node types representing member/field access across languages. */ +/** + * Await expressions, per grammar. The walk previously stopped here — an await + * node is neither a call nor a field access — so `(await svc.getUserAsync()).save()` + * minted NO chain at all and the receiver fell to the text cascade. + */ +const AWAIT_EXPRESSION_NODE_TYPES = new Set([ + 'await_expression', // TS/JS/C#/Rust + 'await', // Python +]); + +/** + * Subscript / index expressions, per grammar. Same story as await: the walk + * stopped, so `repos[0].save()` minted no chain — which is why `indexElement` + * is an INVISIBLE-GAP (no edge AND no recorded drop) in all 14 languages, the + * most uniform cell in the matrix. + */ +const SUBSCRIPT_NODE_TYPES = new Set([ + 'subscript_expression', // TS/JS/PHP/C/C++ + 'subscript', // Python + 'index_expression', // Go/Rust + 'element_access_expression', // C# + 'array_access', // Java + 'indexing_expression', // Kotlin +]); + +/** + * Can the chain walk descend into this node, or is it the base? + * + * ONE predicate for all four branches. The call and field branches previously + * tested only the call/field sets, so a receiver like `x[0].f().g()` stopped at + * the subscript and returned the literal text `x[0]` as the base — which + * `isEncodableSegment` accepts, minting a chain whose base binds to nothing. And + * `(await f()).g.h()` returned `await f()`, rejected on whitespace, minting no + * chain at all. Adding a fifth step kind must not require remembering four + * separate call sites. + */ +function isChainableReceiverNode(node: SyntaxNode): boolean { + return ( + CALL_EXPRESSION_TYPES.has(node.type) || + FIELD_ACCESS_NODE_TYPES.has(node.type) || + AWAIT_EXPRESSION_NODE_TYPES.has(node.type) || + SUBSCRIPT_NODE_TYPES.has(node.type) + ); +} + const FIELD_ACCESS_NODE_TYPES = new Set([ 'member_expression', // TS/JS 'member_access_expression', // C# @@ -497,11 +561,25 @@ const TRANSPARENT_RECEIVER_WRAPPERS = new Set([ 'parenthesized_expression', // `(svc)` ]); +/** + * Iteration bound for the wrapper peel. Its OWN constant, not `MAX_CHAIN_DEPTH`. + * + * The two answer unrelated questions — "how many chain hops do we type?" versus + * "how many redundant parens might someone write?" — and sharing one number + * meant raising the chain cap silently widened this loop as a side effect. That + * coupling is easy to miss precisely because the shared name reads as + * intentional. `((x))` nests twice; nothing real nests deeply. + */ +const MAX_TRANSPARENT_WRAPPER_DEPTH = 3; + /** Peel transparent wrappers off a base receiver node. */ function unwrapTransparentReceiver(node: SyntaxNode): SyntaxNode { let current = node; - // Bounded: `((x))` nests twice; nothing real nests deeply. - for (let i = 0; i < MAX_CHAIN_DEPTH && TRANSPARENT_RECEIVER_WRAPPERS.has(current.type); i++) { + for ( + let i = 0; + i < MAX_TRANSPARENT_WRAPPER_DEPTH && TRANSPARENT_RECEIVER_WRAPPERS.has(current.type); + i++ + ) { const inner = current.namedChildren?.find((c) => c !== null); if (inner === undefined || inner === null) break; current = inner; @@ -516,6 +594,12 @@ export function extractMixedChain( let current: SyntaxNode = receiverNode; while (chain.length < MAX_CHAIN_DEPTH) { + // Peel transparent wrappers at LOOP ENTRY, not only where a base is + // returned. `(await svc.getUserAsync()).save()` hands this walk a + // `parenthesized_expression`, which matches no branch below, so the walk + // fell straight through to the base case with an empty chain and minted + // nothing — the await step could never be reached. + current = unwrapTransparentReceiver(current); if (CALL_EXPRESSION_TYPES.has(current.type)) { // ── Call expression: extract method name + inner receiver ──────────── const funcNode = @@ -572,10 +656,7 @@ export function extractMixedChain( } if (!innerReceiver) break; - if ( - CALL_EXPRESSION_TYPES.has(innerReceiver.type) || - FIELD_ACCESS_NODE_TYPES.has(innerReceiver.type) - ) { + if (isChainableReceiverNode(innerReceiver)) { current = innerReceiver; } else { return { @@ -624,10 +705,7 @@ export function extractMixedChain( if (!innerObject) break; - if ( - CALL_EXPRESSION_TYPES.has(innerObject.type) || - FIELD_ACCESS_NODE_TYPES.has(innerObject.type) - ) { + if (isChainableReceiverNode(innerObject)) { current = innerObject; } else { return { @@ -635,6 +713,50 @@ export function extractMixedChain( baseReceiverName: unwrapTransparentReceiver(innerObject).text || undefined, }; } + } else if (AWAIT_EXPRESSION_NODE_TYPES.has(current.type)) { + // Name-free: the awaited call's method name already lives on its own + // `call` step, so this records only that an await happened. + chain.unshift({ kind: 'await' }); + const inner = + current.childForFieldName?.('argument') ?? + current.childForFieldName?.('expression') ?? + current.namedChildren?.find((c: SyntaxNode) => c !== null) ?? + null; + if (!inner) break; + if (isChainableReceiverNode(inner)) { + current = inner; + } else { + return { + chain, + baseReceiverName: unwrapTransparentReceiver(inner).text || undefined, + }; + } + } else if (SUBSCRIPT_NODE_TYPES.has(current.type)) { + // Name-free: a subscript key is a VALUE, not an identifier the resolver + // could look up, so there is no member name to record. + chain.unshift({ kind: 'index' }); + // Shared per-grammar table — it knows Python's `value` and Java's `array`, + // which a locally-written ladder omitted (they worked only because the + // container happened to be the first named child, an ordering coincidence + // rather than a contract). Falls back for grammars whose subscript node + // carries no `index` field. + const obj = + subscriptBase(current) ?? + current.childForFieldName?.('object') ?? + current.childForFieldName?.('argument') ?? + current.childForFieldName?.('operand') ?? + current.childForFieldName?.('expression') ?? + current.namedChildren?.find((c: SyntaxNode) => c !== null) ?? + null; + if (!obj) break; + if (isChainableReceiverNode(obj)) { + current = obj; + } else { + return { + chain, + baseReceiverName: unwrapTransparentReceiver(obj).text || undefined, + }; + } } else if (current.type === 'selector') { // ── Dart: flat selector siblings (user.address.save() uses selector nodes) ── // Extract field name from unconditional_assignable_selector child diff --git a/gitnexus/src/core/ingestion/utils/callable-flow-captures.ts b/gitnexus/src/core/ingestion/utils/callable-flow-captures.ts index eb18a0f8b..4ca872ec4 100644 --- a/gitnexus/src/core/ingestion/utils/callable-flow-captures.ts +++ b/gitnexus/src/core/ingestion/utils/callable-flow-captures.ts @@ -1092,7 +1092,10 @@ function unaryOperator(node: SyntaxNode): string | undefined { * `tbl[i]()` join (#2522 review). Field names cover the grammars that field * their subscript nodes; others keep the generic traversal. */ -function subscriptBase(node: SyntaxNode): SyntaxNode | null { +/** The container operand of a subscript node, per grammar. Exported because the + * receiver-chain walk needs the same per-grammar answer — two divergent field + * tables for one question is how a new grammar gets half-supported. */ +export function subscriptBase(node: SyntaxNode): SyntaxNode | null { if (node.childForFieldName('index') === null) return null; return ( node.childForFieldName('argument') ?? // C/C++ subscript_expression diff --git a/gitnexus/src/core/ingestion/utils/receiver-chain-codec.ts b/gitnexus/src/core/ingestion/utils/receiver-chain-codec.ts index 4bb68eace..6d4b8678f 100644 --- a/gitnexus/src/core/ingestion/utils/receiver-chain-codec.ts +++ b/gitnexus/src/core/ingestion/utils/receiver-chain-codec.ts @@ -16,10 +16,10 @@ * and every chain array as a distinct allocation on every warm load, while a * string collapses to one interned instance per distinct chain. * - * ## Wire format (version `1`) + * ## Wire format (version `2`) * * ``` - * 1|||…[|~] + * 2|||…[|~] * ``` * * - One-character version prefix, then the BASE receiver name, then ordered @@ -29,6 +29,12 @@ * character of the segment and the name follows immediately, so a member * whose name begins with `c` or `f` needs no escaping (`ccount` decodes as a * call to `count`). + * - `a` = await and `i` = index are NAME-FREE and encode as a BARE sigil: an + * awaited call's name already lives on its `c` step, and a subscript's key is + * a value rather than an identifier the resolver could look up. The decoder + * rejects any trailing characters after `a` or `i`, which is what keeps an + * accidentally empty-name `c` or `f` segment refusing instead of decoding as + * one of these. * - A trailing `|~` segment is the TRUNCATION MARKER. NOTE: no current producer * mints one. `extractMixedChain` signals "stopped early" by returning * `baseReceiverName: undefined`, and the encoder requires a base, so a @@ -46,17 +52,44 @@ * different valid chain. * * For `svc.getUser().address.save()`, the receiver of `save` encodes as - * `1|svc|cgetUser|faddress` — 23 bytes. + * `2|svc|cgetUser|faddress` — 23 bytes. For `(await svc.getUserAsync()).save()` + * it is `2|svc|cgetUserAsync|a`. */ import type { MixedChainStep } from 'gitnexus-shared'; import { MAX_CHAIN_DEPTH } from './call-analysis.js'; -const VERSION = '1'; +/** Wire version. Bumped 1 → 2 when the name-free `await` and `index` step kinds + * were added: a v1 decoder reading a v2 payload must REFUSE, not decode the + * prefix it happens to understand, because a chain missing its await or index + * hop decodes cleanly as a different, shorter chain and would type the receiver + * against the wrong member. */ +const VERSION = '2'; const SEPARATOR = '|'; const TRUNCATED = '~'; +/** Sigil per step kind. ONE table, and both directions derive from it — the + * decoder used to hand-write `sigil === 'c' ? 'call' : 'field'` and its own + * await/index comparison, which meant the decoder was exactly the side that + * could drift from the table claiming to prevent drift. */ +const SIGIL_BY_KIND = { + call: 'c', + field: 'f', + await: 'a', + index: 'i', +} as const; + +type StepKind = keyof typeof SIGIL_BY_KIND; + +/** Kinds that encode as a BARE sigil, because they have no member name to + * carry. Derived from the step union rather than listed twice. */ +const NAME_FREE_KINDS = new Set(['await', 'index']); + +const KIND_BY_SIGIL: ReadonlyMap = new Map( + Object.entries(SIGIL_BY_KIND).map(([kind, sigil]) => [sigil, kind as StepKind]), +); + /** Hard cap on the encoded payload. `MAX_CHAIN_DEPTH` already bounds the step * COUNT; this bounds the total bytes so a pathological identifier cannot grow * a shard without limit. Generous against real identifiers — the encoding for @@ -103,8 +136,15 @@ export function encodeReceiverChain( const parts = [VERSION, baseReceiverName]; for (const step of steps) { + // Name-free kinds encode as a bare sigil. They are exempt from the + // non-empty-name guard because they HAVE no name to check — not because the + // guard is relaxed: an empty-name `call` or `field` is still refused below. + if (NAME_FREE_KINDS.has(step.kind)) { + parts.push(SIGIL_BY_KIND[step.kind]); + continue; + } if (!isEncodableSegment(step.name)) return undefined; - parts.push(`${step.kind === 'call' ? 'c' : 'f'}${step.name}`); + parts.push(`${SIGIL_BY_KIND[step.kind]}${step.name}`); } if (options?.truncated === true) parts.push(TRUNCATED); @@ -139,9 +179,20 @@ export function decodeReceiverChain(value: unknown): DecodedReceiverChain | unde for (const part of stepParts) { const sigil = part[0]; const name = part.slice(1); - if (sigil !== 'c' && sigil !== 'f') return undefined; + // Name-free kinds must be EXACTLY their sigil. Rejecting a trailing tail is + // what keeps an accidentally empty-name call or field from decoding as one + // of these: `c` alone stays malformed, it does not become an await. + const kind = sigil === undefined ? undefined : KIND_BY_SIGIL.get(sigil); + if (kind === undefined) return undefined; + if (NAME_FREE_KINDS.has(kind)) { + // Must be EXACTLY the sigil. Rejecting a trailing tail is what keeps an + // accidentally empty-name call or field from decoding as one of these. + if (name.length > 0) return undefined; + steps.push({ kind: kind as 'await' | 'index' }); + continue; + } if (!isEncodableSegment(name)) return undefined; - steps.push({ kind: sigil === 'c' ? 'call' : 'field', name }); + steps.push({ kind: kind as 'call' | 'field', name }); } return { baseReceiverName, steps, truncated }; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 1914b0101..3a80eaa71 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -283,7 +283,10 @@ export interface ExtractedCall { * `svc.getUser().save()` → chain=[{kind:'call',name:'getUser'}], receiverName='svc' * `user.address.save()` → chain=[{kind:'field',name:'address'}], receiverName='user' * `svc.getUser().address.save()` → chain=[{kind:'call',name:'getUser'},{kind:'field',name:'address'}] - * Length is capped at MAX_CHAIN_DEPTH (3). + * Length is capped at MAX_CHAIN_DEPTH. Deliberately NOT restating the number + * here: this comment previously hardcoded `(3)` and would have drifted the + * moment the cap moved, which is exactly the kind of stale doc that reads as + * authoritative. */ receiverMixedChain?: MixedChainStep[]; argTypes?: (string | undefined)[]; diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 690c48c8f..631296a81 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -94,7 +94,10 @@ import { findImportCycles } from '../../core/graph/import-cycles.js'; import { decodeTaintPath } from '../../core/ingestion/taint/path-codec.js'; import { decodeReachingDefReason } from '../../core/ingestion/cfg/reaching-def-reason-codec.js'; import { EXTENSIONS } from '../../core/ingestion/import-resolvers/utils.js'; -import { lookupUnresolvedCallCount } from '../../core/ingestion/scope-resolution/unresolved-receivers.js'; +import { + lookupExternalCallCount, + lookupUnresolvedCallCount, +} from '../../core/ingestion/scope-resolution/unresolved-receivers.js'; import { fnLineOf, isPdgDegradedLayerStatus, @@ -472,13 +475,101 @@ export interface CodebaseContext { /** Collapse dropped-site boundary notes into an epistemic verdict: any note at * all means the count is a lower bound, none means it is exact (#2744). */ -function epistemicFrom(droppedBoundaries: readonly string[]): { +/** + * Why a count is a lower bound, as a machine-readable split. + * + * `epistemic` is a single enum and `boundaries` is prose, so a consumer that is + * not a human — a coding agent gating its own edits on this result — can tell + * THAT the answer is short but not WHY, and cannot branch on the difference. + * The two causes are independent and have opposite remedies: + * + * - `receiverTyping` — the analyzer dropped call sites because it could not + * establish the receiver's type. A resolver defect. Fixable, and shrinking: + * this is the population the structural-receiver work targets. + * - `dispatchBoundary` — the symbol sits behind an interface with real + * consumers or multiple implementations, so callers binding through a DI + * container or dynamic dispatch are genuinely untraceable statically. NOT a + * defect; a compiler would refuse here too. + * + * Collapsing them told the reader "impact may be higher" for both, which made + * the fixable cause indistinguishable from the irreducible one — and made + * "the hedge should stop appearing" an unfalsifiable goal, because there was no + * way to see which producer was still firing. + * + * Every field counts MISSING THINGS, never notes. The unit is stated per field + * because the two producers can only measure at different granularities (see + * `dispatchBoundary`), and a consumer comparing the numbers has to know which + * it is holding. Counting notes here is the specific mistake to avoid: there is + * one note per symbol name / per boundary node, so a note count reports the + * number of SENTENCES, which has no relation to how much is missing. + */ +export interface EpistemicCauses { + /** + * Call SITES dropped at index time because the receiver's type could not be + * established. Unit: call sites, taken from the index's + * `unresolvedReceiverMembers` summary — the same number the prose note quotes. + */ + readonly receiverTyping: number; + /** + * Symbols on the far side of a dispatch boundary that the traversal could not + * attribute to the queried symbol: implementations plus interface-level + * consumers, summed over the boundary nodes that were flagged. + * + * Unit: SYMBOLS, not call sites — deliberately, because a call-site count is + * not derivable on this side. The graph does not retain per-site multiplicity + * for these edges: consumers are counted with `COUNT(DISTINCT other.id)`, and + * languages that set `collapseMemberCallsByCallerTarget` emit one CALLS edge + * per (caller, target) pair no matter how many syntactic sites exist. A + * symbol reachable through two flagged boundary nodes is counted once per + * node, so this is itself a lower bound. + * + * It is still directly comparable in magnitude with `receiverTyping` — both + * answer "how much is missing" — which `boundaries.length` was not. + */ + readonly dispatchBoundary: number; + /** + * Call sites whose receiver was rooted OUTSIDE the indexed program — + * `System.out.println`, `fetch(...)`, `os.environ.*`. Reported, but NOT a + * reason the count is short: there is no in-graph node an edge could have + * reached, so the analysis is complete for the program as given. + * + * Surfaced so "no uncertainty" is distinguishable from "we judged 76 calls to + * be outside the program". A compiler resolves these against the JDK / BCL / + * lib.d.ts; lacking those, this number IS the boundary. + * + * Unit: call sites — same unit and same source as `receiverTyping`. + */ + readonly externalBoundary: number; +} + +function epistemicFrom(dropped: { notes: readonly string[]; sites: number; external: number }): { epistemic: 'exact' | 'lower-bound'; boundaries?: string[]; + causes?: EpistemicCauses; } { - return droppedBoundaries.length === 0 - ? { epistemic: 'exact' } - : { epistemic: 'lower-bound', boundaries: [...droppedBoundaries] }; + // An index whose only drops were external still reports `exact` — nothing was + // lost — but carries the boundary count so "complete" is distinguishable from + // "we judged N calls to leave the program". + return dropped.notes.length === 0 + ? dropped.external > 0 + ? { + epistemic: 'exact', + causes: { receiverTyping: 0, dispatchBoundary: 0, externalBoundary: dropped.external }, + } + : { epistemic: 'exact' } + : { + epistemic: 'lower-bound', + boundaries: [...dropped.notes], + // SITES, not notes. There is one note per symbol name but it reports N + // dropped sites, so counting notes would have published `1` next to + // prose saying `2 call sites` — a consumer branching on the number + // would read a different magnitude than the human reading the text. + causes: { + receiverTyping: dropped.sites, + dispatchBoundary: 0, + externalBoundary: dropped.external, + }, + }; } interface RepoHandle { @@ -5738,7 +5829,11 @@ export class LocalBackend { symId: string, symType: string, symName: string, - ): Promise<{ epistemic: 'exact' | 'lower-bound'; boundaries?: string[] }> { + ): Promise<{ + epistemic: 'exact' | 'lower-bound'; + boundaries?: string[]; + causes?: EpistemicCauses; + }> { const HERITAGE_TYPES = EPISTEMIC_HERITAGE_RELATION_TYPES; const CONSUMER_TYPES = EPISTEMIC_CONSUMER_RELATION_TYPES; // #2744 — call sites dropped for want of a receiver type. Checked BEFORE @@ -5803,6 +5898,12 @@ export class LocalBackend { ]); const boundaries: string[] = []; + // Magnitude, not note count: see `EpistemicCauses.dispatchBoundary`. One + // note can describe an interface with 40 implementations and hundreds of + // interface-level consumers, so publishing `boundaries.length` would put + // `1` next to a `receiverTyping` of `12` and tell a consumer branching on + // the numbers that receiver typing dominates — the opposite of the truth. + let dispatchBoundarySymbols = 0; for (const [id, info] of boundary) { const impls = implCounts.get(id) ?? 0; const consumers = consumerCounts.get(id) ?? 0; @@ -5811,6 +5912,7 @@ export class LocalBackend { // (runtime dispatch is ambiguous). A concrete type implementing an // interface nothing references is fully traced → stays exact. if (consumers >= 1 || impls >= 2) { + dispatchBoundarySymbols += impls + consumers; const label = (info.label || 'Interface').toLowerCase(); const name = info.name || '(unnamed)'; const article = /^[aeiou]/.test(label) ? 'an' : 'a'; @@ -5829,7 +5931,15 @@ export class LocalBackend { } } if (boundaries.length === 0) return epistemicFrom(droppedBoundaries); - return { epistemic: 'lower-bound', boundaries: [...droppedBoundaries, ...boundaries] }; + return { + epistemic: 'lower-bound', + boundaries: [...droppedBoundaries.notes, ...boundaries], + causes: { + receiverTyping: droppedBoundaries.sites, + dispatchBoundary: dispatchBoundarySymbols, + externalBoundary: droppedBoundaries.external, + }, + }; } catch { // Never let the heritage probe's failure suppress a drop we already know // about — the whole point is that silence must not read as certainty. @@ -5844,8 +5954,11 @@ export class LocalBackend { * index written before the summary existed, which is why the schema version * was bumped rather than treating "absent" as "none". */ - private async unresolvedReceiverBoundaries(repo: RepoHandle, symName: string): Promise { - if (symName.length === 0) return []; + private async unresolvedReceiverBoundaries( + repo: RepoHandle, + symName: string, + ): Promise<{ notes: string[]; sites: number; external: number }> { + if (symName.length === 0) return { notes: [], sites: 0, external: 0 }; try { const meta = await loadMeta(path.dirname(repo.lbugPath)); const summary = meta?.unresolvedReceiverMembers; @@ -5853,8 +5966,9 @@ export class LocalBackend { // returns a Function for `constructor`/`toString`/… and `NaN <= 0` is false, // so the old guard let it through into user-facing text. const sites = lookupUnresolvedCallCount(summary, symName); - if (sites === undefined) return []; - return [ + const external = lookupExternalCallCount(summary, symName) ?? 0; + if (sites === undefined) return { notes: [], sites: 0, external }; + const notes = [ `${sites} call ${sites === 1 ? 'site' : 'sites'} invoking \`${symName}\` ${ sites === 1 ? 'was' : 'were' } dropped at index time because the receiver's type could not be ` + @@ -5862,8 +5976,9 @@ export class LocalBackend { `expression). Those callers are absent from this result — actual ` + `impact may be higher.`, ]; + return { notes, sites, external }; } catch { - return []; + return { notes: [], sites: 0, external: 0 }; } } @@ -5934,9 +6049,14 @@ export class LocalBackend { // optional here (the union's `{}` subtype). computeEpistemicBoundary's own // return keeps `epistemic` REQUIRED — only this promise widens to the skip // subtype. + // `causes` is part of the annotation, not just of the runtime value: the + // spread below is what publishes these fields, and a narrower annotation + // erases `causes` at the type level while still shipping it at runtime — + // so every consumer would be reading a field the compiler says is absent. const epistemicPromise: Promise<{ epistemic?: 'exact' | 'lower-bound'; boundaries?: string[]; + causes?: EpistemicCauses; }> = opts.skipEpistemic ? Promise.resolve({}) : this.computeEpistemicBoundary(repo, symId, symType, (sym.name || sym[1]) as string); diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index b48de9b8d..bf9dca07a 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -284,6 +284,16 @@ Handles disambiguation: if multiple symbols share the same name, returns ranked NOTE: ACCESSES edges (field read/write tracking) are included in context results with reason 'read' or 'write'. CALLS edges resolve through field access chains and method-call chains (e.g., user.address.getCity().save() produces CALLS edges at each step). +COMPLETENESS OF incoming: alongside symbol/incoming/outgoing the result carries the same epistemic envelope impact() returns: +- epistemic: 'exact' | 'lower-bound' — 'lower-bound' means callers exist that this view provably does not list. +- boundaries: string[] — one plain-language sentence per reason. Prose for humans; branch on causes instead. +- causes: { receiverTyping, dispatchBoundary, externalBoundary } — machine-readable WHY. Every field counts MISSING THINGS, never sentences: + - causes.receiverTyping (unit: call sites) > 0 — RESOLVER GAP: the analyzer dropped that many call sites on this name because it could not type the receiver, so they are missing from incoming. Do not read an absent caller as proof none exists. + - causes.externalBoundary (unit: call sites) > 0 — the calls left the indexed program (System.out.println, fetch(...)). NOT a defect: no in-graph node could have been reached. An epistemic:'exact' result can carry this. + - causes.dispatchBoundary (unit: symbols) > 0 — DI / interface dispatch: implementations plus interface-level consumers behind a boundary static analysis cannot cross. Irreducible. + +REQUIRES RE-INDEX: causes.receiverTyping and causes.externalBoundary come from index-time metadata only a current analyzer writes; against an older index they read as absent/0, which is indistinguishable from "nothing was dropped". Re-run \`gitnexus analyze\` before trusting a zero there. + GROUP MODE: set "repo" to "@" to run context in each member repo (aggregated list), or "@/" for one member. If you use "@" only, the member defaults to the lexicographically first key in group.yaml "repos". SERVICE: optional monorepo path prefix (case-sensitive path segments). When "repo" starts with "@", prefix-matches resolved symbol file paths; when a hit is outside the prefix, that member returns an empty payload for the symbol. Ignored for a normal indexed repo name.`, @@ -448,6 +458,14 @@ Output includes: - affected_processes: which execution flows break and at which step - affected_modules: which functional areas are hit (direct vs indirect) - byDepth: affected symbols grouped by traversal depth (paginated by limit/offset; omitted when summaryOnly:true — use byDepthCounts for totals per depth, pagination object when truncated). Each item includes a processes:[{id,label,processType,step}] field listing the execution flows that symbol participates in. Empty when the symbol has no process membership. Can ALSO be empty when partial:true is set — either the process-aggregation pass hit its cap before detecting affected processes, or per-symbol enrichment was capped on a very large page. When partial:true, do NOT treat processes:[] as proof of no participation; cross-check the top-level affected_processes list. +- epistemic: 'exact' | 'lower-bound' — whether impactedCount is the whole story. 'lower-bound' means the walk provably missed callers, so the count is a floor. Absent only on skipped probes (ambiguous-candidate lists, group fan-out). +- boundaries: string[] — one plain-language sentence per reason the count is short. Prose for humans; branch on causes instead. +- causes: { receiverTyping, dispatchBoundary, externalBoundary } — the machine-readable split of WHY, so an agent gating its own edits can tell a fixable analyzer gap from an irreducible one. Every field counts MISSING THINGS, never sentences: + - causes.receiverTyping (unit: call sites) > 0 — the RESOLVER GAP signal: the analyzer dropped that many call sites because it could not establish the receiver's type (unresolved constructor, factory, chained expression). Those callers are absent from byDepth. Treat the result as incomplete: grep the symbol name before deleting or renaming. + - causes.externalBoundary (unit: call sites) > 0 — those calls left the indexed program (System.out.println, fetch(...), os.environ.*). NOT a defect and NOT a reason the count is short: there is no in-graph node any edge could have reached. An epistemic:'exact' result can carry this. + - causes.dispatchBoundary (unit: symbols) > 0 — DI / interface dispatch: that many implementations plus interface-level consumers sit on the far side of a boundary a static walk cannot cross. Irreducible; a compiler refuses here too. A symbol count, not a site count — per-site multiplicity is not retained for these edges — so compare its magnitude with receiverTyping, not its exact value. + +REQUIRES RE-INDEX: causes.receiverTyping and causes.externalBoundary are read from index-time metadata that only a current analyzer writes. Against an older index they read as absent/0, which is indistinguishable from "nothing was dropped" — re-run \`gitnexus analyze\` before trusting a zero there. Depth groups: - d=1: WILL BREAK (direct callers/importers) diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 635285d44..f8c7f64db 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -153,8 +153,26 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // parse (#2771), so a class body that previously error-recovered away now // survives. The chunk key hashes raw on-disk bytes and `preprocessSource` runs // after it is computed, so unchanged Swift files would otherwise replay their -// pre-fix `ParseWorkerResult` verbatim — including across `--force`. -const SCHEMA_BUMP = 38; +// pre-fix `ParseWorkerResult` verbatim — including across `--force`. Allocated +// on `main`, NOT by this branch — kept so the number is not reused a third time. +// v39: receiver-chain wire format v2 — the encoded chain gained name-free +// `await` and `index` step kinds, so the VERSION prefix moved 1 -> 2 and every +// persisted chain string changed. A v2 decoder REFUSES a v1 payload (that is +// the point: a chain missing its await or index hop decodes cleanly as a +// different, shorter chain and would type the receiver against the wrong +// member), so a stale cache replays chains this build silently discards — +// the feature degrades to the text cascade with no error anywhere. Bumped so +// the stale cache is rejected rather than half-read. +// +// NUMBERED 39, AFTER TWO REALLOCATIONS. This branch first used 37; `main` took +// 37 for Spring AOP (#2416) mid-flight, so it moved to 38; `main` then took 38 +// for the Swift directive fix (#2771), landing on the branch's number AGAIN. +// That is the EIGHTH collision in this series and the SECOND exact clash — two +// incompatible schemas claiming one number, twice running. The lesson is not +// "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; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 896fe863f..db3823d6d 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -23,6 +23,7 @@ import { getInferredRepoName, resolveRepoIdentityRoot } from './git.js'; import { stripWindowsLongPathPrefix } from '../lib/utils.js'; import { retryRename } from './fs-atomic.js'; import { logger } from '../core/logger.js'; +import type { UnresolvedReceiverSummary } from '../core/ingestion/scope-resolution/unresolved-receivers.js'; import { acquireIndexLock, IndexLockTimeoutError, type IndexLockHandle } from './index-lock.js'; import { branchSlug, @@ -240,12 +241,14 @@ export interface RepoMeta { * callee is unknown by definition, so the drop cannot be attributed to any * target. Absent when a run dropped nothing, which is the common case and * keeps `epistemic` exact for cleanly-resolving repos. + * + * The persisted shape IS `UnresolvedReceiverSummary` — referenced, not + * re-declared. The writer stores the whole summary, so a structural mirror + * here silently drops any field added on the producing side (a reader then + * sees `undefined` for keys that are present on disk). Type-only import, so + * this adds no runtime dependency from storage/ on core/. */ - unresolvedReceiverMembers?: { - counts: Record; - totalSites: number; - omittedNames?: number; - }; + unresolvedReceiverMembers?: UnresolvedReceiverSummary; /** * SHA-256 of every file's content at the time of the last successful * indexing run. The next run computes current hashes and diffs against @@ -673,8 +676,19 @@ export interface RepoMeta { * (#2416). LadybugDB fixes allowed endpoint pairs when the relation table is * created, so an older index cannot persist these edges through incremental * writeback. Force a full re-analyze. + * + * v34: receiver-chain wire format v2 (name-free `await` / `index` steps). Every + * persisted `ReferenceSite.receiverChain` string changed prefix, and a v2 + * decoder refuses a v1 payload by design, so a pre-v34 index carries chains this + * build cannot read. Resolution would silently fall back to the text cascade for + * every chain-carrying site — no error, just quietly worse edges. Force a full + * re-analyze. + * + * Numbered 34, not 33: `main` took 33 for Spring AOP (#2416) mid-flight, landing + * on exactly this branch's number — the seventh collision in this series and the + * first exact clash. Re-check against origin/main before merge. */ -export const INCREMENTAL_SCHEMA_VERSION = 33; +export const INCREMENTAL_SCHEMA_VERSION = 34; export interface IndexedRepo { repoPath: string; diff --git a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json index bed021324..6271e6115 100644 --- a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json @@ -89,7 +89,7 @@ }, "csharp-chain-call/Program.cs": { "captureGroups": 12, - "digest": "595cb902e60ce3e2b2dbc5036dda3738fe3d5f2ae3e438f9fc58374d0c43878d" + "digest": "3bde63f4bc679c03ef068eb5464d22c4b068028fa635067a3ad2d33d6004d760" }, "csharp-chain-call/Services/UserService.cs": { "captureGroups": 11, @@ -125,7 +125,7 @@ }, "csharp-deep-field-chain/Service.cs": { "captureGroups": 14, - "digest": "f1994535de8f70d68adfe640c6f904f5e71ef4c2d0e7e0e91d4ebb0cda9b46a7" + "digest": "30a18501a48916294ef08b2694d297bd46c72ad1d16bb654968c4293b9c1cd14" }, "csharp-dictionary-keys-values/App.cs": { "captureGroups": 21, @@ -153,7 +153,7 @@ }, "csharp-field-types/Service.cs": { "captureGroups": 11, - "digest": "d66a8bd0a78fe20a843c36793e222a33c3bcae4b2eaadf47632dd782ad1d1c41" + "digest": "c38e3db8241460f2c3c295536c760a2452c0f1bc0ee084f7c76e63979ad84b51" }, "csharp-foreach/Models/Repo.cs": { "captureGroups": 8, @@ -209,15 +209,15 @@ }, "csharp-grandparent-resolution/Services/App.cs": { "captureGroups": 14, - "digest": "0223f17f864b5de421fa5906f3e0746bf855800c3a65122479fcc59cdd1a55bd" + "digest": "29a9220fcd1726794e0335200048b297d770a2feef9303431097537f8b07eaba" }, "csharp-hello/Hello.cs": { "captureGroups": 22, - "digest": "f942922d1a30fe794f9c807491f1a54c7ba6881a255a9234eab68ffe0953f88e" + "digest": "8952ec5a995332bc204cab3a9132370dfdf78594f592d569aca3c08db2da74a6" }, "csharp-inline-constructor-receiver/src/Runner.cs": { "captureGroups": 22, - "digest": "0a73830a8629fae4d4f7212c982747b43bebb744ad5e20a566b0097f5220a9e7" + "digest": "10da1e899928afc576e66e5effaf9bcab52d9f83cbc1dd8f0d95174de7410ecb" }, "csharp-inline-constructor-receiver/src/Svc.cs": { "captureGroups": 19, @@ -305,11 +305,11 @@ }, "csharp-local-shadow/App/Main.cs": { "captureGroups": 14, - "digest": "be38b4045eac483dbd95e7cea8af82a38923e4b7ef5b2caa6915304ce012c4e3" + "digest": "b326e2800d010f3f0daa3ffa75444c59de53dbe4962ce4af4e90c76f8b6271c9" }, "csharp-local-shadow/Utils/Logger.cs": { "captureGroups": 10, - "digest": "40206dabd6f5b316fb40ac523d7a8fb66fcdd180f48de6b9313e9dfea02a6fd3" + "digest": "8089cf6d43685f35d479d58106d2a1a7a35994ddd52522cb256e009943bc7844" }, "csharp-member-calls/Models/User.cs": { "captureGroups": 8, diff --git a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json index a27bb8c1e..0205468f6 100644 --- a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json @@ -5,7 +5,7 @@ }, "go-aliased-package-import/main.go": { "captureGroups": 7, - "digest": "3eb2e6d441dadede554b271b7a87b7b9ca557ab418bfb9e8524e6ace4c8fc547" + "digest": "3dad1a14917f597d87776732ed4e1e221af133a6465984fecf0a6841d402e88a" }, "go-ambiguous/internal/models/handler.go": { "captureGroups": 9, @@ -21,7 +21,7 @@ }, "go-assignment-chain/cmd/main.go": { "captureGroups": 54, - "digest": "2bd7f8e2df13345ed77b4b438fa5af0b666aa238fe32be0d9ef39865b04a27e2" + "digest": "2bf328aa2aaa17f6e4cd04d7f3df27f2e275c12e9177a6ec29eab1cb83830760" }, "go-assignment-chain/models/repo.go": { "captureGroups": 8, @@ -33,7 +33,7 @@ }, "go-call-result-binding/cmd/main.go": { "captureGroups": 17, - "digest": "fadab2167d8037193746a4bd13b50c63a7053c18a4810b75da16e931cfcdbb8a" + "digest": "a15141026f87c7c189b9392ce91effaa42f5dde5617c481dfb38eb0be089806f" }, "go-call-result-binding/models/user.go": { "captureGroups": 10, @@ -53,7 +53,7 @@ }, "go-chain-call/cmd/main.go": { "captureGroups": 20, - "digest": "149b55b1cf523c13f9ed06cd02ce293f00407c08dd7d3bc0dd9173bc016e72e5" + "digest": "90aed1ac18f7d992d47f24b7a432b9077cdc113e1b356ba9bd4e430be81c76f2" }, "go-chain-call/models/repo.go": { "captureGroups": 10, @@ -73,7 +73,7 @@ }, "go-child-extends-parent/services/app.go": { "captureGroups": 10, - "digest": "05f5df0369c90bc6e0da5a8a76e0661be36cddbe913ab2f83da2ee3733a46ec2" + "digest": "0794d3e8cbe530daeb5c09d8e325f66a4cd8c32bdba66b913092ef65c439d62c" }, "go-cmd-helper/cmd/server/internal/config/config.go": { "captureGroups": 5, @@ -81,11 +81,11 @@ }, "go-cmd-helper/cmd/server/main.go": { "captureGroups": 7, - "digest": "a1f9453bd71926d60e3f148f43b9af813cbd1cccc11b323896a55bdb443f8931" + "digest": "78fcfc3f827e0531593208f5b76fe0d14edc56473a46a2149a04105d00d6cf4b" }, "go-constructor-type-inference/cmd/main.go": { "captureGroups": 19, - "digest": "3cfb964e7c0fa6d5b702a1312e4cd7dc5c6153d45e29f8d422afebcf131930f4" + "digest": "4f2e8bb6dbca59bcb82fc2efd43899b7a92816af28811cfc1858f0ce7ba9f6fe" }, "go-constructor-type-inference/models/repo.go": { "captureGroups": 8, @@ -97,7 +97,7 @@ }, "go-deep-field-chain/cmd/main.go": { "captureGroups": 14, - "digest": "f9a16407d136307fa11299fadb4dff527a910dd9f2ffe4a50afc95dc3e1dd483" + "digest": "0c58d64b8f1cbd8aa30197a97eeada471215e70290e150f16ff3d7684f21009b" }, "go-deep-field-chain/models/models.go": { "captureGroups": 33, @@ -105,7 +105,7 @@ }, "go-field-types/cmd/main.go": { "captureGroups": 10, - "digest": "d79a7120276b1a5297f6e39bc88574a18b82ad26faede903dd449309544293c6" + "digest": "dfe8fb6cff7e28cc209ad1237b28e11ee25ca34e89d7101dfa3a69de3186cac9" }, "go-field-types/models/models.go": { "captureGroups": 22, @@ -113,7 +113,7 @@ }, "go-for-call-expr/cmd/main.go": { "captureGroups": 27, - "digest": "95217f85260d85baeb57638fff470c0a358be92cc49a5f918b084d809f47ba2a" + "digest": "e6d355eb633b2ca03848caf4eae79c3425ee2546bd37e637ce6b8121f26a13a0" }, "go-for-call-expr/models/repo.go": { "captureGroups": 14, @@ -137,7 +137,7 @@ }, "go-make-builtin/main.go": { "captureGroups": 18, - "digest": "7c56328d8416338ae0075ae7dd9669b16f2035aa353fac7ee1e46109a58e80a6" + "digest": "e4ab42c7af140f1322325c69f9f0cbc69ea48ed14714642981bb25895455c6c4" }, "go-make-builtin/models.go": { "captureGroups": 15, @@ -145,7 +145,7 @@ }, "go-map-range/main.go": { "captureGroups": 11, - "digest": "5e8b9ed5580cd51f988049c4a43e212bf9d5b655f5de85c80a956baf5892a6a0" + "digest": "9add416b9d98bfc08a0d7c96d3c9c1cd937269d6cd78fc200bce0e5a352c51b4" }, "go-map-range/models/repo.go": { "captureGroups": 9, @@ -157,7 +157,7 @@ }, "go-member-calls/cmd/main.go": { "captureGroups": 11, - "digest": "b65bbfd200e4be9a991403a44bc68fb0a0abfe0bbf764b2acbd472d3eaf6742e" + "digest": "fbb2a3747e4b5da351c6895b0898440e57bf96de6a7e7167880e6dbb02b52f7e" }, "go-member-calls/models/user.go": { "captureGroups": 8, @@ -165,7 +165,7 @@ }, "go-method-chain-binding/cmd/main.go": { "captureGroups": 22, - "digest": "ede80f2009f68d880d957bc90af74898dc0b3f7d86a48bf26e4d9d3587dec2d4" + "digest": "3b23b286ee2da646434fac5eb98199422fb2701d962a62840bb83961ec134987" }, "go-method-chain-binding/models/user.go": { "captureGroups": 24, @@ -177,11 +177,11 @@ }, "go-method-enrichment/app.go": { "captureGroups": 15, - "digest": "ac0bdc2e6daf7d4e28fd255fe2edd143e8fab7ff316f4e32e2ca04a3b33f71c2" + "digest": "35929a31f6921feb074f50385aa91879e93f25b88d7f50774fddcfbb7b4e8539" }, "go-mixed-chain/cmd/main.go": { "captureGroups": 22, - "digest": "ee4f286d8b6baa15476e6316665f6d51a891701f9c994e14514aaee2738e811a" + "digest": "1c927e49a4271c1bd088b504051a9f02f2f30ec13fdec14b37c85e1afb5bb721" }, "go-mixed-chain/models/models.go": { "captureGroups": 41, @@ -189,7 +189,7 @@ }, "go-multi-assign/app.go": { "captureGroups": 18, - "digest": "6ca3fdfb8708da1503367902ea458927bd2eb33582eeccbbb0a1dab585cc30c1" + "digest": "42821f44316f1b03c306f502fb4f80b344587f77795ce12cc9f91faa5017194d" }, "go-multi-assign/models.go": { "captureGroups": 19, @@ -197,7 +197,7 @@ }, "go-multi-return-inference/cmd/main.go": { "captureGroups": 38, - "digest": "9f7320349e8fd04f40c23a9a04ab67826140034e98d9851538db1b8a6f9f2b7b" + "digest": "a1246c7603634e4e417310ca8cf105ff6465e3b466450f597b2f78ac25713c05" }, "go-multi-return-inference/models/repo.go": { "captureGroups": 10, @@ -209,7 +209,7 @@ }, "go-new-builtin/main.go": { "captureGroups": 12, - "digest": "1177b99217a42a28b0d768d29e1df4198f0d5ca5f1c2b584d528f5f02354b38f" + "digest": "e8e3ebc47c4b0fdff1f4b4a3631544a58699bcf43ed21505a2362965af32f774" }, "go-new-builtin/models.go": { "captureGroups": 17, @@ -217,7 +217,7 @@ }, "go-nullable-receiver/cmd/main.go": { "captureGroups": 27, - "digest": "7430c0c8f4877d1b41e62aee9017eadc66d2a8f01b70acf2836584a84f254335" + "digest": "93fe7c4b261918716d48cedae9c83759a223c7e4a9a97587a385abd80a9a9635" }, "go-nullable-receiver/models/repo.go": { "captureGroups": 8, @@ -237,11 +237,11 @@ }, "go-pkg/cmd/main.go": { "captureGroups": 15, - "digest": "b763b372c88c64d31b4a746d24a422b490dfad0f33a55ac4e0f697979995b34f" + "digest": "5e4954f0f7815b0dcc832dbc63c9b1a279e4d657ce5d8ebea775a1c64ca6aee7" }, "go-pkg/internal/auth/service.go": { "captureGroups": 21, - "digest": "8a5a2fef24f2803396b2314ffc951487b1adbc2a28a733bd2dcb65e2961fc479" + "digest": "9e8fa89a195ce26a79aeb7178a9727c42a4aff0cd5995ad5b0a7010f8733cc2b" }, "go-pkg/internal/models/admin.go": { "captureGroups": 17, @@ -257,7 +257,7 @@ }, "go-pointer-constructor-inference/cmd/main.go": { "captureGroups": 15, - "digest": "39f9030e909a37f0e13724f50673dd4a72ba240fb6ac263192ee4feadbc88adf" + "digest": "621f11069c2b44f9aec20a17834685f643bd9eaa2ef14b62194bd73ace840a3e" }, "go-pointer-constructor-inference/models/repo.go": { "captureGroups": 10, @@ -267,6 +267,14 @@ "captureGroups": 10, "digest": "4193f6356f75505415e36d4b090dc9f5265937efe72b4251c33e5b5f06aaa99d" }, + "go-pointer-receiver-field-chain/handlers/handler.go": { + "captureGroups": 116, + "digest": "ba27c7e7ddc71de0a2696963a50a1afcd2b0faed9716c55c02ce66a4232cd7fa" + }, + "go-pointer-receiver-field-chain/repository/repo.go": { + "captureGroups": 20, + "digest": "9e1c600c201dd4f5fdcb15820aaa3ad7fa1a3722e4c29bdf8daadf2e2f9f4b9e" + }, "go-qualified-base/base/base.go": { "captureGroups": 17, "digest": "c2f2241a4e31ad1b003c1649d7511437f3884815412aeac65754cf3cfb9b6dc1" @@ -289,7 +297,7 @@ }, "go-receiver-resolution/cmd/main.go": { "captureGroups": 13, - "digest": "e92c59312a46972a6083ba489888b550cdd024b809acbbf367ad025efb844a5c" + "digest": "8e3f9109f70a44604836ab3542dc79cc9e4c505ccabe00e20104276bc7bdbaf4" }, "go-receiver-resolution/models/repo.go": { "captureGroups": 8, @@ -301,7 +309,7 @@ }, "go-return-type-inference/cmd/main.go": { "captureGroups": 41, - "digest": "6133d61b247732eddad431c28c1374e46d85937ab42e88d7b1ab134aca1e593b" + "digest": "fb0dfc3a89d5a4047f58680f4f36bcbcd03b6e2d9f32aa9a2e4c4f38a2911f2b" }, "go-return-type-inference/models/repo.go": { "captureGroups": 17, @@ -313,7 +321,7 @@ }, "go-same-package-factory/main.go": { "captureGroups": 14, - "digest": "4638aceb638f4cf9c11d9f51f43f50edf6e5358da69cde78d4bffee1f3dce3cc" + "digest": "16b78486154a15e873cf72f7085f0840bb14948632b5dbf38d843329ab3871d4" }, "go-same-package-factory/repo.go": { "captureGroups": 8, @@ -325,7 +333,7 @@ }, "go-split-method-owner/main.go": { "captureGroups": 9, - "digest": "e9c105208ad6ef2f087f758972475aa403294e886751d75113b4ca45fa4e0b8a" + "digest": "6ea2f1057754c77d90b9ca71818db4c7d8f20f7a38e649468650f635dcb082c3" }, "go-split-method-owner/repo.go": { "captureGroups": 8, @@ -341,7 +349,7 @@ }, "go-struct-literals/app.go": { "captureGroups": 11, - "digest": "bcdf97254bb1518219d85ff75795f72ef7a41c4e43ddeb7a09648349d46ec347" + "digest": "15d17d1c75ce72dfe9e569210fa9bfe84f9eed2cb1b044bfc4c2e8b706d3cff1" }, "go-struct-literals/user.go": { "captureGroups": 10, @@ -353,7 +361,7 @@ }, "go-structural-interface-cross-package/cmd/main.go": { "captureGroups": 33, - "digest": "340f5f3f501be8e4f0fb7fdc63949ec2c6ee535eac1480d0090f9f5aab0dfd11" + "digest": "4392284404a0cc21f355ce851bbd481b44dd4fa6bef94bfdc07be423ea433d79" }, "go-structural-interface-cross-package/contracts/read_closer.go": { "captureGroups": 6, @@ -373,11 +381,11 @@ }, "go-structural-interface-dispatch/repository.go": { "captureGroups": 142, - "digest": "986d18b1db74887f5f6f91876aeb332111d85be658dd5d737d104a86204b5575" + "digest": "5bb89d0695cd94082b0657abd3435726372fafa5c570d01b1440ac604217574b" }, "go-type-assertion/main.go": { "captureGroups": 12, - "digest": "046533d699a7a037ea6b891b0e5dbcdeda19cc1554c2c0aca14005151a72c7f5" + "digest": "21c3bd9cc1321544f2d027c1b847fd041befbb697d0b0324e813b9ad44a75396" }, "go-type-assertion/models.go": { "captureGroups": 18, diff --git a/gitnexus/test/fixtures/lang-resolution/go-pointer-receiver-field-chain/go.mod b/gitnexus/test/fixtures/lang-resolution/go-pointer-receiver-field-chain/go.mod new file mode 100644 index 000000000..832355c3f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-pointer-receiver-field-chain/go.mod @@ -0,0 +1,3 @@ +module fixture + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-pointer-receiver-field-chain/handlers/handler.go b/gitnexus/test/fixtures/lang-resolution/go-pointer-receiver-field-chain/handlers/handler.go new file mode 100644 index 000000000..85f9e0d8b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-pointer-receiver-field-chain/handlers/handler.go @@ -0,0 +1,111 @@ +// Regression fixture for #2766. +// +// A Go method with a POINTER receiver binds its receiver to the literal string +// `*Holder` (synthesizeGoReceiverBinding stores typeNode.text raw, deliberately, +// because method-owners.ts consumes the `*T` vs `T` distinction to model Go's +// value and pointer method sets). Before the decoration fallback in +// findClassBindingInScope, that string matched no class binding, so receiver +// typing declined at the BASE and every `h.field.Method()` here emitted no CALLS +// edge — the dominant Go idiom, silently missing from the graph. +// +// The value-receiver twin at the bottom is the control: it resolved before the +// fix and must keep resolving after it. Field decoration is NOT the variable — +// Go already normalizes field type bindings at capture via normalizeGoTypeName. +package handlers + +import "fixture/repository" + +type Holder struct { + thing repository.Thing + impl *repository.Impl + cart *repository.CartRepo +} + +// Pointer receiver, interface-typed cross-package field. +func (h *Holder) RunInterface() error { + return h.thing.DoWork() +} + +// Pointer receiver, concrete-typed cross-package field. +func (h *Holder) RunConcrete() error { + return h.impl.DoWork() +} + +// Pointer receiver, concrete-typed cross-package field returning a value. +func (h *Holder) RunCart(tx int) *repository.CartRepo { + return h.cart.WithTx(tx) +} + +// Control: a local variable receiver typed in the same function resolved even +// before the fix, via the text cascade rather than the decorated base. +func (h *Holder) RunLocal() error { + local := &repository.Impl{} + return local.DoWork() +} + +type ValueHolder struct { + impl *repository.Impl +} + +// Control: VALUE receiver. Binds as `ValueHolder` with no decoration, so this +// resolved before the fix and must not change. +func (v ValueHolder) RunFromValueReceiver() error { + return v.impl.DoWork() +} + +// #2766 / U8 control: SAME-PACKAGE field receiver through a pointer receiver. +// Before the base fix this emitted an ACCESSES edge to the method and NO CALLS +// edge — the member name resolved while the CALLS leg, which needs the +// receiver's class, did not. It is the shape that made the miss look like an +// edge-classification bug rather than a receiver-typing one. +type LocalDep struct{} + +func (d *LocalDep) Work() error { return nil } + +type LocalHost struct { + dep *LocalDep +} + +func (h *LocalHost) RunSamePackage() error { + return h.dep.Work() +} + +// #2782 review: a FUNC-TYPED STRUCT FIELD is dispatched with exactly the same +// `x.f()` syntax as a method, so a selector in callee position is NOT always a +// phantom read. `Callbacks` is the shape of every callback struct, hook struct +// and hand-rolled mock in real Go (`mock.DoFunc`, `opts.OnEvent`), and the field +// read is their ONLY ACCESSES evidence — dropping callee-position reads at the +// capture layer erased it. +// +// The receivers below are deliberately BARE names rather than field chains: +// `h.x.y` reads do not resolve at all today (a separate, pre-existing compound +// receiver gap), so a chained spelling would assert nothing either way. +type Callbacks struct { + OnEvent func() error + Label string +} + +// The row the callee-position drop deleted. `OnEvent` is a field, so the +// selector is a genuine read; the call goes through the value it holds. +func CallFuncField(c *Callbacks) error { + return c.OnEvent() +} + +// Control: a plain (non-func) field read is never in callee position. +func ReadPlainField(c *Callbacks) string { + return c.Label +} + +// Control: a METHOD VALUE is not in callee position either, so its read must +// survive even though the tail resolves to a Method — the kind test alone would +// wrongly suppress it. +func MethodValue(i *repository.Impl) func() error { + f := i.DoWork + return f +} + +// Control: the ORIGINAL defect. A real method call emits CALLS only; an ACCESSES +// to the same method at the same position is the phantom that must stay gone. +func RealMethodCall(i *repository.Impl) error { + return i.DoWork() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-pointer-receiver-field-chain/repository/repo.go b/gitnexus/test/fixtures/lang-resolution/go-pointer-receiver-field-chain/repository/repo.go new file mode 100644 index 000000000..f177d4c27 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-pointer-receiver-field-chain/repository/repo.go @@ -0,0 +1,17 @@ +package repository + +// Thing is an interface-typed dependency, the shape a DI-wired Go service +// stores in a struct field. +type Thing interface { + DoWork() error +} + +// Impl is the concrete implementation behind Thing. +type Impl struct{} + +func (i *Impl) DoWork() error { return nil } + +// CartRepo is a concrete-typed dependency reached through a struct field. +type CartRepo struct{} + +func (c *CartRepo) WithTx(tx int) *CartRepo { return c } diff --git a/gitnexus/test/fixtures/php-captures-golden/expected-captures.json b/gitnexus/test/fixtures/php-captures-golden/expected-captures.json index 5f926cb93..7e242d5fc 100644 --- a/gitnexus/test/fixtures/php-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/php-captures-golden/expected-captures.json @@ -133,7 +133,7 @@ }, "php-constructor-promotion-fields/Service.php": { "captureGroups": 9, - "digest": "1b75dbc0f3123399ba9fd8be0ccac4c145fd1d36baceb62e0d0cc18d4f0a1240" + "digest": "f0f326d5b73b7d34e5ff0aacb1cef79389e22402bc9b6590a154a961ea758b6a" }, "php-constructor-type-inference/app/Models/Repo.php": { "captureGroups": 7, @@ -161,7 +161,7 @@ }, "php-deep-field-chain/Service.php": { "captureGroups": 10, - "digest": "fa5a413c11f99286cbc16ad337f7f2a6c3f399bd39c25fea1775c97c4b3c707f" + "digest": "c9944b2f72a722048a39332c77aa9661c7302413d5d13673a529fabe518a25e8" }, "php-default-params/app.php": { "captureGroups": 11, @@ -185,7 +185,7 @@ }, "php-field-types/Service.php": { "captureGroups": 9, - "digest": "1b75dbc0f3123399ba9fd8be0ccac4c145fd1d36baceb62e0d0cc18d4f0a1240" + "digest": "f0f326d5b73b7d34e5ff0aacb1cef79389e22402bc9b6590a154a961ea758b6a" }, "php-foreach-call-expr/Repo.php": { "captureGroups": 19, @@ -265,7 +265,7 @@ }, "php-grandparent-resolution/app/Services/App.php": { "captureGroups": 12, - "digest": "2b4b12fae10aeeb2e4c0987cce02171237b68976731d1b34ac0d74b8d005de53" + "digest": "27cc7ce04ef5628a918e968bd51319888294592c2567093601d8bb6e7f7d4822" }, "php-grouped-imports/app/Models/Repo.php": { "captureGroups": 7, diff --git a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json index e9131d748..9ea6b6708 100644 --- a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json @@ -109,7 +109,7 @@ }, "python-chain-call/app.py": { "captureGroups": 9, - "digest": "63798aa4bc97cd70bac26d971dd8980030a35a6bab5e1f88acff139bd1dcd67b" + "digest": "32a50d4cb43aecefbd97efd812eb1c8d63fc3034fb3c948ca2f4cc9116e11303" }, "python-chain-call/models/repo.py": { "captureGroups": 7, @@ -177,7 +177,7 @@ }, "python-constructor-field-receiver/memory_service.py": { "captureGroups": 59, - "digest": "c46b8b91e9f7a41f8fcdd13a8dbc6132458ea2e4e4d19b852b054ced848e91c4" + "digest": "7fbc07c361997c2e44b401b301f10081345a0bcadf86feae30bc895a1858f5d5" }, "python-constructor-field-receiver/test_fixture.py": { "captureGroups": 13, @@ -273,7 +273,7 @@ }, "python-django-app-imports/config/asgi.py": { "captureGroups": 7, - "digest": "44e7118a9123e137d361f2cde199c1ed653a01503449577f8e260d6670f8c243" + "digest": "c6f0099622890188e8aed9b0cd0631040bc44d7e47a261a1a6ba00a144d3f6d8" }, "python-django-app-imports/config/settings.py": { "captureGroups": 21, @@ -285,11 +285,11 @@ }, "python-django-app-imports/config/wsgi.py": { "captureGroups": 7, - "digest": "32a6daf4cf7ff4938c7077e27f4f911a4630890f72ba4d6c11c25d05fab18f69" + "digest": "fad4cb18554c2433f6a9ce32de800ef22d5e78def2387e30f2eb2ebf97a80312" }, "python-django-app-imports/manage.py": { "captureGroups": 11, - "digest": "4c86987fe515c4417d6157dabbed6f0abe50b21f9fe08497a0d2a5e04223b322" + "digest": "49022e52142b861dbeab7f2aac57cf76c2d782d7e37fc37ad2e9e6b3b995d0ff" }, "python-enumerate-loop/app.py": { "captureGroups": 27, @@ -309,7 +309,7 @@ }, "python-field-type-disambig/service.py": { "captureGroups": 7, - "digest": "d406758347cfa612e0a7c69e9613ce19d9b7658744c2a7c8d079182eded41fde" + "digest": "4460b3bf3219f1a7c7633839adfd50a293cc2df6fa0db436358b1c112b12d6b9" }, "python-field-type-disambig/user.py": { "captureGroups": 12, @@ -321,7 +321,7 @@ }, "python-field-types/service.py": { "captureGroups": 7, - "digest": "6022bc25143590d4736a7a470c0df6442c9b1478738b0812d53b038b5843822d" + "digest": "11fce687b8092ea0ebc12348dd7fb314ab95ddf61bae813474ec3994fbb60c52" }, "python-for-call-expr/main.py": { "captureGroups": 15, @@ -341,7 +341,7 @@ }, "python-from-module-alias/pkg/app.py": { "captureGroups": 23, - "digest": "afb50d3e98def472f9215e986dd4f964eedd197a9acfc8048d92b882e8fe868a" + "digest": "eac5246ce801935a97cb9be9f55a3307bd86392bdac8fec8715171485d82a90e" }, "python-from-module-alias/pkg/models.py": { "captureGroups": 14, @@ -365,7 +365,7 @@ }, "python-grandparent-resolution/app.py": { "captureGroups": 9, - "digest": "dd0012c9188d9145bdfa8b74a2c3f213ec658b0efaa0c2ac9a09c1f6f7d6c4d3" + "digest": "e8658e76b1e0e6a4a8847bb60554a102a7b72ef582cf4065a19f5da78a73f568" }, "python-grandparent-resolution/models/__init__.py": { "captureGroups": 0, @@ -621,7 +621,7 @@ }, "python-overload-dispatch/service.py": { "captureGroups": 37, - "digest": "a0b60d32ee7065e8e5b0eb9d1bf180b81485e0899d141542663615b74ef55f95" + "digest": "fd23d8926c3debb335ab3ae7126d4314ec2f5d81f5e81f47c12a5a7d8b25537d" }, "python-parent-resolution/models/__init__.py": { "captureGroups": 0, diff --git a/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json b/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json index dea96d926..a5e825c71 100644 --- a/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json @@ -53,7 +53,7 @@ }, "ruby-chain-call/lib/app.rb": { "captureGroups": 13, - "digest": "5d99da3bf38492726eb120eca6ed43a5c16fe97de65981f177a62754b077b86a" + "digest": "6822e1574762e81aac1bde8b00e6e91d6b054b7c41c4e78eb866e488378655e5" }, "ruby-chain-call/lib/repo.rb": { "captureGroups": 6, @@ -101,7 +101,7 @@ }, "ruby-construction-selector/lib/app.rb": { "captureGroups": 18, - "digest": "890fe5496c51f93745d1c0b0a9a9dcc402cc46a84709498ce21078f34ca81366" + "digest": "644fe79259b7d9370dc9c4b4229ece41df3278e76055863b052a8792bf437728" }, "ruby-construction-selector/lib/factory.rb": { "captureGroups": 25, @@ -133,7 +133,7 @@ }, "ruby-field-type-disambig/service.rb": { "captureGroups": 9, - "digest": "7a935707c6a6c5a7c72fa377f4d8d2816d9a1bfa502491770512701d0fa27090" + "digest": "e4c2d56d115a996e64143e2a0a51b5e1e9a69686f3eb9096bb72a835bc5d6b9a" }, "ruby-field-type-disambig/user.rb": { "captureGroups": 13, @@ -145,7 +145,7 @@ }, "ruby-field-types/service.rb": { "captureGroups": 9, - "digest": "fe9329f558d73e6dd006085dc8f8aecff3814d7f6712014e21beea0d1bcb87cc" + "digest": "4c48517a1efd7d556ea445d98887178aafc96568874a47bc5889854b52957698" }, "ruby-for-in-loop/app.rb": { "captureGroups": 9, @@ -161,7 +161,7 @@ }, "ruby-grandparent-resolution/lib/app.rb": { "captureGroups": 10, - "digest": "9c684d0b2e8f247a93a3d4ab6db75e4a13f3878a670fbc25f5e19ee7e38099df" + "digest": "d3609320dda5e0ca184f5adb2510babd58c1150e42e3226eceadfcbea3f752c3" }, "ruby-grandparent-resolution/lib/models/a.rb": { "captureGroups": 10, @@ -181,7 +181,7 @@ }, "ruby-inline-constructor-receiver/lib/app.rb": { "captureGroups": 17, - "digest": "41c63faf13560f27f183a49da10b7e64ffa772b30d37611dbdcf083a9ac0efd3" + "digest": "ec09516e939d1283eeb6af78f215391043b93ce829485cf120f77a83628377f3" }, "ruby-inline-constructor-receiver/lib/svc.rb": { "captureGroups": 6, diff --git a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json index 4856f886f..86150a01d 100644 --- a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json @@ -197,7 +197,7 @@ }, "rust-chain-call/src/main.rs": { "captureGroups": 25, - "digest": "4ec0ae45ee576d539774b3dd6b0cc102665278f86f0290c643c9823d091e3421" + "digest": "b9325f0a7f5398c7695eaac5a8381df3f714b19763487d193cfe136c1d5f1fda" }, "rust-chain-call/src/models/mod.rs": { "captureGroups": 5, @@ -269,7 +269,7 @@ }, "rust-deep-field-chain/service.rs": { "captureGroups": 16, - "digest": "82cd5c09f7db44a7f22697edefc7a66eb2b9d1baabdfd15443e052bdcfaae73a" + "digest": "84b1da3df9c40b1dd044d1cb33b6334ad4d17ab9609e9b1d63dfab4860908be9" }, "rust-default-constructor/src/main.rs": { "captureGroups": 36, @@ -381,7 +381,7 @@ }, "rust-field-types/service.rs": { "captureGroups": 11, - "digest": "d7a51207d8056e862523bae284bdbc3bd0fda03c85374fd71e449148731f9b9f" + "digest": "24a82b599a0ba6a669608ef368f42410cef7b7f42ce7d7927705666895de4386" }, "rust-for-call-expr/src/main.rs": { "captureGroups": 26, @@ -617,7 +617,7 @@ }, "rust-nullable-receiver/src/main.rs": { "captureGroups": 41, - "digest": "ea8a4e442cacf4d5647aa3db5bdd3c2ea5485e8dae72c9a2f9c61eb062a6bb05" + "digest": "10b8b250f5bc19363013357c3ce47e6681f89b5db5d89532ee1ebea6b55a6494" }, "rust-nullable-receiver/src/repo.rs": { "captureGroups": 10, @@ -729,7 +729,7 @@ }, "rust-self-struct-literal/models.rs": { "captureGroups": 32, - "digest": "f9c89cb8c9f2b9c10140ae6971a09756b27112ab61d483b9ffe7fbcb36fefa8d" + "digest": "6745d17a43e50c7dc1cdd985f3f892be5f5116a096bad0db3deb69dde0b4cf94" }, "rust-self-this-resolution/src/repo.rs": { "captureGroups": 10, diff --git a/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json b/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json index cd815cc99..316af34d4 100644 --- a/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json @@ -85,7 +85,7 @@ }, "swift-field-types/App.swift": { "captureGroups": 7, - "digest": "97750185ce98b162ecaed76917408e715756a9256e1bc961a6439815db3c0715" + "digest": "a001ad8823683c4e5cf80d640f9715085e34c719ec877b9e973fb6409ff10caf" }, "swift-field-types/Models.swift": { "captureGroups": 20, diff --git a/gitnexus/test/helpers/scope-model.ts b/gitnexus/test/helpers/scope-model.ts new file mode 100644 index 000000000..f19ed6794 --- /dev/null +++ b/gitnexus/test/helpers/scope-model.ts @@ -0,0 +1,76 @@ +/** + * Shared scope-model builder for scope-resolution unit tests. + * + * `finalizeScopeModel({ hooks: { resolveImportTarget, mergeBindings } })` is + * the incantation a test must perform before it can call any resolution pass, + * and it was being hand-copied per file — and, inside a single file, once per + * language. The only thing that ever varied was the resolver, the source and + * the file path, so those are the parameters here. + * + * The model is built from REAL extraction rather than a hand-assembled index + * on purpose: the defects these tests pin are cases where source-level + * intuition about what a binding CONTAINS is wrong (Go normalizes a free + * parameter's `*Host` to `Host` at capture but leaves a method receiver's + * spelled `*Host` intact), so a fixture that asserted the binding shape by + * hand would pin the intuition instead of the code. + */ +import type { ParsedFile, ScopeId } from 'gitnexus-shared'; +import { extractParsedFile } from '../../src/core/ingestion/scope-extractor-bridge.js'; +import { finalizeScopeModel } from '../../src/core/ingestion/finalize-orchestrator.js'; +import { + buildWorkspaceResolutionIndex, + type WorkspaceResolutionIndex, +} from '../../src/core/ingestion/scope-resolution/workspace-index.js'; +import type { ScopeResolver } from '../../src/core/ingestion/scope-resolution/contract/scope-resolver.js'; +import type { ScopeResolutionIndexes } from '../../src/core/ingestion/model/scope-resolution-indexes.js'; + +export interface ScopeModelFixture { + /** The extracted file — for tests that must start a fold in a scope other + * than the module scope, or read the raw scope list. */ + readonly parsed: ParsedFile; + readonly scopes: ScopeResolutionIndexes; + readonly index: WorkspaceResolutionIndex; + /** `parsed.referenceSites`: the sites a resolution pass walks. */ + readonly sites: ParsedFile['referenceSites']; + /** `parsed.moduleScope`: the default `inScope` for a top-level position. */ + readonly moduleScope: ScopeId; + /** Carried so callers can thread the language's own contract hooks + * (`elementTypeOf`, `stripTypePreservingDecoration`, …) without naming the + * resolver a second time. */ + readonly resolver: ScopeResolver; +} + +/** + * Extract `source` with `resolver`'s provider, run owner population and the + * shared finalize, and return the resolution indexes a pass needs. + * + * Throws rather than asserting, because callers build fixtures at module load + * where a failed `expect` has no test to attach to. + */ +export function buildScopeModel( + resolver: ScopeResolver, + source: string, + filePath: string, +): ScopeModelFixture { + const parsed = extractParsedFile(resolver.languageProvider, source, filePath); + if (parsed === undefined) throw new Error(`scope extraction failed for ${filePath}`); + resolver.populateOwners(parsed); + const parsedFiles: ParsedFile[] = [parsed]; + const allFilePaths = new Set(parsedFiles.map((p) => p.filePath)); + const scopes = finalizeScopeModel(parsedFiles, { + hooks: { + resolveImportTarget: (targetRaw, fromFile) => + resolver.resolveImportTarget(targetRaw, fromFile, allFilePaths), + mergeBindings: (existing, incoming, scopeId) => + resolver.mergeBindings(existing, incoming, scopeId), + }, + }); + return { + parsed, + scopes, + index: buildWorkspaceResolutionIndex(parsedFiles), + sites: parsed.referenceSites, + moduleScope: parsed.moduleScope, + resolver, + }; +} diff --git a/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts b/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts index 425364c49..58557daad 100644 --- a/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts +++ b/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts @@ -63,6 +63,25 @@ withTestLbugDB( expect(result.boundaries.join(' ')).toContain('Logger'); }); + it('publishes causes.dispatchBoundary as untraced SYMBOLS, not the note count', async () => { + const result = await backend.callTool('impact', { + target: 'EmailLogger', + direction: 'upstream', + }); + // One boundary node (Logger) produces exactly one note, so publishing + // `boundaries.length` here would print `1` — the number of SENTENCES — + // next to a `receiverTyping` that counts call sites, and a consumer + // branching on the two would read the smaller cause as the dominant one. + // The magnitude behind this boundary is 2 implementations (EmailLogger, + // FileLogger) + 1 interface-level consumer (SignupController) = 3. + expect(result.boundaries).toHaveLength(1); + expect(result.causes).toMatchObject({ + receiverTyping: 0, + dispatchBoundary: 3, + externalBoundary: 0, + }); + }); + it('flags impact() on the interface itself as lower-bound', async () => { const result = await backend.callTool('impact', { target: 'Logger', diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts index fe99476c3..7ccea1aeb 100644 --- a/gitnexus/test/integration/resolvers/go.test.ts +++ b/gitnexus/test/integration/resolvers/go.test.ts @@ -1655,3 +1655,142 @@ describe('Go Child embeds Parent — inherited method resolution (SM-9)', () => expect(parentMethodCall!.source).toBe('Run'); }); }); + +// --------------------------------------------------------------------------- +// #2766: pointer-receiver base resolution +// --------------------------------------------------------------------------- + +describe('Go pointer-receiver field chains (#2766)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-pointer-receiver-field-chain'), + () => {}, + ); + }, 60000); + + const calls = (): string[] => edgeSet(getRelationships(result, 'CALLS')); + /** ACCESSES rows with each target's KIND appended — `→ Work` alone cannot + * tell a func-typed field apart from the method that shadowed it. */ + const accesses = (): string[] => + getRelationships(result, 'ACCESSES').map((e) => `${e.source} → ${e.target}:${e.targetLabel}`); + + // The three rows that emitted nothing before the decoration fallback. All + // three have a POINTER receiver, which bound as the literal `*Holder` and + // matched no class, so receiver typing declined at the base. + it('resolves an interface-typed cross-package field through a pointer receiver', () => { + expect(calls()).toContain('RunInterface → DoWork'); + }); + + it('resolves a concrete-typed cross-package field through a pointer receiver', () => { + expect(calls()).toContain('RunConcrete → DoWork'); + }); + + it('resolves a concrete cross-package field returning a value', () => { + expect(calls()).toContain('RunCart → WithTx'); + }); + + // Controls: these resolved BEFORE the fix. R11 requires they still resolve to + // the same target, so a regression here means the fallback moved an edge + // rather than adding one. + it('keeps resolving a local-variable receiver', () => { + expect(calls()).toContain('RunLocal → DoWork'); + }); + + it('keeps resolving a value receiver', () => { + expect(calls()).toContain('RunFromValueReceiver → DoWork'); + }); + + // U8: the same-package field receiver that previously produced an ACCESSES + // edge to the method and no CALLS edge. Typing the base is what emits CALLS; + // the ACCESSES now correctly targets the PROPERTY being read instead. + it('emits CALLS for a same-package field receiver, not ACCESSES alone', () => { + expect(calls()).toContain('RunSamePackage → Work'); + }); + + it('retargets the field ACCESSES to the property, not the method', () => { + // Unlabeled on purpose: the negative must reject `→ Work` under ANY target + // kind, which the kind-qualified rows below cannot express. + const accessEdges = edgeSet(getRelationships(result, 'ACCESSES')); + expect(accessEdges).toContain('RunSamePackage → dep'); + expect(accessEdges).not.toContain('RunSamePackage → Work'); + }); + + // The assertion above used to pass by ACCIDENT: a pointer receiver's text + // cascade failed for an unrelated reason, so the phantom never resolved. The + // value-receiver twin proves the rule holds when the lookup SUCCEEDS — before + // the callee read-site was dropped, this emitted `RunFromValueReceiver → + // DoWork` as an ACCESSES edge duplicating its own CALLS edge. + it('emits no method-targeted ACCESSES for a value receiver either', () => { + const accessEdges = edgeSet(getRelationships(result, 'ACCESSES')); + expect(accessEdges).toContain('RunFromValueReceiver → impl'); + expect(accessEdges).not.toContain('RunFromValueReceiver → DoWork'); + }); + + // The invariant, stated once rather than per-fixture: a member call whose + // callee resolves to a METHOD must not also emit a field read for it. + // Asserted as the EXACT edge set INCLUDING each target's kind, so the two + // failure directions are both caught on rows nobody wrote a targeted + // assertion for: a new phantom (an ACCESSES to a Method at a call position) + // fails here, and so does a deleted genuine read (a missing ACCESSES to a + // Property). The kinds are load-bearing — `→ Work` alone cannot tell a + // func-typed field apart from the method that shadowed it. + it('emits exactly the expected ACCESSES set, target kinds included', () => { + expect(accesses().sort()).toEqual([ + // #2782 review: callee position is a POSITION, not a verdict. Go + // dispatches `c.OnEvent()` through a func-typed struct field with exactly + // the same syntax as a method call, so the capture layer cannot decide + // which it is — `call_expression` looks identical and the tail may be + // declared in another package. Suppressing every callee-position read + // deleted the only ACCESSES evidence for callback structs, hook structs + // and hand-rolled mocks; this row is that evidence. + 'CallFuncField → OnEvent:Property', + // A METHOD VALUE resolves to a Method just like the phantom does, and is + // the reason the suppression cannot key on target kind alone. + 'MethodValue → DoWork:Method', + // A plain (non-func) field read, never in callee position. + 'ReadPlainField → Label:Property', + 'RunCart → cart:Property', + 'RunConcrete → impl:Property', + 'RunFromValueReceiver → impl:Property', + 'RunInterface → thing:Property', + 'RunSamePackage → dep:Property', + ]); + }); + + it('keeps CALLS for every field-receiver call', () => { + expect(calls()).toContain('RunSamePackage → Work'); + expect(calls()).toContain('RunFromValueReceiver → DoWork'); + }); + + // --------------------------------------------------------------------- + // #2782 review: callee position is a POSITION, not a verdict + // --------------------------------------------------------------------- + // That the READS survive is asserted by the exact-set test above, row by + // row. What that set cannot show is that keeping them did not cost the + // CALLS edge those same sites must still emit — which is what remains here. + + it('still emits the call through the func-typed field', () => { + expect(calls()).toContain('CallFuncField → OnEvent'); + }); + + // The original defect, on a bare-name receiver whose lookup definitely + // succeeds: CALLS only, and no ACCESSES duplicating it. + it('emits CALLS but no duplicate ACCESSES for a real method call', () => { + expect(calls()).toContain('RealMethodCall → DoWork'); + // Unlabeled: the duplicate must be absent under any target kind. + expect(edgeSet(getRelationships(result, 'ACCESSES'))).not.toContain('RealMethodCall → DoWork'); + }); + + // #2782 review finding 2: the fixture spans two packages and imports + // `fixture/repository`, but carried no `go.mod` — so `resolveGoImportTarget` + // matched NEITHER tier (tier 1 needs the module prefix; tier 2's ≥2-segment + // suffix rule cannot match `fixture/repository` against `repository/`) and the + // guard for this PR's headline fix exercised an import path no real Go repo + // takes. With `module fixture` the go.mod tier resolves. + it('resolves the cross-package import through the go.mod tier', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.map((e) => `${e.source} → ${e.target}`)).toContain('handler.go → repo.go'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 02528c34d..ee4193923 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -1,6 +1,7 @@ /** * Shared test helpers for language resolution integration tests. */ +import fs from 'node:fs'; import path from 'path'; import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js'; import type { PipelineOptions } from '../../../src/core/ingestion/pipeline.js'; @@ -16,6 +17,23 @@ export const CROSS_FILE_FIXTURES = path.resolve( 'cross-file-binding', ); +/** + * Materialize an in-memory fixture repo under `root`, creating parent + * directories as needed. + * + * Writes in `Object.entries` order, i.e. the literal's own key order — some + * callers depend on that (a fixture whose second file must be written after + * the first for the property under test to mean anything), so this must stay + * insertion-ordered rather than sorting or parallelizing the writes. + */ +export function writeFixtureRepo(root: string, files: Record): void { + for (const [relPath, content] of Object.entries(files)) { + const fullPath = path.join(root, relPath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content, 'utf8'); + } +} + export type RelEdge = { source: string; target: string; diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index 2fc63a0ea..be2e0a7ba 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -1,8 +1,10 @@ /** * Java: class extends + implements multiple interfaces + ambiguous package disambiguation */ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import path from 'path'; +import fs from 'node:fs'; +import os from 'node:os'; import { FIXTURES, CROSS_FILE_FIXTURES, @@ -10,7 +12,9 @@ import { getNodesByLabel, getNodesByLabelFull, edgeSet, + getResolutionOutcomes, runPipelineFromRepo, + writeFixtureRepo, type PipelineResult, } from './helpers.js'; @@ -3207,3 +3211,70 @@ describe('Java enum-constant receiver dispatch (#2561)', () => { expect(dispatch!.rel.targetId).toBe('Method:src/Plain.java:Plain.m#0'); }); }); + +// --------------------------------------------------------------------------- +// Program boundary vs. analysis uncertainty (#2744). +// +// The origin classifier's only source of positive EXTERNAL evidence is +// `LanguageProvider.isBuiltInName`. Java declared no built-in set, so no Java +// drop could ever be judged external and every one of them hedged `impact()` +// down to `epistemic: 'lower-bound'` — safe, but it left the language unable to +// name its own boundary. This exercises the whole wiring end to end (provider → +// `runScopeResolution` → pass options → classifier → drop record), which the +// classifier unit tests deliberately do not. +// --------------------------------------------------------------------------- + +describe('Java receiver-unresolved drops name the program boundary (#2744)', () => { + let repoDir: string; + let result: PipelineResult; + + beforeAll(async () => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-java-receiver-origin-')); + writeFixtureRepo(repoDir, { + 'src/Boundary.java': `public class Boundary { + public void platform(String raw) { + // Rooted at \`System\`, which the language itself names. Nothing was + // lost: no node in this index could have been the target. + System.out.println(raw); + } + + public void unknownReceiver(OrderRepository repo) { + // \`OrderRepository\` is declared nowhere here and is not a platform + // name. Absence of evidence is not evidence of externality — this must + // stay hedged, or a genuinely missing in-program caller gets published + // as \`exact\`. Spelled as a CHAIN on purpose: a bare \`repo.findAll()\` + // never reaches the drop recorder at all (the pass takes a different + // arm and records nothing), so it would assert on an empty set. + repo.find().save(); + } +} +`, + }); + result = await runPipelineFromRepo(repoDir, () => {}, {}); + }, 60000); + + afterAll(() => { + if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + it('marks a System.out.println(...) drop external', () => { + const drops = getResolutionOutcomes(result).filter( + (outcome) => outcome.kind === 'suppressed' && outcome.reason === 'receiver-unresolved', + ); + expect(drops).toContainEqual( + expect.objectContaining({ name: 'println', siteKind: 'call', receiverOrigin: 'external' }), + ); + }); + + it('does not mark a drop on an undeclared user receiver external', () => { + const drops = getResolutionOutcomes(result).filter( + (outcome) => outcome.kind === 'suppressed' && outcome.reason === 'receiver-unresolved', + ); + expect(drops).toContainEqual( + expect.objectContaining({ name: 'save', siteKind: 'call', receiverOrigin: 'unknown' }), + ); + expect(drops).not.toContainEqual( + expect.objectContaining({ name: 'save', receiverOrigin: 'external' }), + ); + }); +}); diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index 46adea764..db2c2d155 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -13,17 +13,10 @@ import { getNodesByLabelFull, edgeSet, runPipelineFromRepo, + writeFixtureRepo, type PipelineResult, } from './helpers.js'; -function writeFixtureRepo(root: string, files: Record): void { - for (const [relPath, content] of Object.entries(files)) { - const fullPath = path.join(root, relPath); - fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, content, 'utf8'); - } -} - // --------------------------------------------------------------------------- // Heritage: relative imports + class inheritance // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/typescript-esm-js-extension.test.ts b/gitnexus/test/integration/resolvers/typescript-esm-js-extension.test.ts index 476082563..cf752761e 100644 --- a/gitnexus/test/integration/resolvers/typescript-esm-js-extension.test.ts +++ b/gitnexus/test/integration/resolvers/typescript-esm-js-extension.test.ts @@ -8,15 +8,12 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import path from 'path'; import fs from 'node:fs'; import os from 'node:os'; -import { getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js'; - -function writeFixtureRepo(root: string, files: Record): void { - for (const [relPath, content] of Object.entries(files)) { - const fullPath = path.join(root, relPath); - fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, content, 'utf8'); - } -} +import { + getRelationships, + runPipelineFromRepo, + writeFixtureRepo, + type PipelineResult, +} from './helpers.js'; describe('TypeScript ESM .js extension → CALLS edges', () => { let result: PipelineResult; diff --git a/gitnexus/test/integration/resolvers/typescript-value-refs.test.ts b/gitnexus/test/integration/resolvers/typescript-value-refs.test.ts index 50affb44c..d008753bd 100644 --- a/gitnexus/test/integration/resolvers/typescript-value-refs.test.ts +++ b/gitnexus/test/integration/resolvers/typescript-value-refs.test.ts @@ -17,6 +17,7 @@ import os from 'node:os'; import { getRelationships, runPipelineFromRepo, + writeFixtureRepo, type PipelineResult, type RelEdge, } from './helpers.js'; @@ -26,14 +27,6 @@ import { } from '../../../src/core/ingestion/scope-resolution/passes/property-dispatch.js'; import { _captureLogger, type PinoLogRecord } from '../../../src/core/logger.js'; -function writeFixtureRepo(root: string, files: Record): void { - for (const [relPath, content] of Object.entries(files)) { - const fullPath = path.join(root, relPath); - fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, content, 'utf8'); - } -} - function edgesFrom(edges: RelEdge[], sourceFile: string): RelEdge[] { return edges.filter((c) => c.sourceFilePath === sourceFile); } diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index d01f68609..6fa9f3213 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -13,17 +13,10 @@ import { edgeSet, getResolutionOutcomes, runPipelineFromRepo, + writeFixtureRepo, type PipelineResult, } from './helpers.js'; -function writeFixtureRepo(root: string, files: Record): void { - for (const [relPath, content] of Object.entries(files)) { - const fullPath = path.join(root, relPath); - fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, content, 'utf8'); - } -} - // --------------------------------------------------------------------------- // Generic-base heritage (#1951): extends Box already worked (value: identifier // captures Base; type_args are a sibling), and `implements IFoo` is resolved @@ -3344,12 +3337,17 @@ export class Service { `, 'main.ts': `import { Service } from './models'; -export async function droppedCall(svc: Service): Promise { - // An await-parenthesized receiver. Structural typing does NOT cover this - // shape — \`extractMixedChain\` reaches \`await …\`, which is not a chain node, - // so no chain is minted and the site still reaches the drop recorder. The - // \`!\` spelling used to serve here until structural typing resolved it. - (await svc.getUserAsync()).save(); +export async function droppedCall(svc): Promise { + // An UNANNOTATED parameter. The chain mints fine, but the base has no type + // binding to resolve against, so the site reaches the drop recorder. + // + // Third fixture for this case: \`!\` served until structural typing resolved + // it, then the await-parenthesized form served until name-free step kinds + // resolved that too. Both were shapes the resolver merely did not SUPPORT + // yet, so each fix moved the goalposts. An untyped receiver carries no type + // information at all, so no amount of resolver work can type it — which is + // what makes it a stable choice rather than the next one to be fixed. + svc.getUser().save(); } export function droppedWrite(svc: Service | null): void { @@ -3379,6 +3377,24 @@ export function droppedWrite(svc: Service | null): void { ); expect(drops).toContainEqual(expect.objectContaining({ name: 'name', siteKind: 'write' })); }); + + // The origin travels with the drop too, and its value here is the whole + // point: `svc` is an UNANNOTATED parameter, so the scope model records it + // nowhere — no type binding, no value binding, no qualified name. A + // classifier that read that silence as `external` published + // `epistemic: 'exact'` over a call it had genuinely lost. `unknown` is the + // honest answer and it counts toward the hedge exactly like `in-program`. + it('does not call an untyped in-program receiver external', () => { + const drops = getResolutionOutcomes(result).filter( + (outcome) => outcome.kind === 'suppressed' && outcome.reason === 'receiver-unresolved', + ); + expect(drops).toContainEqual( + expect.objectContaining({ name: 'save', siteKind: 'call', receiverOrigin: 'unknown' }), + ); + expect(drops).not.toContainEqual( + expect.objectContaining({ name: 'save', receiverOrigin: 'external' }), + ); + }); }); // --------------------------------------------------------------------------- diff --git a/gitnexus/test/unit/call-summary-schema-version.test.ts b/gitnexus/test/unit/call-summary-schema-version.test.ts index 95bc624d6..85a37f318 100644 --- a/gitnexus/test/unit/call-summary-schema-version.test.ts +++ b/gitnexus/test/unit/call-summary-schema-version.test.ts @@ -73,12 +73,12 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => { }); describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { - it('INCREMENTAL_SCHEMA_VERSION is bumped to 33 (Spring AOP relation pairs, #2416)', () => { + it('INCREMENTAL_SCHEMA_VERSION is bumped to 34 (Spring AOP relation pairs #2416, then receiver-chain wire format v2)', () => { // Moves with every bump BY DESIGN — that is the point of pinning it. A // change that alters emitted ids or edges without bumping would otherwise // ship silently, and an existing index would keep serving the old graph // through the reuse gate below. - expect(INCREMENTAL_SCHEMA_VERSION).toBe(33); + expect(INCREMENTAL_SCHEMA_VERSION).toBe(34); }); it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => { @@ -194,7 +194,9 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { // class — so such an index carries both pre-rollout edges for 13 languages // and fabricated ones. The gate is a strict `===`, so it must NOT reuse. expect(passesReuseGate(26)).toBe(false); - // A pre-v28 (v27) index was stamped mid-series: TypeScript-only structural + // A pre-v31 index predates the receiver-chain wire format v2, so its + // persisted chains carry the v1 prefix a v2 decoder refuses by design. + // Original note: a pre-v28 (v27) index was stamped mid-series: TypeScript-only structural // typing, and the fold still typed a local that merely shadowed a class name // as that class — so it carries pre-rollout edges AND fabricated ones. expect(passesReuseGate(27)).toBe(false); @@ -216,7 +218,12 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { // A pre-v33 (v32) index predates the Spring AOP Interface→CodeElement // relation pair (#2416), so it cannot persist all evidence edges. expect(passesReuseGate(32)).toBe(false); + // A pre-v34 (v33) index carries `receiverChain` strings in wire format v1, + // which the v2 decoder refuses by design (#2766) — an incremental top-up + // would silently fall back to the text cascade for every chain-carrying + // site → must NOT reuse. + expect(passesReuseGate(33)).toBe(false); // The current stamp passes the gate (incremental top-up eligible). - expect(passesReuseGate(33)).toBe(true); + expect(passesReuseGate(34)).toBe(true); }); }); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 26578c8a5..e22a301e4 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -101,9 +101,22 @@ describe('fileContentHash', () => { }); describe('PARSE_CACHE_VERSION', () => { - // 37 -> 38 for the Swift conditional-directive parse-semantics change (#2771). - it('pins SCHEMA_BUMP to 38 so concurrent bumps cannot silently collide (#2771)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(38); + // 35 -> 36 for the bound-callable start-line join (#2735), 36 -> 37 for + // Java/Kotlin Spring AOP capture side-channels (#2416), 37 -> 38 for the Swift + // 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. + // + // 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 + // already used 37, and then took 38 for #2771 after this branch had moved to + // 38. Both times two incompatible schemas claimed one number. Note when the + // 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); }); it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => { diff --git a/gitnexus/test/unit/noise-filter.test.ts b/gitnexus/test/unit/noise-filter.test.ts index 3b1b0085d..3f7c1a054 100644 --- a/gitnexus/test/unit/noise-filter.test.ts +++ b/gitnexus/test/unit/noise-filter.test.ts @@ -71,17 +71,66 @@ describe('isBuiltInOrNoise (per-language)', () => { }); describe('languages without builtInNames', () => { - it('Java has no language-specific noise', () => { - expect(isBuiltIn('System', SupportedLanguages.Java)).toBe(false); - expect(isBuiltIn('println', SupportedLanguages.Java)).toBe(false); - }); - it('Go has no language-specific noise', () => { expect(isBuiltIn('fmt', SupportedLanguages.Go)).toBe(false); expect(isBuiltIn('Println', SupportedLanguages.Go)).toBe(false); }); }); + // Java's set exists so `classifyReceiverOrigin` can name the program boundary + // (#2744): without it no Java drop could ever be judged `external` and every + // one of them hedged `impact()` to `lower-bound`. It lists TYPE names only — + // the shape a receiver base actually has — never method names. + describe('Java platform types (#2744)', () => { + it('names the java.lang types that appear unqualified as receiver bases', () => { + expect(isBuiltIn('System', SupportedLanguages.Java)).toBe(true); + expect(isBuiltIn('String', SupportedLanguages.Java)).toBe(true); + expect(isBuiltIn('Integer', SupportedLanguages.Java)).toBe(true); + expect(isBuiltIn('Math', SupportedLanguages.Java)).toBe(true); + expect(isBuiltIn('Thread', SupportedLanguages.Java)).toBe(true); + expect(isBuiltIn('StringBuilder', SupportedLanguages.Java)).toBe(true); + expect(isBuiltIn('RuntimeException', SupportedLanguages.Java)).toBe(true); + expect(isBuiltIn('Object', SupportedLanguages.Java)).toBe(true); + }); + + it('names the java.util utility holders whose imports never resolve in-workspace', () => { + expect(isBuiltIn('Optional', SupportedLanguages.Java)).toBe(true); + expect(isBuiltIn('List', SupportedLanguages.Java)).toBe(true); + expect(isBuiltIn('Arrays', SupportedLanguages.Java)).toBe(true); + expect(isBuiltIn('Collections', SupportedLanguages.Java)).toBe(true); + expect(isBuiltIn('Objects', SupportedLanguages.Java)).toBe(true); + }); + + // The asymmetry that governs the set: an entry here can NEVER be reported + // as in-program, so every name an application plausibly declares itself + // stays out. Missing one only costs a hedge. + it('omits platform names that double as ordinary domain nouns', () => { + expect(isBuiltIn('Map', SupportedLanguages.Java)).toBe(false); + expect(isBuiltIn('Set', SupportedLanguages.Java)).toBe(false); + expect(isBuiltIn('Collection', SupportedLanguages.Java)).toBe(false); + expect(isBuiltIn('Stream', SupportedLanguages.Java)).toBe(false); + expect(isBuiltIn('Record', SupportedLanguages.Java)).toBe(false); + expect(isBuiltIn('Error', SupportedLanguages.Java)).toBe(false); + expect(isBuiltIn('Number', SupportedLanguages.Java)).toBe(false); + }); + + // The same hook gates `type-env.ts` return-type inference and the #2545 + // free-call shadow guard, both keyed on the CALLEE name. Java method names + // are camelCase and collide with user code, so none are listed. + it('lists no method names, so Java callee resolution is untouched', () => { + expect(isBuiltIn('println', SupportedLanguages.Java)).toBe(false); + expect(isBuiltIn('format', SupportedLanguages.Java)).toBe(false); + expect(isBuiltIn('toString', SupportedLanguages.Java)).toBe(false); + expect(isBuiltIn('run', SupportedLanguages.Java)).toBe(false); + expect(isBuiltIn('get', SupportedLanguages.Java)).toBe(false); + }); + + it('does not name user-defined types', () => { + expect(isBuiltIn('UserService', SupportedLanguages.Java)).toBe(false); + expect(isBuiltIn('OrderRepository', SupportedLanguages.Java)).toBe(false); + }); + }); + describe('domain names not filtered', () => { it('does not filter arbitrary names', () => { expect(isBuiltIn('processOrder', SupportedLanguages.TypeScript)).toBe(false); diff --git a/gitnexus/test/unit/parsedfile-store.test.ts b/gitnexus/test/unit/parsedfile-store.test.ts index f59f3fd53..6c95f6ffd 100644 --- a/gitnexus/test/unit/parsedfile-store.test.ts +++ b/gitnexus/test/unit/parsedfile-store.test.ts @@ -523,12 +523,12 @@ describe('parsedfile-store receiverChain sanitation', () => { const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-chain-')); try { await persistParsedFileChunk(dir, 'chunk-0', [ - makeStoreEntry('x.ts', { referenceSites: [siteWith('1|svc|cgetUser')] }), + makeStoreEntry('x.ts', { referenceSites: [siteWith('2|svc|cgetUser')] }), ]); const loaded = (await loadParsedFilesForPaths(dir, new Set(['x.ts']))).get('x.ts')!; expect(loaded.referenceSites[0]).toMatchObject({ name: 'save', - receiverChain: '1|svc|cgetUser', + receiverChain: '2|svc|cgetUser', }); } finally { await rm(dir, { recursive: true, force: true }); @@ -551,8 +551,9 @@ describe('parsedfile-store receiverChain sanitation', () => { it.each([ ['malformed', 'not-a-chain'], - ['wrong version', '2|svc|cgetUser'], - ['over depth', '1|svc|ca|cb|cc|cd'], + ['unknown future version', '3|svc|cgetUser'], + ['superseded v1 payload', '1|svc|cgetUser'], + ['over depth', '2|svc|ca|cb|cc|cd'], ['non-string', 42], ])( 'strips a %s chain but KEEPS the site — it still resolves via the text cascade', @@ -577,7 +578,7 @@ describe('parsedfile-store receiverChain sanitation', () => { try { await persistParsedFileChunk(dir, 'chunk-0', [ makeStoreEntry('garbage.ts', { referenceSites: 'nonsense' }), - makeStoreEntry('ok.ts', { referenceSites: [siteWith('1|svc|cgetUser')] }), + makeStoreEntry('ok.ts', { referenceSites: [siteWith('2|svc|cgetUser')] }), ]); const loaded = await loadParsedFilesForPaths(dir, new Set(['garbage.ts', 'ok.ts'])); expect(loaded.has('garbage.ts')).toBe(false); @@ -596,17 +597,17 @@ describe('parsedfile-store receiverChain sanitation', () => { await persistParsedFileChunk(dir, 'chunk-0', [ makeStoreEntry('x.ts', { referenceSites: [ - siteWith('1|svc|cgetUser'), + siteWith('2|svc|cgetUser'), siteWith('not-a-chain'), - siteWith('1|other|ffield'), + siteWith('2|other|ffield'), ], }), ]); const loaded = (await loadParsedFilesForPaths(dir, new Set(['x.ts']))).get('x.ts')!; expect(loaded.referenceSites).toHaveLength(3); - expect(loaded.referenceSites[0]).toMatchObject({ receiverChain: '1|svc|cgetUser' }); + expect(loaded.referenceSites[0]).toMatchObject({ receiverChain: '2|svc|cgetUser' }); expect(loaded.referenceSites[1]).not.toHaveProperty('receiverChain'); - expect(loaded.referenceSites[2]).toMatchObject({ receiverChain: '1|other|ffield' }); + expect(loaded.referenceSites[2]).toMatchObject({ receiverChain: '2|other|ffield' }); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/gitnexus/test/unit/receiver-chain-codec.test.ts b/gitnexus/test/unit/receiver-chain-codec.test.ts index 7518e862b..1231d636c 100644 --- a/gitnexus/test/unit/receiver-chain-codec.test.ts +++ b/gitnexus/test/unit/receiver-chain-codec.test.ts @@ -20,7 +20,7 @@ describe('receiver-chain codec', () => { { kind: 'call', name: 'getUser' }, { kind: 'field', name: 'address' }, ]); - expect(encoded).toBe('1|svc|cgetUser|faddress'); + expect(encoded).toBe('2|svc|cgetUser|faddress'); expect(decodeReceiverChain(encoded)).toEqual({ baseReceiverName: 'svc', steps: [ @@ -60,7 +60,7 @@ describe('receiver-chain codec', () => { const encoded = encodeReceiverChain('svc', [{ kind: 'call', name: 'getUser' }], { truncated: true, }); - expect(encoded).toBe('1|svc|cgetUser|~'); + expect(encoded).toBe('2|svc|cgetUser|~'); expect(decodeReceiverChain(encoded)).toMatchObject({ truncated: true }); }); @@ -92,13 +92,14 @@ describe('receiver-chain codec', () => { ['undefined', undefined], ['empty', ''], ['no version', 'svc|cgetUser'], - ['wrong version', '2|svc|cgetUser'], - ['no steps', '1|svc'], - ['unknown kind sigil', '1|svc|xgetUser'], - ['empty step name', '1|svc|c'], + ['unknown future version', '3|svc|cgetUser'], + ['superseded v1 payload', '1|svc|cgetUser'], + ['no steps', '2|svc'], + ['unknown kind sigil', '2|svc|xgetUser'], + ['empty step name', '2|svc|c'], ['empty base', '1||cgetUser'], - ['over depth', '1|svc|ca|cb|cc|cd'], - ['truncation marker only', '1|svc|~'], + ['over depth', '2|svc|ca|cb|cc|cd'], + ['truncation marker only', '2|svc|~'], ])('decodes %s as undefined rather than throwing', (_label, payload) => { expect(decodeReceiverChain(payload)).toBeUndefined(); expect(isValidReceiverChain(payload)).toBe(false); @@ -139,9 +140,9 @@ describe('receiver-chain codec', () => { for (const hostile of [ '|'.repeat(512), '1|' + '|'.repeat(400), - '1|svc|c\u0000name', - '1|svc|c\uD800', - `1|svc|c${'x'.repeat(MAX_RECEIVER_CHAIN_BYTES)}`, + '2|svc|c\u0000name', + '2|svc|c\uD800', + `2|svc|c${'x'.repeat(MAX_RECEIVER_CHAIN_BYTES)}`, '1|'.repeat(300), {}, [], @@ -155,6 +156,67 @@ describe('receiver-chain codec', () => { // Emit and load must agree. A bound applied only on load is a writer that // keeps minting what the reader keeps refusing — a permanent, unlogged // warm-cache-miss reparse loop. - expect(isValidReceiverChain(`1|svc|c${'x'.repeat(MAX_RECEIVER_CHAIN_BYTES)}`)).toBe(false); + expect(isValidReceiverChain(`2|svc|c${'x'.repeat(MAX_RECEIVER_CHAIN_BYTES)}`)).toBe(false); + }); +}); + +describe('codec v2 — name-free step kinds', () => { + it('round-trips an await step', () => { + const encoded = encodeReceiverChain('svc', [ + { kind: 'call', name: 'getUserAsync' }, + { kind: 'await' }, + ]); + expect(encoded).toBe('2|svc|cgetUserAsync|a'); + expect(decodeReceiverChain(encoded)).toMatchObject({ + baseReceiverName: 'svc', + steps: [{ kind: 'call', name: 'getUserAsync' }, { kind: 'await' }], + truncated: false, + }); + }); + + it('round-trips an index step', () => { + const encoded = encodeReceiverChain('repos', [{ kind: 'index' }]); + expect(encoded).toBe('2|repos|i'); + expect(decodeReceiverChain(encoded)).toMatchObject({ + baseReceiverName: 'repos', + steps: [{ kind: 'index' }], + }); + }); + + it('decodes a chain mixing named and name-free steps', () => { + const encoded = encodeReceiverChain('svc', [ + { kind: 'index' }, + { kind: 'field', name: 'address' }, + ]); + expect(decodeReceiverChain(encoded)).toMatchObject({ + steps: [{ kind: 'index' }, { kind: 'field', name: 'address' }], + }); + }); + + // The guard that keeps the name-free exemption from widening: a name-free + // sigil must be EXACTLY the sigil, so a corrupt payload cannot smuggle a + // trailing tail through the branch that skips the non-empty-name check. + it('refuses a name-free sigil carrying a trailing tail', () => { + expect(decodeReceiverChain('2|svc|await')).toBeUndefined(); + expect(decodeReceiverChain('2|repos|i0')).toBeUndefined(); + }); + + // An empty-name call or field is still malformed — it does NOT become an + // await or index just because those kinds are name-free. + it('still refuses an empty-name call or field segment', () => { + expect(decodeReceiverChain('2|svc|c')).toBeUndefined(); + expect(decodeReceiverChain('2|svc|f')).toBeUndefined(); + }); + + // The whole reason the version moved: a v1 payload decoded under v2 rules + // would be a chain missing whichever hop v1 could not express, which reads as + // a complete-but-different chain and types the receiver against the wrong + // member. Refusing is the correct, lossy-but-safe outcome. + it('refuses a v1 payload outright', () => { + expect(decodeReceiverChain('1|svc|cgetUser|faddress')).toBeUndefined(); + }); + + it('emits the v2 prefix for an ordinary named chain', () => { + expect(encodeReceiverChain('svc', [{ kind: 'call', name: 'getUser' }])).toBe('2|svc|cgetUser'); }); }); diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts index d40fe2c62..39b6430fc 100644 --- a/gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts @@ -560,3 +560,57 @@ describe('emitCppScopeCaptures — callable-flow passing modes (#2522 review, M5 }); }); }); + +/** + * `TypeRef.declaredSpelling` for a C++ pointer parameter. + * + * tree-sitter-cpp hangs the `*` on the DECLARATOR, so `@type-binding.type` is a + * bare `User` and the binding records `User` — indistinguishable from a class + * the source subscripted through `operator[]`. The receiver-chain fold declines + * an index step without container evidence, so `repos[0].save()` depends + * entirely on the spelling being reconstructed here. + */ +describe('C++ pointer parameter — declared spelling', () => { + function bindingsFor(src: string): Record { + const parsed = extractParsedFile(cppProvider, src, 'test.cpp'); + const out: Record = {}; + for (const scope of parsed?.scopes ?? []) { + for (const [name, ref] of scope.typeBindings) { + out[name] = { raw: ref.rawName, spelling: ref.declaredSpelling }; + } + } + return out; + } + + it('reconstructs `User*` for a pointer parameter and leaves a value parameter alone', () => { + expect(bindingsFor('struct User {};\nvoid f(User* repos, User one) {}\n')).toMatchObject({ + repos: { raw: 'User', spelling: 'User*' }, + // Nothing was normalized away, so there is no spelling to keep — and an + // index step on it correctly finds no container evidence. + one: { raw: 'User', spelling: undefined }, + }); + }); + + it('reconstructs the same spelling regardless of where the star is written', () => { + expect(bindingsFor('struct User {};\nvoid f(User *repos) {}\n')).toMatchObject({ + repos: { raw: 'User', spelling: 'User*' }, + }); + }); + + it('reconstructs NOTHING for shapes the exact match rejects', () => { + // Each of these is a real pointer-ish declaration whose element type is NOT + // the captured type: a reference is not a container at all, and `const T*` + // is not the shape being matched. A loose match would claim container + // evidence and re-mint the wrong edge. + const bindings = bindingsFor( + 'struct User {};\nvoid f(User** grid, User& one, const User* ro, User (*fn)(int)) {}\n', + ); + expect(bindings).toMatchObject({ + one: { spelling: undefined }, + ro: { spelling: undefined }, + }); + // `User**` matches no type-binding pattern at all, so there is no binding to + // carry evidence — the same safe outcome by a different route. + expect(bindings).not.toHaveProperty('grid'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/go/go-captures-smoke.test.ts b/gitnexus/test/unit/scope-resolution/go/go-captures-smoke.test.ts index 337b040a5..ec3ba65d8 100644 --- a/gitnexus/test/unit/scope-resolution/go/go-captures-smoke.test.ts +++ b/gitnexus/test/unit/scope-resolution/go/go-captures-smoke.test.ts @@ -63,6 +63,41 @@ func main() { expect(tags).toContain('@reference.write'); }); + // #2782: a selector in CALLEE position is MARKED, not dropped. The capture + // layer cannot tell `h.dep.Work()` dispatching through a method from the same + // syntax dispatching through a `Work func() error` struct field — only the + // resolved tail's kind can, so the position is recorded and edge emission + // decides. Dropping the site here deleted the func-typed field's only read. + it('marks a member call callee as callee-position instead of dropping the read', () => { + const src = ` +package main + +type Dep struct{ Work func() error } +type Host struct{ dep *Dep } + +func (h *Host) Run() error { + f := h.dep.Work + _ = f + return h.dep.Work() +} +`; + const reads = emitGoScopeCaptures(src, 'main.go') + .filter((m) => m['@reference.read'] !== undefined) + .map((m) => ({ + text: m['@reference.read']!.text, + calleePosition: m['@reference.callee-position'] !== undefined, + })); + // Four reads, in source order: the method VALUE's inner `h.dep` and outer + // `h.dep.Work` (neither in callee position), then the CALL's inner `h.dep` + // and its outer `h.dep.Work` — the only one in callee position. + expect(reads).toEqual([ + { text: 'h.dep', calleePosition: false }, + { text: 'h.dep.Work', calleePosition: false }, + { text: 'h.dep', calleePosition: false }, + { text: 'h.dep.Work', calleePosition: true }, + ]); + }); + it('emits every name from multi-name const, var, and field declarations', () => { const src = ` package main diff --git a/gitnexus/test/unit/scope-resolution/receiver-chain-fold.test.ts b/gitnexus/test/unit/scope-resolution/receiver-chain-fold.test.ts index 77eededc2..c4b782f7c 100644 --- a/gitnexus/test/unit/scope-resolution/receiver-chain-fold.test.ts +++ b/gitnexus/test/unit/scope-resolution/receiver-chain-fold.test.ts @@ -7,13 +7,12 @@ */ import { describe, it, expect } from 'vitest'; -import type { ParsedFile } from 'gitnexus-shared'; -import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js'; +import type { ScopeId } from 'gitnexus-shared'; import { typescriptScopeResolver } from '../../../src/core/ingestion/languages/typescript/scope-resolver.js'; -import { finalizeScopeModel } from '../../../src/core/ingestion/finalize-orchestrator.js'; -import { buildWorkspaceResolutionIndex } from '../../../src/core/ingestion/scope-resolution/workspace-index.js'; +import { csharpScopeResolver } from '../../../src/core/ingestion/languages/csharp/scope-resolver.js'; import { foldReceiverChain } from '../../../src/core/ingestion/scope-resolution/passes/compound-receiver.js'; import { decodeReceiverChain } from '../../../src/core/ingestion/utils/receiver-chain-codec.js'; +import { buildScopeModel, type ScopeModelFixture } from '../../helpers/scope-model.js'; const SOURCE = `export class Address { save(): void {} @@ -30,10 +29,23 @@ export class Base { } } +export class Item { + run(): void {} +} + export class Service extends Base { getUser(): User { return new User(); } + getUserAsync(): Promise { + return Promise.resolve(new User()); + } + getItems(): Item[] { + return []; + } + getMap(): Map { + return new Map(); + } } // A class with NO member \`save\`, whose FIELD's type has one. The field @@ -42,88 +54,101 @@ export class Holder { user: User = new User(); } -const svc: Service = new Service(); -const holder: Holder = new Holder(); -`; - -function build() { - const parsed = extractParsedFile(typescriptScopeResolver.languageProvider, SOURCE, 'main.ts'); - if (parsed === undefined) throw new Error('scope extraction failed'); - typescriptScopeResolver.populateOwners(parsed); - const parsedFiles: ParsedFile[] = [parsed]; - const allFilePaths = new Set(parsedFiles.map((p) => p.filePath)); - const scopes = finalizeScopeModel(parsedFiles, { - hooks: { - resolveImportTarget: (targetRaw, fromFile) => - typescriptScopeResolver.resolveImportTarget(targetRaw, fromFile, allFilePaths), - mergeBindings: (existing, incoming, scopeId) => - typescriptScopeResolver.mergeBindings(existing, incoming, scopeId), - }, - }); - const index = buildWorkspaceResolutionIndex(parsedFiles); - return { scopes, index, inScope: parsed.moduleScope }; +// NOT a container: an ordinary class that happens to be subscriptable, with a +// member whose name COLLIDES with the element type's. Indexing it yields an +// \`Item\`, so \`grid[0].run()\` is \`Item.run\` — but an index step that folded +// on identity stayed on \`Grid\` and found \`Grid.run\` instead. +export class Grid { + run(): void {} + [i: number]: Item; } -const ctx = build(); +// Not subscriptable at all, and shares the member name too. +export class Plain { + run(): void {} +} -/** Fold with the language contract flags overridden — used to exercise the - * OFF branch of `hoistTypeBindingsToModule`, which six wired languages - * (c, cobol, dart, python, ruby, swift) actually run. */ -function foldWith(encoded: string, overrides: Record) { +const svc: Service = new Service(); +const holder: Holder = new Holder(); +declare const grid: Grid; +declare const plain: Plain; +declare const repos: User[]; +declare const nested: User[][]; +declare const byId: Record; +`; + +/** A built scope model plus the scope a fold starts in. */ +interface FoldContext { + readonly fixture: ScopeModelFixture; + readonly inScope: ScopeId; +} + +/** `foldReceiverChain`'s options bag. The interface itself is module-private + * in `compound-receiver`, so it is read off the function rather than + * re-declared (which would let the two drift). */ +type FoldOptions = NonNullable[4]>; + +const tsFixture = buildScopeModel(typescriptScopeResolver, SOURCE, 'main.ts'); +const ctx: FoldContext = { fixture: tsFixture, inScope: tsFixture.moduleScope }; + +/** + * Fold `encoded` in `ctx`, under the language's own contract flags — exactly + * as the resolver pass would pass them. TypeScript hoists method return-type + * bindings out of the class body, so the fold needs that flag to find any of + * them; `elementTypeOf` is what an index step consults, and passing it here is + * what makes these tests measure the same fold `emitReceiverBoundCalls` runs. + * + * `overrides` replaces individual flags — used to exercise the OFF branch of + * `hoistTypeBindingsToModule` (which six wired languages: c, cobol, dart, + * python, ruby, swift, actually run) and the no-`elementTypeOf` branch (which + * twelve of the fourteen do). + */ +function foldIn(ctx: FoldContext, encoded: string, overrides: FoldOptions = {}) { const decoded = decodeReceiverChain(encoded); expect(decoded).toBeDefined(); - return foldReceiverChain(decoded!, ctx.inScope, ctx.scopes, ctx.index, { + return foldReceiverChain(decoded!, ctx.inScope, ctx.fixture.scopes, ctx.fixture.index, { fieldFallback: false, hoistTypeBindingsToModule: true, + elementTypeOf: ctx.fixture.resolver.elementTypeOf, ...overrides, }); } -function fold(encoded: string) { - const decoded = decodeReceiverChain(encoded); - expect(decoded).toBeDefined(); - // The language's own contract flags, exactly as the resolver pass would - // pass them. TypeScript hoists method return-type bindings out of the class - // body, so the fold needs that flag to find any of them. - return foldReceiverChain(decoded!, ctx.inScope, ctx.scopes, ctx.index, { - fieldFallback: false, - hoistTypeBindingsToModule: true, - }); -} +const fold = (encoded: string) => foldIn(ctx, encoded); describe('foldReceiverChain', () => { it('types a single call step through the method return type', () => { - expect(fold('1|svc|cgetUser')).toMatchObject({ qualifiedName: 'User', type: 'Class' }); + expect(fold('2|svc|cgetUser')).toMatchObject({ qualifiedName: 'User', type: 'Class' }); }); it('types a mixed call/field chain, base-first', () => { - expect(fold('1|svc|cgetUser|faddress')).toMatchObject({ + expect(fold('2|svc|cgetUser|faddress')).toMatchObject({ qualifiedName: 'Address', type: 'Class', }); }); it('types a step inherited through the MRO', () => { - expect(fold('1|svc|cinherited')).toMatchObject({ qualifiedName: 'User', type: 'Class' }); + expect(fold('2|svc|cinherited')).toMatchObject({ qualifiedName: 'User', type: 'Class' }); }); it('returns undefined when a step names no member of the previous class', () => { - expect(fold('1|svc|cgetUser|fnoSuchField')).toBeUndefined(); + expect(fold('2|svc|cgetUser|fnoSuchField')).toBeUndefined(); }); it('returns undefined when the base does not resolve', () => { - expect(fold('1|noSuchLocal|cgetUser')).toBeUndefined(); + expect(fold('2|noSuchLocal|cgetUser')).toBeUndefined(); }); it('does not consult the field fallback', () => { // `Holder` has no member `save` — only its field's TYPE does. The field // fallback would walk Holder's fields, find `User.save` and answer: a // guess, at O(fields x depth x names) per step. The fold declines. - expect(fold('1|holder|csave')).toBeUndefined(); + expect(fold('2|holder|csave')).toBeUndefined(); }); it('declines a truncated chain even though the producer refuses to mint one', () => { - expect(fold('1|svc|cgetUser|~')).toBeUndefined(); + expect(fold('2|svc|cgetUser|~')).toBeUndefined(); }); it('declines a construction-selector step and leaves it to the cascade', () => { @@ -133,15 +158,7 @@ describe('foldReceiverChain', () => { // named `new`; a chain step records only a name, so the fold cannot make the // distinction and must not try. Folding it turned a correct Ruby edge // (`Factory.new.run` → `Factory#run`) into a wrong one (`Product.run`). - const decoded = decodeReceiverChain('1|svc|cnew'); - expect(decoded).toBeDefined(); - expect( - foldReceiverChain(decoded!, ctx.inScope, ctx.scopes, ctx.index, { - fieldFallback: false, - hoistTypeBindingsToModule: true, - constructionSyntax: { selector: 'new' }, - }), - ).toBeUndefined(); + expect(foldIn(ctx, '2|svc|cnew', { constructionSyntax: { selector: 'new' } })).toBeUndefined(); }); it('does NOT climb to module scope when hoistTypeBindingsToModule is off', () => { @@ -151,9 +168,165 @@ describe('foldReceiverChain', () => { // how an unrelated module-level binding of the same name gets picked up, // which is exactly what the flag's own contract warns against. Same chain, // opposite answers, so this pins the branch rather than the happy path. - expect(foldWith('1|svc|cgetUser', { hoistTypeBindingsToModule: true })).toMatchObject({ + expect(foldIn(ctx, '2|svc|cgetUser', { hoistTypeBindingsToModule: true })).toMatchObject({ qualifiedName: 'User', }); - expect(foldWith('1|svc|cgetUser', { hoistTypeBindingsToModule: false })).toBeUndefined(); + expect(foldIn(ctx, '2|svc|cgetUser', { hoistTypeBindingsToModule: false })).toBeUndefined(); + }); +}); + +/** + * The `index` step. Every case here would have folded onto the CONTAINER before + * the step demanded positive evidence — `rawName` is `User` for both + * `repos: User[]` and `grid: Grid`, so identity could not tell an element from + * the thing that holds it. + */ +describe('foldReceiverChain — index step', () => { + it('declines a subscript on a non-container class whose member name collides with the element type', () => { + // `Grid` declares `[i: number]: Item` AND its own `run`. Identity kept the + // fold on `Grid`, so `grid[0].run()` emitted `Grid.run`; the element's owner + // is `Item`. The element type of a TypeScript index signature is not + // recorded anywhere the fold can read, so declining is the only sound + // answer — and it must never be the container. + expect(fold('2|grid|i')).toBeUndefined(); + }); + + it('declines a subscript on a class that is not subscriptable at all', () => { + // `plain: Plain` reduces to nothing — `Plain` IS the written spelling — so + // there is no container evidence and `plain[0]` types to nothing. Identity + // answered `Plain`, which then owned every member looked up after it. + expect(fold('2|plain|i')).toBeUndefined(); + }); + + it('resolves a genuine container whose spelling capture already reduced away', () => { + // `repos: User[]` binds to the bare `User`; only `declaredSpelling` still + // says `User[]`. This is the shape the feature exists for. + expect(fold('2|repos|i')).toMatchObject({ qualifiedName: 'User', type: 'Class' }); + }); + + it('resolves a container spelling capture left intact', () => { + // A multi-arg generic survives TypeScript's capture-time normalization, so + // `rawName` IS the container here and the hook unwraps it to the value type. + expect(fold('2|byId|i')).toMatchObject({ qualifiedName: 'Item', type: 'Class' }); + }); + + it('declines a nested container: ONE subscript leaves a container, not an element', () => { + // `nested: User[][]` reduces to the SAME `User` a single-level `User[]` + // produces — the strip loop runs to a fixed point. So one index step leaves + // `User[]`, and `nested[0].map(...)` is `Array.map`, never `User.map`. + // Identity answered `User` and handed the next member to the wrong owner. + expect(fold('2|nested|i')).toBeUndefined(); + }); + + it('declines when the language answers no index route at all', () => { + // Twelve of the fourteen wired languages leave `elementTypeOf` undefined. + // They used to get identity — every index step in every one of them folded + // onto the container. Answering the route is now the price of index folding. + expect(foldIn(ctx, '2|repos|i', { elementTypeOf: undefined })).toBeUndefined(); + }); + + it('declines when the hook names an element that binds to no class', () => { + expect( + foldIn(ctx, '2|repos|i', { elementTypeOf: () => 'NoSuchClassAnywhere' }), + ).toBeUndefined(); + }); + + it('declines an index step on a position that carries no declared type', () => { + // A static class-name base (`Service`) resolves to a def with NO type + // binding behind it, so there is no spelling to hand the hook. + expect(fold('2|Service|i')).toBeUndefined(); + }); + + it('unwraps a container returned by a method, through the hoisted binding', () => { + // `getItems(): Item[]` — the return-type binding reduces to `Item` and only + // the spelling remembers `Item[]`. TypeScript hoists it out of the class + // body, so this also pins that `typeOfMemberOnClass` carries the spelling + // along the hoisted branch. + expect(fold('2|svc|cgetItems|i')).toMatchObject({ qualifiedName: 'Item', type: 'Class' }); + }); + + it('treats await as identity, and does not require container evidence for it', () => { + // `getUserAsync(): Promise` reduces to `User` at capture, and awaiting + // a non-thenable yields the value itself — both regimes agree, which is + // exactly why `await` may stay identity where `index` may not. + expect(fold('2|svc|cgetUserAsync|a')).toMatchObject({ qualifiedName: 'User', type: 'Class' }); + }); +}); + +/** + * P3: the hoisted branch of `typeOfMemberOnClass` returned `undefined` when a + * member's declared type named no class, while the primary branch deliberately + * carried the declared type forward for exactly that case. Ten languages set + * `hoistTypeBindingsToModule`, so the two branches disagreed about the same + * declared type depending only on where the binding happened to live. + */ +describe('foldReceiverChain — a member whose declared type names no class', () => { + it('unwraps it identically whether the binding is reached by base lookup or by the hoisted walk', () => { + // `Map` names no workspace class on either route. + expect(fold('2|byId|i')).toMatchObject({ qualifiedName: 'Item' }); + expect(fold('2|svc|cgetMap|i')).toMatchObject({ qualifiedName: 'Item' }); + }); + + it('still yields nothing when the chain ENDS on a type that named no class', () => { + // Carrying the declared type forward must not start answering with a class + // the position never had — only an unwrapping step may advance from here. + expect(fold('2|svc|cgetMap')).toBeUndefined(); + expect(fold('2|svc|cgetMap|fnoSuchField')).toBeUndefined(); + }); +}); + +// ── C#: a real indexer, and the containers around it ──────────────────────── + +const CSHARP_SOURCE = `using System.Collections.Generic; + +class Row { + public void Render() {} +} + +class Table { + public Row this[int i] { get { return null; } } + public void Render() {} +} + +class Cap { + void Entry(Table t, Dictionary rows, List items, Row bare) { + } +} +`; + +const csFixture = buildScopeModel(csharpScopeResolver, CSHARP_SOURCE, 'main.cs'); + +// The parameter type bindings live in `Entry`'s own scope, so the fold must +// start there rather than at the module scope. Selected by the binding it +// must carry, not by position — every method body in the file is a Function +// scope and `Row.Render` comes first. +const csEntryScope = csFixture.parsed.scopes.find( + (s) => + s.kind === 'Function' && + csFixture.scopes.scopeTree.getScope(s.id)?.typeBindings.has('t') === true, +); +if (csEntryScope === undefined) throw new Error('no C# scope binding `t`'); + +const csCtx: FoldContext = { fixture: csFixture, inScope: csEntryScope.id as ScopeId }; + +describe('foldReceiverChain — C# indexer vs C# containers', () => { + it('declines a subscript on a class that declares an indexer', () => { + // `Table` has `public Row this[int i]` AND its own `Render`. `t[0].Render()` + // is `Row.Render`; identity made it `Table.Render`. + expect(foldIn(csCtx, '2|t|i')).toBeUndefined(); + }); + + it('declines a subscript on a class with neither indexer nor container spelling', () => { + expect(foldIn(csCtx, '2|bare|i')).toBeUndefined(); + }); + + it('resolves a dictionary subscript to the VALUE type', () => { + expect(foldIn(csCtx, '2|rows|i')).toMatchObject({ qualifiedName: 'Row', type: 'Class' }); + }); + + it('resolves a list subscript whose spelling capture reduced away', () => { + // C#'s `stripGeneric` collapses `List` to `Row` at capture, so this + // cell depends entirely on the retained spelling. + expect(foldIn(csCtx, '2|items|i')).toMatchObject({ qualifiedName: 'Row', type: 'Class' }); }); }); diff --git a/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts b/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts index a8ebf4480..890d3c6ea 100644 --- a/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts +++ b/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts @@ -490,6 +490,46 @@ describe('Pass 5: reference sites', () => { ); expect(result.referenceSites[0]!.arity).toBe(2); }); + + // #2782: languages whose member-read pattern also matches the callee of a + // member call mark that site rather than dropping it — the phantom-vs-genuine + // decision needs the resolved tail's kind and so belongs at edge emission. + it('records @reference.callee-position as inCalleePosition without becoming the anchor', () => { + const result = extract( + [ + scopeMatch('module', 1, 0, 100, 0), + refMatch('read', 'Work', 3, 0, 3, 10, { + // Widest capture in the match: if it were not a known sub-tag it + // would win `anchorCaptureFor` and route the site to an unknown kind. + '@reference.callee-position': cap( + '@reference.callee-position', + 3, + 0, + 3, + 20, + 'h.dep.Work', + ), + }), + ], + 'a.ts', + mockProvider(), + ); + expect(result.referenceSites).toHaveLength(1); + expect(result.referenceSites[0]).toMatchObject({ + name: 'Work', + kind: 'read', + inCalleePosition: true, + }); + }); + + it('leaves inCalleePosition unset on an ordinary read', () => { + const result = extract( + [scopeMatch('module', 1, 0, 100, 0), refMatch('read', 'Label', 3, 0, 3, 10)], + 'a.ts', + mockProvider(), + ); + expect(result.referenceSites[0]!.inCalleePosition).toBeUndefined(); + }); }); // ─── §Pass 6: callable-value-flow facts ─────────────────────────────────── diff --git a/gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts b/gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts index 7d6ad9dfe..5661467fb 100644 --- a/gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts +++ b/gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts @@ -7,14 +7,29 @@ import { describe, it, expect } from 'vitest'; import { MAX_UNRESOLVED_RECEIVER_MEMBERS, + lookupExternalCallCount, lookupUnresolvedCallCount, summarizeUnresolvedReceivers, } from '../../../src/core/ingestion/scope-resolution/unresolved-receivers.js'; -import type { ResolutionOutcome } from '../../../src/core/ingestion/scope-resolution/resolution-outcome.js'; +import { classifyReceiverShape } from '../../../src/core/ingestion/scope-resolution/resolution-outcome.js'; +import type { + ReceiverOrigin, + ResolutionOutcome, +} from '../../../src/core/ingestion/scope-resolution/resolution-outcome.js'; +import { classifyReceiverOrigin } from '../../../src/core/ingestion/scope-resolution/passes/receiver-bound-calls.js'; +import { decodeReceiverChain } from '../../../src/core/ingestion/utils/receiver-chain-codec.js'; +import { typescriptScopeResolver } from '../../../src/core/ingestion/languages/typescript/scope-resolver.js'; +import { goScopeResolver } from '../../../src/core/ingestion/languages/go/scope-resolver.js'; +import { javaScopeResolver } from '../../../src/core/ingestion/languages/java/scope-resolver.js'; +import { buildScopeModel, type ScopeModelFixture } from '../../helpers/scope-model.js'; const range = { startLine: 1, startCol: 0, endLine: 1, endCol: 1 }; -function dropped(name: string, siteKind: 'call' | 'read' | 'write' = 'call'): ResolutionOutcome { +function dropped( + name: string, + siteKind: 'call' | 'read' | 'write' = 'call', + receiverOrigin?: ReceiverOrigin, +): ResolutionOutcome { return { kind: 'suppressed', reason: 'receiver-unresolved', @@ -24,6 +39,7 @@ function dropped(name: string, siteKind: 'call' | 'read' | 'write' = 'call'): Re name, range, siteKind, + ...(receiverOrigin === undefined ? {} : { receiverOrigin }), }; } @@ -127,3 +143,347 @@ describe('summarizeUnresolvedReceivers', () => { expect(lookupUnresolvedCallCount(undefined, 'save')).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Origin routing. `external` is the one verdict that makes a drop STOP hedging, +// so it is the one verdict that must come from positive evidence — everything +// else has to keep counting. +// --------------------------------------------------------------------------- + +describe('summarizeUnresolvedReceivers origin routing', () => { + it('keeps an external-rooted drop out of totalSites but inside the artifact', () => { + const summary = summarizeUnresolvedReceivers([ + dropped('save', 'call', 'in-program'), + dropped('log', 'call', 'external'), + ]); + expect(summary).toMatchObject({ + counts: { save: 1 }, + totalSites: 1, + externalCounts: { log: 1 }, + externalSites: 1, + }); + // Routed, not discarded: the split stays auditable and reversible. + expect(summary?.counts).not.toHaveProperty('log'); + }); + + it('counts an unknown-origin drop, because unproven completeness is the unsafe direction', () => { + // The `droppedCall(svc)` population: an unannotated parameter is recorded + // nowhere in the scope model, so the classifier can prove nothing about it. + // It must hedge, exactly like `in-program`. + expect( + summarizeUnresolvedReceivers([ + dropped('save', 'call', 'unknown'), + dropped('run', 'call', 'in-program'), + ]), + ).toMatchObject({ counts: { save: 1, run: 1 }, totalSites: 2 }); + }); + + it('counts a drop that carries no origin at all', () => { + expect(summarizeUnresolvedReceivers([dropped('save')])).toMatchObject({ + counts: { save: 1 }, + totalSites: 1, + }); + }); + + it('reports external truncation past the cap, symmetrically with omittedNames', () => { + // Without the twin, `lookupExternalCallCount` returns `undefined` for a + // truncated name — indistinguishable from "this member had no external + // drops" — and `externalSites` exceeds the sum of `externalCounts` with + // nothing in the artifact to explain the gap. + const outcomes: ResolutionOutcome[] = []; + for (let i = 0; i < 5; i++) outcomes.push(dropped('zzz_hottest', 'call', 'external')); + for (let i = 0; i < MAX_UNRESOLVED_RECEIVER_MEMBERS + 10; i++) { + outcomes.push(dropped(`ext${i}`, 'call', 'external')); + } + const summary = summarizeUnresolvedReceivers(outcomes); + expect(Object.keys(summary!.externalCounts!)).toHaveLength(MAX_UNRESOLVED_RECEIVER_MEMBERS); + expect(summary).toMatchObject({ + totalSites: 0, + externalSites: MAX_UNRESOLVED_RECEIVER_MEMBERS + 15, + externalOmittedNames: 11, + }); + // The hottest name survives the cap on count alone and still reads back. + expect(lookupExternalCallCount(summary, 'zzz_hottest')).toBe(5); + }); + + it('omits the external truncation marker when nothing was truncated', () => { + const summary = summarizeUnresolvedReceivers([dropped('log', 'call', 'external')]); + expect(summary).toMatchObject({ externalSites: 1 }); + expect(summary).not.toHaveProperty('externalOmittedNames'); + }); +}); + +// --------------------------------------------------------------------------- +// `classifyReceiverOrigin` — the classifier the routing above consumes. +// +// Built from real scope extraction rather than hand-assembled indexes: the +// defect being pinned is that source-level intuition about what a binding +// CONTAINS is wrong (Go normalizes a free parameter's `*Host` to `Host` at +// capture but leaves a method receiver's spelled `*Host` intact), so a fixture +// that asserts the binding shape by hand would pin the intuition, not the code. +// --------------------------------------------------------------------------- + +/** The provider-hook bag `classifyReceiverOrigin` reads. Derived from the + * function so the two cannot drift. */ +type OriginHooks = Parameters[4]; + +/** Classify the receiver of the (unique) reference site invoking `memberName` + * under `hooks`, through exactly the arguments the pass threads at its drop + * recorder. */ +function classifyOriginOf( + fixture: ScopeModelFixture, + memberName: string, + hooks: OriginHooks, +): ReceiverOrigin { + const site = fixture.sites.find( + (candidate) => candidate.name === memberName && candidate.explicitReceiver !== undefined, + ); + expect(site).toBeDefined(); + return classifyReceiverOrigin( + decodeReceiverChain(site!.receiverChain), + site!.inScope, + site!.explicitReceiver!.name, + fixture.scopes, + hooks, + ); +} + +/** The normal path: the language's own contract hooks, exactly as the pass + * supplies them. */ +function originOf(fixture: ScopeModelFixture, memberName: string): ReceiverOrigin { + return classifyOriginOf(fixture, memberName, { + stripTypePreservingDecoration: fixture.resolver.stripTypePreservingDecoration, + isBuiltInName: fixture.resolver.languageProvider.isBuiltInName, + }); +} + +/** The degradation path: the same site with the language's provider hooks + * WITHHELD, as for a language that declares neither. + * + * Named rather than spelled `originOf(fixture, name, {})` — an empty options + * object reads as "defaults", so a reader "simplifying" it away silently + * flips the assertion from the degradation path to the normal path, and some + * of these would still pass while no longer testing anything. */ +function originOfWithoutHooks(fixture: ScopeModelFixture, memberName: string): ReceiverOrigin { + return classifyOriginOf(fixture, memberName, {}); +} + +const goFixture = buildScopeModel( + goScopeResolver, + `package main + +type Host struct{ name string } + +func (h *Host) Inner() *Host { return h } + +func (h *Host) Run() { + h.Inner().Dispatch() +} +`, + 'main.go', +); + +const tsFixture = buildScopeModel( + typescriptScopeResolver, + `export class User { + save(): void {} +} + +export class Service { + getUser(): User { + return new User(); + } +} + +// The PR's own integration fixture. An unannotated parameter is recorded +// NOWHERE in the scope model — no type binding, no value binding, no qualified +// name — so nothing about it can be demonstrated in either direction. +export function droppedCall(svc): void { + svc.getUser().save(); +} + +// A local the program demonstrably declares, whose initializer we cannot type. +export function viaLocal(): void { + const loc = makeIt(); + loc.getUser().persist(); +} + +// Genuinely outside: the language itself names \`console\`. +export function viaConsole(): void { + console.log('x'); +} + +// Declared type is a bare built-in, so the member lives outside too. +export function viaDate(d: Date): void { + d.getTime(); +} +`, + 'main.ts', +); + +// #2744. Java is the language the boundary signal matters most for (the +// Spring/DI analysis is built on it) and the one that had no built-in set at +// all, so every drop in it hedged. Same construction as the fixtures above — +// real scope extraction, real provider hook — because the cases that matter are +// exactly the ones source-level intuition gets wrong: a `java.util` import does +// NOT produce an in-program binding (so the base still reaches the built-in +// set), and a `List` declaration does not bind its base to `List`. +const javaFixture = buildScopeModel( + javaScopeResolver, + `import java.util.List; +import java.util.Map; + +public class Probe { + private UserService svc; + private List names; + + public void go(OrderRepository repo, String raw) { + System.out.println("x"); + String.format("%s", raw); + raw.trim(); + names.iterator(); + List.of("a"); + Map.entry("a", "b"); + Helper.assist(); + svc.loadUser(); + repo.findAll(); + } +} + +class Helper { + static void assist() {} +} + +class UserService { + User loadUser() { return null; } +} + +class User {} +`, + 'src/Probe.java', +); + +describe('classifyReceiverOrigin', () => { + // #2766. \`func (h *Host)\` binds \`h\` to the literal \`*Host\`; a free parameter + // \`x *Host\` is normalized to \`Host\` at capture. Only the receiver spelling + // needs the stripper, which is why the defect hid behind passing tests. + it('reads a Go pointer receiver as in-program', () => { + expect(originOf(goFixture, 'Dispatch')).toBe('in-program'); + }); + + it('degrades to unknown, never external, when the language gives no stripper', () => { + // The same site with the hook withheld: \`*Host\` still resolves to no class, + // and the honest answer is that we could not tell — NOT that the JDK owns + // it. Returning `external` here is what published `epistemic: 'exact'` over + // every drop in a Go method body. + expect(originOfWithoutHooks(goFixture, 'Dispatch')).toBe('unknown'); + }); + + it('does not call an unannotated parameter external', () => { + expect(originOf(tsFixture, 'save')).not.toBe('external'); + expect(originOf(tsFixture, 'save')).toBe('unknown'); + }); + + it('reads a declared local with an untypable initializer as in-program', () => { + expect(originOf(tsFixture, 'persist')).toBe('in-program'); + }); + + // The test that proves the feature was fixed rather than deleted. + it('still reports a language built-in receiver as external', () => { + expect(originOf(tsFixture, 'log')).toBe('external'); + }); + + it('still reports a base whose declared type is a bare built-in as external', () => { + expect(originOf(tsFixture, 'getTime')).toBe('external'); + }); + + it('never claims external without the built-in hook', () => { + // A language that declares no built-in set has no positive external evidence + // available at all, so every one of its drops must hedge. Java used to be + // such a language; COBOL still is. + expect(originOfWithoutHooks(tsFixture, 'log')).toBe('unknown'); + expect(originOfWithoutHooks(tsFixture, 'getTime')).toBe('unknown'); + }); + + // ── Java (#2744) ──────────────────────────────────────────────────────── + // Before Java had a built-in set, every assertion in this block read + // `lower-bound`-inducing `unknown`. + + it('reports a Java static platform receiver as external', () => { + // `System.out.println(...)` — the chain base is `System`, not `out`. + expect(originOf(javaFixture, 'println')).toBe('external'); + expect(originOf(javaFixture, 'format')).toBe('external'); + }); + + it('reports a Java base whose declared type is a platform type as external', () => { + // `String raw` — the parameter is in-program, the member it dispatches is not. + expect(originOf(javaFixture, 'trim')).toBe('external'); + // A FIELD declared `List`. Java binds a known container to its + // ELEMENT type, so what reaches the built-in check is `String`, not `List` — + // asserting this from the source spelling would pin the wrong thing. Either + // way the verdict is the honest one, and without the set it read `in-program` + // off the value channel and hedged every JDK collection call in the repo. + expect(originOf(javaFixture, 'iterator')).toBe('external'); + }); + + it('reports a java.util static receiver as external despite its import', () => { + // `import java.util.List` resolves to no workspace file, so it leaves no + // in-program binding and `List` falls through to the built-in set. + expect(originOf(javaFixture, 'of')).toBe('external'); + }); + + it('still reports Java receivers the program declares as in-program', () => { + expect(originOf(javaFixture, 'assist')).toBe('in-program'); + expect(originOf(javaFixture, 'loadUser')).toBe('in-program'); + }); + + it('still hedges a Java receiver whose declared type is simply unknown here', () => { + // `OrderRepository` is declared nowhere in this program and is not a platform + // name. Absence of evidence stays `unknown` — the safe direction. + expect(originOf(javaFixture, 'findAll')).not.toBe('external'); + expect(originOf(javaFixture, 'findAll')).toBe('unknown'); + }); + + it('hedges a platform name deliberately kept OUT of the set', () => { + // `Map.entry(...)` is the exact syntactic twin of the `List.of(...)` above; + // the only thing separating the two verdicts is set membership. `Map` is a + // name applications really do declare (and in Java a same-package type needs + // no import to shadow it), so it stays out and its drops keep hedging. This + // pins the under-inclusion choice as a choice, not an oversight. + expect(originOf(javaFixture, 'entry')).toBe('unknown'); + }); + + it('degrades every Java verdict to unknown when the hook is withheld', () => { + expect(originOfWithoutHooks(javaFixture, 'format')).toBe('unknown'); + expect(originOfWithoutHooks(javaFixture, 'trim')).toBe('unknown'); + }); +}); + +describe('classifyReceiverShape', () => { + it('reports no-chain when the site carried no chain', () => { + expect(classifyReceiverShape(undefined)).toBe('no-chain'); + }); + + it('reports no-chain for a chain with no steps', () => { + expect(classifyReceiverShape({ steps: [] })).toBe('no-chain'); + }); + + it('reports chain-call when every step is a call', () => { + expect(classifyReceiverShape({ steps: [{ kind: 'call' }, { kind: 'call' }] })).toBe( + 'chain-call', + ); + }); + + it('reports chain-field when every step is a field', () => { + expect(classifyReceiverShape({ steps: [{ kind: 'field' }] })).toBe('chain-field'); + }); + + // The distinction that makes the census actionable: a mixed chain fails for + // different reasons than a pure one, so collapsing it into either bucket + // would misattribute the population a fix has to target. + it('reports chain-mixed when the chain interleaves calls and fields', () => { + expect(classifyReceiverShape({ steps: [{ kind: 'call' }, { kind: 'field' }] })).toBe( + 'chain-mixed', + ); + }); +}); diff --git a/gitnexus/test/unit/ts-receiver-chain-capture.test.ts b/gitnexus/test/unit/ts-receiver-chain-capture.test.ts index 86394456a..5414eaae9 100644 --- a/gitnexus/test/unit/ts-receiver-chain-capture.test.ts +++ b/gitnexus/test/unit/ts-receiver-chain-capture.test.ts @@ -34,28 +34,45 @@ declare const repos: User[]; describe('TypeScript receiver-chain capture', () => { it('emits a chain for a plain call-chain receiver', () => { expect(chainsFor(`${MODELS}\nsvc.getUser().save();\n`)).toMatchObject({ - save: '1|svc|cgetUser', + save: '2|svc|cgetUser', }); }); it('emits a mixed call/field chain base-first', () => { expect(chainsFor(`${MODELS}\nsvc.getUser().address.save();\n`)).toMatchObject({ - save: '1|svc|cgetUser|faddress', + save: '2|svc|cgetUser|faddress', }); }); it('emits a chain for an optional-chained receiver — one of the shapes that resolves to nothing today', () => { expect(chainsFor(`${MODELS}\nsvc?.getUser().save();\n`)).toMatchObject({ - save: '1|svc|cgetUser', + save: '2|svc|cgetUser', }); }); it('emits a chain for an explicit-type-argument receiver', () => { expect(chainsFor(`${MODELS}\nsvc.getTyped().save();\n`)).toMatchObject({ - save: '1|svc|cgetTyped', + save: '2|svc|cgetTyped', }); }); + it('emits a name-free index step for a subscript receiver', () => { + // The declaration `repos: User[]` at the top of MODELS exists for this. + // A subscript receiver contains neither `.` nor `(`, so Case 0's old + // punctuation gate never fired and this call was INVISIBLE — no edge and no + // recorded drop. The chain is what makes it visible; the `i` step carries no + // name because a subscript key is a value, not a member. + expect(chainsFor(`${MODELS}\nrepos[0].save();\n`)).toMatchObject({ save: '2|repos|i' }); + }); + + it('emits one index step per subscript, so a nested container is not flattened', () => { + // `User[][]` reduces to the same `User` a single `User[]` does, so the STEP + // COUNT is the only thing that distinguishes `nested[0]` (still a container) + // from `nested[0][1]` (an element). + const src = `${MODELS}\ndeclare const nested: User[][];\nnested[0][1].save();\n`; + expect(chainsFor(src)).toMatchObject({ save: '2|nested|i|i' }); + }); + it('emits no chain for a bare-name receiver — there is nothing to fold', () => { expect(chainsFor(`${MODELS}\nconst u = new User();\nu.save();\n`)).toEqual({}); }); From 74409a37f602d18f5dab053b2089fe3f313b4fa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sun, 2 Aug 2026 15:52:33 +0100 Subject: [PATCH 07/13] perf(cpp): index qualified namespace members once per pipeline run (#2788) (#2794) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(cpp): index qualified namespace members once per pipeline run (#2788) `resolveCppQualifiedNamespaceMember` walked every parsed file — rebuilding a per-file `scopesById` map each time — once per qualified `ns::member()` call site, so the scope-resolution emit phase cost O(callsites x scopes). On a 1,473-file C++ repo that was 25.3 min of a 33-min analyze, with 75% of total self-time in this one function. Its inner `findMemberInNamespaceTransitive` compounded it: each recursion step filtered `scopesById.values()` by parent, O(scopes^2) per file on its own. This is the same bug #1990 fixed in the sibling ADL path (`pickCppAdlCandidates` -> `AdlCandidateIndex`), so it gets the same fix: a `QualifiedNsMemberIndex` (receiver simple name -> member simple name -> callable defs) built lazily once per `parsedFiles` identity and reset by `clearCppInlineNamespaces`, which runs from `cppScopeResolver.loadResolutionConfig` at the start of every pass. Per call site the work drops to two Map lookups. Ordering is preserved exactly — file-major, `parsed.scopes` declaration order, a namespace's own `ownedDefs` before its inline-namespace children, depth-first — because the caller takes `allHits[0]` for the single-hit case and `narrowOverloadCandidates` is first-wins. Non-inline nested namespaces are still not descended into, and same-name hits across inline children still report `'ambiguous'` (#1564). Measured with `PROF_SCOPE_RESOLUTION=1 analyze --force --index-only` on a synthetic corpus (`namespace ns_i { inline namespace v1 { ... } }` plus 20 `ns_j::fn()` call sites per file): | files | emit before | emit after | |-------|-------------|------------| | 100 | 153ms | 16ms | | 200 | 704ms | 24ms | | 400 | 3,293ms | 42ms | | 800 | 16,898ms | 78ms | Before, doubling the file count quadrupled emit; now it doubles. At 800 files total scope resolution goes 17.2s -> 394ms. Output is unchanged, verified rather than assumed: a full graph dump (sorted nodes + relationships) from a baseline build at the parent commit and from this one are byte-identical on all 134 `cpp-*` fixtures merged into a single repo (1573 nodes / 1997 relationships) and on the 400-file synthetic corpus. `test/integration/resolvers/cpp.test.ts` passes 334/334. #1990 shipped its ADL fix without a scaling gate, which is how the bug class came straight back here, so this adds one: `bench/cpp-qualified-ns` measures `(t_large/t_small)/(1600/400)` — 0.93-1.21 indexed versus 3.45 for the old per-call-site scan — alongside a fingerprint over every `receiver::member -> outcome` the corpus resolves, and CI runs it with `--check`. `test/unit/cpp-qualified-ns-index.test.ts` covers the cache invalidation the index introduces. Co-Authored-By: Claude Opus 5 (1M context) * fix(cpp): address tri-review findings on the qualified-namespace index (#2788) Multi-engine review of this PR (Claude swarm + ce-code-review, Codex gpt-5.6-sol swarm + ce + adversarial) returned two P1s and five smaller findings. All are fixed here. P1 — the index defeated the pipeline's post-language memory release. `scope-resolution/pipeline/phase.ts` evicts each language's files and then calls `forceGc()`, on a stated premise that "This language's ParsedFiles are now unreachable", sizing C/C++ at ~17-20GB on the Linux kernel. The module-level `let qualifiedNsIndexSource` falsified that: it pinned the whole `parsedFiles` array, and the index held defs reaching into those files' scopes, until the *next* C++ pass cleared it — which in a single analyze never comes. C++ is 7th of 16 in SCOPE_RESOLVERS, so the set survived nine later language passes plus emit. Replaced with a `WeakMap`, the pattern already used by `moduleScopeIndexByPass` in `cpp/file-local-linkage.ts`. `clearCppInlineNamespaces` still swaps in a fresh WeakMap, because the index has a second input (`inlineNamespaceScopeIds`) the key cannot observe. Measured with `--expose-gc`: 61.2MB retained after the caller drops the array before, 0.1MB after. The ADL twin (`adl.ts`) has the same pattern, so the hazard predates this PR — but `pickCppAdlCandidates` returns early before `ensureAdlIndex` on `noAdlSites`/empty `argInfoBySite`, so it rarely arms, whereas a qualified `ns::member()` index arms on almost every C++ workspace. Moving the ADL twin to a WeakMap is left as a follow-up. P1 — the new bench could not see the regression class it exists to gate. `callSites()` drew every receiver from `ns_${...}`, so the receiver lookup never missed; production is the opposite, since Case 1.5 in `receiver-bound-calls.ts` is reached by every plain-identifier receiver call and misses on most. A rescan reintroduced only on the receiver-bucket-absent path scored 1.279 and PASSED the old bench. The corpus now mirrors production (~1 in 5 receivers name a declared namespace) and adds a namespace reopened across files, a same-name inline nest, a member declared at both namespace and inline-child level, and call sites carrying a real `Callsite` so `narrowOverloadCandidates`/`cppConversionRank`/ `isOverloadAmbiguousAfterNormalization` are inside the fingerprinted surface at all. That same rescan now measures 4.538 and FAILS; defeating the dedup now fails the fingerprint arm where it previously passed byte-identical. The fingerprint moved once, deliberately, for the corpus expansion — recorded in `_rebaseline_2788_review`, explicitly not precedent. Also fixed: - Unbounded recursion aborted analyze. `collectNamespaceMembers` recursed per inline child with no bound and threw an uncontained `RangeError` at inline depth 8000 (`phase.ts`'s try has a `finally`, no `catch`), and a receiver *miss* paid full recursion where the deleted walker skipped on a name mismatch. An explicit work-stack alone would only have converted that into an OOM at depth 6000, because the eager table was quadratic in memory too: for a depth-D chain it legitimately holds D(D+1)/2 entries, since `v2::foo()` is a valid receiver at every level. Replaced with a lazily-queried node graph (per-scope own-member buckets plus direct child links, resolved on demand and memoized per receiver+member). Build is now linear; depth 100000 costs 133ms where 8000 previously threw. - "#1990 shipped without a scaling gate" was false. #1990 did ship `test/integration/cpp-adl-benchmark.test.ts` (f1b843838). Corrected in the bench header and the CI step comment. The accurate point is narrower and stronger: that bench asserts `callsResolved === 0`, so it never drives the qualified-receiver path, and it is `skipIf(!GITNEXUS_BENCH)` while the only step setting that variable lists neither C++ bench — so it has never run in CI. Wiring it in is a follow-up. - "Ordering is load-bearing" was not a live property: `allHits[0]` is only reached at length 1, and both tail branches return `'ambiguous'`. Order is still preserved for byte-identity with the pre-#2788 walker; the comment now says that instead, and the test named for ordering is renamed to the parent/inline-child visibility it actually asserts. - "Same contract as `ensureAdlIndex`" overstated parity — the sibling ships a `validateAdlSeqCoverage` guard because it reads `seqByNodeId.get(...) ?? 0`, which can silently collapse candidates. This index has no analogous defaulting read, so no guard is added; the comment now says why. - Two coverage gaps closed, both mutation-verified: cross-file merge of one namespace reopened in two files (no existing test covered it — confirmed by making each file clobber the previous and watching only the new test fail), and the same-name inline nest whose dedup, when defeated, flips a resolved def to `'ambiguous'`. - `resolveCppQualifiedNamespaceMember`'s JSDoc now names both production call sites, including the callsite-less `resolveAdlCandidates` path. - Memoized candidate buckets are frozen, so a future in-place sort in `overload-narrowing.ts` throws instead of silently corrupting later resolutions now that the array is shared across call sites. - `_scaling_note`'s "measured 0.93-1.21" band did not reproduce; it is now the honestly observed 1.28-1.45, with the small arm widened to ~14ms (halves the spread) and a triage line saying a scaling failure is a timing signal to re-run, unlike the deterministic fingerprint arm. Verification: 840,000 differential probes against the pre-#2788 walker extracted from base, 0 mismatches, plus 48,000 candidate-order comparisons, 0 mismatches. cpp resolver integration suite 334/334. Unit suite 10/10. tsc, eslint, prettier clean. Bench --check PASS. Co-Authored-By: Claude Opus 5 (1M context) * fix(bench): escape the NUL separator so measure.mjs stays a text file The fingerprint key separator was written as a literal NUL byte instead of the `\u0000` escape. Git classifies any file containing a NUL as binary, so the whole bench showed as `Bin` with no diff on GitHub and could not be reviewed — the same defect this branch already fixed once before the tri-review. Escaping it is byte-for-byte equivalent at runtime (both produce U+0000), so the committed fingerprint is unchanged and `--check` still passes. Co-Authored-By: Claude Opus 5 (1M context) * refactor(cpp): quality cleanups from a four-angle review of the #2788 series Reuse, simplification, efficiency and altitude passes over the diff. No resolution behaviour changes: the bench fingerprint is unchanged and the C++ resolver integration suite still passes in full. Efficiency - The `Object.freeze` added in the review-fix commit to close an aliasing residual costs 4.6x on the narrowing path — V8 moves frozen arrays to PACKED_FROZEN_ELEMENTS, off the fast path for the `.filter`/`.map`/`.some` runs `narrowOverloadCandidates` does at every multi-candidate call site. The hazard it guards is already a compile error (both the memo and the parameter are `readonly SymbolDefinition[]`), so it is now a dev-only tripwire. Gated on `isSemanticModelValidatorEnabled()` — the repo's opt-IN form used by `phase.ts` and `validate-bindings-immutability.ts` — not `adl.ts`'s opt-out `NODE_ENV !== 'production'`, which would keep paying the cost in CLI runs where `NODE_ENV` is unset. Large bench arm 100.3 -> 70.6ms. - The index build scanned every scope in every file twice; pass 1 now collects `[scope, node]` pairs for pass 2 to iterate. 800k scopes 14.40 -> 8.21ms. - `simpleNameOf` uses `lastIndexOf('.')` + `slice` instead of `split('.').pop()` (83 -> 24 ns/call), semantics verified byte-equivalent over 12 edge cases including `undefined`, `''`, `'a.'`, `'.b'` and `'a..b'`. - The per-call `hookCtx` literal is hoisted to a module const. - The bench's fingerprint pass resolved 960k call sites to produce 5,800 distinct outcomes; it now dedups on the key it already builds. Bench wall time 2.4 -> 1.9s, fingerprint byte-identical. Reuse - `bucketOwnMembers` used an inlined `Function | Method | Constructor` compare; it now calls the canonical `isOverloadableCallable`. `graph-bridge/ids.ts` already carries a note that inlining this list recreated twin-list drift once. Altitude - `adl.ts` had the same retention defect this series just fixed next door: a module-level `let adlIndex` + `let adlIndexSource` strongly pinning the whole `parsedFiles` array until the next C++ pass, which in a single analyze never comes. Converted to the same `WeakMap` shape. Measured with `--expose-gc`: 89.11MB retained after the caller drops the array before, 0.17MB after. Six file-local helpers now take the index as a parameter; no exported signature changed. - The index's freshness depended on `clearCppInlineNamespaces()` being called from another file, guarded only by a warning paragraph. An epoch bumped in both `populateCppInlineNamespaceScopes` and the clear is now stored with the memo, so a missed clear degrades to a rebuild instead of a stale answer — confirmed by driving inline state mid-pass without the clear. Roughly line neutral, since it replaces most of the paragraph. - `test/integration/cpp-adl-benchmark.test.ts` is wired into the `GITNEXUS_BENCH` step. It is `skipIf`-gated and was absent from that step's explicit file list, so #1990's ADL emit-scaling guard had never executed in CI. It passes; ~50s added to a 25-minute job. `cpp-pipeline-benchmark.test.ts` is deliberately NOT wired: it costs 115s for guards covering generic per-language pipeline scaling that nothing here touches. Simplification - Deleted a comment referencing a `inlineChildrenByParent` map that only ever existed inside this branch's own first commit, so "the legacy map" pointed a reader at code that never shipped. - Dropped three unreachable `undefined` guards (`strict: false`, no `noUncheckedIndexedAccess`), keeping the load-bearing `visited` check. - Compressed the `rootsByReceiver` doc from 17 lines to 7 — it was the longest comment in the file and guarded the least consequential property — the `validateAdlSeqCoverage` paragraph from 7 lines to 3, and turned three restatements of the uncaught-throw and dedup arguments into pointers. - Test fixtures: dropped the dead `'Module'` union arm, added a one-line `ns()` builder for the nine hand-written scope literals, and moved the file to `test/unit/scope-resolution/cpp/` where every other C++ scope-resolution unit test lives. 403 -> 337 lines, same 10 tests, and the cross-file mutation check still fails exactly one test. Not done, and why: merging this index into `AdlCandidateIndex` (they key on different names with different inline-transparency depth — a refactor with correctness risk, not a cleanup); `ScopeTree.getChildren` (trades in-memory `parsed.scopes` for store hits on a hot path); a shared `cpp/` util for the five pre-existing `simpleName` copies; sharing fixtures across the bench/test boundary (no precedent in this repo); and an exact-count arm for the bench, which is a gate redesign worth its own change. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Gergo Magyar Co-authored-by: Claude Opus 5 (1M context) --- .github/workflows/ci-tests.yml | 13 + .../bench/cpp-qualified-ns/baselines.json | 7 + gitnexus/bench/cpp-qualified-ns/measure.mjs | 496 ++++++++++++++++++ .../src/core/ingestion/languages/cpp/adl.ts | 112 ++-- .../languages/cpp/inline-namespaces.ts | 404 +++++++++++--- .../cpp/cpp-qualified-ns-index.test.ts | 337 ++++++++++++ 6 files changed, 1258 insertions(+), 111 deletions(-) create mode 100644 gitnexus/bench/cpp-qualified-ns/baselines.json create mode 100644 gitnexus/bench/cpp-qualified-ns/measure.mjs create mode 100644 gitnexus/test/unit/scope-resolution/cpp/cpp-qualified-ns-index.test.ts diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index b4fbb3d56..87d2c4377 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -500,6 +500,14 @@ jobs: run: node --import tsx bench/callable-value-flow/measure.mjs --check working-directory: gitnexus + - name: C++ qualified-namespace resolution guards (#2788) + # Build-free: asserts resolveCppQualifiedNamespaceMember resolves an + # unchanged symbol set (fingerprint) and that per-call-site cost stays + # independent of corpus size. Rationale and history: see the header of + # bench/cpp-qualified-ns/measure.mjs. + run: node --import tsx bench/cpp-qualified-ns/measure.mjs --check + working-directory: gitnexus + - name: Receiver-resolution drop guards # NOT build-free: this one runs the real pipeline, so it needs dist/ # (the setup action above builds). ~2m15s. @@ -566,12 +574,17 @@ jobs: working-directory: gitnexus - name: Cross-language pipeline benchmarks (GITNEXUS_BENCH, serial) + # cpp-adl-benchmark.test.ts is not a `*-pipeline-benchmark.test.ts` but + # belongs here for the same reason: it is skipIf-gated on GITNEXUS_BENCH, + # so it had never run in CI and the PR #1990 ADL emit-scaling guard it + # holds was dead. ~45s of test time. env: GITNEXUS_BENCH: '1' run: >- npx vitest run --no-file-parallelism test/integration/cobol-pipeline-benchmark.test.ts test/integration/csharp-pipeline-benchmark.test.ts + test/integration/cpp-adl-benchmark.test.ts test/integration/instance-ownership-pipeline-benchmark.test.ts test/integration/spring-bean-resource-benchmark.test.ts test/integration/rust-pipeline-benchmark.test.ts diff --git a/gitnexus/bench/cpp-qualified-ns/baselines.json b/gitnexus/bench/cpp-qualified-ns/baselines.json new file mode 100644 index 000000000..51ce5405d --- /dev/null +++ b/gitnexus/bench/cpp-qualified-ns/baselines.json @@ -0,0 +1,7 @@ +{ + "_comment": "Baselines for bench/cpp-qualified-ns/measure.mjs --check (#2788). `fingerprint` is a sha256 over every `receiver::member(arity|argumentTypes) -> outcome` the synthetic corpus resolves at the LARGE scale (hit nodeId, `` per #1564, or ``); it is a CORRECTNESS gate, so drift means C++ qualified `ns::member()` lookup started resolving a different symbol set and must be explained, never re-baselined to make CI green. THAT RULE IS UNCHANGED and applies to every future edit of inline-namespaces.ts. `scaling_budget` is a timing gate and carries deliberate headroom for shared CI runners.", + "_rebaseline_2788_review": "This fingerprint was moved ONCE, deliberately, during review of #2788 — because the bench CORPUS was expanded, not because a check failed. Do not read it as precedent. What changed: (1) receivers now mirror production — ~1 in 5 name a declared namespace, ~4 in 5 are plain identifiers naming none (`obj0`, `Widget3`, `buf12`). The previous corpus drew every receiver from `ns_${…}`, so the receiver lookup NEVER missed, while Case 1.5 in scope-resolution/passes/receiver-bound-calls.ts is reached by every plain-identifier receiver call and misses on the overwhelming majority. (2) A namespace reopened across two files (C++ namespaces are open — the cross-file merge property). (3) A same-name inline nest `namespace ns { inline namespace ns { … } }`, which is the only shape that observes `gatherQualifiedNsMember`'s `visited` dedup. (4) A member declared at BOTH the namespace level and in an inline child, selected apart by argument type, pinning both collection sources by nodeId. (5) Call sites carrying a real `Callsite`, without which narrowOverloadCandidates / cppConversionRank / isOverloadAmbiguousAfterNormalization were outside the fingerprinted surface entirely. Measured effect, same patched resolver, old bench vs new: removing the `visited` dedup — old PASS with a byte-identical fingerprint, new FAIL (fingerprint 1e6c51b9… != aba39c34…); resetting `visited` per root instead of across roots — old PASS, new FAIL on the same fingerprint. A PURE reorder of a candidate list still passes both, and correctly so: the resolver's return contract is order-blind by construction (see QualifiedNsMemberIndex's doc comment), so there is no behaviour there to gate.", + "fingerprint": "aba39c342ce536006bebded8b32260dc7807487be91f5c7ee9548f9e9283f9c9", + "scaling_budget": 1.8, + "_scaling_note": "(t_large/t_small)/(1600/400). ~1.0 is linear. OBSERVED BAND: 1.28-1.45 over ten unloaded runs on a 24-core dev box. The band this file previously claimed — 0.93-1.21 — did not reproduce and was an artifact: the small arm then measured ~1.7 ms, small enough that timer granularity and JIT warm-up, not scaling, set the number (the same ten-run sweep of that bench spanned 1.11-1.40). CALLS_PER_FILE is now sized so the small arm lands at ~14 ms; that halves the unloaded spread (0.30 -> 0.16) and costs ~2.0 s of wall time for the whole bench. The residual above 1.0 is real and not a defect: at LARGE the index and corpus are 4x the working set, so per-call-site locality is worse (~85 ns/site vs ~63 ns) while the algorithm stays linear. TRIAGE: a scaling failure is a TIMING signal — RE-RUN IT on an idle machine before investigating. Runner contention dominates everything above: pinned to 2 CPUs against 2 spinners the identical binary produced 1.16-2.18, i.e. a spurious FAIL, and the sibling bench/callable-value-flow drifts out of its own documented band the same way. The fingerprint arm is the opposite — it is deterministic; a re-run never changes it and must never be used to wish it away. Floor check: a per-call-site workspace rescan reintroduced ONLY on the receiver-bucket-absent path (the most plausible way #2788 returns) measures 4.538 at these same 400/1600 file scales — 812x slower on the small arm, 2850x on the large — while leaving the fingerprint byte-identical. The old always-hits corpus scored that same patch 1.279 and printed PASS. Resolution is timed alone; the fingerprint's outcome strings are built in a separate untimed pass because their allocation cost grows with the corpus and would otherwise show up as scaling." +} diff --git a/gitnexus/bench/cpp-qualified-ns/measure.mjs b/gitnexus/bench/cpp-qualified-ns/measure.mjs new file mode 100644 index 000000000..6ffbc18a0 --- /dev/null +++ b/gitnexus/bench/cpp-qualified-ns/measure.mjs @@ -0,0 +1,496 @@ +/** + * Build-free scaling + identity bench for `resolveCppQualifiedNamespaceMember`, + * the C++ qualified `ns::member()` receiver resolver (issue #2788). + * + * Before #2788 this function re-scanned EVERY parsed file — rebuilding a + * per-file `scopesById` map each time — once per qualified call site, so the + * scope-resolution emit phase cost O(callsites × scopes). On a 1,473-file C++ + * repo that was 25.3 min of a 33-min analyze, with 75% of total self-time in + * this one function. It is the same bug #1990 had already fixed in the sibling + * ADL path (`pickCppAdlCandidates` → `AdlCandidateIndex`). #1990 DID ship a + * scaling gate for that path — `test/integration/cpp-adl-benchmark.test.ts`, + * which asserts `emitRatio < fileRatio^1.5` — but it could never have caught + * this one, for two independent reasons: its corpus asserts + * `callsResolved === 0`, i.e. it generates only UNRESOLVED ADL sites, so it + * never drives the qualified-receiver path at all; and it is + * `describe.skipIf(!BENCH_ENABLED)` while the single CI step that sets + * `GITNEXUS_BENCH=1` names its test files explicitly and, until this PR wired + * it in, listed neither C++ bench — so it had never executed in CI. Even now + * that it runs, the `callsResolved === 0` half stands: it still cannot reach + * this path. Hence this bench, in an always-on step: a per-call-site workspace + * scan must not be reintroduced silently. + * + * For a synthetic corpus at two scales it reports: + * - `elapsed_ms` per scale (fastest of REPS, see `fastest`) for resolving + * every call site once, INCLUDING the one-time index build — that build is + * the work the per-site scan was traded for, so hiding it would let an + * index that is itself quadratic pass; + * - a scaling ratio `(t_large/t_small)/(LARGE/SMALL)`: ~1.0 linear, + * ~4.x quadratic at this scale gap; + * - a sha256 fingerprint over every `receiver::member(callsite) → outcome` + * the corpus resolves, as the correctness gate. A fingerprint change means + * qualified lookup started resolving different symbols — a behaviour + * change, never a performance one. + * + * A gate only covers the code path its corpus drives. Two properties below are + * therefore load-bearing and must not be "simplified" away: + * + * 1. **The receiver mix is production-shaped: ~1 in 5 receivers names a + * namespace, the other ~4 name nothing.** Case 1.5 in + * `scope-resolution/passes/receiver-bound-calls.ts` is reached by EVERY + * plain-identifier receiver call — `obj.size()`, `Widget::make()`, + * `buf.data()` — so in real source the overwhelming majority of calls into + * this resolver are receiver MISSES, not member misses inside a resolved + * receiver. An earlier revision of this bench drew every receiver from + * `ns_${…}`, i.e. always a namespace the corpus declared, so the receiver + * lookup never missed. Measured consequence: a "defensive full rescan when + * the receiver bucket is absent" regression — the single most plausible + * way this bug returns — scored 1.332 against the 1.8 budget and printed + * PASS, while costing 507× on a production-shaped corpus. + * 2. **The corpus contains every structural shape whose loss the fingerprint + * is supposed to catch** (see `buildCorpus`), including a batch of sites + * that pass a real `Callsite`. Without those, `narrowOverloadCandidates` / + * `cppConversionRank` / `isOverloadAmbiguousAfterNormalization` are not in + * the fingerprinted surface at all, and behaviour-only regressions there + * re-fingerprint byte-identically. + * + * Build-free: imports the `.ts` hotpath through tsx + * (`node --import tsx bench/cpp-qualified-ns/measure.mjs`). + * + * Without args: prints the JSON report. + * With `--check`: asserts the fingerprint == the committed baseline AND the + * scaling ratio is within budget; exits non-zero on drift/regression. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +import { + clearCppInlineNamespaces, + markCppInlineNamespaceRange, + populateCppInlineNamespaceScopes, + resolveCppQualifiedNamespaceMember, +} from '../../src/core/ingestion/languages/cpp/inline-namespaces.ts'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BASELINE_PATH = path.resolve(__dirname, 'baselines.json'); + +const SMALL = 400; +const LARGE = 1600; +/** Sized so the SMALL arm measures in the tens of ms rather than ~1.7 ms. + * Sub-2 ms samples are dominated by timer granularity and scheduler noise on + * a shared runner, which is what made the ratio drift out of its documented + * band under load; see `_scaling_note` in baselines.json. */ +const CALLS_PER_FILE = 480; +const REPS = 7; +const WARMUP = 3; + +/** 1 in N receivers names a declared namespace; the rest name nothing. Header + * property 1 is why this ratio, and not an always-hits corpus. */ +const NS_RECEIVER_IN = 5; + +const NO_SCOPES = {}; + +/** + * Deterministic 32-bit avalanche (murmur3 finalizer). Stands in for + * `Math.random()` — the corpus, the receiver mix and therefore the fingerprint + * must be byte-reproducible across machines and Node versions. + */ +function mix(n) { + let x = n >>> 0; + x = Math.imul(x ^ (x >>> 16), 0x85ebca6b) >>> 0; + x = Math.imul(x ^ (x >>> 13), 0xc2b2ae35) >>> 0; + return (x ^ (x >>> 16)) >>> 0; +} + +/** + * Deterministic synthetic corpus — no randomness, so the fingerprint is stable. + * + * Per file `f`, three top-level namespaces. Every shape here exists because + * some behaviour of `resolveCppQualifiedNamespaceMember` is unobservable + * without it; dropping one silently un-gates that behaviour. + * + * namespace ns_f { // ABI-versioning idiom (std::__1) + * void own0(); void own1(); // direct members → hit + * void both(int); // ALSO declared in v1 below + * void over(int); // overload set spanning levels + * inline namespace v1 { // transitively visible + * void inl0(); // → hit + * void dup(); + * void both(double); // the inline-child twin of `both` + * void over(int, int); void over(double); + * void same(int); + * } + * inline namespace v2 { + * void dup(); // two inline children → ambiguous + * void same(int); // identical signature → ambiguous + * } + * namespace detail { void hidden0(); } // NOT inline → invisible → miss + * } + * + * namespace twin_f { inline namespace twin_f { void twinned(); } } + * namespace shared_{f>>1} { void part{f&1}(); } + * + * What each shape gates: + * - `own0` / `inl0` / `hidden0` / `nosuch`: the three outcome classes (hit + * from the namespace's own defs, hit through an inline child, miss), each + * a different exit from the resolver. + * - `dup` across v1 and v2: `'ambiguous'` (#1564). + * - `twin_f`: the same-name inline nest — the only shape that observes + * `gatherQualifiedNsMember`'s `visited` dedup, without which the one + * `twinned` is collected twice and a resolved def flips to `'ambiguous'` + * (that function's comment explains why both scopes land on one receiver). + * - `shared_g` declared by files 2g and 2g+1: C++ namespaces are open, so one + * receiver's members are spread over however many files reopen it. The + * legacy per-call-site scan got this for free; the index has to merge + * across the whole `parsedFiles` array. `part0` and `part1` are declared in + * DIFFERENT files and both must resolve. + * - `both` at the namespace level and in the inline child: pins BOTH + * collection sources by nodeId, via the two `both` call sites that select + * between them on argument type. Drop own-def collection and the `int` + * probe moves; drop inline-child descent and the `double` probe moves. A + * pure REORDER of the two stays invisible, and correctly so: the return + * contract is order-blind — see `QualifiedNsMemberIndex.rootsByReceiver`. + * - `over` / `same` with a real `Callsite`: see `NS_PROBES`. + */ +function buildCorpus(fileCount) { + const parsedFiles = []; + for (let f = 0; f < fileCount; f++) { + const filePath = `src/file${f}.cpp`; + const scopes = []; + const inlineRanges = []; + let line = 1; + /** Push one Namespace scope with a range unique within this file, so + * `populateCppInlineNamespaceScopes` marks exactly the intended scopes. */ + const scope = (id, parent, ownedDefs, isInline = false) => { + const range = { startLine: line, startCol: 0, endLine: line + 1, endCol: 0 }; + line += 2; + scopes.push({ id, kind: 'Namespace', parent, ownedDefs, range }); + if (isInline) inlineRanges.push(range); + return id; + }; + const ns = (qualifiedName) => ({ + nodeId: `def:${filePath}#${qualifiedName}`, + type: 'Namespace', + qualifiedName, + }); + /** A callable def. `parameterTypes` are what makes overloads distinguishable + * both to `narrowOverloadCandidates` and — via the nodeId, exactly as the + * real C++ node keys do it — to the fingerprint. */ + const fn = (qualifiedName, parameterTypes) => + parameterTypes === undefined + ? { nodeId: `def:${filePath}#${qualifiedName}`, type: 'Function', qualifiedName } + : { + nodeId: `def:${filePath}#${qualifiedName}(${parameterTypes.join(',')})`, + type: 'Function', + qualifiedName, + parameterTypes, + parameterCount: parameterTypes.length, + requiredParameterCount: parameterTypes.length, + }; + + const nsId = scope(`sc:${f}:ns`, null, [ + ns(`ns_${f}`), + fn(`ns_${f}.own0`), + fn(`ns_${f}.own1`), + fn(`ns_${f}.both`, ['int']), + fn(`ns_${f}.over`, ['int']), + ]); + scope( + `sc:${f}:v1`, + nsId, + [ + ns(`ns_${f}.v1`), + fn(`ns_${f}.v1.inl0`), + fn(`ns_${f}.v1.dup`), + fn(`ns_${f}.v1.both`, ['double']), + fn(`ns_${f}.v1.over`, ['int', 'int']), + fn(`ns_${f}.v1.over`, ['double']), + fn(`ns_${f}.v1.same`, ['int']), + ], + true, + ); + scope( + `sc:${f}:v2`, + nsId, + [ns(`ns_${f}.v2`), fn(`ns_${f}.v2.dup`), fn(`ns_${f}.v2.same`, ['int'])], + true, + ); + scope(`sc:${f}:detail`, nsId, [ns(`ns_${f}.detail`), fn(`ns_${f}.detail.hidden0`)]); + + const twinId = scope(`sc:${f}:twin`, null, [ns(`twin_${f}`)]); + scope( + `sc:${f}:twin@inner`, + twinId, + [ns(`twin_${f}.twin_${f}`), fn(`twin_${f}.twin_${f}.twinned`)], + true, + ); + + const group = f >> 1; + scope(`sc:${f}:shared`, null, [ns(`shared_${group}`), fn(`shared_${group}.part${f & 1}`)]); + + parsedFiles.push({ filePath, scopes, inlineRanges }); + } + return parsedFiles; +} + +/** Capture-time inline marking + `populateOwners`-time scope-id resolution, in + * the same order the pipeline runs them. Must re-run after every + * `clearCppInlineNamespaces`, which drops both the marks and the index. */ +function populateInlineState(parsedFiles) { + clearCppInlineNamespaces(); + for (const parsed of parsedFiles) { + for (const range of parsed.inlineRanges) markCppInlineNamespaceRange(parsed.filePath, range); + populateCppInlineNamespaceScopes(parsed); + } +} + +/** + * Namespace-receiver probes: `[family, member, callsite]`. Drawn for the ~1 in + * `NS_RECEIVER_IN` call sites whose receiver actually names a namespace. + * + * The tail entries pass a real `Callsite`, which is the only way any of + * `narrowOverloadCandidates`, `cppConversionRank` or + * `isOverloadAmbiguousAfterNormalization` is reached — the resolver threads + * `callsite?.arity` / `callsite?.argumentTypes` into narrowing, and with no + * callsite those filters are pass-throughs. Each one is chosen to land on a + * DIFFERENT exit, so the fingerprint pins the whole narrowing ladder: + * - `over(int)` → exact-type filter, unique survivor (ns level) + * - `over(int,int)` → arity filter, unique survivor (inline child) + * - `over(double)` → exact-type filter, unique survivor (inline child) + * - `over(char)` → no exact match, `cppConversionRank` dominance + * picks `over(int)` (promotion 1) over + * `over(double)` (standard conversion 2) + * - `over(braced-init)` → conversion ranking rejects every candidate and + * `CPP_CONVERSION_ONLY_ARG_TYPE_PREFIXES` turns + * that into an empty set → `undefined` + * - `over` with arity 9 → arity filter empties an all-known-bounds set, + * the authoritative-empty branch → `undefined` + * - `same(int)` → two identical signatures survive narrowing → + * `isOverloadAmbiguousAfterNormalization` → `'ambiguous'` + * - `both(int)`/`both(double)` → select the namespace-level def and the + * inline-child def respectively, pinning both + * collection sources by nodeId + */ +const NS_PROBES = [ + ['ns', 'own0', undefined], + ['ns', 'own1', undefined], + ['ns', 'inl0', undefined], + ['ns', 'dup', undefined], + ['ns', 'hidden0', undefined], + ['ns', 'nosuch', undefined], + ['ns', 'both', undefined], + ['twin', 'twinned', undefined], + ['twin', 'nosuch', undefined], + ['shared', 'part0', undefined], + ['shared', 'part1', undefined], + ['ns', 'over', { arity: 1, argumentTypes: ['int'] }], + ['ns', 'over', { arity: 2, argumentTypes: ['int', 'int'] }], + ['ns', 'over', { arity: 1, argumentTypes: ['double'] }], + ['ns', 'over', { arity: 1, argumentTypes: ['char'] }], + ['ns', 'over', { arity: 1, argumentTypes: ['braced-init:int:3'] }], + ['ns', 'over', { arity: 9, argumentTypes: [] }], + ['ns', 'same', { arity: 1, argumentTypes: ['int'] }], + ['ns', 'both', { arity: 1, argumentTypes: ['int'] }], + ['ns', 'both', { arity: 1, argumentTypes: ['double'] }], +]; + +/** Members asked of the non-namespace receivers. Real-source member names, so + * the miss is a receiver miss and not a member miss. */ +const MISS_MEMBERS = ['size', 'begin', 'data', 'reset', 'own0', 'dup']; + +/** A plain identifier naming NO namespace in the corpus — a local, a type, a + * buffer. The ~4-in-5 majority of header property 1. */ +function missReceiver(key) { + const shape = key % 3; + if (shape === 0) return `obj${key % 97}`; + if (shape === 1) return `Widget${key % 31}`; + return `buf${key % 197}`; +} + +/** + * The call sites: `[receiver, member, callsite]`, deterministic, with the + * production receiver mix (~1 in `NS_RECEIVER_IN` names a namespace). + * + * Two independently mixed keys per site so the receiver class (`a`) and the + * probe choice (`b`) do not correlate — deriving both from one linear key made + * `key % NS_RECEIVER_IN === 0` imply `key % NS_PROBES.length ∈ {0, 5}`, which + * silently reduced the probe set to two entries. + */ +function callSites(fileCount) { + const sharedGroups = Math.ceil(fileCount / 2); + const sites = []; + let nsReceiverSites = 0; + /** The declared-namespace receiver a probe family asks for. */ + const nsReceiver = (family, key) => { + if (family === 'twin') return `twin_${key % fileCount}`; + if (family === 'shared') return `shared_${key % sharedGroups}`; + return `ns_${key % fileCount}`; + }; + const pushNs = (probe, key) => { + sites.push([nsReceiver(probe[0], key), probe[1], probe[2]]); + nsReceiverSites++; + }; + // Coverage prelude: every probe at least once at BOTH scales, so the + // fingerprinted outcome set never depends on how the mixer happens to spread. + for (let p = 0; p < NS_PROBES.length; p++) pushNs(NS_PROBES[p], p); + for (let f = 0; f < fileCount; f++) { + for (let c = 0; c < CALLS_PER_FILE; c++) { + const a = mix(f * 65599 + c); + const b = mix(a ^ 0x9e3779b9); + if (a % NS_RECEIVER_IN === 0) pushNs(NS_PROBES[b % NS_PROBES.length], b); + else sites.push([missReceiver(b), MISS_MEMBERS[a % MISS_MEMBERS.length], undefined]); + } + } + return { sites, nsReceiverSites }; +} + +/** The timed loop: resolution only. The outcome strings the fingerprint needs + * are built in a separate untimed pass (`outcomesOf`), so their allocation + * cost — which grows with the corpus and would inflate the scaling ratio on + * its own — never lands in the measurement. `sink` keeps the calls live. */ +function resolveAll(parsedFiles, sites) { + let sink = 0; + for (const [receiver, member, callsite] of sites) { + const hit = resolveCppQualifiedNamespaceMember( + receiver, + member, + parsedFiles, + NO_SCOPES, + callsite, + ); + if (hit !== undefined) sink++; + } + return sink; +} + +/** Fingerprint key for one site. The callsite is part of the key: `ns::over` + * resolves to a different def per arity/argument-type, and collapsing those + * onto one key would drop the whole narrowing ladder from the gate. */ +function siteKey(receiver, member, callsite) { + const args = + callsite === undefined ? '' : `${callsite.arity}|${callsite.argumentTypes.join(',')}`; + return `${receiver}::${member}(${args})`; +} + +/** Untimed identity pass, one resolve per DISTINCT `siteKey`. On a fixed corpus + * the resolver is a pure function of `(receiver, member, callsite)`, so a + * repeated site can only re-derive what the first occurrence already put in + * the Set — the same argument that makes collecting into a Set correct makes + * skipping the repeat correct. That is nearly the whole pass: the 192k/768k + * sites carry only 2,330/3,470 distinct outcomes. */ +function outcomesOf(parsedFiles, sites) { + const outcomes = new Set(); + const seen = new Set(); + for (const [receiver, member, callsite] of sites) { + const key = siteKey(receiver, member, callsite); + if (seen.has(key)) continue; + seen.add(key); + const hit = resolveCppQualifiedNamespaceMember( + receiver, + member, + parsedFiles, + NO_SCOPES, + callsite, + ); + outcomes.add( + `${key}\u0000${hit === undefined ? '' : hit === 'ambiguous' ? '' : hit.nodeId}`, + ); + } + return outcomes; +} + +/** + * MIN, not median — same rationale as bench/callable-value-flow: both scales + * are timed in one process and every error source (scheduler preemption, GC, a + * noisy neighbour on a shared CI runner) is additive, so the fastest observed + * run is the closest estimate of the uncontended cost and keeps the derived + * ratio comparable across machines. + */ +function fastest(values) { + return Math.min(...values); +} + +/** Time one full pass: index build (lazy, on the first call) + every call + * site. The corpus state is reset OUTSIDE the timer so the reset's own + * O(files) cost never lands in the measurement. */ +function timeResolution(parsedFiles, sites) { + for (let w = 0; w < WARMUP; w++) { + populateInlineState(parsedFiles); + resolveAll(parsedFiles, sites); + } + const samples = []; + for (let r = 0; r < REPS; r++) { + populateInlineState(parsedFiles); + const t0 = performance.now(); + resolveAll(parsedFiles, sites); + samples.push(performance.now() - t0); + } + return { ms: fastest(samples), outcomes: outcomesOf(parsedFiles, sites) }; +} + +function fingerprint(outcomes) { + return crypto + .createHash('sha256') + .update([...outcomes].sort().join('\n')) + .digest('hex'); +} + +const scales = {}; +for (const [name, fileCount] of [ + ['small', SMALL], + ['large', LARGE], +]) { + const parsedFiles = buildCorpus(fileCount); + const { sites, nsReceiverSites } = callSites(fileCount); + const { ms, outcomes } = timeResolution(parsedFiles, sites); + scales[name] = { + files: fileCount, + call_sites: sites.length, + // Reported, not asserted: `ns_receiver_sites` evidences header property 1's + // mix and `distinct_outcomes` the fingerprinted surface's size — a corpus + // edit collapsing either still yields a "valid" fingerprint over far less. + ns_receiver_sites: nsReceiverSites, + distinct_outcomes: outcomes.size, + ms: Number(ms.toFixed(3)), + fingerprint: fingerprint(outcomes), + }; +} + +const scalingRatio = scales.large.ms / scales.small.ms / (LARGE / SMALL); + +const report = { + small: scales.small, + large: scales.large, + scaling_ratio: Number(scalingRatio.toFixed(3)), + fingerprint: scales.large.fingerprint, +}; + +if (!process.argv.includes('--check')) { + console.log(JSON.stringify(report, null, 2)); + process.exit(0); +} + +const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf-8')); +const failures = []; +if (report.fingerprint !== baseline.fingerprint) { + failures.push( + `fingerprint drift: ${report.fingerprint} != ${baseline.fingerprint} — qualified ` + + `namespace lookup resolved a DIFFERENT symbol set. This is a behaviour change, not a perf one.`, + ); +} +if (report.scaling_ratio > baseline.scaling_budget) { + failures.push( + `scaling ${report.scaling_ratio} > budget ${baseline.scaling_budget} — per-call-site cost ` + + `now grows with corpus size again (#2788). Timing arm: re-run on an idle machine before ` + + `investigating (see _scaling_note in baselines.json); the fingerprint arm never warrants a re-run.`, + ); +} + +console.log(JSON.stringify(report, null, 2)); +if (failures.length > 0) { + console.error(`[cpp-qualified-ns --check] FAIL\n - ${failures.join('\n - ')}`); + process.exit(1); +} +console.log('[cpp-qualified-ns --check] PASS'); diff --git a/gitnexus/src/core/ingestion/languages/cpp/adl.ts b/gitnexus/src/core/ingestion/languages/cpp/adl.ts index 7a1bd9f66..4c9efb744 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/adl.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/adl.ts @@ -58,8 +58,8 @@ * - `noAdlSites` — call sites with parenthesized function (capture-time) * - `classToNamespaceQualifiedName` — class def → its enclosing namespace * qualified name (`populateCppAssociatedNamespaces` time) - * - `adlIndex` / `adlIndexSource` — the lazily-built candidate index and the - * `parsedFiles` reference it was built from (first-`pickCppAdlCandidates` + * - `adlIndexByPass` — the lazily-built candidate index, memoized weakly on + * the `parsedFiles` array it was built from (first-`pickCppAdlCandidates` * time; see `ensureAdlIndex`) * * The class→namespace map uses qualified names (not scope IDs) because @@ -176,8 +176,21 @@ export interface AdlCandidateIndex { readonly seqByNodeId: Map; } -let adlIndex: AdlCandidateIndex | undefined; -let adlIndexSource: readonly ParsedFile[] | undefined; +/** + * Per-pass memo: `parsedFiles` array identity → the built index. `WeakMap`-keyed + * so the entry lives only as long as the caller's `parsedFiles` array does, and + * is reclaimed with the pass — mirrors `qualifiedNsIndexByPass` in + * `inline-namespaces.ts` and `moduleScopeIndexByPass` in `file-local-linkage.ts`. + * + * Weak keying is load-bearing, not stylistic: the index holds + * `SymbolDefinition` references reaching into every ParsedFile's scopes, so the + * previous module-level strong `let` pair (index + source array) kept the whole + * C/C++ ParsedFile set alive past the point `scope-resolution/pipeline/phase.ts` + * evicts `files`/`contents`/`preExtractedByPath` and calls `forceGc()` to + * reclaim it — C++ is 7th of 16 `SCOPE_RESOLVERS` entries, so the retention + * survived nine later language passes plus emit. + */ +let adlIndexByPass = new WeakMap(); function siteKey(filePath: string, line: number, col: number): string { return `${filePath}:${line}:${col}`; @@ -373,25 +386,39 @@ export function validateAdlSeqCoverage(idx: AdlCandidateIndex): string[] { } /** Build the ADL index on first use of a given `parsedFiles` set; reuse it for - * all subsequent call sites in the same pipeline run. Reset by + * every subsequent call site in the same pipeline run. Reset by * `clearCppAdlState`. * + * Lifetime: the memoized entry is reachable only while the caller still holds + * the `parsedFiles` array. Once the pipeline drops it, the entry — and the + * `SymbolDefinition` references it holds into those files' scopes — becomes + * collectable; nothing here pins it (see {@link adlIndexByPass}). + * * Staleness is keyed on `parsedFiles` reference identity ONLY, but the index * is a function of THREE inputs: `parsedFiles` (namespace/friend candidates), * `scopes` (`classDefsBySimple`, read from `scopes.defs.byId`), and the - * module-level `classToNamespaceQualifiedName` (friend-candidate keys). This - * is sound for the current pipeline because all three are built together once - * per `runScopeResolution` pass and `clearCppAdlState` runs in - * `loadResolutionConfig` at the start of every pass. Callers MUST call - * `clearCppAdlState` between any two passes that change `scopes` or - * `classToNamespaceQualifiedName` while reusing the same `parsedFiles` array - * reference — otherwise a stale index would be served. (No such caller exists - * today; widening the guard to also key on `scopes` is deferred until one - * does.) */ -function ensureAdlIndex(scopes: ScopeResolutionIndexes, parsedFiles: readonly ParsedFile[]): void { - if (adlIndex !== undefined && adlIndexSource === parsedFiles) return; - adlIndex = buildAdlIndex(scopes, parsedFiles); - adlIndexSource = parsedFiles; + * module-level `classToNamespaceQualifiedName` (friend-candidate keys). The + * WeakMap key covers only the first, so the explicit clear is still REQUIRED + * for the other two: a later pass may hand back the same `parsedFiles` + * reference with different `scopes` / class→namespace state, and identity + * alone would serve the stale index. This is sound for the current pipeline + * because all three are built together once per `runScopeResolution` pass and + * `clearCppAdlState` runs in `loadResolutionConfig` at the start of every + * pass. Callers MUST call `clearCppAdlState` between any two passes that + * change `scopes` or `classToNamespaceQualifiedName` while reusing the same + * `parsedFiles` array reference — otherwise a stale index would be served. + * (No such caller exists today; widening the key to also cover `scopes` is + * deferred until one does.) */ +function ensureAdlIndex( + scopes: ScopeResolutionIndexes, + parsedFiles: readonly ParsedFile[], +): AdlCandidateIndex { + let index = adlIndexByPass.get(parsedFiles); + if (index === undefined) { + index = buildAdlIndex(scopes, parsedFiles); + adlIndexByPass.set(parsedFiles, index); + } + return index; } /** Record per-call-site argument info. Called once per call site from @@ -504,8 +531,14 @@ export function clearCppAdlState(): void { argInfoSiteKeysByFile.clear(); noAdlSiteKeysByFile.clear(); classToNamespaceQualifiedName.clear(); - adlIndex = undefined; - adlIndexSource = undefined; + // The candidate index is dropped by REASSIGNING a fresh `WeakMap`: `WeakMap` + // has no `.clear()`, and swapping the instance discards every memoized entry + // at once — the exact invalidation the previous `adlIndex = undefined` pair + // provided. Required even though the map is keyed on `parsedFiles` identity, + // because the index also depends on `scopes` and + // `classToNamespaceQualifiedName`, which the key cannot observe (see + // {@link ensureAdlIndex}). + adlIndexByPass = new WeakMap(); } /** @@ -572,15 +605,20 @@ export function pickCppAdlCandidates( if (args === undefined || args.length === 0) return undefined; // Build the workspace-wide ADL candidate index once; reuse for every site. - ensureAdlIndex(scopes, parsedFiles); + const idx = ensureAdlIndex(scopes, parsedFiles); // Collect associated namespace QNames from every participating class-typed arg // and from function-reference args. const associatedNamespaces = new Set(); for (const arg of args) { - collectAssociatedNamespacesForAdlArg(arg, scopes, associatedNamespaces); + collectAssociatedNamespacesForAdlArg(arg, scopes, idx, associatedNamespaces); if (arg.functionRefText !== undefined) { - collectFunctionTypeAssociatedNamespaces(arg.functionRefText, scopes, associatedNamespaces); + collectFunctionTypeAssociatedNamespaces( + arg.functionRefText, + scopes, + idx, + associatedNamespaces, + ); } } if (associatedNamespaces.size === 0) return undefined; @@ -593,8 +631,6 @@ export function pickCppAdlCandidates( // (`friendCandidates`, ISO C++ `[basic.lookup.argdep]` §2). // Dedup by nodeId and sort by visitation sequence so the candidate list is // byte-for-byte identical to the legacy file-major scan order. - const idx = adlIndex; - if (idx === undefined) return undefined; const bySeq = new Map(); const seenKey = new Set(); const collectFrom = (buckets: Map>): void => { @@ -621,12 +657,13 @@ export function pickCppAdlCandidates( function collectAssociatedNamespacesForAdlArg( arg: CppAdlArgInfo, scopes: ScopeResolutionIndexes, + idx: AdlCandidateIndex, associatedNamespaces: Set, ): void { // For template args this may be the template name itself (e.g. `vector`); // simple-name lookup can match project classes with the same name (known // V1/V2 simplification). - addAssociatedNamespaceForClassName(arg.simpleClassName, scopes, associatedNamespaces); + addAssociatedNamespaceForClassName(arg.simpleClassName, scopes, idx, associatedNamespaces); // Includes template-owner namespaces (e.g. `std` in std::vector). If // that surfaces extra candidates, merged-candidate overload narrowing in @@ -637,17 +674,18 @@ function collectAssociatedNamespacesForAdlArg( if (ns.length > 0) associatedNamespaces.add(ns); } for (const className of arg.templateArgClassNames) { - addAssociatedNamespaceForClassName(className, scopes, associatedNamespaces); + addAssociatedNamespaceForClassName(className, scopes, idx, associatedNamespaces); } } function addAssociatedNamespaceForClassName( simpleClassName: string, scopes: ScopeResolutionIndexes, + idx: AdlCandidateIndex, associatedNamespaces: Set, ): void { if (simpleClassName.length === 0) return; - const classLookup = findCppClassDefBySimpleName(simpleClassName); + const classLookup = findCppClassDefBySimpleName(idx, simpleClassName); if (classLookup === undefined) return; const { classDef, ambiguous } = classLookup; const nsQName = classToNamespaceQualifiedName.get(classDef.nodeId); @@ -762,11 +800,12 @@ function findNamespaceDefInScope(scope: { * ISO C++ `[basic.lookup.argdep]` §2: enumerations contribute their * enclosing namespace to the associated set, just like class types. */ function findCppClassDefBySimpleName( + idx: AdlCandidateIndex, simpleName: string, ): { classDef: SymbolDefinition; ambiguous: boolean } | undefined { // `classDefsBySimple` preserves `scopes.defs.byId` order, so `[0]` is the // legacy first-match and `length > 1` is the legacy `ambiguous` flag. - const matches = adlIndex?.classDefsBySimple.get(simpleName); + const matches = idx.classDefsBySimple.get(simpleName); if (matches === undefined) return undefined; const first = matches[0]; if (first === undefined) return undefined; @@ -780,10 +819,9 @@ function findCppClassDefBySimpleName( function collectFunctionTypeAssociatedNamespaces( refText: string, scopes: ScopeResolutionIndexes, + idx: AdlCandidateIndex, out: Set, ): void { - const idx = adlIndex; - if (idx === undefined) return; const colonIdx = refText.lastIndexOf('::'); if (colonIdx !== -1) { // Qualified ref: extract namespace prefix and normalise :: → dot notation. @@ -796,7 +834,7 @@ function collectFunctionTypeAssociatedNamespaces( // contributing `a` to the associated set (false-positive CALLS edge). const matches = idx.nsFunctionsByQName.get(nsText)?.get(simpleName); if (matches !== undefined) { - for (const def of matches) collectAssociatedNamespacesForFunctionDef(def, scopes, out); + for (const def of matches) collectAssociatedNamespacesForFunctionDef(def, scopes, idx, out); } return; } @@ -807,32 +845,34 @@ function collectFunctionTypeAssociatedNamespaces( // the function's own enclosing namespace. const matches = idx.nsFunctionsBySimple.get(refText); if (matches !== undefined) { - for (const def of matches) collectAssociatedNamespacesForFunctionDef(def, scopes, out); + for (const def of matches) collectAssociatedNamespacesForFunctionDef(def, scopes, idx, out); } } function collectAssociatedNamespacesForFunctionDef( def: SymbolDefinition, scopes: ScopeResolutionIndexes, + idx: AdlCandidateIndex, out: Set, ): void { const parameterTypes = def.parameterTypeClasses?.map((typeClass) => typeClass.base); for (const paramType of parameterTypes ?? def.parameterTypes ?? []) { - collectAssociatedNamespacesForFunctionTypeText(paramType, scopes, out); + collectAssociatedNamespacesForFunctionTypeText(paramType, scopes, idx, out); } if (def.returnType !== undefined) { - collectAssociatedNamespacesForFunctionTypeText(def.returnType, scopes, out); + collectAssociatedNamespacesForFunctionTypeText(def.returnType, scopes, idx, out); } } function collectAssociatedNamespacesForFunctionTypeText( typeText: string, scopes: ScopeResolutionIndexes, + idx: AdlCandidateIndex, out: Set, ): void { for (const token of extractCppTypeNameTokens(typeText)) { if (isIgnoredCppAdlNamespace(token.namespaceName)) continue; - addAssociatedNamespaceForClassName(token.simpleName, scopes, out); + addAssociatedNamespaceForClassName(token.simpleName, scopes, idx, out); if (token.namespaceName !== '') out.add(token.namespaceName); } } diff --git a/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts b/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts index 134738dfd..47f76d845 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts @@ -14,12 +14,14 @@ * 2. **Transitive qualified visibility.** `outer::foo()` resolves to * `outer::v1::foo()` when `v1` is inline. The qualified-namespace * receiver resolver (`resolveCppQualifiedNamespaceMember`) walks - * inline-namespace children transitively when collecting candidates. + * inline-namespace children transitively when collecting candidates — + * once per pipeline run, into {@link QualifiedNsMemberIndex} (#2788). * * State lifecycle: capture-time `markCppInlineNamespaceRange` records each * inline namespace's source range; `populateCppInlineNamespaceScopes` * resolves ranges to `ScopeId`s during `populateOwners`. Cleared via - * `clearCppInlineNamespaces`, called from `clearFileLocalNames`. + * `clearCppInlineNamespaces`, called from + * `cppScopeResolver.loadResolutionConfig` at the start of every pass. * * STL idiom this enables: `std::__1::vector` (libc++) and `std::__cxx11` * (libstdc++) are inline namespaces of `std`. With this support, @@ -32,7 +34,10 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe import { isOverloadAmbiguousAfterNormalization, narrowOverloadCandidates, + type OverloadNarrowingHookCtx, } from '../../scope-resolution/passes/overload-narrowing.js'; +import { isOverloadableCallable } from '../../utils/callable-labels.js'; +import { isSemanticModelValidatorEnabled } from '../../utils/env.js'; import { CPP_CONVERSION_ONLY_ARG_TYPE_PREFIXES, cppConversionRank } from './conversion-rank.js'; interface RangeKey { @@ -44,6 +49,11 @@ interface RangeKey { const inlineNamespaceRangesByFile = new Map>(); const inlineNamespaceScopeIds = new Set(); +/** Bumped by every writer of {@link inlineNamespaceScopeIds}. The qualified-ns + * index is a function of both `parsedFiles` and that Set; its WeakMap key sees + * only the first, so the memo stamps this epoch and a missed + * {@link clearCppInlineNamespaces} degrades to a rebuild, not a stale answer. */ +let inlineNamespaceEpoch = 0; function rangeKey(r: RangeKey): string { return `${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`; @@ -85,10 +95,19 @@ export function applyCppInlineNamespaceSideChannel( for (const r of ranges) set.add(r); } -/** Clear all inline-namespace state. Called from `clearFileLocalNames`. */ +/** Clear all inline-namespace state. Called from + * `cppScopeResolver.loadResolutionConfig` at the start of every pass. + * + * The qualified-namespace index is dropped by REASSIGNING a fresh `WeakMap` + * (`WeakMap` has no `.clear()`) so its entries — and the `SymbolDefinition` + * references they hold — are released here rather than waiting on the key. + * Correctness does not rest on that: {@link inlineNamespaceEpoch} invalidates + * any surviving entry. */ export function clearCppInlineNamespaces(): void { inlineNamespaceRangesByFile.clear(); inlineNamespaceScopeIds.clear(); + inlineNamespaceEpoch++; + qualifiedNsIndexByPass = new WeakMap(); } /** Resolve captured ranges to actual ScopeIds by matching scope ranges @@ -98,6 +117,7 @@ export function clearCppInlineNamespaces(): void { export function populateCppInlineNamespaceScopes(parsed: ParsedFile): void { const ranges = inlineNamespaceRangesByFile.get(parsed.filePath); if (ranges === undefined || ranges.size === 0) return; + inlineNamespaceEpoch++; for (const scope of parsed.scopes) { if (scope.kind !== 'Namespace') continue; if (ranges.has(rangeKey(scope.range))) { @@ -115,17 +135,316 @@ export function isCppInlineNamespaceScope(scopeId: ScopeId): boolean { } /** - * Walk every parsed file looking for a Namespace scope whose qualified - * name matches `receiverName`, collect its callable ownedDefs matching - * `memberName`, transitively descending into any inline-namespace - * children (since they're members of the enclosing namespace under ISO - * C++). + * Qualified-namespace member index — built **once** per pipeline run from + * `parsedFiles` and reused by every qualified call site. + * + * The legacy lookup re-scanned every parsed file (rebuilding a per-file + * `scopesById` map each time) once **per qualified call site**, making the + * scope-resolution emit phase O(callsites × scopes): 25.3 min of a 33-min + * analyze on a 1,473-file C++ repo, 75% of total self-time in this one + * function (#2788). Mirrors the same fix #1990 applied to ADL + * (`pickCppAdlCandidates` → {@link AdlCandidateIndex}); per-site cost drops + * to a Map lookup once a `(receiver, member)` pair has been resolved. + * + * **Why the index is a graph and not a flattened member table.** Every + * `Namespace` scope is a legal qualified receiver on its own — in + * `outer { inline v1 { inline v2 { … } } }`, `v2::foo()` is valid C++ — so an + * eager table has to record, for every namespace, every member of every inline + * descendant. On a chain of depth D that is D + (D−1) + … + 1 entries: quadratic + * in **both** time and memory, and materializing it recursively also recursed D + * deep. Neither bound was theoretical — a generated chain 6,000 deep with one + * function per level exhausted a 4 GB heap, and a member-less one 8,000 deep + * threw `RangeError: Maximum call stack size exceeded`. Nothing on the analyze + * path catches either (`run.ts` only wraps the CFG/PDG emit block; `phase.ts`'s + * `try` has a `finally` and no `catch`), so one pathological generated file + * aborted the whole `analyze` — the failure class of #2769. + * + * Storing the *shape* instead — each namespace's own members plus links to its + * inline children — makes the build linear in scopes+defs, and a call site pays + * one iterative pre-order walk of the receiver's inline subtree, memoized per + * `(receiver, member)`. Real inline nesting is 1–2 deep (`std::__1`, + * `std::__cxx11`), so that walk is 2–3 nodes. + */ +interface QualifiedNsMemberIndex { + /** + * Namespace simple name → the scopes declaring it, in the order the legacy + * linear scan visited them (file-major, then `parsed.scopes` declaration + * order). Nothing downstream observes that order — every multi-candidate + * path in {@link resolveCppQualifiedNamespaceMember} either narrows to a + * unique survivor or returns `'ambiguous'` — but it is free to preserve, and + * a future tie-break should inherit the legacy order rather than Map + * insertion happenstance. + */ + readonly rootsByReceiver: ReadonlyMap; + /** + * Memo of resolved candidate lists, receiver → member → defs. Filled on + * demand by {@link qualifiedNsMembers}; only pairs a call site actually asks + * for are ever materialized, which is what keeps the deep-chain case linear. + */ + readonly membersByReceiver: Map>; +} + +/** One `Namespace` scope as the qualified-lookup walk sees it. */ +interface QualifiedNsNode { + /** This scope's OWN callable defs bucketed by member simple name, in + * `ownedDefs` order. Computed once at build time, so a namespace with many + * members costs one pass no matter how many distinct members are queried. */ + readonly ownMembers: ReadonlyMap; + /** Inline-namespace children in `parsed.scopes` declaration order. Direct + * object links rather than `ScopeId` lookups, so the walk needs no scope + * table and ids that repeat across files can never splice one file's + * children under another file's namespace. */ + readonly inlineChildren: readonly QualifiedNsNode[]; +} + +/** Build-time view of {@link QualifiedNsNode}: `inlineChildren` is appended to + * as the build's second pass links each child, then only ever read. */ +interface MutableNsNode extends QualifiedNsNode { + readonly inlineChildren: QualifiedNsNode[]; +} + +type NsScope = ParsedFile['scopes'][number]; + +/** + * Dev/test-only mutation tripwire on the arrays this module shares across call + * sites ({@link NO_DEFS} and every `membersByReceiver` memo entry): an in-place + * `.sort()`/`.splice()` ever added to `overload-narrowing.ts` would corrupt + * every LATER resolution of the same pair, not just its own call, and freezing + * makes that a loud `TypeError` instead. DEV-ONLY because the types already + * reject it — the memo and `narrowOverloadCandidates`' parameter are both + * `readonly SymbolDefinition[]`, so only a cast gets past — while the freeze is + * not free: V8 moves a frozen array to `PACKED_FROZEN_ELEMENTS`, off the + * builtin fast path for the `.filter`/`.map`/`.some` narrowing runs over it at + * every multi-candidate call site (measured 4.6×; large bench arm 100.3 → 70.6ms + * with it off). Gated on `isSemanticModelValidatorEnabled()` — the OPT-IN form, + * not `adl.ts`'s opt-out `NODE_ENV !== 'production'`: `NODE_ENV` is unset in a + * CLI `analyze`, so the opt-out form would keep paying the freeze exactly where + * the cost lands. Read once at load because this one is per-call-site. + */ +const FREEZE_SHARED_CANDIDATES = isSemanticModelValidatorEnabled(); + +/** Shared empties: most namespace scopes declare no callables, and most + * qualified receivers name no namespace at all. `NO_DEFS` is process-wide, so + * it gets the same dev-only freeze the memoized arrays do. */ +const NO_MEMBERS: ReadonlyMap = new Map(); +const NO_DEFS: readonly SymbolDefinition[] = FREEZE_SHARED_CANDIDATES ? Object.freeze([]) : []; + +/** Simple (last-segment) name of a def, matching the legacy scan exactly — + * including the empty-string fallback for a def with no `qualifiedName`, and + * the empty last segment of a trailing-dot name. `lastIndexOf` + `slice` + * rather than `split('.').pop()`: same result, no intermediate array, and this + * runs per callable def and per namespace scope at build. */ +function simpleNameOf(def: SymbolDefinition): string { + const qualified = def.qualifiedName; + if (qualified == null) return ''; + const lastDot = qualified.lastIndexOf('.'); + return lastDot === -1 ? qualified : qualified.slice(lastDot + 1); +} + +/** + * Per-pass memo: `parsedFiles` array identity → the built index. `WeakMap`-keyed + * so the entry lives only as long as the caller's `parsedFiles` array does, and + * is reclaimed with the pass — mirrors `moduleScopeIndexByPass` in + * `file-local-linkage.ts`. + * + * Weak keying is load-bearing, not stylistic: the index holds + * `SymbolDefinition` references reaching into every ParsedFile's scopes, so a + * module-level strong `let` pair (index + source array) kept the whole C/C++ + * ParsedFile set alive past the point `scope-resolution/pipeline/phase.ts` + * evicts `files`/`contents`/`preExtractedByPath` and calls `forceGc()` to + * reclaim it — C++ is 7th of 16 `SCOPE_RESOLVERS` entries, so the retention + * survived nine later language passes plus emit (104.2 MB measured). + */ +let qualifiedNsIndexByPass = new WeakMap(); + +/** Memo cell: the index plus the {@link inlineNamespaceEpoch} it was built under. */ +interface MemoizedIndex { + readonly epoch: number; + readonly index: QualifiedNsMemberIndex; +} + +/** Build the index in two linear passes per file: one to make a node per + * `Namespace` scope, one to link inline children and register receivers. + * Linking needs both endpoints to exist and `parsed.scopes` does not promise + * parents precede children, hence two passes rather than one — but only the + * first pass filters `parsed.scopes`; it hands the second the `[scope, node]` + * pairs it already found. */ +function buildQualifiedNsMemberIndex(parsedFiles: readonly ParsedFile[]): QualifiedNsMemberIndex { + const rootsByReceiver = new Map(); + + for (const parsed of parsedFiles) { + const nodesByScope = new Map(); + const namespaces: [NsScope, MutableNsNode][] = []; + for (const sc of parsed.scopes) { + if (sc.kind !== 'Namespace') continue; + const node: MutableNsNode = { ownMembers: bucketOwnMembers(sc), inlineChildren: [] }; + nodesByScope.set(sc.id, node); + namespaces.push([sc, node]); + } + + for (const [sc, node] of namespaces) { + // A non-`Namespace` parent (a Module scope, say) has no node, so the + // inline child links to nothing — hence the `?.` below. + if (sc.parent !== null && inlineNamespaceScopeIds.has(sc.id)) { + nodesByScope.get(sc.parent)?.inlineChildren.push(node); + } + const nsDef = findNamespaceDefInScope(sc); + if (nsDef === undefined) continue; + const nsName = simpleNameOf(nsDef); + let roots = rootsByReceiver.get(nsName); + if (roots === undefined) { + roots = []; + rootsByReceiver.set(nsName, roots); + } + roots.push(node); + } + } + return { rootsByReceiver, membersByReceiver: new Map() }; +} + +/** Bucket a namespace scope's own callable `ownedDefs` by member simple name. + * No descent: inline children are separate nodes reached by the walk. */ +function bucketOwnMembers(scope: NsScope): ReadonlyMap { + let members: Map | undefined; + for (const def of scope.ownedDefs) { + if (!isOverloadableCallable(def.type)) continue; + const simple = simpleNameOf(def); + members ??= new Map(); + let arr = members.get(simple); + if (arr === undefined) { + arr = []; + members.set(simple, arr); + } + arr.push(def); + } + return members ?? NO_MEMBERS; +} + +/** Candidates for `receiver::member`, memoized on the index per pair so a + * repeated call site costs two Map lookups. */ +function qualifiedNsMembers( + index: QualifiedNsMemberIndex, + receiverName: string, + memberName: string, +): readonly SymbolDefinition[] { + const roots = index.rootsByReceiver.get(receiverName); + // No namespace by that name — the most common outcome in real source. Left + // unmemoized deliberately: it is already O(1), and memoizing would grow the + // index by an entry per unresolved receiver name in the workspace. + if (roots === undefined) return NO_DEFS; + let byMember = index.membersByReceiver.get(receiverName); + if (byMember === undefined) { + byMember = new Map(); + index.membersByReceiver.set(receiverName, byMember); + } + let hits = byMember.get(memberName); + if (hits === undefined) { + // Memoization makes this array shared by every call site in the pass rather + // than rebuilt per call, so it carries the dev-only mutation tripwire (see + // {@link FREEZE_SHARED_CANDIDATES}). + hits = gatherQualifiedNsMember(roots, memberName); + if (FREEZE_SHARED_CANDIDATES) hits = Object.freeze(hits); + byMember.set(memberName, hits); + } + return hits; +} + +/** Pre-order depth-first walk of every scope named `receiverName` and its + * transitive inline-namespace children, collecting callables named + * `memberName` — the index twin of the legacy + * `findMemberInNamespaceTransitive`. + * + * Iterative, not recursive: nesting depth is whatever the input file says, and + * a recursive descent threw `RangeError` past ~8k levels (see + * {@link QualifiedNsMemberIndex}). */ +function gatherQualifiedNsMember( + roots: readonly QualifiedNsNode[], + memberName: string, +): readonly SymbolDefinition[] { + const hits: SymbolDefinition[] = []; + const stack: QualifiedNsNode[] = []; + // `visited` spans ALL roots, reproducing the legacy per-receiver `seenNodeId` + // dedup: `namespace ns { inline namespace ns { … } }` registers both scopes + // under receiver `ns`, and the outer walk already collected the inner's defs. + // Node identity is equivalent to the legacy nodeId key because a node always + // contributes the same defs. It doubles as the guard that a malformed parent + // cycle terminates instead of spinning — the one regression an explicit stack + // could otherwise introduce over recursion's `RangeError`. + const visited = new Set(); + for (const root of roots) { + stack.push(root); + while (stack.length > 0) { + const node = stack.pop(); + if (visited.has(node)) continue; + visited.add(node); + const own = node.ownMembers.get(memberName); + if (own !== undefined) for (const def of own) hits.push(def); + // Reverse push so children pop in declaration order — the legacy walk's + // pre-order depth-first candidate order (see + // {@link QualifiedNsMemberIndex.rootsByReceiver}). + const kids = node.inlineChildren; + for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]); + } + } + return hits.length === 0 ? NO_DEFS : hits; +} + +/** Build the index on first use of a given `parsedFiles` set; reuse it for + * every subsequent call site that passes the same array reference AND the same + * {@link inlineNamespaceEpoch}. + * + * Both keys are needed because the index is a function of TWO inputs: + * `parsedFiles` and the module-level `inlineNamespaceScopeIds` (which inline + * children get descended into). A later pass may hand back the same + * `parsedFiles` reference with different inline-namespace state, so identity + * alone would serve a stale index — the epoch is what makes that a checked + * rebuild instead of a documented obligation on the clear site. + * + * Lifetime: the entry is reachable only while the caller still holds the + * `parsedFiles` array; nothing here pins it (see {@link qualifiedNsIndexByPass}). + * + * Same STALENESS contract as `ensureAdlIndex` in `adl.ts`, minus that + * sibling's dev-gated `validateAdlSeqCoverage` guard: this index has no `?? 0` + * defaulting read to silently collide on, so there is nothing for such a guard + * to catch. */ +function qualifiedNsMemberIndex(parsedFiles: readonly ParsedFile[]): QualifiedNsMemberIndex { + const memo = qualifiedNsIndexByPass.get(parsedFiles); + if (memo !== undefined && memo.epoch === inlineNamespaceEpoch) return memo.index; + const index = buildQualifiedNsMemberIndex(parsedFiles); + qualifiedNsIndexByPass.set(parsedFiles, { epoch: inlineNamespaceEpoch, index }); + return index; +} + +/** Constant narrowing hooks for the C++ qualified-receiver path — hoisted so + * the literal is not reallocated at every multi-candidate call site. */ +const CPP_NARROWING_HOOKS: OverloadNarrowingHookCtx = { + conversionRankFn: cppConversionRank, + conversionOnlyArgTypePrefixes: CPP_CONVERSION_ONLY_ARG_TYPE_PREFIXES, +}; + +/** + * Find the Namespace scopes whose simple name matches `receiverName` and + * return their callable members matching `memberName`, transitively + * including inline-namespace children (since they're members of the + * enclosing namespace under ISO C++). Served from a per-pipeline index + * ({@link QualifiedNsMemberIndex}), not a per-call-site workspace scan. * * Returns the most specific (innermost) match — for `outer::foo()` * where `inline namespace v1` declares `foo`, returns `v1::foo`. When * multiple inline-namespace children declare the same name, ISO C++ * leaves the call ambiguous; returns `'ambiguous'` so the caller * suppresses edge emission rather than picking arbitrarily (#1564). + * + * Two production call sites, both in `scope-resolver.ts`: + * - `resolveQualifiedReceiverMember` — the `outer::foo()` qualified-receiver + * hook. Passes `callsite`, so a multi-candidate set can be narrowed by + * arity and argument types. + * - `resolveAdlCandidates` — resolving a `using ns::name;` named import back + * to its namespace member, for template-class method bodies where the + * lexical walk misses that visibility. Passes NO `callsite`, which makes + * the narrowing filters below pass-throughs: on that path any receiver + * whose member has more than one candidate returns `'ambiguous'` (and the + * caller then skips it) rather than being disambiguated. */ export function resolveCppQualifiedNamespaceMember( receiverName: string, @@ -134,27 +453,7 @@ export function resolveCppQualifiedNamespaceMember( _scopes: ScopeResolutionIndexes, callsite?: Callsite, ): SymbolDefinition | 'ambiguous' | undefined { - const allHits: SymbolDefinition[] = []; - const seenNodeId = new Set(); - for (const parsed of parsedFiles) { - const scopesById = new Map(); - for (const sc of parsed.scopes) scopesById.set(sc.id, sc); - for (const scope of parsed.scopes) { - if (scope.kind !== 'Namespace') continue; - const nsDef = findNamespaceDefInScope(scope); - if (nsDef === undefined) continue; - const nsName = nsDef.qualifiedName?.split('.').pop() ?? nsDef.qualifiedName ?? ''; - if (nsName !== receiverName) continue; - // Found a matching namespace scope in this file. Collect ALL - // members transitively through any inline-namespace children. - const hits = findMemberInNamespaceTransitive(scope, scopesById, memberName); - for (const hit of hits) { - if (seenNodeId.has(hit.nodeId)) continue; - seenNodeId.add(hit.nodeId); - allHits.push(hit); - } - } - } + const allHits = qualifiedNsMembers(qualifiedNsMemberIndex(parsedFiles), receiverName, memberName); if (allHits.length === 0) return undefined; if (allHits.length === 1) return allHits[0]; @@ -167,12 +466,7 @@ export function resolveCppQualifiedNamespaceMember( allHits, callsite?.arity, callsite?.argumentTypes, - callsite !== undefined - ? { - conversionRankFn: cppConversionRank, - conversionOnlyArgTypePrefixes: CPP_CONVERSION_ONLY_ARG_TYPE_PREFIXES, - } - : undefined, + callsite !== undefined ? CPP_NARROWING_HOOKS : undefined, ); if (narrowed.length === 1) return narrowed[0]; if (narrowed.length === 0) return undefined; @@ -182,46 +476,6 @@ export function resolveCppQualifiedNamespaceMember( return 'ambiguous'; } -/** Recursively search a namespace scope and any inline-namespace - * descendants for callable defs with the given simple name. Non-inline - * nested namespaces are NOT traversed — they require explicit - * qualification (`outer::nested::foo`). Returns ALL matches so the - * caller can detect same-name ambiguity across inline children (#1564). */ -function findMemberInNamespaceTransitive( - scope: { - readonly id: ScopeId; - readonly ownedDefs: readonly SymbolDefinition[]; - readonly parent: ScopeId | null; - }, - scopesById: ReadonlyMap< - ScopeId, - { - readonly id: ScopeId; - readonly kind: string; - readonly parent: ScopeId | null; - readonly ownedDefs: readonly SymbolDefinition[]; - } - >, - memberName: string, -): SymbolDefinition[] { - const results: SymbolDefinition[] = []; - // Check this scope's own ownedDefs first. - for (const def of scope.ownedDefs) { - if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') continue; - const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; - if (simple === memberName) results.push(def); - } - // Descend into inline-namespace children. - for (const childScope of scopesById.values()) { - if (childScope.parent !== scope.id) continue; - if (childScope.kind !== 'Namespace') continue; - if (!inlineNamespaceScopeIds.has(childScope.id)) continue; - const childHits = findMemberInNamespaceTransitive(childScope, scopesById, memberName); - for (const hit of childHits) results.push(hit); - } - return results; -} - function findNamespaceDefInScope(scope: { readonly ownedDefs: readonly SymbolDefinition[]; }): SymbolDefinition | undefined { diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-qualified-ns-index.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-qualified-ns-index.test.ts new file mode 100644 index 000000000..de7fd9f06 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-qualified-ns-index.test.ts @@ -0,0 +1,337 @@ +/** + * #2788 — `resolveCppQualifiedNamespaceMember` serves qualified `ns::member()` + * lookups from a per-pipeline index instead of rescanning every parsed file per + * call site. These tests pin the properties the index must not lose: + * + * 1. Transitive inline-namespace collection — parent- and child-level member + * visibility through one receiver — and same-name ambiguity (#1564): the + * semantics the old linear scan provided. + * 2. Cross-file accumulation — C++ namespaces are open, so one receiver's + * members are spread over however many files reopen it. The legacy scan + * re-derived its hits per call site and got this for free; the index has + * to merge across the whole `parsedFiles` array to match it. + * 3. Cache invalidation — a new `parsedFiles` array, or a + * `clearCppInlineNamespaces()` between passes, must not serve stale hits. + * This is the failure mode the index introduces; nothing else covers it. + */ + +import type { + ParsedFile, + ScopeId, + ScopeResolutionIndexes, + SymbolDefinition, +} from 'gitnexus-shared'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { + clearCppInlineNamespaces, + markCppInlineNamespaceRange, + populateCppInlineNamespaceScopes, + resolveCppQualifiedNamespaceMember, +} from '../../../../src/core/ingestion/languages/cpp/inline-namespaces.js'; + +const NO_SCOPES = {} as unknown as ScopeResolutionIndexes; + +interface ScopeSpec { + readonly id: string; + readonly parent: string | null; + readonly defs: readonly SymbolDefinition[]; + /** Distinguishes each scope's range so inline marking targets exactly one. */ + readonly line: number; +} + +function def(nodeId: string, type: string, qualifiedName: string): SymbolDefinition { + return { nodeId, type, qualifiedName } as unknown as SymbolDefinition; +} + +function nsDef(nodeId: string, qualifiedName: string): SymbolDefinition { + return def(nodeId, 'Namespace', qualifiedName); +} + +function fnDef(nodeId: string, qualifiedName: string): SymbolDefinition { + return def(nodeId, 'Function', qualifiedName); +} + +function ns( + id: string, + parent: string | null, + defs: readonly SymbolDefinition[], + line: number, +): ScopeSpec { + return { id, parent, defs, line }; +} + +function range(line: number): { + startLine: number; + startCol: number; + endLine: number; + endCol: number; +} { + return { startLine: line, startCol: 0, endLine: line + 1, endCol: 0 }; +} + +/** Build one `ParsedFile` from scope specs, marking the scopes named in + * `inlineIds` as inline namespaces (capture-time range mark + + * `populateOwners`-time scope-id resolution, same order as the pipeline). + * Separate from {@link makeParsedFiles} so a test can compose a genuinely + * MULTI-file `parsedFiles` array, which is the only way to exercise the + * index's cross-file accumulation. */ +function makeParsedFile( + filePath: string, + specs: readonly ScopeSpec[], + inlineIds: readonly string[], +): ParsedFile { + const parsed = { + filePath, + scopes: specs.map((s) => ({ + id: s.id as unknown as ScopeId, + kind: 'Namespace', + parent: s.parent as unknown as ScopeId | null, + ownedDefs: s.defs, + range: range(s.line), + })), + } as unknown as ParsedFile; + markInline(parsed, specs, inlineIds); + return parsed; +} + +/** Single-file `parsedFiles` array — the shape most of these tests need. */ +function makeParsedFiles( + filePath: string, + specs: readonly ScopeSpec[], + inlineIds: readonly string[], +): readonly ParsedFile[] { + return [makeParsedFile(filePath, specs, inlineIds)]; +} + +/** Capture-time inline marking + `populateOwners`-time scope-id resolution, + * in the same order the pipeline runs them. Spec lookup is a Map, not + * `Array.find`, so marking a deep chain stays linear in `specs`. */ +function markInline( + parsed: ParsedFile, + specs: readonly ScopeSpec[], + inlineIds: readonly string[], +): void { + const byId = new Map(specs.map((s) => [s.id, s])); + for (const id of inlineIds) { + const spec = byId.get(id); + if (spec === undefined) throw new Error(`inline scope ${id} must exist`); + markCppInlineNamespaceRange(parsed.filePath, range(spec.line)); + } + populateCppInlineNamespaceScopes(parsed); +} + +/** `namespace outer { inline namespace v1 { } }` */ +function outerWithInlineChild( + filePath: string, + ownDefs: readonly SymbolDefinition[], + inlineDefs: readonly SymbolDefinition[], +): readonly ParsedFile[] { + return makeParsedFiles( + filePath, + [ + ns('sc:outer', null, [nsDef('n:outer', 'outer'), ...ownDefs], 1), + ns('sc:v1', 'sc:outer', [nsDef('n:v1', 'outer.v1'), ...inlineDefs], 10), + ], + ['sc:v1'], + ); +} + +/** `namespace n0 { inline namespace n1 { … inline namespace n { + * void leaf(); } … } }` — one function, declared in the innermost namespace, + * reachable by qualified lookup from every level above it. */ +function inlineChain(filePath: string, depth: number): readonly ParsedFile[] { + const specs: ScopeSpec[] = []; + for (let d = 0; d < depth; d++) { + const self = nsDef(`n:ns${d}`, `n${d}`); + specs.push( + ns( + `sc:n${d}`, + d === 0 ? null : `sc:n${d - 1}`, + d === depth - 1 ? [self, fnDef('n:leaf', `n${d}.leaf`)] : [self], + d * 2 + 1, + ), + ); + } + // Every level below the outermost is `inline`, so the whole chain is one + // transitively-visible run of namespaces. + return makeParsedFiles( + filePath, + specs, + specs.slice(1).map((s) => s.id), + ); +} + +describe('C++ qualified-namespace member index (#2788)', () => { + beforeEach(() => { + clearCppInlineNamespaces(); + }); + + it('resolves outer::foo through an inline-namespace child', () => { + const files = outerWithInlineChild('a.cpp', [], [fnDef('n:foo@v1', 'outer.v1.foo')]); + expect(resolveCppQualifiedNamespaceMember('outer', 'foo', files, NO_SCOPES)).toMatchObject({ + nodeId: 'n:foo@v1', + }); + }); + + it('returns undefined for an unknown namespace or member', () => { + const files = outerWithInlineChild('a.cpp', [], [fnDef('n:foo@v1', 'outer.v1.foo')]); + expect(resolveCppQualifiedNamespaceMember('nope', 'foo', files, NO_SCOPES)).toBeUndefined(); + expect(resolveCppQualifiedNamespaceMember('outer', 'nope', files, NO_SCOPES)).toBeUndefined(); + }); + + it('does not descend into a non-inline nested namespace', () => { + const files = makeParsedFiles( + 'a.cpp', + [ + ns('sc:outer', null, [nsDef('n:outer', 'outer')], 1), + ns( + 'sc:nested', + 'sc:outer', + [nsDef('n:nested', 'outer.nested'), fnDef('n:foo@nested', 'outer.nested.foo')], + 10, + ), + ], + [], + ); + expect(resolveCppQualifiedNamespaceMember('outer', 'foo', files, NO_SCOPES)).toBeUndefined(); + expect(resolveCppQualifiedNamespaceMember('nested', 'foo', files, NO_SCOPES)).toMatchObject({ + nodeId: 'n:foo@nested', + }); + }); + + it('reports same-name hits across two inline children as ambiguous (#1564)', () => { + const files = makeParsedFiles( + 'a.cpp', + [ + ns('sc:outer', null, [nsDef('n:outer', 'outer')], 1), + ns('sc:v1', 'sc:outer', [nsDef('n:v1', 'outer.v1'), fnDef('n:foo@v1', 'outer.v1.foo')], 10), + ns('sc:v2', 'sc:outer', [nsDef('n:v2', 'outer.v2'), fnDef('n:foo@v2', 'outer.v2.foo')], 20), + ], + ['sc:v1', 'sc:v2'], + ); + expect(resolveCppQualifiedNamespaceMember('outer', 'foo', files, NO_SCOPES)).toBe('ambiguous'); + }); + + it('resolves through an inline namespace that reuses its parent’s name', () => { + // Pins the `visited` dedup: `namespace ns { inline namespace ns { … } }` + // registers both scopes under receiver `ns`, and without dedup the one + // `foo` is collected twice and degrades to 'ambiguous', dropping the CALLS + // edge. Why node identity is the right dedup key: see + // `gatherQualifiedNsMember` in `inline-namespaces.ts`. + const files = makeParsedFiles( + 'a.cpp', + [ + ns('sc:ns@outer', null, [nsDef('n:ns@outer', 'ns')], 1), + ns( + 'sc:ns@inner', + 'sc:ns@outer', + [nsDef('n:ns@inner', 'ns.ns'), fnDef('n:foo', 'ns.ns.foo')], + 10, + ), + ], + ['sc:ns@inner'], + ); + expect(resolveCppQualifiedNamespaceMember('ns', 'foo', files, NO_SCOPES)).toMatchObject({ + nodeId: 'n:foo', + }); + }); + + it('finds both a namespace-owned member and its inline child member', () => { + // Both levels are visible through the same receiver: `foo` is owned by + // `outer` itself, `bar` only by its inline child `v1`. Distinct member + // names per level on purpose — two same-named candidates with no call-site + // info collapse to 'ambiguous', which would assert nothing about either. + const files = outerWithInlineChild( + 'a.cpp', + [fnDef('n:foo@outer', 'outer.foo')], + [fnDef('n:bar@v1', 'outer.v1.bar')], + ); + expect(resolveCppQualifiedNamespaceMember('outer', 'foo', files, NO_SCOPES)).toMatchObject({ + nodeId: 'n:foo@outer', + }); + expect(resolveCppQualifiedNamespaceMember('outer', 'bar', files, NO_SCOPES)).toMatchObject({ + nodeId: 'n:bar@v1', + }); + }); + + it('resolves through a 20,000-deep inline chain without exhausting the stack', () => { + // Pins that a deep chain neither overflows the stack nor blows the heap — + // the two failure modes a recursive build and an eager member table had. + // Why an uncaught throw here aborts the whole `analyze`: see + // `QualifiedNsMemberIndex` in `inline-namespaces.ts`. 20,000 is ~2.5x the + // depth that overflowed in this same runner, for margin over per-platform + // stack sizes. + const files = inlineChain('deep.cpp', 20_000); + // From the outermost namespace: the full chain is one transitive walk. + expect(resolveCppQualifiedNamespaceMember('n0', 'leaf', files, NO_SCOPES)).toMatchObject({ + nodeId: 'n:leaf', + }); + // Every inline namespace is a legal qualified receiver of its own, so + // mid-chain and innermost receivers must resolve too. + expect(resolveCppQualifiedNamespaceMember('n10000', 'leaf', files, NO_SCOPES)).toMatchObject({ + nodeId: 'n:leaf', + }); + expect(resolveCppQualifiedNamespaceMember('n19999', 'leaf', files, NO_SCOPES)).toMatchObject({ + nodeId: 'n:leaf', + }); + // A miss down the same chain stays a miss rather than becoming a throw. + expect(resolveCppQualifiedNamespaceMember('n0', 'nosuch', files, NO_SCOPES)).toBeUndefined(); + }); + + it('merges one namespace’s members across every file that reopens it', () => { + // C++ namespaces are open: `namespace outer { void a(); }` in one file and + // `namespace outer { void b(); }` in another are the SAME namespace, and + // `outer::a()` / `outer::b()` must both resolve. The legacy scan re-derived + // its hits from all of `parsedFiles` per call site; the index accumulates + // once, so the per-receiver state has to outlive the file loop. Distinct + // scope ids per file, as the real pipeline mints them. + const files: readonly ParsedFile[] = [ + makeParsedFile( + 'a.cpp', + [ns('sc:outer@a', null, [nsDef('n:outer@a', 'outer'), fnDef('n:a', 'outer.a')], 1)], + [], + ), + makeParsedFile( + 'b.cpp', + [ns('sc:outer@b', null, [nsDef('n:outer@b', 'outer'), fnDef('n:b', 'outer.b')], 1)], + [], + ), + ]; + expect(resolveCppQualifiedNamespaceMember('outer', 'a', files, NO_SCOPES)).toMatchObject({ + nodeId: 'n:a', + }); + expect(resolveCppQualifiedNamespaceMember('outer', 'b', files, NO_SCOPES)).toMatchObject({ + nodeId: 'n:b', + }); + }); + + it('does not serve one parsedFiles array’s index to another', () => { + const first = outerWithInlineChild('a.cpp', [], [fnDef('n:foo@a', 'outer.v1.foo')]); + expect(resolveCppQualifiedNamespaceMember('outer', 'foo', first, NO_SCOPES)).toMatchObject({ + nodeId: 'n:foo@a', + }); + const second = outerWithInlineChild('b.cpp', [], [fnDef('n:foo@b', 'outer.v1.foo')]); + expect(resolveCppQualifiedNamespaceMember('outer', 'foo', second, NO_SCOPES)).toMatchObject({ + nodeId: 'n:foo@b', + }); + }); + + it('rebuilds after clearCppInlineNamespaces even when parsedFiles is reused', () => { + // Pass 1: `v1` is inline, so `outer::foo` reaches through it. + const specs: readonly ScopeSpec[] = [ + ns('sc:outer', null, [nsDef('n:outer', 'outer')], 1), + ns('sc:v1', 'sc:outer', [nsDef('n:v1', 'outer.v1'), fnDef('n:foo@v1', 'outer.v1.foo')], 10), + ]; + const files = makeParsedFiles('a.cpp', specs, ['sc:v1']); + expect(resolveCppQualifiedNamespaceMember('outer', 'foo', files, NO_SCOPES)).toMatchObject({ + nodeId: 'n:foo@v1', + }); + + // Pass 2: SAME `parsedFiles` reference (so identity alone would serve the + // cached index), but `v1` is no longer inline. Without the index reset in + // `clearCppInlineNamespaces` the stale pass-1 hit survives. + clearCppInlineNamespaces(); + markInline(files[0], specs, []); + expect(resolveCppQualifiedNamespaceMember('outer', 'foo', files, NO_SCOPES)).toBeUndefined(); + }); +}); From 010a7d806a57ce94cb03cf4e52dbfa5c1e34f50d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sun, 2 Aug 2026 16:26:25 +0100 Subject: [PATCH 08/13] fix(schema): declare the full scope-resolution relation cross product (#2792) (#2793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(schema): declare the full scope-resolution relation cross product (#2792) `RELATION_SCHEMA` was hand-listed, and every prior fix added only the FROM/TO pair named in a crash report — `Const→Method` in #2769, the Swift/Rust member pairs before it. So `analyze` kept aborting at `assertDeclaredPair` on the next codebase whose edges happened to land on a different pair; #2792 reports `Class→Variable` on Java. Audit the surface instead of the symptom. `buildGraphNodeLookup` skips any node whose label is not in `isLinkableLabel`, so the lookup holds only linkable-labelled nodes — and both endpoints of every graph-bridge edge resolve through that lookup. The emittable surface is therefore exactly: FROM LINKABLE_LABELS + File (the module-level caller fallback) TO LINKABLE_LABELS + CALL_TARGET_TYPES `isCallerAnchorLabel` is a strict subset of linkable and contributes nothing on top. `CALL_TARGET_TYPES` contributes `Delegate`, which `tryEmitEdgeWithExplicitTargetId` can emit without going through the lookup at all. Generate that 14x14 block into the DDL rather than listing it: 223 -> 322 declared pairs, and no future pair from these sets can be missing by construction. The containment/inheritance/DI/route/cluster/PDG pairs stay hand-declared — no single predicate describes them. Both label sets live in the ingestion layer, which `core/lbug` must not import, so schema.ts carries twin lists. test/unit/schema-pair-coverage.ts derives the requirement from the originals and fails CI when either set grows without the pairs landing here — the piecemeal loop this fix ends. Measured before widening: at 322 pairs the cost is inside noise (1.09s vs 1.12s per 300 anchored queries on a 32-table DB), but the full 32x32 cross product is ~1.8x on untyped-endpoint anchored queries. The audited subset is the right scope, not "declare everything". INCREMENTAL_SCHEMA_VERSION 34 -> 35: LadybugDB fixes endpoint pairs when the rel table is created, so a pre-v35 database physically cannot store these edges. Closes #2792 Co-Authored-By: Claude Opus 5 (1M context) * fix(schema): declare the non-bridge structural pairs COBOL and Vue emit The generated scope-resolution block closed the half of RELATION_SCHEMA a label predicate can describe. The hand-declared half was still stale: with #2791's Function->Variable fix applied, `analyze` continued to abort on this repo's own test/fixtures/lang-resolution with Relationship label pair Module→Property is not declared A full sweep (assertDeclaredPair patched to log-and-skip, run over the whole fixture corpus) found 13 undeclared pairs over 106 edges. This branch already covered 3 of them via the cross product; the remaining 10 come from emitters outside the graph bridge: - cobol-processor.ts mints Module / Namespace / Record / Property / CodeElement and wires them with CONTAINS, CALLS and ACCESSES (9 pairs) - vue-sfc-extractor.ts emits BINDS_EVENT_HANDLER from a handler Function to the child component's File, the only edge whose target is a File (1 pair) CodeElement, Namespace, Record and File are in neither scope-bridge label set, so neither the generated block nor schema-pair-coverage.test.ts can reach them. Adds test/integration/structural-pair-coverage.test.ts, which derives the requirement from a corpus instead of a predicate: it runs the real pipeline over the non-bridge fixtures and requires every FROM/TO pair they produce to be declared. Mutation-checked — dropping `FROM Function TO File` fails it with exactly Function|File. Verified: cobol-app, vue-basic and php-transitive-traits now index instead of aborting; the full lang-resolution corpus completes at 10,876 nodes / 18,517 edges; scrypster/muninndb at 0b7a4272 (the #2789 repro) completes at 20,069 nodes / 71,580 edges, matching #2791 exactly, so this supersedes that PR. * refactor(test): simplify the structural pair coverage guard Cleanup pass over the previous commit. No behaviour change to the schema. - reuse `FIXTURES` and `runPipelineFromRepo` from resolvers/helpers.ts instead of re-deriving the fixture root and importing pipeline.js directly - gate on `distWorkerExists()` like every other integration test that passes `workerUrlForTest`, so a missing dist skips rather than fails - run the three fixtures with `it.concurrent.each`; they share nothing and the cost is almost all worker spawn plus grammar load, which overlaps well (tests phase 21-24s -> 5.6s measured) - replace the sentinel-in-a-Set filter with a plain `.filter()` chain, matching the sibling unit test, and move the declared/table lookups off the per-edge path onto the deduped set - move the pure string pin out of the integration tier into schema-pair-coverage.test.ts, where the identical construct already lives, so it needs no build and survives fixture deletion - trim the schema and test prose that restated the code, and correct the BINDS_EVENT_HANDLER attribution: it is emitted by languages/vue/scope-resolver.ts, not vue-sfc-extractor.ts - amend the v35 comment to mention the 10 structural pairs it now also stamps Still mutation-checked: dropping `FROM Function TO File` now fails both the integration sweep and the unit pin with exactly Function|File. 89 tests green. * fix(schema): generate the attachment pair surface and close four analyze aborts Review of the generated scope-bridge cross product found four `analyze` hard-aborts still live at head, each reproduced end-to-end on the default user path (`analyze --index-only --skip-git`): Method→Annotation Spring `@Bean` + `@ConditionalOnMissingBean` (Java + Kotlin) Method→File Vue Options-API `methods:` handler bound to a child event Namespace→Record COBOL `DECLARATIVES` / `USE AFTER STANDARD ERROR ON ` Class→Tool `@mcp.tool()` applied to a class All four are pre-existing on main, and both existing guards were structurally blind to them: the unit guard derives from LINKABLE_LABELS ∪ CALL_TARGET_TYPES (none of Annotation/Tool/Record/File-as-target is a member) and the corpus guard ran three fixtures that exercise none of these emitters. All 16 tests passed while all four crashes were live. The PR's model — "bridge endpoint × structural endpoint" — does not fit: Namespace→Record is structural on both sides. The property that does hold is that the ANCHOR is a lookup result, not a literal at the emit site, so the emitter cannot constrain its label. That gives a second closed-form rule: DEFINITION_ANCHOR_LABELS × ATTACHMENT_TARGET_LABELS DEFINITION_ANCHOR_LABELS is derived from NODE_TABLES by subtraction, so a new node table joins automatically. 332 → 450 declared pairs. Sized against a committed harness (gitnexus/bench/schema-pairs), real @ladybugdb/core, identical data: 450 costs 0.93–1.05× of 332 on untyped-endpoint anchored queries — inside noise — versus 1.22–1.43× at 641 and 2.03–2.34× at 1024. The harness reproduces the known #2792 cliff, which is what makes the 450 figure trustworthy. Also in this change: - Delete the 161 hand-declared pairs the rules already generate (233 → 72). The declared set is byte-identical at 450; those lines were load-bearing shadow, because the generator suppresses anything already declared structurally, so narrowing a rule later would silently keep pairs alive. A new guard fails CI if a hand-declared pair is ever re-added inside a rule. - Import LINKABLE_LABELS / CALL_TARGET_TYPES instead of hand-copying them. The twins' stated justification ("the ingestion layer must not be imported here") is false: csv-generator.ts and lbug-adapter.ts, siblings in the same directory, already do, and no rule in AGENTS.md / ARCHITECTURE.md / CONTRIBUTING.md / GUARDRAILS.md states otherwise. - Resolve `resolveStreamGraphEmit` after the guards that rebind `options.force`, not at function entry. It gates on `force`, and every freshness guard runs ~360 lines later, so the v34→v35 bump would have pushed every existing index down the non-streamed emit path — losing the #2680 memory streaming added for the #2649 kernel-scale OOM, for exactly the population most likely to be memory-constrained. - `UndeclaredRelationPairError` now carries the relationship type, both node ids and the source file, with a matching CLI branch. The old message named only the abstract label pair, which a user could not act on. Found through the cause chain, since pipeline-phases/runner.ts rewraps every phase failure. - Share one classifier (`relPairKeyFor`) across the router, both emit sinks and the corpus guard, which previously hand-mirrored the router's skip rule; one cause-chain walker in lib/utils.ts; one exported pair-matching regex. - Corpus guard: four new fixtures reproducing the aborts, per-fixture sentinel pairs so a fixture that stops emitting fails loudly instead of passing vacuously on an empty graph. The per-edge path stays allocation-free: the failure context is passed positionally and the message is built only inside the throw. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0182jkjQqzACkJKYw4MLDnhX * test(bench): re-baseline the COBOL capture fingerprint for the new fixture `bench/scope-capture` globs `lang-resolution/cobol-*`, so the `cobol-declaratives` fixture added in 81daf370e (to reproduce the `Namespace→Record` analyze abort) joined that corpus and shifted the fingerprint — 14 → 15 files. Verified corpus-only, not a capture change: with that one fixture moved aside the fingerprint is byte-identical to the prior baseline (d45bb091…), and 81daf370e touches no COBOL capture code. The new value reproduces CI's reported hash exactly. Scaling 0.677 < 1.5 budget. `bench/scope-capture/measure.mjs --check` → PASS (15 languages). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0182jkjQqzACkJKYw4MLDnhX --------- Co-authored-by: Gergo Magyar Co-authored-by: Claude Opus 5 (1M context) --- gitnexus/bench/schema-pairs/README.md | 100 +++++ gitnexus/bench/schema-pairs/baselines.json | 4 + gitnexus/bench/schema-pairs/measure.mjs | 357 ++++++++++++++++ gitnexus/bench/scope-capture/baselines.json | 5 +- gitnexus/src/cli/analyze.ts | 46 +- gitnexus/src/cli/cli-message.ts | 3 +- .../scope-resolution/graph-bridge/ids.ts | 22 +- .../graph-bridge/node-lookup.ts | 81 ++-- gitnexus/src/core/lbug/csv-generator.ts | 8 +- gitnexus/src/core/lbug/graph-emit-sink.ts | 39 +- gitnexus/src/core/lbug/pdg-emit-sink.ts | 41 +- gitnexus/src/core/lbug/rel-pair-routing.ts | 261 +++++++++-- gitnexus/src/core/lbug/schema.ts | 404 +++++++++++------- gitnexus/src/core/run-analyze.ts | 20 +- gitnexus/src/lib/utils.ts | 54 +++ gitnexus/src/mcp/local/local-backend.ts | 7 + gitnexus/src/storage/repo-manager.ts | 22 +- .../cobol-declaratives/ERRDEMO.cbl | 48 +++ .../lang-resolution/mcp-tool-class/server.py | 22 + .../java/AppAutoConfiguration.java | 30 ++ .../java/PaymentService.java | 5 + .../kotlin/KotlinAutoConfiguration.kt | 20 + .../lang-resolution/vue-basic/OptionsHost.vue | 29 ++ .../structural-pair-coverage.test.ts | 180 ++++++++ .../analyze-undeclared-pair-error.test.ts | 169 ++++++++ .../unit/call-summary-schema-version.test.ts | 10 +- .../test/unit/lbug/graph-emit-sink.test.ts | 2 +- gitnexus/test/unit/lbug/pdg-emit-sink.test.ts | 2 +- gitnexus/test/unit/rel-pair-routing.test.ts | 248 ++++++++++- .../test/unit/schema-pair-coverage.test.ts | 211 +++++++++ gitnexus/test/unit/schema.test.ts | 21 +- .../stream-graph-emit-force-ordering.test.ts | 110 +++++ 32 files changed, 2278 insertions(+), 303 deletions(-) create mode 100644 gitnexus/bench/schema-pairs/README.md create mode 100644 gitnexus/bench/schema-pairs/baselines.json create mode 100644 gitnexus/bench/schema-pairs/measure.mjs create mode 100644 gitnexus/test/fixtures/lang-resolution/cobol-declaratives/ERRDEMO.cbl create mode 100644 gitnexus/test/fixtures/lang-resolution/mcp-tool-class/server.py create mode 100644 gitnexus/test/fixtures/lang-resolution/spring-conditional-app/java/AppAutoConfiguration.java create mode 100644 gitnexus/test/fixtures/lang-resolution/spring-conditional-app/java/PaymentService.java create mode 100644 gitnexus/test/fixtures/lang-resolution/spring-conditional-app/kotlin/KotlinAutoConfiguration.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/vue-basic/OptionsHost.vue create mode 100644 gitnexus/test/integration/structural-pair-coverage.test.ts create mode 100644 gitnexus/test/unit/analyze-undeclared-pair-error.test.ts create mode 100644 gitnexus/test/unit/schema-pair-coverage.test.ts create mode 100644 gitnexus/test/unit/stream-graph-emit-force-ordering.test.ts diff --git a/gitnexus/bench/schema-pairs/README.md b/gitnexus/bench/schema-pairs/README.md new file mode 100644 index 000000000..29594b9d7 --- /dev/null +++ b/gitnexus/bench/schema-pairs/README.md @@ -0,0 +1,100 @@ +# Schema pair-set bench (#2793) + +What a bigger `CodeRelation` FROM/TO pair set costs at query time, measured +against a real `@ladybugdb/core` database. + +```bash +# from gitnexus/ +node --import tsx bench/schema-pairs/measure.mjs # print one JSON line per size + a summary +node --import tsx bench/schema-pairs/measure.mjs --check # gate vs baselines.json +``` + +## Why it exists + +`src/core/lbug/schema.ts` generates its relation pairs from two cross products, +and declines to add a third one **on the strength of a number** — roughly 1.04× +at 450 declared pairs, 1.6× at 786, 2.1× at 1024. That measurement used to live +in a scratch directory, so nobody proposing a third rule could re-run it. This +harness is that measurement, committed — and it reproduces those figures. + +Run it before widening a rule, and quote the new ratio in the review. + +Observed on the reference box, **four runs** (ratios vs the 332-pair list): + +| pairs | untyped | typed (floor) | +| ----- | ---------- | ------------- | +| 332 | 1.00× | 1.00× | +| 450 | 0.93–1.05× | 0.98–1.17× | +| 641 | 1.22–1.43× | 1.11–1.23× | +| 786 | 1.52–1.75× | 1.19–1.31× | +| 1024 | 2.03–2.34× | 1.31–1.57× | + +Production's 450 came out _faster_ than 332 on three of the four runs, so at this +size the pair count is inside run-to-run noise. Everything past ~640 is not. +**Quote the range, not a single run** — one run is not evidence here. + +## What it measures + +For each pair-set size it builds a fresh database with all 32 node tables, a +`CodeRelation` table declaring exactly that many FROM/TO pairs, and **identical +data**, then times two query shapes over 40 anchors × 15 reps (median): + +- **`untyped_ms_`** — `MATCH (a {id: $id})-[r:CodeRelation]->(b)`. Neither + endpoint is labelled, so LadybugDB must treat every declared pair as a + candidate. This is the shape `impact`, `context` and `detect_changes` issue + when they walk out from one node id, and the only one whose plan depends on + how many pairs the table declares. +- **`typed_ms_`** — `MATCH (a:Function {…})-[r]->(b:Function)`, the lower + bound. Both endpoints labelled prunes the plan to a single pair, so this was + expected to be flat in the pair count. **It is not** — up to 1.17× at 450 and + 1.57× at 1024 — so a declared-but-unused pair costs something even when the + planner never considers it. `typed_ratio_*` is therefore the floor, not a noise + control; the real cost of widening sits between it and `ratio_*`. A run where + `typed_ratio` moves _more_ than `ratio` is noise-dominated and should be + rerun. +- **`ratio_`** — `untyped_ms_ / untyped_ms_332`. `ratio_450` is the + figure `schema.ts` quotes. + +### Sizes + +The pair set is a prefix of a fixed 32×32 (`NODE_TABLES`²) enumeration, so each +size is a strict superset of the smaller ones. The four pairs the synthetic data +uses are pinned to the front, so **the same rows are reachable by the same query +at every size** — the only variable is how many unused pairs are declared. The +harness fails if the row counts ever differ across sizes. + +| size | what it is | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 332 | the pre-#2792 hand-written list — the reference for every ratio | +| 450 | production today (two cross products + 72 hand-declared pairs) | +| 641 | the third cross product `schema.ts` defers (`DEFINITION_ANCHOR_LABELS × {CodeElement, Section, Typedef, Union, Namespace, Impl, TypeAlias, Static, Template}`), which would leave ~29 hand-declared lines | +| 786 | the size an earlier revision of that comment attributed to the third rule — it is 641; kept as a measured waypoint | +| 1024 | the full cross product, the ceiling | + +## Correctness gate + +Before timing anything, the harness round-trips the **real** `SCHEMA_QUERIES` +through a real database and asserts that `CALL SHOW_CONNECTION('CodeRelation')` +reports exactly the pairs `parseRelationSchemaPairs` finds in `RELATION_SCHEMA`. + +No magic number is baked in: the invariant is that the DDL LadybugDB _accepted_ +carries the pair set our own parser believes it declares. The absolute count is +reported as `declared_pairs`. A pair declared twice would not reach this check at +all — LadybugDB rejects the `CREATE REL TABLE` outright, which is why a duplicate +kills every `analyze` rather than one repository's. + +## What it does NOT measure + +- **Ingest / `COPY` cost.** Pair-set size also multiplies the number of per-pair + CSVs the emitter routes to (`src/core/lbug/rel-pair-routing.ts`); that cost is + covered by `bench/emit-persistence`. +- **At-scale absolute numbers.** Row counts here are small and deliberately + constant. The ratios are the signal; the milliseconds are box-specific. + +## Regenerating the baseline + +`baselines.json` holds one budget, `ratio_450_budget` — the ceiling on what +production's own pair count may cost relative to the 332-pair hand-list it +replaced. Re-run without `--check` **several times** and copy the top of the +observed `ratio_450` range plus headroom — the spread between runs on this box +is wider than the effect being measured at 450, so a single run cannot set it. diff --git a/gitnexus/bench/schema-pairs/baselines.json b/gitnexus/bench/schema-pairs/baselines.json new file mode 100644 index 000000000..41a12d485 --- /dev/null +++ b/gitnexus/bench/schema-pairs/baselines.json @@ -0,0 +1,4 @@ +{ + "_comment": "ratio_450_budget — ceiling on what production's 450-pair set may cost on untyped-endpoint anchored queries, relative to the 332-pair hand-list it replaced. Observed 0.94x and 1.05x across two runs on the reference box (i.e. inside run-to-run noise; it came out faster than 332 once). The budget carries headroom for that spread — compare typed_ratio_450 (1.10-1.17x) for this box's floor. Raise it only with a measured range, never a single run.", + "ratio_450_budget": 1.3 +} diff --git a/gitnexus/bench/schema-pairs/measure.mjs b/gitnexus/bench/schema-pairs/measure.mjs new file mode 100644 index 000000000..59fe92db7 --- /dev/null +++ b/gitnexus/bench/schema-pairs/measure.mjs @@ -0,0 +1,357 @@ +/** + * What a bigger `CodeRelation` FROM/TO pair set costs at query time (#2793). + * + * `src/core/lbug/schema.ts` declares its relation pairs from two cross products + * plus a small hand-written remainder, and it justifies NOT adding a third cross + * product with a number: anchored queries cost ~1.04× at 450 declared pairs but + * 1.6× at 786 and 2.1× at 1024. That measurement previously lived in a scratch + * directory, so the claim could not be re-checked when someone proposed + * widening a rule. This is it, committed. + * + * WHAT IT MEASURES. Against a real `@ladybugdb/core` database, with byte-identical + * DATA at every size, it times the query shape whose plan actually depends on the + * declared pair set: + * + * MATCH (a {id: $id})-[r:CodeRelation]->(b) RETURN b.id + * + * Neither endpoint is labelled, so LadybugDB must consider every declared + * FROM/TO pair as a candidate — this is the shape `impact`, `context` and + * `detect_changes` all issue when they walk out from one node id. + * + * A LABEL-typed query (`MATCH (a:Function)-[r]->(b:Function)`) is measured + * alongside it as the LOWER BOUND. Its plan prunes to a single pair, so it was + * expected to be flat in the pair count — it is NOT. Measured here it reaches + * 1.17× at 450 and 1.57× at 1024 against the same 332-pair reference, i.e. a + * declared-but-unused pair costs something even when the planner never + * considers it (per-pair catalog/storage overhead the query pays regardless). + * So `typed_ratio_*` is not a noise control: it is the floor, and the true cost + * of a wider pair set lies between it and `ratio_*`. Treat any run where + * `typed_ratio` moves MORE than `ratio` as noise-dominated. + * + * SIZES. The pair set is a prefix of a fixed 32×32 (`NODE_TABLES`²) enumeration + * so every size is a strict SUPERSET of the smaller ones, and the four pairs the + * data actually uses are pinned first — so the same rows are reachable by the + * same query at every size, and the only variable is how many UNUSED pairs the + * table declares: + * - 332 — the pre-#2792 hand-written list (the historical baseline); + * - 450 — production today (two cross products + 72 hand-declared); + * - 641 — the third cross product schema.ts defers + * (`DEFINITION_ANCHOR_LABELS × {CodeElement, Section, Typedef, Union, + * Namespace, Impl, TypeAlias, Static, Template}`), which would leave + * only ~29 hand-declared lines; + * - 786 — the size an earlier revision of that comment attributed to the + * third rule (it is 641; 786 is kept as a measured waypoint); + * - 1024 — the full cross product, the ceiling. + * + * Ratios are reported against 332, the smallest size — `ratio_450` is the + * number schema.ts quotes. + * + * CORRECTNESS GATE. Before timing anything it round-trips the REAL + * `SCHEMA_QUERIES` through a real database and asserts that + * `CALL SHOW_CONNECTION('CodeRelation')` reports exactly the pairs + * `parseRelationSchemaPairs` finds in `RELATION_SCHEMA`. That is the invariant + * that matters and it needs no magic number: it proves the DDL LadybugDB + * ACCEPTED carries the pair set our own parser believes it declares. (A + * duplicated FROM/TO would not even get this far — LadybugDB rejects the + * `CREATE REL TABLE` outright, which is why that failure kills every `analyze`.) + * The absolute count is reported as `declared_pairs` for the record. + * + * Build-free: imports the `.ts` sources through tsx. + * + * node --import tsx bench/schema-pairs/measure.mjs # print JSON lines + * node --import tsx bench/schema-pairs/measure.mjs --check # gate vs baselines.json + * + * `--check` fails if the correctness gate breaks, or if `ratio_450` exceeds its + * budget — i.e. if production's own pair count starts costing materially more + * than the hand-written list it replaced. + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { NODE_TABLES } from 'gitnexus-shared'; +import { + NODE_SCHEMA_QUERIES, + RELATION_SCHEMA, + REL_TABLE_NAME, +} from '../../src/core/lbug/schema.ts'; +import { parseRelationSchemaPairs } from '../../src/core/lbug/rel-pair-routing.ts'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BASELINE_PATH = path.resolve(__dirname, 'baselines.json'); + +const lbug = (await import('@ladybugdb/core')).default; + +// ---- sizes + the pair enumeration every size is a prefix of ---- + +const SIZES = [332, 450, 641, 786, 1024]; +const REFERENCE_SIZE = 332; // ratios are relative to this +const PRODUCTION_SIZE = 450; // the size schema.ts ships + +// The four pairs the synthetic data uses. Pinned to the FRONT of the +// enumeration so they are declared at every size — otherwise a smaller pair set +// would simply carry fewer rows and the comparison would measure data volume, +// not pair-set size. +const DATA_PAIRS = [ + ['File', 'Function'], + ['Function', 'Function'], + ['Function', 'Class'], + ['Class', 'Method'], +]; + +const pairKey = ([from, to]) => `${from}|${to}`; + +// NODE_TABLES² in declaration order, data pairs first, deduped. 32² = 1024. +const PAIR_UNIVERSE = (() => { + const seen = new Set(DATA_PAIRS.map(pairKey)); + const all = [...DATA_PAIRS]; + for (const from of NODE_TABLES) { + for (const to of NODE_TABLES) { + const key = `${from}|${to}`; + if (seen.has(key)) continue; + seen.add(key); + all.push([from, to]); + } + } + return all; +})(); + +if (PAIR_UNIVERSE.length !== NODE_TABLES.length ** 2) { + throw new Error( + `bench: pair universe is ${PAIR_UNIVERSE.length}, expected ${NODE_TABLES.length ** 2} ` + + `(NODE_TABLES changed — update SIZES, the 1024 ceiling is no longer the ceiling)`, + ); +} +for (const size of SIZES) { + if (size > PAIR_UNIVERSE.length) { + throw new Error(`bench: size ${size} exceeds the ${PAIR_UNIVERSE.length}-pair universe`); + } +} + +const relTableDdlFor = (size) => { + const pairs = PAIR_UNIVERSE.slice(0, size).map(([from, to]) => ` FROM \`${from}\` TO \`${to}\``); + return `CREATE REL TABLE ${REL_TABLE_NAME} (\n${pairs.join(',\n')},\n type STRING,\n confidence DOUBLE,\n reason STRING,\n step INT32\n)`; +}; + +// ---- synthetic data (identical at every size) ---- + +const FILES = 20; +const FNS_PER_FILE = 8; +const CLASSES = 40; +const METHODS_PER_CLASS = 4; +const CALLS_PER_FN = 3; +const REPS = 15; // median over reps +const ANCHORS = 40; // distinct anchor ids queried per rep + +// Batched with UNWIND rather than one statement per row: per-statement overhead +// dwarfs the insert itself here, and load time is not what this bench measures. +function dataStatements() { + const stmts = []; + const fnIds = []; + const classIds = []; + const methodIds = []; + const fileIds = []; + for (let f = 0; f < FILES; f++) fileIds.push(`file-${f}`); + for (let f = 0; f < FILES; f++) { + for (let i = 0; i < FNS_PER_FILE; i++) fnIds.push(`fn-${f}-${i}`); + } + for (let c = 0; c < CLASSES; c++) { + classIds.push(`cls-${c}`); + for (let m = 0; m < METHODS_PER_CLASS; m++) methodIds.push(`m-${c}-${m}`); + } + + const nodeBatch = (label, ids) => + `UNWIND [${ids.map((id) => `{id: '${id}'}`).join(', ')}] AS r ` + + `CREATE (:\`${label}\` {id: r.id, name: r.id, filePath: 'bench.ts'})`; + stmts.push(nodeBatch('File', fileIds)); + stmts.push(nodeBatch('Function', fnIds)); + stmts.push(nodeBatch('Class', classIds)); + stmts.push(nodeBatch('Method', methodIds)); + + const relBatch = (fromLabel, toLabel, type, edges) => + `UNWIND [${edges.map(([f, t]) => `{f: '${f}', t: '${t}'}`).join(', ')}] AS e ` + + `MATCH (a:\`${fromLabel}\` {id: e.f}), (b:\`${toLabel}\` {id: e.t}) ` + + `CREATE (a)-[:${REL_TABLE_NAME} {type: '${type}', confidence: 1.0, reason: 'bench', step: 0}]->(b)`; + + const contains = []; + for (let f = 0; f < FILES; f++) { + for (let i = 0; i < FNS_PER_FILE; i++) contains.push([`file-${f}`, `fn-${f}-${i}`]); + } + stmts.push(relBatch('File', 'Function', 'CONTAINS', contains)); + + // Function→Function calls: each fn calls the next CALLS_PER_FN, wrapping. + const calls = []; + for (let i = 0; i < fnIds.length; i++) { + for (let k = 1; k <= CALLS_PER_FN; k++) calls.push([fnIds[i], fnIds[(i + k) % fnIds.length]]); + } + stmts.push(relBatch('Function', 'Function', 'CALLS', calls)); + + const uses = fnIds.map((id, i) => [id, classIds[i % classIds.length]]); + stmts.push(relBatch('Function', 'Class', 'USES', uses)); + + const hasMethod = []; + for (let c = 0; c < CLASSES; c++) { + for (let m = 0; m < METHODS_PER_CLASS; m++) hasMethod.push([`cls-${c}`, `m-${c}-${m}`]); + } + stmts.push(relBatch('Class', 'Method', 'HAS_METHOD', hasMethod)); + + // Anchors: functions, which have out-edges on two distinct declared pairs. + return { stmts, anchors: fnIds.slice(0, ANCHORS) }; +} + +const { stmts: DATA_STATEMENTS, anchors: ANCHOR_IDS } = dataStatements(); + +// ---- timing ---- + +const median = (xs) => { + const s = [...xs].sort((a, b) => a - b); + const m = Math.floor(s.length / 2); + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; +}; + +const withDb = async (fn) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-bench-pairs-')); + const db = new lbug.Database(path.join(dir, 'db')); + const conn = new lbug.Connection(db); + try { + return await fn(conn); + } finally { + await conn.close().catch(() => {}); + await db.close?.().catch?.(() => {}); + fs.rmSync(dir, { recursive: true, force: true }); + } +}; + +async function runAll(conn, statements) { + for (const s of statements) await conn.query(s); +} + +// The measured shape: BOTH endpoints untyped, anchored by id. LadybugDB must +// consider every declared FROM/TO pair as a candidate. +const UNTYPED_QUERY = (id) => + `MATCH (a {id: '${id}'})-[r:${REL_TABLE_NAME}]->(b) RETURN b.id AS id, r.type AS type`; +// The lower bound: both endpoints labelled, so the planner prunes to one pair. +// Still not flat in the pair count (see the header) — an unused declared pair +// costs something even when the plan never touches it. +const TYPED_QUERY = (id) => + `MATCH (a:Function {id: '${id}'})-[r:${REL_TABLE_NAME}]->(b:Function) RETURN b.id AS id`; + +async function timeQueries(conn, build) { + // Warm: run the whole anchor sweep once uncounted (plan cache + page cache). + for (const id of ANCHOR_IDS) await (await conn.query(build(id))).getAll(); + const samples = []; + let rows = 0; + for (let rep = 0; rep < REPS; rep++) { + const start = process.hrtime.bigint(); + let n = 0; + for (const id of ANCHOR_IDS) n += (await (await conn.query(build(id))).getAll()).length; + samples.push(Number(process.hrtime.bigint() - start) / 1e6); + rows = n; + } + return { ms: median(samples), rows }; +} + +async function measureSize(size) { + return withDb(async (conn) => { + for (const q of NODE_SCHEMA_QUERIES) await conn.query(q); + await conn.query(relTableDdlFor(size)); + await runAll(conn, DATA_STATEMENTS); + const untyped = await timeQueries(conn, UNTYPED_QUERY); + const typed = await timeQueries(conn, TYPED_QUERY); + return { + pairs: size, + untyped_ms: Number(untyped.ms.toFixed(3)), + untyped_rows: untyped.rows, + typed_ms: Number(typed.ms.toFixed(3)), + typed_rows: typed.rows, + }; + }); +} + +// ---- correctness gate: the REAL schema, round-tripped ---- + +async function verifyRealSchema() { + return withDb(async (conn) => { + for (const q of NODE_SCHEMA_QUERIES) await conn.query(q); + // If RELATION_SCHEMA declared a pair twice, LadybugDB rejects this outright + // — the failure mode that kills every `analyze`, not just one repo's. + await conn.query(RELATION_SCHEMA); + const res = await conn.query(`CALL SHOW_CONNECTION('${REL_TABLE_NAME}') RETURN *`); + const rows = await res.getAll(); + const actual = new Set( + rows.map( + (r) => + `${r['source table name'] ?? r.source}|${r['destination table name'] ?? r.destination}`, + ), + ); + const expected = parseRelationSchemaPairs(RELATION_SCHEMA); + const missing = [...expected].filter((p) => !actual.has(p)).sort(); + const extra = [...actual].filter((p) => !expected.has(p)).sort(); + return { declared_pairs: expected.size, db_pairs: actual.size, missing, extra }; + }); +} + +// ---- run ---- + +const CHECK = process.argv.includes('--check'); +const failures = []; + +const verified = await verifyRealSchema(); +if (verified.missing.length > 0 || verified.extra.length > 0) { + failures.push( + `RELATION_SCHEMA round-trip mismatch: ${verified.missing.length} pair(s) parsed but absent ` + + `from SHOW_CONNECTION (${verified.missing.slice(0, 5).join(', ')}), ${verified.extra.length} ` + + `present in the DB but unparsed (${verified.extra.slice(0, 5).join(', ')})`, + ); +} + +const results = []; +for (const size of SIZES) results.push(await measureSize(size)); + +const reference = results.find((r) => r.pairs === REFERENCE_SIZE); +const summary = { + ...verified, + missing: undefined, + extra: undefined, + reference_pairs: REFERENCE_SIZE, +}; +for (const r of results) { + summary[`untyped_ms_${r.pairs}`] = r.untyped_ms; + summary[`typed_ms_${r.pairs}`] = r.typed_ms; + summary[`ratio_${r.pairs}`] = Number((r.untyped_ms / reference.untyped_ms).toFixed(3)); + summary[`typed_ratio_${r.pairs}`] = Number((r.typed_ms / reference.typed_ms).toFixed(3)); +} + +// Row counts must be identical at every size — otherwise the sizes are not +// carrying the same data and the ratios mean nothing. +const rowShapes = new Set(results.map((r) => `${r.untyped_rows}/${r.typed_rows}`)); +if (rowShapes.size !== 1) { + failures.push( + `row counts differ across pair-set sizes (${[...rowShapes].join(' vs ')}) — the data pins ` + + `in DATA_PAIRS are not holding, so the ratios compare different graphs`, + ); +} + +if (!CHECK) { + for (const r of results) process.stdout.write(JSON.stringify(r) + '\n'); + process.stdout.write(JSON.stringify(summary) + '\n'); +} else { + const baselines = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8')); + const budget = baselines[`ratio_${PRODUCTION_SIZE}_budget`]; + if (budget !== undefined && summary[`ratio_${PRODUCTION_SIZE}`] >= budget) { + failures.push( + `production pair set (${PRODUCTION_SIZE}) costs ${summary[`ratio_${PRODUCTION_SIZE}`]}× vs ` + + `${REFERENCE_SIZE} pairs, >= budget ${budget} (untyped ${reference.untyped_ms}ms -> ` + + `${summary[`untyped_ms_${PRODUCTION_SIZE}`]}ms; typed control ` + + `${summary[`typed_ratio_${PRODUCTION_SIZE}`]}×)`, + ); + } + process.stdout.write(JSON.stringify(summary) + '\n'); +} + +if (failures.length > 0) { + for (const f of failures) process.stderr.write(`[schema-pairs] FAIL: ${f}\n`); + process.exit(1); +} +if (CHECK) process.stderr.write(`[schema-pairs --check] PASS (${results.length} sizes)\n`); diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 8b712832c..84a235786 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -14,10 +14,11 @@ "_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." }, "cobol": { - "fingerprint": "d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e", + "fingerprint": "c8c00b56a7da24e04080eb885714fbbf45e3903324f0cf9df0754f5b5a92e3aa", "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." + "_note": "Updated for F17-F23 fixes (P2: TIMES guard, ADD GIVING, SQL AS alias). See PR #1959.", + "_rebaselined_2793_declaratives": "PR #2793: corpus-only re-baseline. `cobol-declaratives` was added to test/fixtures/lang-resolution to reproduce the `Namespace\u2192Record` analyze abort (DECLARATIVES / USE AFTER STANDARD ERROR ON ), and this bench globs `lang-resolution/cobol-*`, so the corpus grew 14 -> 15 files. Verified capture-neutral: with that one fixture moved aside the fingerprint is byte-identical to the prior d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e. No COBOL capture code changed in that PR. Scaling 0.677 < 1.5." }, "c": { "fingerprint": "3418cded9f7072152f68992f0a426f43ae7d9d553579a47075fc0cab185848a5", diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 9475ee25f..6851c2478 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -15,6 +15,8 @@ import v8 from 'v8'; import cliProgress from 'cli-progress'; import { isLbugReady, LbugWipeError } from '../core/lbug/lbug-adapter.js'; import { boundedCheckpointBeforeExit } from '../core/lbug/shutdown-helpers.js'; +import { findUndeclaredRelationPairError } from '../core/lbug/rel-pair-routing.js'; +import { causeChain } from '../lib/utils.js'; import { getOsPageSize, isLbugCheckpointIoError, @@ -101,15 +103,13 @@ const writeFatalToStderr = (label: string, err: unknown): void => { // #2068) is only reachable via `.cause`. Without this the user sees the // wrapper's main-thread stack and never the real frame. `cause.stack` already // begins with the cause's message, so we print the stack alone (not message + - // stack) to avoid repeating it. Depth-bounded so a cyclic `cause` can't loop - // (the phase runner wraps one level; the bound leaves headroom for future - // nesting); uses realStderrWrite so the redirected console.error's ANSI - // clear-line wrapping can't erase it (#1169). - const MAX_CAUSE_DEPTH = 5; - let cause: unknown = isErr ? (err as { cause?: unknown }).cause : undefined; - for (let depth = 0; depth < MAX_CAUSE_DEPTH && cause instanceof Error; depth++) { + // stack) to avoid repeating it. `causeChain` owns the traversal and the depth + // bound that stops a cyclic `cause` looping — this used to be one of four + // hand-rolled copies that had already drifted apart on both. Uses + // realStderrWrite so the redirected console.error's ANSI clear-line wrapping + // can't erase it (#1169). The head is skipped: it was just printed above. + for (const cause of causeChain(isErr ? (err as { cause?: unknown }).cause : undefined)) { realStderrWrite(`\n Caused by: ${cause.stack ?? cause.message}\n`); - cause = (cause as { cause?: unknown }).cause; } }; @@ -1717,6 +1717,36 @@ const analyzeCommandImpl = async ( return; } + // An extracted edge whose FROM→TO label pair is missing from GitNexus's own + // relation DDL (#2789). `assertDeclaredPair` aborts the run rather than let + // the bulk COPY fail late and silently drop the edge, so the user sees a + // mid-run crash inside GitNexus internals with nothing to act on. Name the + // pair, the relationship and the file that produced it, and say plainly that + // a re-run cannot help — this is deterministic for the same input. + // Checked by TYPE (repo norm, #2385) BEFORE the message-text heuristics + // below, and through the `cause` chain because the ingestion phase runner + // rewraps every phase failure as `Phase 'X' failed: …`. + const undeclaredPair = findUndeclaredRelationPairError(err); + if (undeclaredPair !== undefined) { + // Render the error's OWN message indented — same idiom as the + // `LbugWipeError` and page-size branches below. `UndeclaredRelationPairError` + // builds a fully self-contained message (pair, relationship type, both node + // ids, source file, issue URL, `.gitnexusignore` workaround) precisely + // because `gitnexus serve` forwards only `err.message` over worker IPC. + // Re-rendering those fields here would be a second copy of one string, free + // to drift from the first — and the actionable half would reach CLI users + // only. `undeclaredPair.message`, not the outer `msg`: the real error may be + // several `cause` levels below the phase wrapper `msg` came from. + cliError(` ${undeclaredPair.message.replace(/\n/g, '\n ')}\n`, { + recoveryHint: 'undeclared-relation-pair', + labelPair: undeclaredPair.pairKey, + relationType: undeclaredPair.relationType, + sourceFile: undeclaredPair.sourceFile, + }); + process.exitCode = 1; + return; + } + // WAL corruption — the index file is unreadable. Give a clear recovery // path without a confusing stack trace (the native error message alone // is enough signal). diff --git a/gitnexus/src/cli/cli-message.ts b/gitnexus/src/cli/cli-message.ts index 61b65dd00..b9b509b52 100644 --- a/gitnexus/src/cli/cli-message.ts +++ b/gitnexus/src/cli/cli-message.ts @@ -60,7 +60,8 @@ export type RecoveryHint = | 'module-not-found' | 'gitnexusrc-invalid' | 'default-branch-invalid' - | 'index-lock-timeout'; + | 'index-lock-timeout' + | 'undeclared-relation-pair'; /** * Common shape for the optional structured-field bag passed to diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts index abe8b48e4..6f942d88b 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts @@ -54,17 +54,19 @@ import { definitionIdPosition } from '../utils/definition-id.js'; * restricted to function/class-likes, those calls correctly fall * through to the File-node fallback at the bottom of the walk. */ +export const CALLER_ANCHOR_LABELS: ReadonlySet = new Set([ + 'Function', + 'Method', + 'Constructor', + 'Module', + 'Class', + 'Interface', + 'Struct', + 'Enum', +]); + function isCallerAnchorLabel(label: NodeLabel): boolean { - return ( - label === 'Function' || - label === 'Method' || - label === 'Constructor' || - label === 'Module' || - label === 'Class' || - label === 'Interface' || - label === 'Struct' || - label === 'Enum' - ); + return CALLER_ANCHOR_LABELS.has(label); } function rangeContainsPoint( diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts index 1c6b94410..a8f90393a 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts @@ -244,38 +244,53 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { return lookup; } +/** + * Every label {@link buildGraphNodeLookup} registers — and therefore the ONLY + * labels `resolveDefGraphId` can ever return an id for. Both endpoints of every + * scope-resolution edge come from that lookup (the one exception is the File + * fallback in `resolveCallerGraphId`), so this set defines the whole FROM/TO + * surface those edges can produce. + * + * That makes it load-bearing for the LadybugDB relation DDL: a label added here + * without the matching `FROM x TO y` pairs in `RELATION_SCHEMA` crashes + * `analyze` at `assertDeclaredPair` on whichever codebase first emits the pair + * (#2792). `test/unit/schema-pair-coverage.test.ts` derives the required pairs + * from this set and fails in CI instead. + */ +export const LINKABLE_LABELS: ReadonlySet = new Set([ + 'Function', + 'Method', + 'Constructor', + // Program-like module declarations are provider-gated callable-value + // targets and need the same def→graph bridge. + 'Module', + 'Class', + 'Interface', + 'Struct', + 'Enum', + // Trait nodes are linkable so MRO builders can bridge PHP/Rust trait + // defs between scope-resolution DefIds and the graph's node ids. + // IMPLEMENTS edges from classes to traits are otherwise invisible to + // the scope-resolution MRO pass. + 'Trait', + // Variable / Property are linkable too — receiver-bound write/read + // ACCESSES edges target field nodes (e.g. `user.name = "x"` → + // ACCESSES edge to User's `name` Variable/Property node). + 'Variable', + 'Property', + // Const is linkable so the value-receiver-owner bridge in + // `receiver-bound-calls.ts` Case 5 can translate the scope-resolution + // `Variable` def for `export const fooService = {...}` to the canonical + // `Const:filePath:name` graph node id, against which object-literal + // method symbols register their `ownerId` (PR #1718 / issue #1358). + 'Const', + // Macro nodes are linkable so a macro invocation (`log!(…)`) resolved + // via `MacroRegistry` can bridge its scope-resolution `Macro` def to + // the legacy `@definition.macro` graph node and emit the `USES` edge + // (Rust #1934 F72; also covers C/C++ `#define` macro defs). + 'Macro', +]); + export function isLinkableLabel(label: NodeLabel): boolean { - return ( - label === 'Function' || - label === 'Method' || - label === 'Constructor' || - // Program-like module declarations are provider-gated callable-value - // targets and need the same def→graph bridge. - label === 'Module' || - label === 'Class' || - label === 'Interface' || - label === 'Struct' || - label === 'Enum' || - // Trait nodes are linkable so MRO builders can bridge PHP/Rust trait - // defs between scope-resolution DefIds and the graph's node ids. - // IMPLEMENTS edges from classes to traits are otherwise invisible to - // the scope-resolution MRO pass. - label === 'Trait' || - // Variable / Property are linkable too — receiver-bound write/read - // ACCESSES edges target field nodes (e.g. `user.name = "x"` → - // ACCESSES edge to User's `name` Variable/Property node). - label === 'Variable' || - label === 'Property' || - // Const is linkable so the value-receiver-owner bridge in - // `receiver-bound-calls.ts` Case 5 can translate the scope-resolution - // `Variable` def for `export const fooService = {...}` to the canonical - // `Const:filePath:name` graph node id, against which object-literal - // method symbols register their `ownerId` (PR #1718 / issue #1358). - label === 'Const' || - // Macro nodes are linkable so a macro invocation (`log!(…)`) resolved - // via `MacroRegistry` can bridge its scope-resolution `Macro` def to - // the legacy `@definition.macro` graph node and emit the `USES` edge - // (Rust #1934 F72; also covers C/C++ `#define` macro defs). - label === 'Macro' - ); + return LINKABLE_LABELS.has(label); } diff --git a/gitnexus/src/core/lbug/csv-generator.ts b/gitnexus/src/core/lbug/csv-generator.ts index 68a66aa0e..4444a9687 100644 --- a/gitnexus/src/core/lbug/csv-generator.ts +++ b/gitnexus/src/core/lbug/csv-generator.ts @@ -17,8 +17,8 @@ import { createWriteStream, WriteStream } from 'fs'; import path from 'path'; import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; import { KnowledgeGraph } from '../graph/types.js'; -import { NodeTableName, NODE_TABLES, RELATION_SCHEMA } from './schema.js'; -import { parseRelationSchemaPairs, RelPairRouter } from './rel-pair-routing.js'; +import { NodeTableName, RELATION_SCHEMA } from './schema.js'; +import { VALID_NODE_TABLES, parseRelationSchemaPairs, RelPairRouter } from './rel-pair-routing.js'; import { parseTruthyEnv } from '../ingestion/utils/env.js'; import { SYMBOL_NODE_LABELS } from '../ingestion/utils/symbol-labels.js'; import { applyCjkSegmentationIfEnabled } from '../search/cjk-segmentation.js'; @@ -793,13 +793,13 @@ export const streamAllCSVsToDisk = async ( const relRouter = new RelPairRouter( csvDir, REL_CSV_HEADER, - new Set(NODE_TABLES), + VALID_NODE_TABLES, DECLARED_RELATION_PAIRS, ); try { let emitted = 0; for (const rel of orderedRelationships(graph, sortOutput)) { - const pending = relRouter.route(rel.sourceId, rel.targetId, buildRelRow(rel)); + const pending = relRouter.route(rel.sourceId, rel.targetId, buildRelRow(rel), rel.type); if (pending) await pending; // Periodically hand the event loop back so the overlapped node COPY and // write-stream drains run instead of starving behind this synchronous diff --git a/gitnexus/src/core/lbug/graph-emit-sink.ts b/gitnexus/src/core/lbug/graph-emit-sink.ts index a0491ab14..f1527c6d2 100644 --- a/gitnexus/src/core/lbug/graph-emit-sink.ts +++ b/gitnexus/src/core/lbug/graph-emit-sink.ts @@ -85,9 +85,10 @@ * ## Correctness contract * * Structural sibling of {@link PdgEmitSink}, and reuses its row builder - * (`buildRelRow`), header (`REL_CSV_HEADER`), label derivation (`getNodeLabel`) - * and `RelPairRouter` validity check, so the streamed row SET equals the - * whole-graph emit's and the bulk COPY loads the same rows. Set-level, not + * (`buildRelRow`), header (`REL_CSV_HEADER`) and pair classification + * (`relPairKeyFor`, which is also what `RelPairRouter` routes and skips by), so + * the streamed row SET equals the whole-graph emit's and the bulk COPY loads + * the same rows. Set-level, not * byte-level: rows stream in emit order and are not re-sorted under * `GITNEXUS_SORT_GRAPH_OUTPUT`. */ @@ -96,8 +97,12 @@ import path from 'path'; import type { GraphNode, GraphRelationship, RelationshipType } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../graph/types.js'; import { DECLARED_RELATION_PAIRS, REL_CSV_HEADER, buildRelRow } from './csv-generator.js'; -import { assertDeclaredPair, getNodeLabel } from './rel-pair-routing.js'; -import { NODE_TABLES } from './schema.js'; +import { + VALID_NODE_TABLES, + assertDeclaredPair, + relPairKeyFor, + splitRelPairKey, +} from './rel-pair-routing.js'; import { DEFAULT_EMIT_CHUNK_ROWS, SyncCsvWriter } from './sync-csv-writer.js'; /** @@ -229,7 +234,6 @@ export class StreamedRelationshipRemovalError extends Error { * {@link finalize} once after the pipeline, before `loadGraphToLbug`. */ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl { - private readonly validTables: Set; private readonly relWriters = new Map(); /** * Ids of relationships already streamed. `KnowledgeGraph.addRelationship` @@ -303,7 +307,6 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl { private readonly csvDir: string, private readonly chunkRows: number = DEFAULT_EMIT_CHUNK_ROWS, ) { - this.validTables = new Set(NODE_TABLES as readonly string[]); // Own directory, distinct from the PDG sink's: PdgEmitSink wipes and // recreates its dir on construction and opens with O_EXCL, so a shared dir // would destroy the other sink's manifest on a combined --pdg run. @@ -407,16 +410,24 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl { } // Mirror KnowledgeGraph.addRelationship's first-writer-wins dedup. - const fromLabel = getNodeLabel(relationship.sourceId); - const toLabel = getNodeLabel(relationship.targetId); - // Skip edges whose endpoint labels are not valid node tables — mirrors - // `RelPairRouter` exactly so the streamed set matches the whole-graph set. - if (!this.validTables.has(fromLabel) || !this.validTables.has(toLabel)) return; + // Classify + skip via the SHARED `relPairKeyFor`, not a local copy of its + // three lines, so the streamed set cannot drift from the whole-graph set + // `RelPairRouter` produces. `undefined` = an endpoint label is not a node + // table, so the edge is dropped exactly as the router drops it. + const pairKey = relPairKeyFor(relationship.sourceId, relationship.targetId, VALID_NODE_TABLES); + if (pairKey === undefined) return; - const pairKey = `${fromLabel}|${toLabel}`; - assertDeclaredPair(pairKey, DECLARED_RELATION_PAIRS); + assertDeclaredPair( + pairKey, + DECLARED_RELATION_PAIRS, + relationship.type, + relationship.sourceId, + relationship.targetId, + ); let writer = this.relWriters.get(pairKey); if (writer === undefined) { + // Cold: once per pair, so decoding the key back into its labels is free. + const [fromLabel, toLabel] = splitRelPairKey(pairKey); try { writer = new SyncCsvWriter( path.join(this.csvDir, `rel_${fromLabel}_${toLabel}.csv`), diff --git a/gitnexus/src/core/lbug/pdg-emit-sink.ts b/gitnexus/src/core/lbug/pdg-emit-sink.ts index cc5560d2a..78d337816 100644 --- a/gitnexus/src/core/lbug/pdg-emit-sink.ts +++ b/gitnexus/src/core/lbug/pdg-emit-sink.ts @@ -24,8 +24,8 @@ * `storage/parsedfile-store.ts`. * * Byte-identity (issue acceptance): the sink reuses the SAME shared row - * builders (`buildBasicBlockRow`, `buildRelRow`) and label derivation - * (`getNodeLabel`) as `streamAllCSVsToDisk`, so the streamed CSV line SET is + * builders (`buildBasicBlockRow`, `buildRelRow`) and pair classification + * (`relPairKeyFor`) as `streamAllCSVsToDisk`, so the streamed CSV line SET is * identical to the whole-graph emit's, and the bulk COPY loads the same rows → * the persisted graph is SET-identical and DB-identical. The guarantee is * set-level, not byte-level on the CSV file: the sink streams rows in emit @@ -54,9 +54,14 @@ import { buildBasicBlockRow, buildRelRow, } from './csv-generator.js'; -import { assertDeclaredPair, getNodeLabel } from './rel-pair-routing.js'; +import { + VALID_NODE_TABLES, + assertDeclaredPair, + relPairKeyFor, + splitRelPairKey, +} from './rel-pair-routing.js'; import { DEFAULT_EMIT_CHUNK_ROWS, SyncCsvWriter } from './sync-csv-writer.js'; -import { NODE_TABLES, type NodeTableName } from './schema.js'; +import { type NodeTableName } from './schema.js'; /** * PDG edge types streamed per-file (all intra-block BasicBlock→BasicBlock). @@ -98,7 +103,6 @@ export interface PdgEmitManifest { * `--pdg` emit, then {@link finalize} once after the last language. */ export class PdgEmitSink implements KnowledgeGraph { - private readonly validTables: Set; private bbWriter: SyncCsvWriter | undefined; /** pairKey (`From|To`) → writer. PDG edges are all `BasicBlock|BasicBlock`, * but the map keeps the sink general and the manifest pair-keyed. */ @@ -129,7 +133,6 @@ export class PdgEmitSink implements KnowledgeGraph { private readonly pdgCsvDir: string, private readonly chunkRows: number = DEFAULT_PDG_EMIT_CHUNK_ROWS, ) { - this.validTables = new Set(NODE_TABLES as readonly string[]); // Clear any streamed CSVs left by a previous (possibly crashed) run so a // later COPY never picks up stale rows. fs.rmSync(pdgCsvDir, { recursive: true, force: true }); @@ -160,15 +163,27 @@ export class PdgEmitSink implements KnowledgeGraph { addRelationship(relationship: GraphRelationship): void { if (PDG_EDGE_TYPES.has(relationship.type)) { - const fromLabel = getNodeLabel(relationship.sourceId); - const toLabel = getNodeLabel(relationship.targetId); - // Skip edges whose endpoint labels are not valid node tables — mirrors - // `RelPairRouter` exactly so the streamed set matches the whole-graph set. - if (!this.validTables.has(fromLabel) || !this.validTables.has(toLabel)) return; - const pairKey = `${fromLabel}|${toLabel}`; - assertDeclaredPair(pairKey, DECLARED_RELATION_PAIRS); + // Classify + skip via the SHARED `relPairKeyFor`, not a local copy of its + // three lines, so the streamed set cannot drift from the whole-graph set + // `RelPairRouter` produces. `undefined` = an endpoint label is not a node + // table, so the edge is dropped exactly as the router drops it. + const pairKey = relPairKeyFor( + relationship.sourceId, + relationship.targetId, + VALID_NODE_TABLES, + ); + if (pairKey === undefined) return; + assertDeclaredPair( + pairKey, + DECLARED_RELATION_PAIRS, + relationship.type, + relationship.sourceId, + relationship.targetId, + ); let writer = this.relWriters.get(pairKey); if (writer === undefined) { + // Cold: once per pair, so decoding the key back into labels is free. + const [fromLabel, toLabel] = splitRelPairKey(pairKey); try { writer = new SyncCsvWriter( path.join(this.pdgCsvDir, `rel_${fromLabel}_${toLabel}.csv`), diff --git a/gitnexus/src/core/lbug/rel-pair-routing.ts b/gitnexus/src/core/lbug/rel-pair-routing.ts index 5a77cec0b..56b95d0aa 100644 --- a/gitnexus/src/core/lbug/rel-pair-routing.ts +++ b/gitnexus/src/core/lbug/rel-pair-routing.ts @@ -31,10 +31,29 @@ import path from 'path'; import { createWriteStream, type WriteStream } from 'fs'; import { once } from 'events'; import { finished } from 'stream/promises'; +import { NODE_TABLES } from 'gitnexus-shared'; +import { findInCauseChain } from '../../lib/utils.js'; /** Injectable for tests (backpressure/error simulation), mirroring split. */ export type WriteStreamFactory = (filePath: string) => WriteStream; +/** + * Every label LadybugDB has a node table for — the filter that decides whether + * an edge is routable at all. + * + * ONE shared instance, deliberately. `RelPairRouter`, `GraphEmitSink` and + * `PdgEmitSink` each used to build their own `new Set(NODE_TABLES)`; three + * copies of the same immutable set are three chances to seed one of them from + * a different source. Typed `ReadonlySet` because that — not `Object.freeze`, + * which does not touch a Set's internal slots — is what actually stops a + * consumer mutating the shared instance. + * + * Imported straight from `gitnexus-shared` rather than `./schema.js`: schema.ts + * imports `parseRelationSchemaPairs` from this module, so the reverse import + * would close a cycle. + */ +export const VALID_NODE_TABLES: ReadonlySet = new Set(NODE_TABLES); + /** * Derive a node's table label from its graph id. Matches the legacy * `getNodeLabel` that lived inline in `loadGraphToLbug`: @@ -48,20 +67,92 @@ export const getNodeLabel = (nodeId: string): string => { return nodeId.split(':')[0]; }; +/** + * Classify one edge into its `From|To` pair key, or `undefined` when the edge + * must be SKIPPED because an endpoint's label is not a real node table. + * + * THE single definition of "which pair does this edge belong to, and is it + * routable at all". `RelPairRouter.route`, `GraphEmitSink.addRelationship`, + * `PdgEmitSink.addRelationship` and the `structural-pair-coverage` corpus guard + * each used to inline the same three lines (label both ends → drop if either + * label is not a node table → join with `|`). The corpus guard's docblock said + * it "mirrors `RelPairRouter.route`" — a mirror is a drift marker: change the + * skip rule here and the guard would keep classifying by the old one, report + * green, and let `analyze` abort on a pair it had already declared covered. + * + * HOT PATH — called once per edge (~1M on a large repo). Returns the key + * string (which every caller needs anyway for its own Map lookup) rather than + * a `{ pairKey, fromLabel, toLabel }` object or a tuple, so the success path + * allocates nothing beyond what `getNodeLabel` already did. Callers that need + * the two labels back — only when opening a new pair's CSV, once per pair — + * decode the key with {@link splitRelPairKey}. + */ +export const relPairKeyFor = ( + fromId: string, + toId: string, + validTables: ReadonlySet, +): string | undefined => { + const fromLabel = getNodeLabel(fromId); + const toLabel = getNodeLabel(toId); + if (!validTables.has(fromLabel) || !validTables.has(toLabel)) return undefined; + return `${fromLabel}|${toLabel}`; +}; + +/** + * Decode a `From|To` pair key back into its two labels. + * + * Safe because `|` cannot occur inside a node label: every label is a + * `NODE_TABLES` identifier (`[A-Za-z][A-Za-z0-9_]*`), so the FIRST `|` is + * always the separator. That invariant was documented in one comment and + * enforced nowhere while every consumer re-derived it with a bare + * `key.split('|')`. + * + * DECODE ONLY — there is deliberately no matching `encode` helper. The key is + * built once per edge inside {@link relPairKeyFor} (~1M edges on a large + * repo), where a function call is a real regression risk; every decode site is + * cold by construction (once per pair when its CSV is opened, or on the + * throw path of {@link assertDeclaredPair}). + */ +export const splitRelPairKey = (key: string): readonly [from: string, to: string] => { + const sep = key.indexOf('|'); + return sep < 0 ? [key, ''] : [key.slice(0, sep), key.slice(sep + 1)]; +}; + +/** + * Build a fresh matcher for the `FROM