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:
Colby Mchenry
2026-06-11 15:30:29 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 38095aa95b
commit 1f15f93feb
8 changed files with 353 additions and 54 deletions
+1 -1
View File
@@ -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;
+166 -12
View File
@@ -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<string>(),
dispatch: new Map<string, CaptureRule>([
['argument_list', { mode: 'args' }],
['pair', { mode: 'value', field: 'value' }],
]),
layers: new Map<string, string | null>([['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<string, CaptureRule>([
@@ -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<string>(),
dispatch: new Map<string, CaptureRule>([['arguments', { mode: 'args' }]]),
layers: new Map<string, string | null>([['argument', null]]),
special: new Set(['encapsed_string', 'string', 'array_creation_expression']),
};
/**
* Capture specs by language.
*/
export const FN_REF_SPECS: Record<string, FnRefSpec | undefined> = {
c: cFamilySpec(),
@@ -334,6 +387,7 @@ export const FN_REF_SPECS: Record<string, FnRefSpec | undefined> = {
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.<m>` (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;
}
+9 -4
View File
@@ -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<string>();
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('::'));
+73 -31
View File
@@ -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) {
+6 -3
View File
@@ -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`