diff --git a/CHANGELOG.md b/CHANGELOG.md index ad5c569..57c3a5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - TypeScript and JavaScript **class fields are now reported as properties instead of methods**. A plain field like `public fonts: Fonts;` previously extracted as a method, misrepresenting class shape and letting calls to same-named functions resolve to data fields (a boolean field named `isArray` was soaking up `Array.isArray(...)` call edges). Fields holding arrow functions or function expressions (`onClick = () => {…}`, including wrapped ones like `onScroll = throttle(() => {…})`) correctly remain methods and their bodies are still analyzed. Field initializers are analyzed too, so `history = createHistory()` records its call — and JavaScript class fields, which previously produced no symbol at all, now appear in the graph. Re-index a project to benefit. (#808) (TypeScript, JavaScript) - Callback registration through `this` now resolves precisely in TypeScript and JavaScript: `window.addEventListener("online", this.onOfflineStatusToggle)` or an API object like `{ mutateElement: this.mutateElement }` produces a reference edge to the **enclosing class's own method** — never a same-named method on an unrelated class, and never a data field. Builds on the callback-registration support below. (#808) (TypeScript, JavaScript) - Callback-registration coverage deepened across four more shapes: a `this.` registration whose method lives on a **base class** now resolves through the inheritance chain (`bus.on("submit", this.handleSubmit)` in a subclass links to the parent's `handleSubmit`); Java and Kotlin **method references to other classes** (`Handlers::onMessage`, `OtherClass::handle`) resolve across files, with `this::` and `super::` scoped to the defining class and references through a variable deliberately left out; and Swift bare callback names now match only the **enclosing type's** methods (implicit `self`), eliminating a class of wrong edges where a parameter like `request` linked to a same-named method on an unrelated type. (Java, Kotlin, Swift, TypeScript, JavaScript) +- PHP **string and array callables** now register: a string passed to a callable-taking core function (`usort($items, 'cmp_items')`, `array_map('absint', …)`, `call_user_func`, `spl_autoload_register`, …) links to that function — including across files — and the array forms `[$this, 'method']` and `[Foo::class, 'method']` link to the named method (the `$this` form resolves through the class and its parents). Strings passed to arbitrary functions are deliberately ignored: only known callable positions are trusted. Validated on WordPress core (+556 edges, every sampled edge a genuine registration). (PHP) +- Ruby **lifecycle-hook symbols** now register: `before_action :authenticate`, `after_save :reindex`, `around_create`, `validate :check`, `rescue_from(…, with: :handler)` and friends link the symbol to the method it names — on the class itself or **inherited from a parent** (`before_action :authenticate` in a controller resolves to `ApplicationController`'s method). `validates` (plural) is excluded since its symbols name attributes, not methods. Validated on rails/rails (+385 edges, every sampled edge genuine). (Ruby) - CodeGraph now sees where a function is **registered as a callback**, not just where it's called. A function name passed as an argument (`signal(SIGINT, handler)`, `qsort(…, compare)`, `addEventListener(…, onBlur)`), assigned to a function pointer or field (`ops->recv_cb = my_cb`, `OnClick := Handler`), or placed in a struct initializer or handler table (`{ .recv_cb = my_cb }`, `{ "get", getCommand }`) now produces a reference edge from the registration site to the function — so `codegraph_callers` and `codegraph_impact` surface callback wiring that previously looked like dead code. Works across all supported languages, including the language-specific forms: C/C++ `&fn`, Java `Class::method`, Kotlin `::fn`, Swift `#selector`, Objective-C `@selector`, Ruby `method(:fn)`, Scala eta-expansion, and Delphi/Pascal `@Handler` and `OnClick := Handler` event wiring. Callers output labels these "via callback registration". Resolution is deliberately conservative: an ambiguous name produces no edge rather than a wrong one. Re-index a project to benefit. Thanks @zmcrazy. (#756) - The `codegraph_node` MCP tool can now **read a whole source file like the built-in Read tool — only faster, served from the index**. Pass a file path with no symbol and it returns that file's current source with line numbers (the same `` shape Read produces, so an assistant can edit straight from it), narrowable with `offset`/`limit` exactly like Read, plus a one-line note of which files depend on it (the file's blast radius). Use it anywhere you'd reach for Read on an indexed source file. Pass `symbolsOnly: true` for just the file's structure. Configuration/data files (`.yml` / `.properties`) are summarized by key only, never dumped, so secrets in them are never surfaced. The agent-facing guidance was also retuned so assistants reach for codegraph while *implementing* a change (not only when answering questions), since one codegraph call returns the same bytes plus the blast radius, faster than re-reading the file. - New `codegraph upgrade` command updates CodeGraph to the latest release in place — it detects how you installed (the standalone `install.sh` / `install.ps1` bundle, npm, or npx) and does the right thing for each, on macOS, Linux, and Windows. Use `codegraph upgrade --check` to see whether an update is available without installing, or `codegraph upgrade ` to move to a specific version. After upgrading it reminds you to re-index your projects so they pick up the newer engine's improvements. (#679) diff --git a/__tests__/function-ref.test.ts b/__tests__/function-ref.test.ts index d7f9b58..440bb82 100644 --- a/__tests__/function-ref.test.ts +++ b/__tests__/function-ref.test.ts @@ -617,6 +617,88 @@ describe('Function-as-value capture (#756)', () => { } }); + it('PHP: HOF string callables, [$this,…] and [Cls::class,…] arrays; non-HOF strings ignored', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-php-')); + fs.writeFileSync( + path.join(tmpDir, 'handlers.php'), + " $b; }\n" + ); + fs.writeFileSync( + path.join(tmpDir, 'main.php'), + [ + ' { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-rubyhooks-')); + fs.writeFileSync( + path.join(tmpDir, 'posts_controller.rb'), + [ + 'class ApplicationController', + ' def authenticate; end', + 'end', + '', + 'class PostsController < ApplicationController', + ' before_action :authenticate', // inherited → ApplicationController + ' after_save :reindex', + ' validates :title, presence: true', // attributes, NOT methods → no edge + ' rescue_from StandardError, with: :render_500', + '', + ' def reindex; end', + ' def render_500; end', + ' def title; end', + 'end', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + const auth = fnRefEdgesInto(cg, 'authenticate'); + expect(auth).toHaveLength(1); + expect(cg.getNode(auth[0]!.target)?.qualifiedName).toContain('ApplicationController'); + + expect(fnRefEdgesInto(cg, 'reindex')).toHaveLength(1); + expect(fnRefEdgesInto(cg, 'render_500')).toHaveLength(1); + // `validates :title` names an attribute — the same-named METHOD must + // get no registration edge. + expect(fnRefEdgesInto(cg, 'title')).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + it('DRAIN: resolvable function_ref rows leave unresolved_refs; re-index is stable', async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-drain-')); fs.writeFileSync( diff --git a/docs/design/function-ref-capture.md b/docs/design/function-ref-capture.md index bb32505..d08fcaf 100644 --- a/docs/design/function-ref-capture.md +++ b/docs/design/function-ref-capture.md @@ -57,7 +57,8 @@ custom `visitNode` hooks like Scala's val/var handler) get a candidates-only | Dart | `arguments` (`argument`) | `assignment_expression.right` | `pair.value` | `list_literal`, `static_final_declaration` | — | | Lua / Luau | `arguments` | `assignment_statement` (`expression_list.value`) | `field.value` (keyed + positional) | (same) | — | | Pascal | `exprArgs` (via `visitPascalBlock`) | `assignment.rhs` (`OnFire := Handler`) | — | — | `@Handler` (`exprUnary.operand`) | -| PHP | **skipped** | — | — | — | first-class callable `fn(...)` already extracts as a `calls` edge; string callables are a precision risk, deferred | +| PHP | string callables ONLY as args of known core HOFs (`usort`, `array_map`, `call_user_func*`… — `PHP_CALLABLE_HOFS`), ungated + unique-or-drop (PHP globals aren't imported) | — | — | — | `[$this, 'm']` → class-scoped `this.m`; `[Foo::class, 'm']` → qualified; `'Cls::m'` → qualified; first-class callable `fn(...)` already extracts as `calls` | +| Ruby hooks | `(skip_)?(before\|after\|around)_*` + `validate`/`set_callback`/`helper_method`/`rescue_from(with:)` symbols → class-scoped `this.` (rides the supertype pass: `before_action :authenticate` → ApplicationController). `validates` (plural) excluded — its symbols are ATTRIBUTES | — | — | — | symbols under any other call yield nothing | ## Precision rules (each one bought by a real-repo false positive) @@ -199,8 +200,18 @@ Index cost on redis: +6% time, +5% db size. imports, so cross-file bare callbacks only resolve when repo-unique (functions; methods are enclosing-type-only). Cross-TYPE `#selector` targets (rare — target-action is normally self) are scoped away too. -- **PHP string callables**, **Ruby bare symbols** outside `method(:sym)`, - **`obj.method` member values** where `obj` isn't `this`/`self`: deferred. +- **`obj.method` member values** where `obj` isn't `this`/`self`: deferred — + the receiver's type is statically unknowable without local data-flow. +- **PHP strings outside known-HOF positions** (a bare `'handler'` to an + arbitrary function; framework registries like WordPress `add_action`): + deliberately uncaptured — a string is only trustworthy as a callable in a + known callable position. Framework registries belong in a `frameworks/` + resolver if ever added. **Ruby symbols outside the hook DSLs** likewise. +- **The supertype pass is NODE-anchored** (file-anchored class node → + implements/extends edge targets → `contains`-anchored member lookup): a + name-keyed `getSupertypes('Engine')` unioned every rails `Engine`'s parents + and produced a cross-class wrong edge; the node walk eliminated it + (rails +440 → +385, all sampled edges genuine). - **`this.X` inherited members resolve through the supertype pass** (`resolveDeferredThisMemberRefs`, depth-capped BFS over implements/extends, runs after edges persist — same lifecycle as the #750 conformance pass). diff --git a/src/extraction/extraction-version.ts b/src/extraction/extraction-version.ts index 76e3f57..4494e6c 100644 --- a/src/extraction/extraction-version.ts +++ b/src/extraction/extraction-version.ts @@ -21,4 +21,4 @@ * turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty * in the product is load-bearing"). */ -export const EXTRACTION_VERSION = 21; +export const EXTRACTION_VERSION = 22; diff --git a/src/extraction/function-ref.ts b/src/extraction/function-ref.ts index 2970cd4..1bae970 100644 --- a/src/extraction/function-ref.ts +++ b/src/extraction/function-ref.ts @@ -25,9 +25,9 @@ * * Deliberately NOT covered (resolving the *dispatch* — `o->cb(x)` → the * registered function — needs data-flow through struct fields; a wrong edge - * is worse than none): indirect-call resolution, PHP string callables, - * Ruby bare symbols outside `method(:sym)`, and `obj.method` member values - * where `obj` isn't `this`/`self`. + * is worse than none): indirect-call resolution and `obj.method` member + * values where `obj` isn't `this`/`self` (the receiver's type is statically + * unknowable without local data-flow). */ import type { Node as SyntaxNode } from 'web-tree-sitter'; @@ -45,6 +45,15 @@ export interface FnRefCandidate { * C++'s flush policy keys on it. */ explicitRef: boolean; + /** + * Skip the same-file/import name gate for this candidate. Set for PHP + * string callables in known HOF positions: PHP global functions are + * referenced cross-file WITHOUT imports (global namespace), so the gate + * can't see them — the strong positional prior (a string argument to + * `usort`/`array_map`/…) plus resolution's unique-or-drop rule carry the + * precision instead. + */ + skipGate?: boolean; } /** How to pull candidate value nodes out of a dispatched container node. */ @@ -252,16 +261,30 @@ const CSHARP_SPEC: FnRefSpec = { const RUBY_SPEC: FnRefSpec = { // Bare identifiers in Ruby args are method CALLS or locals, never function - // values — only the `method(:name)` idiom (and `&method(:name)`) qualifies. + // values — only the `method(:name)` idiom (and `&method(:name)`) plus + // hook-DSL symbols (`before_action :authenticate`) qualify. idTypes: new Set(), dispatch: new Map([ ['argument_list', { mode: 'args' }], ['pair', { mode: 'value', field: 'value' }], ]), layers: new Map([['block_argument', null]]), - special: new Set(['call']), + special: new Set(['call', 'simple_symbol']), }; +/** + * Rails/ActiveSupport-style hook DSLs whose symbol arguments name a method of + * the enclosing class: lifecycle callbacks (`before_action`, `after_save`, + * `around_create`, `skip_before_action`…), `validate :method`, `set_callback`, + * `helper_method`, and `rescue_from(..., with: :handler)`. NOT `validates` + * (plural) — its symbols name ATTRIBUTES, not methods. + */ +const RUBY_HOOK_RE = /^(skip_)?(before|after|around)_[a-z_]+$/; +const RUBY_HOOK_NAMES = new Set(['validate', 'set_callback', 'helper_method', 'rescue_from']); +function isRubyHookCall(name: string): boolean { + return RUBY_HOOK_RE.test(name) || RUBY_HOOK_NAMES.has(name); +} + const SWIFT_SPEC: FnRefSpec = { idTypes: new Set(['simple_identifier']), dispatch: new Map([ @@ -316,9 +339,39 @@ const PASCAL_SPEC: FnRefSpec = { }; /** - * Capture specs by language. PHP is deliberately absent: its first-class - * callable `fn(...)` already extracts as a `calls` edge, and string callables - * (`'fn_name'`) are a precision risk left for a follow-up. + * PHP core functions whose string arguments are CALLABLES — the positional + * prior that makes a bare string trustworthy as a function reference. + * Deliberately core-PHP only; framework registries (WordPress `add_action`) + * belong in a frameworks/ resolver if ever added. + */ +const PHP_CALLABLE_HOFS = new Set([ + 'array_map', 'array_filter', 'array_walk', 'array_walk_recursive', 'array_reduce', + 'usort', 'uasort', 'uksort', + 'array_udiff', 'array_udiff_assoc', 'array_uintersect', 'array_uintersect_assoc', + 'call_user_func', 'call_user_func_array', + 'forward_static_call', 'forward_static_call_array', + 'preg_replace_callback', 'preg_replace_callback_array', + 'register_shutdown_function', 'register_tick_function', + 'set_error_handler', 'set_exception_handler', 'spl_autoload_register', + 'ob_start', 'iterator_apply', 'header_register_callback', + 'is_callable', +]); + +const PHP_SPEC: FnRefSpec = { + // PHP has no bare-identifier function values (the first-class callable + // `fn(...)` already extracts as a `calls` edge). What qualifies: + // - a string argument to a known callable-taking core function + // (`usort($a, 'cmp_items')`) — see PHP_CALLABLE_HOFS + // - array callables: `[$this, 'method']` (class-scoped) and + // `[Foo::class, 'method']` (qualified), in any call's arguments + idTypes: new Set(), + dispatch: new Map([['arguments', { mode: 'args' }]]), + layers: new Map([['argument', null]]), + special: new Set(['encapsed_string', 'string', 'array_creation_expression']), +}; + +/** + * Capture specs by language. */ export const FN_REF_SPECS: Record = { c: cFamilySpec(), @@ -334,6 +387,7 @@ export const FN_REF_SPECS: Record = { java: JAVA_SPEC, kotlin: KOTLIN_SPEC, csharp: CSHARP_SPEC, + php: PHP_SPEC, ruby: RUBY_SPEC, swift: SWIFT_SPEC, scala: SCALA_SPEC, @@ -441,7 +495,7 @@ export function captureFnRefCandidates( // flush, where file scope is known) drops bare ids outside file-scope // initializer tables. const explicitRef = !spec.idTypes.has(v.type); - for (const { name, node } of normalizeValue(v, spec, source, 0)) { + for (const { name, node, skipGate } of normalizeValue(v, spec, source, 0)) { if (!name || NAME_STOPLIST.has(name)) continue; out.push({ name, @@ -449,12 +503,20 @@ export function captureFnRefCandidates( column: node.startPosition.column, mode: rule.mode, explicitRef, + skipGate, }); } } return out; } +/** One normalized function-value: its name, source node, and gate policy. */ +interface NormalizedRef { + name: string; + node: SyntaxNode; + skipGate?: boolean; +} + /** * Normalize one value expression to zero or more function names. Recursion is * bounded (wrapper layers only); anything that isn't a recognized @@ -465,7 +527,7 @@ function normalizeValue( spec: FnRefSpec, source: string, depth: number -): Array<{ name: string; node: SyntaxNode }> { +): NormalizedRef[] { if (depth > 4) return []; const type = node.type; @@ -497,7 +559,7 @@ function normalizeValue( const inner = getChildByField(node, layerField); return inner ? normalizeValue(inner, spec, source, depth + 1) : []; } - const results: Array<{ name: string; node: SyntaxNode }> = []; + const results: NormalizedRef[] = []; for (let i = 0; i < node.namedChildCount; i++) { const child = node.namedChild(i); if (child) results.push(...normalizeValue(child, spec, source, depth + 1)); @@ -551,7 +613,7 @@ function normalizeSpecial( node: SyntaxNode, type: string, source: string -): Array<{ name: string; node: SyntaxNode }> { +): NormalizedRef[] { switch (type) { // Java method references. Receiver decides the resolution route (#808): // `this::run0` / `super::close` → `this.` (class-scoped resolver; @@ -683,7 +745,99 @@ function normalizeSpecial( return isThisReceiver ? [{ name: getNodeText(name, source), node: name }] : []; } + // PHP string callable — trustworthy ONLY as an argument to a known + // callable-taking core function (`usort($a, 'cmp_items')`). PHP global + // functions are referenced cross-file without imports, so these skip the + // name gate and rely on resolution's unique-or-drop rule. A + // `'Cls::method'` string becomes a qualified candidate. + case 'encapsed_string': + case 'string': { + const callee = phpEnclosingCallName(node); + if (!callee || !PHP_CALLABLE_HOFS.has(callee)) return []; + const content = phpStringContent(node, source); + if (!content) return []; + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(content)) { + return [{ name: content, node, skipGate: true }]; + } + if (/^[A-Za-z_][A-Za-z0-9_]*::[A-Za-z_][A-Za-z0-9_]*$/.test(content)) { + return [{ name: content, node, skipGate: true }]; + } + return []; + } + + // PHP array callables, valid in ANY call's arguments (the shape itself is + // unambiguous): `[$this, 'method']` → class-scoped `this.method`; + // `[Foo::class, 'method']` → qualified `Foo::method`. + case 'array_creation_expression': { + if (node.namedChildCount !== 2) return []; + const recv = node.namedChild(0)?.namedChild(0); + const strEl = node.namedChild(1)?.namedChild(0); + if (!recv || !strEl) return []; + if (strEl.type !== 'encapsed_string' && strEl.type !== 'string') return []; + const member = phpStringContent(strEl, source); + if (!member || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(member)) return []; + if (recv.type === 'variable_name' && getNodeText(recv, source) === '$this') { + return [{ name: `this.${member}`, node: strEl }]; + } + if (recv.type === 'class_constant_access_expression') { + const cls = recv.namedChild(0); + const kw = recv.namedChild(1); + if (cls && kw && getNodeText(kw, source) === 'class') { + return [{ name: `${getNodeText(cls, source)}::${member}`, node: strEl }]; + } + } + return []; + } + + // Ruby hook-DSL symbols (`before_action :authenticate`, + // `rescue_from E, with: :render_404`): the symbol names a method of the + // ENCLOSING class — route through the class-scoped `this.` resolver + // (which also walks superclasses, covering ApplicationController-style + // inheritance). Symbols under any other call yield nothing. + case 'simple_symbol': { + const call = rubyEnclosingCall(node); + if (!call) return []; + const method = getChildByField(call, 'method'); + if (!method || !isRubyHookCall(getNodeText(method, source))) return []; + const sym = getNodeText(node, source).replace(/^:/, ''); + if (!/^[A-Za-z_][A-Za-z0-9_?!]*$/.test(sym)) return []; + return [{ name: `this.${sym}`, node }]; + } + default: return []; } } + +/** Content of a PHP string literal node (single- or double-quoted). */ +function phpStringContent(node: SyntaxNode, source: string): string | null { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'string_content') return getNodeText(child, source).trim(); + } + return null; +} + +/** The function name of the PHP call whose arguments contain `node`, if any. */ +function phpEnclosingCallName(node: SyntaxNode): string | null { + let cur: SyntaxNode | null = node.parent; + for (let hops = 0; cur && hops < 4; hops++, cur = cur.parent) { + if (cur.type === 'function_call_expression') { + const fn = getChildByField(cur, 'function'); + return fn ? fn.text : null; + } + if (cur.type === 'member_call_expression' || cur.type === 'scoped_call_expression') { + return null; // method calls aren't core HOFs + } + } + return null; +} + +/** The Ruby `call` node whose argument_list (or keyword pair) contains `node`. */ +function rubyEnclosingCall(node: SyntaxNode): SyntaxNode | null { + let cur: SyntaxNode | null = node.parent; + for (let hops = 0; cur && hops < 4; hops++, cur = cur.parent) { + if (cur.type === 'call') return cur; + } + return null; +} diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index a3e827f..f22ad37 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -454,15 +454,18 @@ export class TreeSitterExtractor { // segment — the simple name Java/Kotlin code uses in `OtherClass::method` // references. const SIMPLE_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/; - const DOTTED_NAME = /^[A-Za-z_$][A-Za-z0-9_$.]*\.([A-Za-z_$][A-Za-z0-9_$]*)$/; + // JVM imports are dotted (`com.example.OtherClass`); PHP `use` imports + // are backslashed (`App\Services\Mailer`). Both contribute their last + // segment — the simple name code uses to reference them. + const QUALIFIED_IMPORT = /^[A-Za-z_$][A-Za-z0-9_$.\\]*[.\\]([A-Za-z_$][A-Za-z0-9_$]*)$/; const importedNames = new Set(); for (const r of this.unresolvedReferences) { if (r.referenceKind !== 'imports') continue; if (SIMPLE_NAME.test(r.referenceName)) { importedNames.add(r.referenceName); } else { - const dotted = r.referenceName.match(DOTTED_NAME); - if (dotted) importedNames.add(dotted[1]!); + const qualified = r.referenceName.match(QUALIFIED_IMPORT); + if (qualified) importedNames.add(qualified[1]!); } } @@ -497,7 +500,9 @@ export class TreeSitterExtractor { // (constant-expression context — see FnRefSpec.ungatedModes). // - everything else: name ∈ same-file functions/methods ∪ imports. if (!c.name.startsWith('this.')) { - const skipGate = ungated?.has(c.mode) === true && atFileScope; + const skipGate = + (ungated?.has(c.mode) === true && atFileScope) || + c.skipGate === true; // PHP HOF-position string callables (see FnRefCandidate.skipGate) if (!skipGate) { if (c.name.includes('::')) { const scopeName = c.name.slice(0, c.name.indexOf('::')); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 1e49202..0d7ec43 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -1212,9 +1212,17 @@ export class ReferenceResolver { if (!member) return null; const fromNode = this.queries.getNodeById(ref.fromNodeId); if (!fromNode) return null; - const sep = fromNode.qualifiedName.lastIndexOf('::'); - if (sep <= 0) return null; // not inside a class scope - const classPrefix = fromNode.qualifiedName.slice(0, sep); + // A hook declared at class-body level (Ruby `before_action :authenticate`) + // attributes to the CLASS node itself — its qualified name IS the scope. + // For members, strip the member segment. + let classPrefix: string; + if (SUPERTYPE_BEARING_KINDS.has(fromNode.kind) || fromNode.kind === 'module') { + classPrefix = fromNode.qualifiedName; + } else { + const sep = fromNode.qualifiedName.lastIndexOf('::'); + if (sep <= 0) return null; // not inside a class scope + classPrefix = fromNode.qualifiedName.slice(0, sep); + } const candidates = this.context .getNodesByQualifiedName(`${classPrefix}::${member}`) .filter( @@ -1260,38 +1268,72 @@ export class ReferenceResolver { const member = ref.referenceName.slice('this.'.length); const fromNode = this.queries.getNodeById(ref.fromNodeId); if (!fromNode || !member) continue; - const sep = fromNode.qualifiedName.lastIndexOf('::'); - if (sep <= 0) continue; - const classPrefix = fromNode.qualifiedName.slice(0, sep); - const className = classPrefix.includes('::') - ? classPrefix.slice(classPrefix.lastIndexOf('::') + 2) - : classPrefix; + // Class-body-level hooks (Ruby) attribute to the CLASS node itself. + let className: string; + if (SUPERTYPE_BEARING_KINDS.has(fromNode.kind) || fromNode.kind === 'module') { + className = fromNode.name; + } else { + const sep = fromNode.qualifiedName.lastIndexOf('::'); + if (sep <= 0) continue; + const classPrefix = fromNode.qualifiedName.slice(0, sep); + className = classPrefix.includes('::') + ? classPrefix.slice(classPrefix.lastIndexOf('::') + 2) + : classPrefix; + } - // BFS up the supertype graph by simple name. - const seen = new Set([className]); - let frontier = this.context.getSupertypes?.(className, ref.language) ?? []; + // NODE-anchored BFS up the supertype graph: start from the class node + // in the ref's own file (never a same-named class elsewhere — rails has + // a dozen `Engine`s), follow implements/extends EDGES to supertype + // NODES, and look members up through `contains` edges. No name-based + // unions anywhere — a name-keyed getSupertypes('Engine') merged every + // Engine's parents and produced a cross-class wrong edge on rails. + let frontierNodes = this.context + .getNodesByName(className) + .filter( + (n) => + SUPERTYPE_BEARING_KINDS.has(n.kind) && + n.filePath === ref.filePath + ); + if (frontierNodes.length === 0) { + // The class itself may be declared in another file (partial/reopened + // classes); fall back to same-family nodes of that name. + frontierNodes = this.context + .getNodesByName(className) + .filter( + (n) => + SUPERTYPE_BEARING_KINDS.has(n.kind) && + sameLanguageFamily(n.language, ref.language) + ); + } + const seenNodes = new Set(frontierNodes.map((n) => n.id)); let target: Node | null = null; - for (let depth = 0; depth < 5 && frontier.length > 0 && !target; depth++) { - const next: string[] = []; - for (const superName of frontier) { - if (seen.has(superName)) continue; - seen.add(superName); - const members = this.context - .getNodesByName(member) - .filter( - (n) => - (n.kind === 'function' || n.kind === 'method') && - sameLanguageFamily(n.language, ref.language) && - (n.qualifiedName === `${superName}::${member}` || - n.qualifiedName.endsWith(`::${superName}::${member}`)) - ); - if (members.length > 0) { - target = members.reduce((a, b) => (a.startLine <= b.startLine ? a : b)); - break; + for (let depth = 0; depth < 5 && frontierNodes.length > 0 && !target; depth++) { + const next: Node[] = []; + for (const typeNode of frontierNodes) { + for (const edge of this.queries.getOutgoingEdges(typeNode.id, ['implements', 'extends'])) { + const superNode = this.queries.getNodeById(edge.target); + if (!superNode || seenNodes.has(superNode.id)) continue; + seenNodes.add(superNode.id); + if (!SUPERTYPE_BEARING_KINDS.has(superNode.kind)) continue; + // Member lookup anchored on the supertype's contains edges. + for (const c of this.queries.getOutgoingEdges(superNode.id, ['contains'])) { + const m = this.queries.getNodeById(c.target); + if ( + m && + m.name === member && + (m.kind === 'function' || m.kind === 'method') && + sameLanguageFamily(m.language, ref.language) + ) { + target = m; + break; + } + } + if (target) break; + next.push(superNode); } - next.push(...(this.context.getSupertypes?.(superName, ref.language) ?? [])); + if (target) break; } - frontier = next; + frontierNodes = next; } if (target) { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 9b91aea..9990d69 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -192,12 +192,15 @@ export function matchFunctionRef( // A/B finding; same pattern in vendored docopt.py). Python's `self.m` // form keeps method targets via its own capture shape. C++ likewise: a // bare identifier can only be a FREE function (member values need - // `&Cls::method`). Other languages keep method targets: C# method groups, - // Swift/Dart implicit-self, Java/Kotlin method references. + // `&Cls::method`). PHP string callables name global FUNCTIONS (methods + // need the `[$obj, 'm']` array form, which carries its own shape). Other + // languages keep method targets: C# method groups, Swift/Dart + // implicit-self, Java/Kotlin method references. const bareFnOnly = ref.language === 'typescript' || ref.language === 'tsx' || ref.language === 'javascript' || ref.language === 'jsx' || - ref.language === 'cpp' || ref.language === 'python'; + ref.language === 'cpp' || ref.language === 'python' || + ref.language === 'php'; // Qualified member-pointer (`&Widget::on_click` → "Widget::on_click"): // resolve the member ON THAT SCOPE — exempt from bareFnOnly (the `&Cls::m`