fix(scope): keep nested declarations out of module bindings (#3502)

This commit is contained in:
Parafee41 2026-10-08 16:05:41 +08:00 • committed by GitHub
parent e230914189
commit 4ccba12004
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1099 additions and 119 deletions

View file

@ -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<string, readonly BindingRef[]>;
}
/** 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<string, readonly BindingRef[]>();
// 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);

View file

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

View file

@ -1 +1 @@
dde4450f8bb763575b0464255f063fd0ded24980ecf43d7d2a7d4dc4880f2665
1ab17d9cb80c3863ac9df17e4eefe4983a21f8d9908c110e8a8d9fa1b35efcc0

View file

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

View file

@ -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>): FinalizeHooks {
return {
ownedMembersBindAtModuleScope: partial.ownedMembersBindAtModuleScope,
importsBindAtLexicalScope: partial.importsBindAtLexicalScope === true,
resolveImportTarget: partial.resolveImportTarget ?? (() => null),
isNamespaceImport: partial.isNamespaceImport,

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string> = 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<object, ReadonlyMap<number, ReadonlySet<string>>>();
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<number, Set<string>>();
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');
}

View file

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

View file

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

View file

@ -1867,6 +1867,8 @@ const KNOWN_SUB_TAGS: ReadonlySet<string> = new Set<string>([
'@declaration.name',
'@declaration.qualified_name',
'@declaration.is-synthetic',
'@declaration.global',
'@declaration.lexical-method',
'@import.name',
'@import.source',
'@import.alias',

View file

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

View file

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

View file

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

View file

@ -0,0 +1,13 @@
<?php
function boot(): void {
#[Deprecated]
FUNCTION /* legal declaration trivia */ target(): void {}
}
$handler = function (): void {};
function caller(): void {
boot();
target();
}

View file

@ -0,0 +1,5 @@
<?php
class Other {
public static function target(): void {}
}

View file

@ -0,0 +1,67 @@
def boot():
global target
def target():
return "nested"
def caller():
boot()
target()
def outer():
global leaked
class Inner:
def leaked(self):
return "class-local"
def class_boundary_caller():
leaked()
def install_class():
global Published
class Published:
def published_ping(self):
return "global class"
def global_class_caller():
install_class()
published = Published()
published.published_ping()
def class_global_boundary():
class Inner:
global retained
def retained():
return "function-local"
retained()
def inverse_class_boundary_caller():
retained()
class Installer:
global class_target, ClassTarget
def class_target(value):
return value
class ClassTarget:
def class_ping(self):
return "class-body global"
def class_global_caller():
class_target(1)
target = ClassTarget()
target.class_ping()

View file

@ -0,0 +1,16 @@
class Other:
def target(self):
return "unrelated"
def leaked(self):
return "also unrelated"
class Published:
def published_ping(self):
return "unrelated published class"
class ClassTarget:
def class_ping(self):
return "unrelated class target"

View file

@ -0,0 +1,2 @@
def target(msg):
return msg

View file

@ -0,0 +1,11 @@
def outer():
def target(msg):
return msg
target("inner")
def caller():
from facade import target
target("caller")

View file

@ -0,0 +1,9 @@
class Box:
def target(self, msg):
return msg
def method_function_caller():
from facade import target
target("caller")

View file

@ -0,0 +1,10 @@
from facade import target
class Box:
def target(self, msg):
return msg
def method_module_caller():
target("caller")

View file

@ -0,0 +1,7 @@
class Box:
def target(self, msg):
return msg
def method_unbound_caller():
target("caller")

View file

@ -0,0 +1,12 @@
from facade import target
def outer():
def target(msg):
return msg
target("inner")
def caller():
target("caller")

View file

@ -0,0 +1,9 @@
def outer():
def target(msg):
return msg
target("inner")
def caller():
target("caller")

View file

@ -0,0 +1,59 @@
def boot
def target
:nested
end
end
def caller
boot
target
end
def boot_commented
def # an ordinary method despite the comment before its name
commented_target
:nested
end
end
def commented_caller
boot_commented
commented_target
end
class Host
def boot_class
def class_target
:nested_class_method
end
end
def class_caller
boot_class
class_target
end
Other.class_eval do
def rebound
:rebound
end
end
def class_eval_caller
rebound
end
def self.install(&body)
Other.class_eval(&body)
end
install do
def helper_target
:belongs_to_other
end
end
def helper_caller
helper_target
end
end

View file

@ -0,0 +1,21 @@
class Other
def target
:unrelated
end
def commented_target
:unrelated
end
def class_target
:unrelated
end
def helper_target
:existing
end
def rebound
:existing
end
end

View file

