From 4ccba12004f325ae777c5a8fe92a6474e2e6c79c Mon Sep 17 00:00:00 2001 From: Parafee41 Date: Thu, 8 Oct 2026 16:05:41 +0800 Subject: [PATCH] fix(scope): keep nested declarations out of module bindings (#3502) --- .../scope-resolution/finalize-algorithm.ts | 57 +++++- .../src/scope-resolution/parsed-file.ts | 10 +- .../python-scope/baseline-fingerprint.txt | 2 +- gitnexus/bench/scope-capture/baselines.json | 15 +- .../core/ingestion/finalize-orchestrator.ts | 20 +- .../ingestion/languages/php/simple-hooks.ts | 19 ++ .../ingestion/languages/python/captures.ts | 16 +- .../languages/python/receiver-binding.ts | 3 + .../languages/python/scope-resolver.ts | 1 + .../languages/python/simple-hooks.ts | 66 +++++-- .../core/ingestion/languages/ruby/captures.ts | 35 ++++ .../ingestion/languages/ruby/simple-hooks.ts | 19 ++ .../src/core/ingestion/scope-extractor.ts | 2 + .../contract/scope-resolver.ts | 5 + .../scope-resolution/pipeline/run.ts | 1 + gitnexus/src/storage/parse-cache.ts | 8 +- .../php-nested-function/main.php | 13 ++ .../php-nested-function/other.php | 5 + .../python-global-nested-def/main.py | 67 +++++++ .../python-global-nested-def/other.py | 16 ++ .../python-nested-def-scope/facade.py | 2 + .../python-nested-def-scope/function_local.py | 11 ++ .../method_function_local.py | 9 + .../method_module_import.py | 10 + .../python-nested-def-scope/method_unbound.py | 7 + .../python-nested-def-scope/module_import.py | 12 ++ .../python-nested-def-scope/unbound.py | 9 + .../ruby-nested-method/main.rb | 59 ++++++ .../ruby-nested-method/other.rb | 21 +++ .../expected-captures.json | 8 + .../expected-captures.json | 36 ++++ .../expected-captures.json | 178 +++++++++--------- .../resolvers/ipynb-python-pipeline.test.ts | 29 +++ .../test/integration/resolvers/php.test.ts | 17 ++ .../test/integration/resolvers/python.test.ts | 105 +++++++++++ .../test/integration/resolvers/ruby.test.ts | 45 +++++ .../test/unit/incremental-parse-cache.test.ts | 4 +- .../finalize-algorithm.test.ts | 65 +++++++ .../python/python-global-declarations.test.ts | 99 ++++++++++ .../ruby/ruby-nested-method.test.ts | 112 +++++++++++ 40 files changed, 1099 insertions(+), 119 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/php-nested-function/main.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-nested-function/other.php create mode 100644 gitnexus/test/fixtures/lang-resolution/python-global-nested-def/main.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-global-nested-def/other.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-nested-def-scope/facade.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-nested-def-scope/function_local.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-nested-def-scope/method_function_local.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-nested-def-scope/method_module_import.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-nested-def-scope/method_unbound.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-nested-def-scope/module_import.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-nested-def-scope/unbound.py create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-nested-method/main.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-nested-method/other.rb create mode 100644 gitnexus/test/unit/scope-resolution/python/python-global-declarations.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/ruby/ruby-nested-method.test.ts diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts index 2dc14b398..dad6ae6ea 100644 --- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -70,6 +70,17 @@ export interface FinalizeFile { * static input. */ readonly localDefs: readonly SymbolDefinition[]; + /** + * The extractor's actual lexical bindings for {@link moduleScope}. + * + * `localDefs` is deliberately a flattened inventory and also contains + * methods and nested functions. Structural `ownedDefs` is not equivalent + * either: scope-creating declarations are owned by their body scope while + * their names are hoisted into the parent binding map. Omitted only by + * legacy/direct callers, where finalize retains the historical `localDefs` + * behavior for compatibility. + */ + readonly moduleBindings?: ReadonlyMap; } /** Input to `finalize`. */ @@ -85,6 +96,10 @@ export interface FinalizeInput { * expects pure answers. */ export interface FinalizeHooks { + /** Retain the legacy module lookup surface for class-owned definitions. + * Providers with strictly lexical bare-name lookup disable this, independently + * of the source container or file extension. Defaults to true. */ + readonly ownedMembersBindAtModuleScope?: boolean; /** Bind imports at their extracted lexical scope. Missing provenance retains * the legacy module-scope behavior. Opt-in: lexical position and language * import-binding semantics are distinct facts. */ @@ -1603,13 +1618,41 @@ function materializeBindings( for (const file of files) { const scopeBindings = new Map(); - // Start with local defs as `origin: 'local'` bindings. - for (const def of file.localDefs) { - const name = deriveSimpleName(def); - if (name === null) continue; - const incoming: BindingRef[] = [{ def, origin: 'local' }]; - const existing = scopeBindings.get(name) ?? []; - scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope)); + // Start with declarations genuinely bound at module scope. Preserve the + // extractor's binding names and refs: structural ownership is different + // from lexical visibility for top-level functions/classes. + if (file.moduleBindings !== undefined) { + for (const [name, incoming] of file.moduleBindings) { + const existing = scopeBindings.get(name) ?? []; + scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope)); + } + // Some language enrichers synthesize members after scope extraction + // (for example Lombok accessors). They have no lexical scope binding to + // project, but existing dispatch/index consumers still need them in the + // finalized lookup surface. Legacy resolvers also depend on + // class-owned definitions for implicit receiver, constructor, inherited, + // and partial-class lookup. Providers with strictly lexical bare-name + // lookup disable this compatibility surface. It can disappear once + // those providers project their implicit member surfaces explicitly. + const allowOwnedCompatibility = hooks.ownedMembersBindAtModuleScope !== false; + for (const def of file.localDefs) { + if (def.isSynthetic !== true && !(allowOwnedCompatibility && def.ownerId !== undefined)) + continue; + const name = deriveSimpleName(def); + if (name === null) continue; + const incoming: BindingRef[] = [{ def, origin: 'local' }]; + const existing = scopeBindings.get(name) ?? []; + scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope)); + } + } else { + // Compatibility for direct/legacy callers that predate scope bindings. + for (const def of file.localDefs) { + const name = deriveSimpleName(def); + if (name === null) continue; + const incoming: BindingRef[] = [{ def, origin: 'local' }]; + const existing = scopeBindings.get(name) ?? []; + scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope)); + } } buckets.set(file.moduleScope, scopeBindings); diff --git a/gitnexus-shared/src/scope-resolution/parsed-file.ts b/gitnexus-shared/src/scope-resolution/parsed-file.ts index 140193afc..e412bc979 100644 --- a/gitnexus-shared/src/scope-resolution/parsed-file.ts +++ b/gitnexus-shared/src/scope-resolution/parsed-file.ts @@ -32,11 +32,11 @@ * * ## Compatibility with `FinalizeFile` * - * `FinalizeFile` (defined in `./finalize-algorithm.ts`) is a structural - * subset of `ParsedFile` — `filePath`, `moduleScope`, `parsedImports`, - * `localDefs`. A `ParsedFile` is trivially convertible to a `FinalizeFile` - * by picking those four fields, so the finalize orchestrator threads - * ParsedFile through to the shared algorithm without shape-shifting. + * `FinalizeFile` (defined in `./finalize-algorithm.ts`) is a projection of + * `ParsedFile`: `filePath`, `moduleScope`, `parsedImports`, and `localDefs` + * copy directly; `moduleBindings` is taken from the module scope's lexical + * binding map so flattened nested declarations do not become module bindings + * while hoisted top-level declarations remain visible. * * ## Source-of-truth invariant * diff --git a/gitnexus/bench/python-scope/baseline-fingerprint.txt b/gitnexus/bench/python-scope/baseline-fingerprint.txt index 19cfd625e..b9a9286eb 100644 --- a/gitnexus/bench/python-scope/baseline-fingerprint.txt +++ b/gitnexus/bench/python-scope/baseline-fingerprint.txt @@ -1 +1 @@ -dde4450f8bb763575b0464255f063fd0ded24980ecf43d7d2a7d4dc4880f2665 +1ab17d9cb80c3863ac9df17e4eefe4983a21f8d9908c110e8a8d9fa1b35efcc0 diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 60ffca32d..1c2992688 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -97,7 +97,9 @@ "fixture_count": 202 }, "php": { - "fingerprint": "b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c", + "fingerprint": "6e92ed6e50c2ba4b383b97b9f9b550a99d2d647a30a2867b53ad94f24114952d", + "_rebaselined_3502_attributed_function_fixture": "#3502 extends the existing php-nested-function fixture with an attributed, comment-interposed named function and a long-whitespace closure control. This is fixture-content growth only: the capture implementation now classifies existing captures instead of scanning source text, so it cannot mint or remove matches. The fixture corpus stays at 143 files; captured groups move 2237 -> 2241, while synthetic scaling counts stay 4258/13608. Prior 0adf565ed79dc1d909c23a18a9e3a17ddaa6556fb73857081840cd7389953075 -> 6e92ed6e50c2ba4b383b97b9f9b550a99d2d647a30a2867b53ad94f24114952d.", + "_rebaselined_3502_nested_function_fixture": "#3502 adds the php-nested-function fixture used by the resolver regression. The capture implementation and query are unchanged; this is fixture-corpus growth only. The unified harness moves from 141 to 143 fixture files and from 2203 to 2237 captured groups, while the synthetic scaling counts remain 4258/13608 and every other language matches its baseline. Prior 421cbd318c8857f840aa60904a675cbde32c6bb57b01e6c0f970d10db572b99e -> 0adf565ed79dc1d909c23a18a9e3a17ddaa6556fb73857081840cd7389953075.", "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.", @@ -107,7 +109,9 @@ "_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": "45e65d9fa8a9e5e905ddb596b179b77c86ed13ab5139c6b17ccaaf7315dfe46a", + "fingerprint": "3c222b5787c23a5d18780784906a8de0a6fe26dd0ff18c729c0690e2852c9389", + "_rebaselined_3502_block_boundary_fixture": "#3502 strengthens ruby-nested-method with a receiver-rebinding class_eval block. The provider fix changes binding placement only; capture emission is unchanged. Corpus size stays 94 entries while capture_groups_fp grows 1431 -> 1445 from the added fixture source. Synthetic counts remain 3011/9611 and measured scaling 0.922 < 1.5. Prior d304127cf066aac754605482102f82c268451ae31dd18d7a7e661ce3a2266a07 -> ff4bff0ddd0d43fe61baaba7937bb4b88bd8e98b4cadba696a6d0d363fdf1963.", + "_rebaselined_3502_nested_method_fixture": "#3502 adds the two-file ruby-nested-method fixture used by the resolver regression. The Ruby provider change projects existing def captures during resolution and does not alter capture emission. This is fixture-corpus growth only: fixture_count 92 -> 94 and capture_groups_fp 1416 -> 1431, while synthetic scaling counts stay 3011/9611. Prior 45e65d9fa8a9e5e905ddb596b179b77c86ed13ab5139c6b17ccaaf7315dfe46a -> d304127cf066aac754605482102f82c268451ae31dd18d7a7e661ce3a2266a07.", "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.", @@ -116,7 +120,12 @@ "_rebaselined_2522_review_fixes": "PR #2522 review fixes: bare identifiers are calls, not callable references (bareNamesAreCalls). Prior bf50ec6a53c8c91680dc6feac63a8956e78b1059249232dc25a0cfed25f31236 -> 070e4e11502442998ddf4048c2981cf1b2b735a87362ff854c5d14d71f98f4e2; scaling ratio re-verified within budget.", "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior fea3edf82f521995147874b7f6c5f9e2eb88efdebf6365668f3260e913f0b558 -> fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83.", "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83 -> 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57.", - "_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: ruby-callable-alternatives/app.rb (+1 file, +58 groups). Emitter delta: +5 / -0: seeds for single-statement `if` / `elsif` branches (run_then, run_else, run_sweep, run_a, run_b); the multi-statement branch contributes nothing. capture_groups_fp 1358 -> 1416, fixture_count 91 -> 92; synthetic counts unchanged; scaling 1.04 < 1.5. Prior 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57 -> 45e65d9fa8a9e5e905ddb596b179b77c86ed13ab5139c6b17ccaaf7315dfe46a." + "_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: ruby-callable-alternatives/app.rb (+1 file, +58 groups). Emitter delta: +5 / -0: seeds for single-statement `if` / `elsif` branches (run_then, run_else, run_sweep, run_a, run_b); the multi-statement branch contributes nothing. capture_groups_fp 1358 -> 1416, fixture_count 91 -> 92; synthetic counts unchanged; scaling 1.04 < 1.5. Prior 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57 -> 45e65d9fa8a9e5e905ddb596b179b77c86ed13ab5139c6b17ccaaf7315dfe46a.", + "capture_groups_small": 3011, + "capture_groups_large": 9611, + "capture_groups_fp": 1488, + "fixture_count": 94, + "_rebaselined_3502_ast_method_ownership": "#3502 review: Ruby ordinary method ownership is now marked from AST node types, including methods nested in a method body inside a class. Singleton methods and receiver-rebinding or unproven blocks do not carry the marker. Golden snapshots attribute the digest changes to the marker and expanded ruby-nested-method regressions; fixture count stays 94, capture_groups_fp becomes 1488, synthetic counts stay 3011/9611. Scaling ratio 1.093 remains below the unchanged 1.5 budget. Prior ff4bff0ddd0d43fe61baaba7937bb4b88bd8e98b4cadba696a6d0d363fdf1963 -> 3c222b5787c23a5d18780784906a8de0a6fe26dd0ff18c729c0690e2852c9389." }, "swift": { "fingerprint": "2507ac75ba6fb47c272a5caa97b5e8b9f6ebc5098c15295d05c7bff3cabc321a", diff --git a/gitnexus/src/core/ingestion/finalize-orchestrator.ts b/gitnexus/src/core/ingestion/finalize-orchestrator.ts index 4a0e99f6b..13320221b 100644 --- a/gitnexus/src/core/ingestion/finalize-orchestrator.ts +++ b/gitnexus/src/core/ingestion/finalize-orchestrator.ts @@ -165,15 +165,28 @@ export function finalizeScopeModel( // ─── Internal ─────────────────────────────────────────────────────────────── -/** Shape-reduce a `ParsedFile` to the narrower `FinalizeFile` the shared - * algorithm reads. The subset is stable — `FinalizeFile` is a proper - * subset of `ParsedFile`. */ +/** Project a `ParsedFile` into the narrower `FinalizeFile` shape. */ function toFinalizeFile(file: ParsedFile): FinalizeFile { + const moduleScope = file.scopes.find((scope) => scope.id === file.moduleScope); + const moduleBindings = new Map(moduleScope?.bindings ?? []); + // Namespace members historically participate in the same-file unqualified + // fallback (notably C/C++ anonymous namespaces). Keep that language surface + // while excluding function/class-body bindings that caused #3499. + for (const scope of file.scopes) { + if (scope.kind !== 'Namespace') continue; + for (const [name, refs] of scope.bindings) { + moduleBindings.set(name, [...(moduleBindings.get(name) ?? []), ...refs]); + } + } return { filePath: file.filePath, moduleScope: file.moduleScope, parsedImports: file.parsedImports, localDefs: file.localDefs, + // The extractor always emits the module scope. Its binding map is the + // authoritative lexical surface; `ownedDefs` is structural ownership and + // excludes hoisted top-level function/class declarations. + moduleBindings, }; } @@ -200,6 +213,7 @@ function collectReferenceSites(parsedFiles: readonly ParsedFile[]) { */ function withDefaultHooks(partial: Partial): FinalizeHooks { return { + ownedMembersBindAtModuleScope: partial.ownedMembersBindAtModuleScope, importsBindAtLexicalScope: partial.importsBindAtLexicalScope === true, resolveImportTarget: partial.resolveImportTarget ?? (() => null), isNamespaceImport: partial.isNamespaceImport, diff --git a/gitnexus/src/core/ingestion/languages/php/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/php/simple-hooks.ts index 89a9a2658..a0f50a575 100644 --- a/gitnexus/src/core/ingestion/languages/php/simple-hooks.ts +++ b/gitnexus/src/core/ingestion/languages/php/simple-hooks.ts @@ -28,6 +28,25 @@ export function phpBindingScopeFor( innermost: Scope, tree: ScopeTree, ): ScopeId | null { + // A named PHP function declared inside another function becomes globally + // callable once the outer declaration executes. Tree-sitter represents the + // declaration as its own Function scope, so the generic equal-range rule + // would bind it only to the enclosing Function. Named declarations carry an + // unsigilled @declaration.name; closures and arrows use the assigned variable + // name, including its `$` sigil. Classify the existing captures instead of + // reparsing declaration text, which may begin with attributes or comments. + const functionDecl = decl['@declaration.function']; + const functionName = decl['@declaration.name']?.text; + if (functionDecl !== undefined && functionName !== undefined && !functionName.startsWith('$')) { + let cur: Scope | undefined = innermost; + while (cur !== undefined && cur.kind !== 'Namespace' && cur.kind !== 'Module') { + const parentId: ScopeId | null = cur.parent ?? null; + if (parentId === null) break; + cur = tree.getScope(parentId); + } + if (cur !== undefined && (cur.kind === 'Namespace' || cur.kind === 'Module')) return cur.id; + } + if (decl['@type-binding.return'] !== undefined) { let cur: Scope | undefined = innermost; while (cur !== undefined && cur.kind !== 'Module') { diff --git a/gitnexus/src/core/ingestion/languages/python/captures.ts b/gitnexus/src/core/ingestion/languages/python/captures.ts index 37ecb8552..3a224d188 100644 --- a/gitnexus/src/core/ingestion/languages/python/captures.ts +++ b/gitnexus/src/core/ingestion/languages/python/captures.ts @@ -39,7 +39,7 @@ import { computePythonArityMetadata } from './arity-metadata.js'; import { recordCacheHit, recordCacheMiss } from './cache-stats.js'; import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; -import { pythonFunctionDefinitionLabel } from './simple-hooks.js'; +import { isPythonGlobalDeclaration, pythonFunctionDefinitionLabel } from './simple-hooks.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; import { @@ -153,6 +153,20 @@ export function emitPythonScopeCaptures( recordPythonSubtypeCallShape(grouped, nodeMap, filePath, subtypeLineMapper); + const declarationNode = nodeMap['@declaration.function'] ?? nodeMap['@declaration.class']; + const declarationName = grouped['@declaration.name']?.text; + if ( + declarationNode !== undefined && + declarationName !== undefined && + isPythonGlobalDeclaration(declarationNode, declarationName) + ) { + grouped['@declaration.global'] = syntheticCapture( + '@declaration.global', + declarationNode, + declarationName, + ); + } + if (grouped['@import.statement'] !== undefined) { // `@import.statement` is captured directly ON the `import_statement` / // `import_from_statement` node (query: `(import_statement) @import.statement` diff --git a/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts index bf92049b4..584f09445 100644 --- a/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts +++ b/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts @@ -13,11 +13,14 @@ import type { CaptureMatch } from 'gitnexus-shared'; import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; import { decoratorNames, isBuiltinDescriptor } from './builtin-descriptors.js'; +import { isPythonGlobalDeclaration } from './simple-hooks.js'; /** Walk up to the enclosing `class_definition`, ignoring the immediate * `decorated_definition` wrapper. Returns `null` when the function is * free, lambda-bodied, or nested inside another function. */ function findEnclosingClassDefinition(node: SyntaxNode): SyntaxNode | null { + const name = node.childForFieldName('name')?.text; + if (name !== undefined && isPythonGlobalDeclaration(node, name)) return null; let cur: SyntaxNode | null = node.parent; while (cur !== null) { if (cur.type === 'class_definition') return cur; diff --git a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts index 1a62f5335..da30b470c 100644 --- a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts @@ -77,6 +77,7 @@ const pythonScopeResolver: ScopeResolver = { // A free call naming a class constructs it: `Service(db).do_work()` (#2708). constructionSyntax: { bare: true }, language: SupportedLanguages.Python, + ownedMembersBindAtModuleScope: false, suppressReceiverLookup: (typeRef) => typeRef.source === 'decorator-unknown', languageProvider: pythonProvider, importEdgeReason: 'python-scope: import', diff --git a/gitnexus/src/core/ingestion/languages/python/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/python/simple-hooks.ts index 6613c6738..94f6bcfa9 100644 --- a/gitnexus/src/core/ingestion/languages/python/simple-hooks.ts +++ b/gitnexus/src/core/ingestion/languages/python/simple-hooks.ts @@ -16,36 +16,80 @@ import type { TypeRef, } from 'gitnexus-shared'; import type { SyntaxNode } from 'tree-sitter'; -import { findAncestorBeforeBoundary, FUNCTION_NODE_TYPES } from '../../utils/ast-helpers.js'; import { walkToScope } from '../../utils/scope-tree-walk.js'; -const PYTHON_METHOD_CONTAINER_TYPES: ReadonlySet = new Set(['class_definition']); +/** Parsed trees are immutable during ingestion. Keep only block IDs and names + * in the cache so repeated declaration/receiver classification scans each tree + * once, without retaining its AST after the tree is released. */ +const globalsByTree = new WeakMap>>(); + +function enclosingPythonCodeBlock(node: SyntaxNode): SyntaxNode | null { + let enclosing = node.parent; + while (enclosing !== null) { + if ( + enclosing.type === 'function_definition' || + enclosing.type === 'class_definition' || + enclosing.type === 'module' + ) { + return enclosing; + } + enclosing = enclosing.parent; + } + return null; +} + +/** `global` belongs to its function or class code block, never to enclosing + * blocks. It affects every declaration of that name in the same block. */ +export function isPythonGlobalDeclaration(node: SyntaxNode, name: string): boolean { + const owner = enclosingPythonCodeBlock(node); + if (owner === null || owner.type === 'module') return false; + let globals = globalsByTree.get(node.tree); + if (globals === undefined) { + const byBlock = new Map>(); + for (const statement of node.tree.rootNode.descendantsOfType('global_statement')) { + const block = enclosingPythonCodeBlock(statement); + if (block === null || block.type === 'module') continue; + let names = byBlock.get(block.id); + if (names === undefined) { + names = new Set(); + byBlock.set(block.id, names); + } + for (const identifier of statement.namedChildren) { + if (identifier.type === 'identifier') names.add(identifier.text); + } + } + globals = byBlock; + globalsByTree.set(node.tree, globals); + } + return globals.get(owner.id)?.has(name) ?? false; +} export function pythonFunctionDefinitionLabel( functionNode: SyntaxNode, defaultLabel: NodeLabel, ): NodeLabel { if (defaultLabel !== 'Function') return defaultLabel; - const ancestor = findAncestorBeforeBoundary( - functionNode, - PYTHON_METHOD_CONTAINER_TYPES, - FUNCTION_NODE_TYPES, - ); - return ancestor === null ? 'Function' : 'Method'; + if (enclosingPythonCodeBlock(functionNode)?.type !== 'class_definition') return 'Function'; + const name = functionNode.childForFieldName('name')?.text; + return name !== undefined && isPythonGlobalDeclaration(functionNode, name) + ? 'Function' + : 'Method'; } // ─── bindingScopeFor ────────────────────────────────────────────────────── /** Python has no block scope, so the central extractor's "innermost * enclosing scope" default is already correct for ordinary bindings. - * Constructor-injected instance fields are the exception: their marker is - * anchored inside `__init__`, but compound receiver resolution needs the - * field type on the enclosing Class scope. */ + * Explicit `global` declarations and constructor-injected instance fields + * are the exceptions. */ export function pythonBindingScopeFor( decl: CaptureMatch, innermost: Scope, tree: ScopeTree, ): ScopeId | null { + if (decl['@declaration.global'] !== undefined) { + return walkToScope(innermost, tree, 'Module'); + } if (decl['@type-binding.instance-field'] !== undefined) { return walkToScope(innermost, tree, 'Class'); } diff --git a/gitnexus/src/core/ingestion/languages/ruby/captures.ts b/gitnexus/src/core/ingestion/languages/ruby/captures.ts index 8482f788d..71809f3de 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/captures.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/captures.ts @@ -245,6 +245,30 @@ function isRubyInstanceIvarWrite(ivarNode: SyntaxNode | undefined): boolean { return false; } +/** Whether an ordinary `def` has a provable lexical class/module definee. */ +function isRubyLexicalMethod(node: SyntaxNode): boolean { + if (node.type !== 'method') return false; + for (let ancestor = node.parent; ancestor !== null; ancestor = ancestor.parent) { + switch (ancestor.type) { + // Singleton bodies choose a different definee. A block's receiver can + // also rebind it, including through user helpers (see the ivar gate). + case 'singleton_method': + case 'singleton_class': + case 'do_block': + case 'block': + case 'lambda': + case 'begin_block': + case 'end_block': + return false; + case 'class': + case 'module': + case 'program': + return true; + } + } + return false; +} + export function emitRubyScopeCaptures( sourceText: string, _filePath: string, @@ -365,6 +389,17 @@ export function emitRubyScopeCaptures( } } + // Preserve AST classification across declaration.function → method + // reclassification. Parsing the source prefix here would confuse + // `def target.target` with an ordinary def and miss comments after def. + if (isRubyLexicalMethod(fnNode)) { + grouped['@declaration.lexical-method'] = syntheticCapture( + '@declaration.lexical-method', + fnNode, + 'true', + ); + } + const arity = computeRubyDeclarationArity(fnNode); if (arity.parameterCount !== undefined) { grouped['@declaration.parameter-count'] = syntheticCapture( diff --git a/gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts index 59d758632..23dfce856 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts @@ -20,6 +20,25 @@ export function rubyBindingScopeFor( if (decl['@type-binding.self'] !== undefined) { return innermost.id; } + // A plain Ruby `def` creates a method on the enclosing class/module (or + // Object at top level), even when it appears textually inside another + // method. The provider proves this from the AST before emitting either a + // function or a reclassified method declaration; singleton methods and + // lambda/proc bindings never carry this marker. + if (decl['@declaration.lexical-method'] !== undefined) { + let cur: Scope | undefined = innermost; + while (cur !== undefined && cur.kind !== 'Class' && cur.kind !== 'Module') { + // A block's receiver can rebind Ruby's default definee (`class_eval`, + // `Class.new`, or a user helper forwarding the block). Its target is not + // recoverable from lexical scope alone, so do not project a method across + // this boundary onto an unrelated enclosing class/module. + if (cur.kind === 'Block') return null; + const parentId: ScopeId | null = cur.parent ?? null; + if (parentId === null) break; + cur = tree.getScope(parentId); + } + if (cur !== undefined && (cur.kind === 'Class' || cur.kind === 'Module')) return cur.id; + } // `@ivar = Foo.new` in `initialize` (or any method) declares a FIELD of the // enclosing class, so its type binding belongs on the Class scope — the only // place `typeOfMemberOnClass` reads it. Left on the method's own Function diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 2c426bd1e..854bca201 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -1867,6 +1867,8 @@ const KNOWN_SUB_TAGS: ReadonlySet = new Set([ '@declaration.name', '@declaration.qualified_name', '@declaration.is-synthetic', + '@declaration.global', + '@declaration.lexical-method', '@import.name', '@import.source', '@import.alias', 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 749665b73..47b34f315 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -893,6 +893,11 @@ export interface ScopeResolver { * publishing every file's imports at module scope. */ readonly importsBindAtLexicalScope?: boolean; + /** Whether class-owned definitions also populate the legacy module lookup + * bucket. Disable when bare names must follow lexical bindings exclusively. + * Defaults to true; synthetic declarations remain available for dispatch. */ + readonly ownedMembersBindAtModuleScope?: boolean; + /** * Two `wildcard` re-exports that both DECLARE a name make it AMBIGUOUS in * this language — ECMAScript `export *` semantics, where the module simply diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index e694ba32e..8a28561ed 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -821,6 +821,7 @@ export function runScopeResolution( } const finalized = finalizeScopeModel(parsedFiles, { hooks: { + ownedMembersBindAtModuleScope: provider.ownedMembersBindAtModuleScope, importsBindAtLexicalScope: provider.importsBindAtLexicalScope === true, resolveImportTarget: (targetRaw, fromFile, _workspaceIndex, parsedImport) => provider.resolveImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig, { diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 77e37a250..44a8cd294 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -829,7 +829,13 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // Warm v126 worker results can retain false tools after a method replacement. // v128 (#3450): SDK namespace imports now prove positional tool receivers. // Warm v127 worker results omit these definitions and must be re-extracted. -const SCHEMA_BUMP = 128; +// v129 (#3499): Python/PHP/Ruby declaration bindings now preserve language- +// specific module visibility. Warm v128 ParsedFiles retain the old binding +// placement and can lose valid calls when finalized by the new projection. +// v130 (#3502): Python globals use exact function/class block ownership and +// Ruby ordinary-method binding uses AST markers. Warm v129 ParsedFiles retain +// incorrect declaration scopes and must be re-extracted. +const SCHEMA_BUMP = 130; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/lang-resolution/php-nested-function/main.php b/gitnexus/test/fixtures/lang-resolution/php-nested-function/main.php new file mode 100644 index 000000000..4343a4850 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-nested-function/main.php @@ -0,0 +1,13 @@ + { + it('keeps class methods out of notebook module bindings', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-ipynb-scope-')); + try { + const declarations = 'class Box:\n def target(self):\n return 1\n'; + writeFixtureRepo(root, { + 'lib.py': 'def target():\n return 2\n', + 'unbound.ipynb': pythonNotebook([ + { source: declarations }, + { source: 'def unbound_caller():\n return target()\n' }, + ]), + 'imported.ipynb': pythonNotebook([ + { source: 'from lib import target\n' }, + { source: declarations }, + { source: 'def imported_caller():\n return target()\n' }, + ]), + }); + const result = await runPipelineFromRepo(root, () => {}, { skipGraphPhases: true }); + const calls = getRelationships(result, 'CALLS').filter((edge) => edge.target === 'target'); + expect(calls.filter((edge) => edge.rel.targetId.includes('Box.target'))).toEqual([]); + expect( + calls + .filter((edge) => edge.source === 'imported_caller') + .map((edge) => edge.targetFilePath), + ).toEqual(['lib.py']); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 60000); + it('indexes notebook functions, imports sibling py, and skip-fails closed', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-ipynb-')); try { diff --git a/gitnexus/test/integration/resolvers/php.test.ts b/gitnexus/test/integration/resolvers/php.test.ts index d25d7da67..6f0715a30 100644 --- a/gitnexus/test/integration/resolvers/php.test.ts +++ b/gitnexus/test/integration/resolvers/php.test.ts @@ -14,6 +14,23 @@ import { type PipelineResult, } from './helpers.js'; +describe('PHP nested named functions become globally callable after declaration', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'php-nested-function'), () => {}); + }, 60000); + + it('resolves a sibling caller to the nested named function', () => { + const calls = getRelationships(result, 'CALLS'); + const edge = calls.find( + (candidate) => + candidate.source === 'caller' && candidate.rel.targetId.includes('boot.target'), + ); + expect(edge).toBeDefined(); + }); +}); + // --------------------------------------------------------------------------- // Heritage: PSR-4 imports, extends, implements, trait use, enums, calls // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index 57dc088d0..35e310fda 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -77,6 +77,111 @@ describe('Python relative import & heritage resolution', () => { }); }); +describe('Python nested declarations stay in their lexical scope (#3499)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'python-nested-def-scope'), () => {}); + }, 60000); + + it('does not expose a nested function to sibling callers', () => { + const calls = getRelationships(result, 'CALLS'); + const siblingEdges = calls.filter( + (edge) => edge.source === 'caller' && edge.rel.targetId.includes('outer.target'), + ); + expect(siblingEdges).toEqual([]); + }); + + it('preserves module and function-local imports shadowed only by a nested name', () => { + const calls = getRelationships(result, 'CALLS'); + const importedCalls = calls.filter( + (edge) => edge.source === 'caller' && edge.rel.targetId.includes('facade.py:target'), + ); + expect(importedCalls).toHaveLength(2); + }); + + it('still resolves each call inside the enclosing function to its nested declaration', () => { + const calls = getRelationships(result, 'CALLS'); + const nestedCalls = calls.filter( + (edge) => edge.source === 'outer' && edge.rel.targetId.includes('outer.target'), + ); + expect(nestedCalls).toHaveLength(3); + }); + + it('does not expose ordinary class methods to unqualified module callers', () => { + const calls = getRelationships(result, 'CALLS'); + const callers = new Set([ + 'method_unbound_caller', + 'method_module_caller', + 'method_function_caller', + ]); + const methodEdges = calls.filter( + (edge) => callers.has(edge.source) && edge.rel.targetId.includes('Box.target'), + ); + expect(methodEdges).toEqual([]); + }); + + it('preserves module and function-local imports shadowed only by a class method', () => { + const calls = getRelationships(result, 'CALLS'); + const importedCalls = calls.filter( + (edge) => + ['method_module_caller', 'method_function_caller'].includes(edge.source) && + edge.rel.targetId.includes('facade.py:target'), + ); + expect(importedCalls).toHaveLength(2); + }); +}); + +describe('Python global nested declarations bind at module scope (#3502)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'python-global-nested-def'), () => {}); + }, 60000); + + it('resolves a sibling caller to the global declaration, not a same-name method', () => { + const calls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'target', + ); + expect(calls.map((edge) => edge.targetFilePath)).toEqual(['main.py']); + }); + + it('does not carry an outer global declaration across a class body', () => { + const calls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'class_boundary_caller' && edge.target === 'leaked', + ); + expect(calls).toEqual([]); + }); + + it('resolves global class declarations from a sibling caller', () => { + const calls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'global_class_caller' && edge.target === 'published_ping', + ); + expect(calls.map((edge) => [edge.targetLabel, edge.targetFilePath])).toEqual([ + ['Method', 'main.py'], + ]); + }); + + it('does not let a nested class global publish its enclosing function-local declaration', () => { + const calls = getRelationships(result, 'CALLS').filter((edge) => edge.target === 'retained'); + expect(calls.map((edge) => edge.source)).toEqual(['class_global_boundary']); + }); + + it('publishes class-owned global functions and classes without creating a method', () => { + const calls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'class_global_caller', + ); + expect( + calls.map((edge) => `${edge.targetFilePath}:${edge.targetLabel}:${edge.target}`).sort(), + ).toEqual(['main.py:Function:class_target', 'main.py:Method:class_ping']); + expect(getNodesByLabel(result, 'Method')).not.toContain('class_target'); + const definition = getNodesByLabelFull(result, 'Function').find( + (node) => node.name === 'class_target', + ); + expect(definition?.properties.parameterCount).toBe(1); + }); +}); + // --------------------------------------------------------------------------- // Qualified / generic bases (#1951). An earlier synth DROPPED these shapes — // only bare `identifier` bases emitted, so production silently omitted their diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index 74fbdb8d1..a5d36cc6e 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -17,6 +17,51 @@ import { type PipelineResult, } from './helpers.js'; +describe('Ruby nested method declarations remain visible on the outer method surface', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-nested-method'), () => {}); + }, 60000); + + it('resolves a sibling caller to the nested method despite a same-name method elsewhere', () => { + const calls = getRelationships(result, 'CALLS'); + const edge = calls.find( + (candidate) => + candidate.source === 'caller' && candidate.rel.targetId.includes('boot.target'), + ); + expect(edge).toBeDefined(); + }); + + it('does not project a receiver-rebinding block method onto the lexical class', () => { + const calls = getRelationships(result, 'CALLS').filter( + (candidate) => candidate.source === 'class_eval_caller' && candidate.target === 'rebound', + ); + expect(calls).toEqual([]); + }); + + it.each([ + ['commented_caller', 'commented_target', 'boot_commented.commented_target'], + ['class_caller', 'class_target', 'Host.boot_class.class_target'], + ])( + 'resolves %s to its nested method despite a competing class member', + (caller, target, owner) => { + const calls = getRelationships(result, 'CALLS').filter( + (candidate) => candidate.source === caller && candidate.target === target, + ); + expect(calls).toHaveLength(1); + expect(calls[0]!.rel.targetId).toContain(owner); + }, + ); + + it('does not project a method through an unknown helper that rebinds the block definee', () => { + const calls = getRelationships(result, 'CALLS').filter( + (candidate) => candidate.source === 'helper_caller' && candidate.target === 'helper_target', + ); + expect(calls).toEqual([]); + }); +}); + // --------------------------------------------------------------------------- // Heritage: require_relative imports + include heritage + attr_* properties + calls // --------------------------------------------------------------------------- diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index e37895fd9..6e27bbfac 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -303,8 +303,8 @@ describe('PARSE_CACHE_VERSION', () => { // Moved 125 -> 126 for #3446: SDK positional tool definitions and attribution. // Moved 126 -> 127 for #3450: reject destructured SDK registration-method writes. // Moved 127 -> 128 for #3450: recognize SDK namespace imports. - it('pins SCHEMA_BUMP to 128 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398, #3396, #3394, #3399, #3414, #3408, #3402, #3446, #3450)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(128); + it('pins SCHEMA_BUMP to 130 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398, #3396, #3394, #3399, #3414, #3408, #3402, #3446, #3450, #3499, #3502)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(130); expect(PARSE_CACHE_BUCKET_COUNT).toBe(128); // The PREVIOUS version must fail the reuse gate, not merely differ from the // current one — a hardcoded number outside the conflict hunk rebases cleanly diff --git a/gitnexus/test/unit/scope-resolution/finalize-algorithm.test.ts b/gitnexus/test/unit/scope-resolution/finalize-algorithm.test.ts index ba742f579..b8fa8c89f 100644 --- a/gitnexus/test/unit/scope-resolution/finalize-algorithm.test.ts +++ b/gitnexus/test/unit/scope-resolution/finalize-algorithm.test.ts @@ -797,6 +797,71 @@ describe('finalize', () => { expect(bindings[0]!.def.nodeId).toBe('def:a.X'); }); + it('does not flatten nested declarations into the module binding bucket', () => { + const topLevel = { ...def('def:a.outer', 'Function', 'a.outer'), filePath: 'a' }; + const nested = { + ...def('def:a.outer.target', 'Function', 'a.outer.target'), + filePath: 'a', + }; + const a: FinalizeFile = { + ...file('a', [topLevel, nested]), + moduleBindings: new Map([['outer', [{ def: topLevel, origin: 'local' as const }]]]), + }; + + const out = finalize({ files: [a], workspaceIndex: undefined }, defaultHooks([a])); + + expect(bindingsFor(out, a.moduleScope, 'outer').map((binding) => binding.def.nodeId)).toEqual( + ['def:a.outer'], + ); + expect(bindingsFor(out, a.moduleScope, 'target')).toEqual([]); + }); + + it('does not duplicate namespace definitions already projected into module bindings', () => { + const namespaced = { + ...def('def:a.ns.target', 'Function', 'a.ns.target'), + filePath: 'a', + namespacePrefix: 'ns', + }; + const a: FinalizeFile = { + ...file('a.cpp', [namespaced]), + moduleBindings: new Map([['target', [{ def: namespaced, origin: 'local' as const }]]]), + }; + + const out = finalize({ files: [a], workspaceIndex: undefined }, defaultHooks([a])); + + expect( + bindingsFor(out, a.moduleScope, 'target').map((binding) => binding.def.nodeId), + ).toEqual(['def:a.ns.target']); + }); + + it('uses provider policy for owned members and retains synthetic dispatch entries', () => { + const method = { ...def('def:a.Box.target', 'Method', 'a.Box.target'), ownerId: 'def:a.Box' }; + const synthetic = { + ...method, + nodeId: 'def:a.Box.generated', + qualifiedName: 'a.Box.generated', + isSynthetic: true, + }; + const a: FinalizeFile = { + ...file('a.ipynb', [method, synthetic]), + moduleBindings: new Map(), + }; + const input = { files: [a], workspaceIndex: undefined }; + const lexical = finalize(input, { + ...defaultHooks([a]), + ownedMembersBindAtModuleScope: false, + }); + expect(bindingsFor(lexical, a.moduleScope, 'target')).toEqual([]); + expect( + bindingsFor(lexical, a.moduleScope, 'generated').map((binding) => binding.def.nodeId), + ).toEqual(['def:a.Box.generated']); + + const legacy = finalize(input, defaultHooks([a])); + expect( + bindingsFor(legacy, a.moduleScope, 'target').map((binding) => binding.def.nodeId), + ).toEqual(['def:a.Box.target']); + }); + it('layers imports on top of local defs via mergeBindings', () => { const b = file('b', [def('def:b.User', 'Class', 'b.User')]); const a = file('a', [def('def:a.User', 'Class', 'a.User')], [named('User', 'User', 'b')]); diff --git a/gitnexus/test/unit/scope-resolution/python/python-global-declarations.test.ts b/gitnexus/test/unit/scope-resolution/python/python-global-declarations.test.ts new file mode 100644 index 000000000..49884e86a --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/python/python-global-declarations.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { emitPythonScopeCaptures } from '../../../../src/core/ingestion/languages/python/captures.js'; +import { pythonProvider } from '../../../../src/core/ingestion/languages/python.js'; +import { extractParsedFile } from '../../../../src/core/ingestion/scope-extractor-bridge.js'; +import { getPythonParser } from '../../../../src/core/ingestion/languages/python/query.js'; +import { pythonFunctionDefinitionLabel } from '../../../../src/core/ingestion/languages/python/simple-hooks.js'; + +function bindingScopes(source: string, name: string) { + const parsed = extractParsedFile(pythonProvider, source, 'globals.py'); + expect(parsed).toBeDefined(); + return parsed!.scopes.filter((scope) => scope.bindings.has(name)).map((scope) => scope.kind); +} + +describe('Python global declaration ownership', () => { + it.each(['def target(): pass', 'class target: pass'])( + 'binds an explicitly global declaration at module scope: %s', + (declaration) => { + expect( + bindingScopes(`def boot():\n global target\n ${declaration}\n`, 'target'), + ).toEqual(['Module']); + }, + ); + + it.each(['def target(): pass', 'class target: pass'])( + 'does not promote a function-local declaration because a nested class declares it global: %s', + (declaration) => { + const source = `def boot(): + class Inner: + global target + ${declaration} +`; + expect(bindingScopes(source, 'target')).toEqual(['Function']); + }, + ); + + it.each(['def target(): pass', 'class target: pass'])( + 'does not promote a declaration because a deeper function declares it global: %s', + (declaration) => { + const source = `def boot(): + def deeper(): + global target + ${declaration} +`; + expect(bindingScopes(source, 'target')).toEqual(['Function']); + }, + ); + + it.each(['def target(self): pass', 'class target: pass'])( + 'does not inherit global declarations across a nested class boundary: %s', + (declaration) => { + const source = `def boot(): + global target + class Inner: + ${declaration} +`; + expect(bindingScopes(source, 'target')).toEqual(['Class']); + }, + ); + + it('does not inherit a global declaration across a nested function boundary', () => { + const source = `def boot(): + global target + def deeper(): + def target(): pass +`; + expect(bindingScopes(source, 'target')).toEqual(['Function']); + }); + + it('applies class-owned globals to class and function declarations through control flow', () => { + const source = `class Installer: + global target, Target + if True: + def target(value): return value + class Target: pass +`; + expect(bindingScopes(source, 'target')).toEqual(['Module']); + expect(bindingScopes(source, 'Target')).toEqual(['Module']); + const matches = emitPythonScopeCaptures(source, 'globals.py'); + const target = matches.find((match) => match['@declaration.name']?.text === 'target')!; + expect(target['@declaration.function']).toBeDefined(); + expect(target['@declaration.method']).toBeUndefined(); + expect(target['@declaration.parameter-count']?.text).toBe('1'); + expect(target['@declaration.required-parameter-count']?.text).toBe('1'); + expect(matches.some((match) => match['@type-binding.self'] !== undefined)).toBe(false); + + const tree = getPythonParser().parse(source); + const functionNode = tree.rootNode.descendantsOfType('function_definition')[0]!; + expect(pythonFunctionDefinitionLabel(functionNode, 'Function')).toBe('Function'); + }); + + it('does not treat comments or strings as global declarations', () => { + const source = `def boot(): + # global target + description = "global target" + def target(): pass +`; + expect(bindingScopes(source, 'target')).toEqual(['Function']); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/ruby/ruby-nested-method.test.ts b/gitnexus/test/unit/scope-resolution/ruby/ruby-nested-method.test.ts new file mode 100644 index 000000000..965595576 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/ruby/ruby-nested-method.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import { emitRubyScopeCaptures } from '../../../../src/core/ingestion/languages/ruby/captures.js'; +import { rubyBindingScopeFor } from '../../../../src/core/ingestion/languages/ruby/simple-hooks.js'; +import { extract } from '../../../../src/core/ingestion/scope-extractor.js'; + +function parse(source: string) { + const captures = emitRubyScopeCaptures(source, 'nested.rb'); + return { + captures, + file: extract(captures, 'nested.rb', { bindingScopeFor: rubyBindingScopeFor }), + }; +} + +describe('Ruby nested method ownership', () => { + it.each(['def target', 'def\n target', 'def # declaration comment\n target'])( + 'publishes an ordinary nested method with header %j at file scope', + (header) => { + const { file } = parse(`def boot\n ${header}\n :nested\n end\nend\n`); + const moduleScope = file.scopes.find((s) => s.kind === 'Module'); + expect(moduleScope?.bindings.get('target')).toHaveLength(1); + }, + ); + + it.each(['class', 'module'])( + 'publishes a nested declaration reclassified as Method on its enclosing %s', + (keyword) => { + const { captures, file } = parse( + `${keyword} Host\n def boot\n def target\n :nested\n end\n end\nend\n`, + ); + const target = captures.find((c) => c['@declaration.name']?.text === 'target'); + expect(target?.['@declaration.method']).toBeDefined(); + const classScope = file.scopes.find((s) => s.kind === 'Class'); + expect(classScope?.bindings.get('target')).toHaveLength(1); + const moduleScope = file.scopes.find((s) => s.kind === 'Module'); + expect(moduleScope?.bindings.has('target')).toBe(false); + }, + ); + + it.each(['def self.target', 'def target.target', 'def target::target'])( + 'does not publish singleton header %j as an ordinary method', + (header) => { + const { captures, file } = parse(`def boot\n ${header}\n :singleton\n end\nend\n`); + const target = captures.find((c) => c['@declaration.name']?.text === 'target'); + expect(target?.['@declaration.function']).toBeDefined(); + expect(target?.['@declaration.lexical-method']).toBeUndefined(); + const moduleScope = file.scopes.find((s) => s.kind === 'Module'); + expect(moduleScope?.bindings.has('target')).toBe(false); + }, + ); + + it.each(['-> { :value }', 'lambda { :value }', 'proc { :value }', 'Proc.new { :value }'])( + 'keeps the local closure %s inside its defining method', + (expression) => { + const { captures, file } = parse(`def boot\n target = ${expression}\nend\n`); + const target = captures.find( + (c) => + c['@declaration.function'] !== undefined && c['@declaration.name']?.text === 'target', + ); + expect(target).toBeDefined(); + expect(target?.['@declaration.lexical-method']).toBeUndefined(); + const moduleScope = file.scopes.find((s) => s.kind === 'Module'); + expect(moduleScope?.bindings.has('target')).toBe(false); + }, + ); + + it.each(['class << self', 'class << Other', 'def self.install'])( + 'does not project a nested def through singleton boundary %j', + (boundary) => { + const { captures, file } = parse( + `class Host\n ${boundary}\n def boot\n def target\n :singleton\n end\n end\n end\nend\n`, + ); + const target = captures.find((c) => c['@declaration.name']?.text === 'target'); + expect(target?.['@declaration.method']).toBeDefined(); + expect(target?.['@declaration.lexical-method']).toBeUndefined(); + const classScope = file.scopes.find((s) => s.kind === 'Class'); + expect(classScope?.bindings.has('target')).toBe(false); + }, + ); + + it('does not project a def passed through a helper that can rebind the block definee', () => { + const { file } = parse(`class Host + def self.install(&body) + Other.class_eval(&body) + end + install do + def target + :belongs_to_other + end + end +end +`); + const classScope = file.scopes.find((s) => s.kind === 'Class'); + expect(classScope?.bindings.has('target')).toBe(false); + const block = file.scopes.find((s) => s.kind === 'Block'); + expect(block?.bindings.get('target')).toHaveLength(1); + }); + + it('uses a class keyword inside an unknown block as a new lexical definee', () => { + const { file } = parse(`helper do + class Host + def boot + def target + :belongs_to_host + end + end + end +end +`); + const classScope = file.scopes.find((s) => s.kind === 'Class'); + expect(classScope?.bindings.get('target')).toHaveLength(1); + }); +});