feat(extraction): PHP string/array callables + Ruby lifecycle-hook symbols (#811)
The last two deferred callback-registration shapes from #756, each scoped to positions where the reference is trustworthy: PHP — a string is a callable ONLY in a known callable position: - string args of core HOFs (usort, array_map, array_filter, call_user_func*, preg_replace_callback, spl_autoload_register, set_error_handler, … — PHP_CALLABLE_HOFS): ungated (PHP globals are referenced cross-file without imports) + resolution unique-or-drop, function-kind only ('Cls::m' strings resolve qualified) - array callables anywhere in call args: [$this, 'method'] routes through the class-scoped this. resolver (parents included); [Foo::class, 'method'] resolves qualified - strings to arbitrary functions: deliberately nothing Ruby — hook-DSL symbols name a method of the enclosing class: (skip_)?(before|after|around)_* / validate / set_callback / helper_method / rescue_from(with:) symbols → class-scoped this.<sym>, riding the supertype pass so `before_action :authenticate` in a controller resolves to ApplicationController's method. `validates` (plural) excluded — its symbols name ATTRIBUTES. Class-body-level hooks attribute to the CLASS node (the scoped resolvers now accept class-like from-nodes). Also hardened while validating: the this.X supertype pass is now NODE-anchored — file-anchored class node → implements/extends edge targets → contains-anchored member lookup — replacing the name-keyed getSupertypes walk, which unioned every same-named class's parents (rails has a dozen `Engine`s) and produced a cross-class wrong edge. A/B vs main: WordPress +556 (14/14 sampled genuine — [$this,'m'] wiring, array_map('absint',…), sodium polyfill call_user_func_array dispatch); rails/rails +385 after the node-anchored fix (16/16 sampled genuine, incl. inherited hooks across real extends edges); controls byte-stable (excalidraw 0-delta, redis identical, typeorm keeps its +4 inherited getters). The only calls-edge deltas anywhere are pre-existing minified-bundle resolution jitter (wp-tinymce.js single-letter symbols). Full suite 1391 passed. EXTRACTION_VERSION 21 → 22 (re-index to benefit). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
38095aa95b
commit
1f15f93feb
+73
-31
@@ -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<string>([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<string>(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) {
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user