@ -343,6 +343,14 @@
"captureGroups": 11,
"digest": "c372e232faf99874eff46877b71f70b533b496832b762833f2b266ca9939978f"
},
"php-nested-function/main.php": {
"captureGroups": 13,
"digest": "7fab180036a0224560d1be1f980e720aed1f0ac145b31bdda2db3ff30dd2df4f"
},
"php-nested-function/other.php": {
"captureGroups": 5,
"digest": "61a67454c4e1a950b86e7240ac3700a6e2ef4256a3a78ddb533c111823a5cbd1"
},
"php-nullable-receiver/app/Models/Repo.php": {
"captureGroups": 7,
"digest": "44921bb2ae5aba27957cd45a7d3e515425f27f8e93af5d6285125b94f5d9ba88"

View file

@ -367,6 +367,14 @@
"captureGroups": 4,
"digest": "efe8b8af2d4e7f547a659aecc00399091af2597b47adc7b296bddb10cab2da69"
},
"python-global-nested-def/main.py": {
"captureGroups": 63,
"digest": "9695170149861f98e96553bf319caf88098f9c764e4896367bbd91db68f279dd"
},
"python-global-nested-def/other.py": {
"captureGroups": 23,
"digest": "a288c41f6cb01923496422ad53b2299ff9034d16c48ef4e9b90a59826205787c"
},
"python-grandparent-resolution/app.py": {
"captureGroups": 9,
"digest": "dda8b7578e22c44f2defdaa5d7bb76c0b04b03cd7017506ef7df6810d6116ade"
@ -631,6 +639,34 @@
"captureGroups": 5,
"digest": "df334f13bf7c81ba01c861c2b9a4ddc969645ccecbd536088c3556a2a2fb1f2b"
},
"python-nested-def-scope/facade.py": {
"captureGroups": 4,
"digest": "6c0cf2efb52f47b3e7284f729d582c40ab4c09a5742caadb088d672a5f17587b"
},
"python-nested-def-scope/function_local.py": {
"captureGroups": 11,
"digest": "c94d3b5d53fe000af4d1e6f6e746d86e5ce9dfbba4de03e9c26bc30df60c2e0d"
},
"python-nested-def-scope/method_function_local.py": {
"captureGroups": 12,
"digest": "53cbe0eb710f8e1412b4942aa2eafc3e0242ad73f0802a6b481154576ee4c0cc"
},
"python-nested-def-scope/method_module_import.py": {
"captureGroups": 12,
"digest": "280e6d4f154cf0d3d29e947b191a4987b9748c6a6bb13c2612c81fcae4199273"
},
"python-nested-def-scope/method_unbound.py": {
"captureGroups": 11,
"digest": "4156064e89d213a389ebe6a1cd65d26e6083e5518ee5557c42528da428840464"
},
"python-nested-def-scope/module_import.py": {
"captureGroups": 11,
"digest": "33af1ecde6841610a45f799ba68032cacb24eb0695eb4ba970979a5dac7b1427"
},
"python-nested-def-scope/unbound.py": {
"captureGroups": 10,
"digest": "9bd9cb68bf573c9da663409c72233f99b21b922c77b95b4d7cd1af94f106d105"
},
"python-nullable-chain/app.py": {
"captureGroups": 33,
"digest": "b8fc05256ec1460896cec13f7c8675389aae795b2138b3fe8f4f352b9aea360f"

View file

@ -1,79 +1,79 @@
{
"ruby-ambiguous/lib/user_handler.rb": {
"captureGroups": 10,
"digest": "5c5588f1417a793f5b5d80ac09d6aa7fbff94dacb743b1da5395f72a91607ebf"
"digest": "b8a871dabb894b4b43c2efd8b4b7616b116a86041abae8cfac6a3d43b717e6e4"
},
"ruby-ambiguous/models/handler.rb": {
"captureGroups": 6,
"digest": "4609ec03a86a4f4f2a93af9dd11437363daf1b3139ebdce366a8f49d47ea71b1"
"digest": "1208eb0ff54bf5ab420656c262958a136e5ca21ac41062daca1980f7473b3320"
},
"ruby-ambiguous/other/handler.rb": {
"captureGroups": 6,
"digest": "d13c8ad253a9310d0bed45730135a947e67dec788686bd08710902eafc307237"
"digest": "895c8a361f4ecc291e8c9964c1e85235198efc25de8774e04a9962f2798e770f"
},
"ruby-app/lib/base_model.rb": {
"captureGroups": 20,
"digest": "999f23c116bf4c0a25834e3fff1b12d5ec1bc5616a29e75716ba753dc253a62c"
"digest": "a229fe836cdf6ade0dfaed192d3b9baec5cddf0f11006f90f2aef4d9bc3edef5"
},
"ruby-app/lib/concerns/cacheable.rb": {
"captureGroups": 6,
"digest": "cc879f2fa31b1d50b7ba33f52c6dd68ba0ff070ffbc3c2d0293510f88643b373"
"digest": "e08d06e759f6729649ff770c09ec46afc530a8c5c0a57d5860ffe5be5100534d"
},
"ruby-app/lib/concerns/loggable.rb": {
"captureGroups": 6,
"digest": "049ea9beaf9c55b55bd836d31af4c8806ce24bb805261ae4d62575b4a19118bc"
"digest": "9efc3642bdd916a0d52fe4397302020612d5001bc0288f040197bc72e8b0889d"
},
"ruby-app/lib/concerns/serializable.rb": {
"captureGroups": 6,
"digest": "5a5a6e8cb7bcd32d828763152b8107e5ee824a5e6244ee55f85925f23e520b02"
"digest": "5aa71b2395429a0ae56a29f71ddfd0ff9c97f4c977b6ecc87816a08cd1e2133b"
},
"ruby-app/lib/service.rb": {
"captureGroups": 13,
"digest": "49bd37b2138dc17820351c126a7a75622cb564e8dd15a9b5f79fcc2d0844474d"
"digest": "696514cb2599e1be68733fdbade94fb901a7724c14f4465decfec33ce3c37222"
},
"ruby-app/lib/user.rb": {
"captureGroups": 24,
"digest": "6567c827276575ef3a087757b5725e381fbedb421ffecfe6c5904d7d62f6bcd4"
"digest": "4ef1e97320a32e044f169589a1724e472abe3ba137d98126c31393b736d75eea"
},
"ruby-call-result-binding/app.rb": {
"captureGroups": 18,
"digest": "194c1ca21a7d5d8d85cf9aecfdc4edd881c5d3447de1ad574b56cff5a411bab1"
"digest": "a05775e13559a8a505f200767a6fb9dc9abdb5b4b4442cb68973c200c6ff00e1"
},
"ruby-callable-alternatives/app.rb": {
"captureGroups": 58,
"digest": "7a7fcac9fd4dae83dfc78eba2864d06b48918eb42713c0429081fdd986b7e8ba"
"digest": "f21447a4fb5db53f2b4dce563ecc86ce1cb2e3cbc0298e8dee7f325c2e9a0a28"
},
"ruby-calls/lib/one_arg.rb": {
"captureGroups": 8,
"digest": "61300083e7a2e6d9a116c3975ddd3b78a593a9a0e397c252bedb160d481c20b7"
"digest": "72037dcb0e066b690e9cb7e580f309f7df7d0c1d34813d22074ecc6978f4d258"
},
"ruby-calls/lib/service.rb": {
"captureGroups": 11,
"digest": "7a4d964547d3ac645274c65fcbceba3f6852286537af2452c87d75bbde3495ab"
"digest": "985bd233953251cdc3d26288af9993600e265a071dd03a37cd0217057a7cae35"
},
"ruby-calls/lib/two_args.rb": {
"captureGroups": 9,
"digest": "0b781a930116eed742de7bfbc0852ecdf4056d5ccc4f49c2b779da49c5ca04e9"
"digest": "12525b90d305542b57092bfe29440d3110bf1cbfdcc5d077de1555b6f4f016d8"
},
"ruby-chain-call/lib/app.rb": {
"captureGroups": 13,
"digest": "6822e1574762e81aac1bde8b00e6e91d6b054b7c41c4e78eb866e488378655e5"
"digest": "7431bc37bbaf82303a9f1760ecbf1922f6d4cc680b7bbecd2b5089fd7261e109"
},
"ruby-chain-call/lib/repo.rb": {
"captureGroups": 6,
"digest": "9effd68932555f44968ec89ea1fb0bff4719185c777e856ae167a2289fa72239"
"digest": "d00bcdbe0167ae669510ef3b07f77bd2a0c39ad76fc7c072ee91d2b42acd05db"
},
"ruby-chain-call/lib/user.rb": {
"captureGroups": 6,
"digest": "5efcef8e4c0dc82ddaa88cf6ed51c8a79e86e34e06e14c4363373444da5d5292"
"digest": "d9f829b49cedf4e93efdb7a7a56cbee92b8d456c570eacc22a2722ca121ff21f"
},
"ruby-chain-call/lib/user_service.rb": {
"captureGroups": 11,
"digest": "bfd4e260c15d1e5da32af458a4304ffa21e4e6e22133fa8dd34ad14e50beb0f6"
"digest": "3d31e1087255396293821e705fa6e70ea4bab5a09f31bff9307607f20ce6bb8e"
},
"ruby-child-extends-parent/lib/app.rb": {
"captureGroups": 12,
"digest": "8a75c4468b2b1f033620e7d233071340c129a06db4b8ca85a54293efdc5b989f"
"digest": "266fe732ed53abcf9dfb1f7faa310f20258a63184f6b087be3afc66ad7f0d280"
},
"ruby-child-extends-parent/lib/child.rb": {
"captureGroups": 6,
@ -81,7 +81,7 @@
},
"ruby-child-extends-parent/lib/parent.rb": {
"captureGroups": 6,
"digest": "e0b6dd3034cc87ed2ae1e46da9681e58074f3b2d2778e6584869bf49a3dd6b7e"
"digest": "28c6adfeebc51980e84e207708e6ecefc059bbab0994d9b810788e7c3f75f78f"
},
"ruby-constant-constructor/app.rb": {
"captureGroups": 7,
@ -89,87 +89,87 @@
},
"ruby-constant-constructor/models.rb": {
"captureGroups": 9,
"digest": "580c49087a657582485e87d528777d2b60e6e29d3d5e652e777c23ba35186f7b"
"digest": "e2c9d47e9b2e44ddb32151042a3c6b6c15669e1920e3e6bc9c8b550a2b4851eb"
},
"ruby-constant-factory-call/admin_service.rb": {
"captureGroups": 9,
"digest": "a62fbc9faded8b59e1bc529d72e6132187a493475d99ff6163cc71cd1de773f4"
"digest": "e656dc6d8654bd7c0a4e37c2144f8c70e91752af83df0f1ebcdddedc1acb44b3"
},
"ruby-constant-factory-call/app.rb": {
"captureGroups": 14,
"digest": "f3b9bc3394854593513e17ef3c0e37375aab959d666b4aed4df443377608eb64"
"digest": "b06a8b9abf30a2e34587e524c2a5f3f058690e9d636790d2a9e4a0c3c15e84c8"
},
"ruby-constant-factory-call/user_service.rb": {
"captureGroups": 9,
"digest": "d9d74ff4dae50c699e4f4f874c4aa75fa5c27e57ffc4fb170aafca4d333bb2a7"
"digest": "158fbe4456c4a963b9160f81a3f0c4e94b67f2a67e13bb30d98df490faa8fe09"
},
"ruby-construction-selector/lib/app.rb": {
"captureGroups": 18,
"digest": "644fe79259b7d9370dc9c4b4229ece41df3278e76055863b052a8792bf437728"
"digest": "429dbea1c9b012f92be4c99fdf2425b60e3223ea73567af8706115949bf018ac"
},
"ruby-construction-selector/lib/factory.rb": {
"captureGroups": 25,
"digest": "536a80717033459f70f20f1af0f6137b6cc66fbd495287ba7dc92b9f559c9498"
"digest": "b1c415b5db1426cfd0f7636fe80ae5d79b11d0575152f1f98f67d35521398e58"
},
"ruby-construction-selector/lib/models.rb": {
"captureGroups": 11,
"digest": "3fd16c4a555b48199e6c501a2b182e1247624f3f00f9bef847eed44a1f428f7b"
"digest": "9e44418e635336d3079dd45fffe2124be8512c8940828d40b2acfb367cf7a6f8"
},
"ruby-constructor-type-inference/models/repo.rb": {
"captureGroups": 9,
"digest": "b0f3b5728c603a2056613091ac75d5014d99c4cdb2d192285c2707117bb46d0c"
"digest": "a766d29044485e2b87022342d3836a5cb283cf63f5c32ff8d2bbfdef06a7c90a"
},
"ruby-constructor-type-inference/models/user.rb": {
"captureGroups": 9,
"digest": "dc7cc04f621b487fd24f417281a6a77b6b5dee234872b98c7d52ea719a991205"
"digest": "9c610d286cd6d2f494c36384a304de4ff2de041862c926fe8692456eab35f1bc"
},
"ruby-constructor-type-inference/services/app.rb": {
"captureGroups": 26,
"digest": "afa655d9a3c7ebea02b529d33904575692a53531a619c3fd34634f0bb43855e4"
"digest": "df6c3ca6d7b15bbdbecaf20f3890aefa1f70365973ef556d697f74815e452a4a"
},
"ruby-default-params/app.rb": {
"captureGroups": 9,
"digest": "5724292fa04411256cc8c43c0bd3b3a58be5b5e457fdead417da379199a18e31"
"digest": "6ce17c31064ba7e441066ed0d441b1cc7e2f092fb19fc4b4be28901638c4d803"
},
"ruby-field-type-disambig/address.rb": {
"captureGroups": 8,
"digest": "cfb08d28049718393f211454b0c0148aaa82d7093915f0d8650a224d8ff687e3"
"digest": "6ec8723b3a6a1a5df2f024cfcf64b304c175043f60823acb623d65243d5a3114"
},
"ruby-field-type-disambig/service.rb": {
"captureGroups": 9,
"digest": "e4c2d56d115a996e64143e2a0a51b5e1e9a69686f3eb9096bb72a835bc5d6b9a"
"digest": "cf74d10705010c4c798db2232ae1fdb8372f17a06cad2e23d784d908cadb77d2"
},
"ruby-field-type-disambig/user.rb": {
"captureGroups": 13,
"digest": "7f4ed543946d9e243aa83e122303558aa336cff91e5323db09502999f64298d3"
"digest": "6ab1bbaf4193173ceba9381d319e51f342e115788b8326408e256f07dddbfcae"
},
"ruby-field-types/models.rb": {
"captureGroups": 19,
"digest": "6673d1c2b299edee80c5d7ff0dddb8741e6b03070fbc119a5c1f14598ed8b005"
"digest": "3aaf978d616df61943616cac4554669659b5a507fb332f5a5618f9bb353054fa"
},
"ruby-field-types/service.rb": {
"captureGroups": 9,
"digest": "4c48517a1efd7d556ea445d98887178aafc96568874a47bc5889854b52957698"
"digest": "56bc5ed9388fc0716ba92095e0a602f4051ef0af321f2c734bb400f7ccc4bf88"
},
"ruby-for-in-loop/app.rb": {
"captureGroups": 9,
"digest": "63e0c857c40f59580432d18045a2e07bc628edb5464850fd277bb4af2bbb1996"
"digest": "3981ec9af3c13030f144f3981a89bade0ac8c76450a3206706fb117cb417e7e3"
},
"ruby-for-in-loop/repo.rb": {
"captureGroups": 11,
"digest": "06ccf6d3f7c370efe1aab899e20eaeae0552c2d068bb2fbeee3ae88a55b0bc60"
"digest": "48883e76450d844e67676f54b1fc4aa6bd916fdcbfeeb627742f827d0bfc3c99"
},
"ruby-for-in-loop/user.rb": {
"captureGroups": 11,
"digest": "5ee42d0becb14846a96ff4bd83832c30cf29bbff31713cc1f3fee484cff721b8"
"digest": "68ee91f6972dbd9d1e5b058ac652a02c2db6203aab649249f091f48f0c733648"
},
"ruby-grandparent-resolution/lib/app.rb": {
"captureGroups": 10,
"digest": "d3609320dda5e0ca184f5adb2510babd58c1150e42e3226eceadfcbea3f752c3"
"digest": "60593906e9e50258423b4181d0152295dc9037a4940f004db84a901fa91ebaf2"
},
"ruby-grandparent-resolution/lib/models/a.rb": {
"captureGroups": 10,
"digest": "83bfdb09dd8a1a0bda06c84092041e257502ca37ae393c57ec2ff43b67d699ae"
"digest": "c7434e0859c5698858c3d14452a5d4641f6bac2ea4e672a24bf2c2bec6fdebe7"
},
"ruby-grandparent-resolution/lib/models/b.rb": {
"captureGroups": 6,
@ -181,43 +181,43 @@
},
"ruby-grandparent-resolution/lib/models/greeting.rb": {
"captureGroups": 6,
"digest": "a5f34e078d09145f2c2921610af56a1dd8273705d31446002d6a28aff1d32abc"
"digest": "a9ba981f1f659dd2979b854a9f5cf7ec8ca63400355d588b3764624decc90722"
},
"ruby-inline-constructor-receiver/lib/app.rb": {
"captureGroups": 17,
"digest": "ec09516e939d1283eeb6af78f215391043b93ce829485cf120f77a83628377f3"
"digest": "5ebc93cd9e546e88d583dcf75313c350c25189808d2b043735a7839c7ee9a2d9"
},
"ruby-inline-constructor-receiver/lib/svc.rb": {
"captureGroups": 6,
"digest": "8aedd668abdfe23d71e55b3fca341588038ef04988846392dd6663cfbe25f3e0"
"digest": "167963d1d827649809ec5b81f74a0ad2821696b3672beb77247060c3aa9834ae"
},
"ruby-local-shadow/lib/app.rb": {
"captureGroups": 8,
"digest": "6413cadbe85841eb6f06f42e8a7dd3902cc0d2908971b61c1a1a5e0e221afec9"
"digest": "aed6f3a8233dd829ab1960fd1d7374b56ad69303f3d7be6844d006bf1c95584c"
},
"ruby-local-shadow/lib/utils.rb": {
"captureGroups": 6,
"digest": "b2c5cce7a167d427de4fea1b955b9ec6401ab70f0fbd61a44fb2051332beb82d"
"digest": "4ccda43334cffffece8eafbd283464efd6dfb722d51000cdfbe6b291f36379a2"
},
"ruby-member-calls/lib/app.rb": {
"captureGroups": 12,
"digest": "78f4ec67d744302bf2827c0b61de167285fdb95794a34738898a8cb92d2529a5"
"digest": "955f6e8bb3c645dc2d25beeb92cc05785fd10fe6884e9d863e41d3f692bfcd26"
},
"ruby-member-calls/lib/user.rb": {
"captureGroups": 6,
"digest": "c621dd1859fe820beedf3fb98f4caa483ca262ea6936c8d6253bfabd56b5d557"
"digest": "7a16445e3441d053082748dc8ae636e850dac64d60bf77e900a5f66d0bc4de06"
},
"ruby-method-chain-binding/app.rb": {
"captureGroups": 39,
"digest": "54fc82a9a0a67ccff1d5ca3055c93044e79e6cd2972d943f9c2e90f1f2198716"
"digest": "de79152aa7bba9eedb4dc8b348f015815f614d78f1b88514c3302ebf497f21f8"
},
"ruby-method-enrichment/lib/animal.rb": {
"captureGroups": 32,
"digest": "692d351da11c65998ebce97ab2c494df6b7aaf50af759caeefbb19d8f2726475"
"digest": "d88df40a3d4f0bcefeef9500d655632c9b12079099d16654a587eb5f7d0d3b11"
},
"ruby-method-enrichment/lib/app.rb": {
"captureGroups": 14,
"digest": "941438b3abc3d34ad79e62eb3bf2eee785ac01fd5034f6f40a6911895d79c9ac"
"digest": "91681242a31b7853d73958bff777334e56c27ddf79b032899c3f367ffa743d8d"
},
"ruby-namespaced-constructor/app.rb": {
"captureGroups": 8,
@ -225,39 +225,47 @@
},
"ruby-namespaced-constructor/models/user_service.rb": {
"captureGroups": 13,
"digest": "5e0334b33bf3971198e58d8d7637bab148381744ce3b44bb7f17846c863b690d"
"digest": "2928793badfb694dcf443146571717ce81ddb1317757a9025f0268455863336b"
},
"ruby-namespaced/namespaced.rb": {
"captureGroups": 16,
"digest": "34e07387fece6c1d2deb49c39fc2bfe0badfe8015dd1f7ae956d57ac98322a1d"
"digest": "f2f9e59c4602ebf54cee431d2dc348b10020fd6dad7cbfa953869a916d27a7dc"
},
"ruby-nested-method/main.rb": {
"captureGroups": 54,
"digest": "0274b3afc8d11ec7c673da95d1e8c817b63ab9659390088e86b5469258f1707e"
},
"ruby-nested-method/other.rb": {
"captureGroups": 18,
"digest": "8e88d743b3947496604ecff3734919767c6bd93eaacfe3fabd14cbc626ea31f9"
},
"ruby-nested-mixin-shortname/app.rb": {
"captureGroups": 11,
"digest": "dfa494facc56b5e07a12befc77cd1e3788f0494f1373960cd9fe88715750e590"
"digest": "6b1733e7f3f4f1fe2be264b28be675faa884ebf53038461d048dbea85eca8112"
},
"ruby-nested-mixin-tail-collision/app.rb": {
"captureGroups": 21,
"digest": "b42c38446b3e5307cd79eec888d5faf9d9683d79bacdd9738f64871d2a1e8bbc"
"digest": "358cf578c44c569a81fa6b24d51384869fdeb84025a09379b5f4972fd8ff487b"
},
"ruby-nested-tail-collision/nested.rb": {
"captureGroups": 31,
"digest": "c48ebe5516a0faf50effbad0a19fe29be70c371b50ba9d6fa6ae3f6f708b3a4e"
"digest": "c91b653c56834f05dbf4bc771e7bbf72dff3298690afa52b4277d561493e6a89"
},
"ruby-overload-dispatch/lib/app.rb": {
"captureGroups": 10,
"digest": "288d5386cf37fb76b52a94bc7da6bf8e7843830ebbb01fcd8100d1590c0e3f72"
"digest": "07f2f6d4931d4718246bfb281a8a09e6c01fca2e85fe6c0e91982902ef5f0cc9"
},
"ruby-overload-dispatch/lib/formatter.rb": {
"captureGroups": 14,
"digest": "48a0c401c3716da69fce6beb17f9be49c7994074a2fb44599aa0fce2a8c6c2b1"
"digest": "093fd3578d904c70f43293eccff8e42b22bc8373e70a3032a6873199dec45607"
},
"ruby-parent-resolution/lib/models/base_model.rb": {
"captureGroups": 6,
"digest": "e108bf68441486968cff17aabfc3489fd295f15d7ad32cac8dad9c1b56e6029b"
"digest": "71c5bf7e807a230485e7fc93cac271df15eea3858fe41de55a5472c6200e38d9"
},
"ruby-parent-resolution/lib/models/serializable.rb": {
"captureGroups": 6,
"digest": "4b415b1bcb31b0290a01279f86305459f53e43efb388e96d6e7b4bf804bd12d8"
"digest": "91de87d56e68d9a7e0e4f9298a10d4e08f29bfba4ac5dc8cb275f1e564178622"
},
"ruby-parent-resolution/lib/models/user.rb": {
"captureGroups": 9,
@ -265,79 +273,79 @@
},
"ruby-qualified-base/lib/derived.rb": {
"captureGroups": 18,
"digest": "a53ab5401694f26da04b02f5be6bc1ba6a292ca93eb16e2df6089e15c60dfba5"
"digest": "b09f52e09a1cc7d76607c794b3bc99366abbfd9507ee41cf0a147e7f66d0a453"
},
"ruby-qualified-base/lib/outer.rb": {
"captureGroups": 18,
"digest": "f81f06be06d013a08a5c9b730a79494251f102f48ca4c25bf0bbd4a2cdcd889e"
"digest": "1296ad022c1ada316594385e9fc3250c8c9772b661df1262e7ea3d7404c50639"
},
"ruby-qualified-mixin/app.rb": {
"captureGroups": 11,
"digest": "30e3ff7538ab8cea9e5bd9c47c6275aa653b110720f24ee08d7fe19fc5c78952"
"digest": "152e9433fe3f8c9f7755bba2e183b3ffc288370a0f403644784a9eefc62001ee"
},
"ruby-qualified-types/lib/admin/user.rb": {
"captureGroups": 8,
"digest": "1bceb829c2429e1415c296ea97a2475203c3cbf69c07123186f4c277a44b2f9f"
"digest": "2aa6b9fee01a7479b59a67ff500c2aa1c1d901922a013837eb00abfe4288153a"
},
"ruby-qualified-types/lib/services/auth/user.rb": {
"captureGroups": 10,
"digest": "21fbc89550147f66806eb9e506870fcf5f4bec23876bdce069897d905e0ae307"
"digest": "013326e4f5742b436f02a9804ad0efc1145304f0b37fe759543bafb02f93abc1"
},
"ruby-return-type/app.rb": {
"captureGroups": 17,
"digest": "0db7356580a8ac5addec69a3259e5a78028e01b44f5e36316a6f76172929f829"
"digest": "55a732b0be9143e51e5c992c31bf30f1417b41601c665394cc6b2ec1a877ed6b"
},
"ruby-return-type/models.rb": {
"captureGroups": 18,
"digest": "e176965a226cc1c84bfd10a2d22083facea402184e9907c6d7858fcd9bc459e5"
"digest": "2fe9eece83b038ce41b362fa95c425f4274d0537ec4af6efe5c9ad15863c54fa"
},
"ruby-return-type/repo.rb": {
"captureGroups": 18,
"digest": "c23aa57b5f96ea094dc201daa0b2d02d7a7438b53488977eadc6ab2ebe63a68b"
"digest": "758be74a400487ef3f4562cedc26ced277adfbb2265780a9e55840e6f855ff74"
},
"ruby-self-this-resolution/lib/models/repo.rb": {
"captureGroups": 6,
"digest": "9effd68932555f44968ec89ea1fb0bff4719185c777e856ae167a2289fa72239"
"digest": "d00bcdbe0167ae669510ef3b07f77bd2a0c39ad76fc7c072ee91d2b42acd05db"
},
"ruby-self-this-resolution/lib/models/user.rb": {
"captureGroups": 10,
"digest": "17c3c937d7d40cffec95393e6b7f2886b44a9117f048fb72eafa88140320c45e"
"digest": "fe47cf237a6a5fbb933f987676c510f71921582a56d8a54e23ce7f811bcc2e2b"
},
"ruby-sequential-mixin/lib/account.rb": {
"captureGroups": 27,
"digest": "e426aff3f6a6b4e403a6bf64aa4c15ddea6afe43b18754bfb71103653b1df8b3"
"digest": "316ae20946216f503802083e0c568bb0ced7a2a158555ca50254fbee11d78481"
},
"ruby-sequential-mixin/lib/greetable.rb": {
"captureGroups": 6,
"digest": "f56ff0b87abb7463dcf5fdbe18c1cf74c11595853ff04ff8d929cc8d5cb71ca5"
"digest": "d2034a1b3b872719da0c2f9726f6d63c85a3a68663db89972bac0241b071a0e3"
},
"ruby-sequential-mixin/lib/logger_mixin.rb": {
"captureGroups": 9,
"digest": "b37cb0302afa4e380fb02828fcd4219edfd6aabbe65c74e4a5f5de9a65b33003"
"digest": "65d8078a0b21bec39af2cee96054628dc9053800d25263c63587f5ae3e2b9665"
},
"ruby-sequential-mixin/lib/prepended_override.rb": {
"captureGroups": 9,
"digest": "90bdfeb850c14a1f85ac69b194c4a37db196d4709b503e00a31968c550a66746"
"digest": "6053105291cf3a5912326275b321b0b426ac9073bd852cfe1e78c21aab650e2e"
},
"ruby-sequential-mixin/lib/usage.rb": {
"captureGroups": 14,
"digest": "c8e1cd74bf9fd8db677fc8e2adbcc8b7eb303884d5339aa964350f81167935bc"
"digest": "6be0c6bcf08525e26c69bd6a2be43c75b357467a9744a15f12cfd7b619b23836"
},
"ruby-super-resolution/lib/models/base_model.rb": {
"captureGroups": 6,
"digest": "e108bf68441486968cff17aabfc3489fd295f15d7ad32cac8dad9c1b56e6029b"
"digest": "71c5bf7e807a230485e7fc93cac271df15eea3858fe41de55a5472c6200e38d9"
},
"ruby-super-resolution/lib/models/repo.rb": {
"captureGroups": 6,
"digest": "9effd68932555f44968ec89ea1fb0bff4719185c777e856ae167a2289fa72239"
"digest": "d00bcdbe0167ae669510ef3b07f77bd2a0c39ad76fc7c072ee91d2b42acd05db"
},
"ruby-super-resolution/lib/models/user.rb": {
"captureGroups": 9,
"digest": "73c8b1725670e841d01fefa807b6148017e181e6bb34d8f5110d970f4292eaff"
"digest": "1466db4d96a012e158a401cc67802267f3f36c24c374c3319e39586c64a35019"
},
"ruby-tail-collision/collision.rb": {
"captureGroups": 15,
"digest": "c071370701e4d8d4046ea2466192648cf72352cfb66e2e08ad15e320e850c683"
"digest": "25f64ba72b3a70e52cdaa1ac04397543ca22796eaea7826070704e857beba1a4"
},
"ruby-write-access/models.rb": {
"captureGroups": 13,
@ -345,26 +353,26 @@
},
"ruby-write-access/service.rb": {
"captureGroups": 21,
"digest": "444cd08dd9f29508da3298f7a14a91dd6edab354ab4ff6345b3744afa65136d7"
"digest": "431c57194d8b29d22b88eead94e1621b34bfcd47d9980abdea225a971646d44a"
},
"ruby-yard-annotations/models.rb": {
"captureGroups": 15,
"digest": "47b1dc604ac3070fcb24575b06e452f9c6ba95424dd8cdc98c65a25b2a635251"
"digest": "16683d038c99a6d75fe7bfe2e2e4efb80b0b9879c530a9f6aade2edaad2cfb49"
},
"ruby-yard-annotations/service.rb": {
"captureGroups": 15,
"digest": "1778fe41038f4a05ecca406ad5cffb0e6b33e69b81265702611a6d498aa68b06"
"digest": "58a03e0288f0e0178f16355246403b7aae6e43fdfd5c749c770f7d3832e6ee65"
},
"ruby-yard-generics/models.rb": {
"captureGroups": 17,
"digest": "3a0722a48ac72ad10a492d10c4728a805990d07789e5b20a239a6c95a914d23f"
"digest": "3b06e09ba2866c9d8aa2954b5906deb993ee514087e0e71f50d3e6350c468344"
},
"ruby-yard-generics/service.rb": {
"captureGroups": 21,
"digest": "f2452a20687ba8adb2ac86a23d4c6e3af51df62f65c28212dfbb0f1244643cb3"
"digest": "4fa3d8bf0288e23596bd0da03ee170f02909129c245b5904c0a0918558d5d1da"
},
"synthetic:dao-20": {
"captureGroups": 201,
"digest": "62db9b6265ac37ece2271596da05196463ae9bbb7d9af40798f55bfe1af1768e"
"digest": "69f9d6f119cad8da2d5a1333f8926af647ed18ae277689cda6dabc4f888b0b7f"
}
}

View file

@ -36,6 +36,35 @@ function pythonNotebook(cells: Array<{ source: string | string[]; language?: str
}
describe('Jupyter notebook Python pipeline', () => {
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 {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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