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
+82
View File
@@ -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'),
"<?php\nfunction cmp_items($a, $b) { return $a <=> $b; }\n"
);
fs.writeFileSync(
path.join(tmpDir, 'main.php'),
[
'<?php',
'class Saver {',
' public function onSave($x) {}',
' public function wire() {',
" register_shutdown_function([$this, 'onSave']);",
' }',
'}',
'class Loader {',
' public static function load($cls) {}',
'}',
'function sorter($items) {',
" usort($items, 'cmp_items');", // known HOF, cross-file string → edge
" spl_autoload_register([Loader::class, 'load']);",
" some_random_fn('cmp_items');", // NOT a known HOF → no edge
' return $items;',
'}',
].join('\n')
);
const cg = CodeGraph.initSync(tmpDir);
try {
await cg.indexAll();
// Exactly ONE source for cmp_items: the usort site, not some_random_fn.
expect(sourceNames(cg, fnRefEdgesInto(cg, 'cmp_items'))).toEqual(['sorter']);
expect(sourceNames(cg, fnRefEdgesInto(cg, 'onSave'))).toEqual(['wire']);
expect(sourceNames(cg, fnRefEdgesInto(cg, 'load'))).toEqual(['sorter']);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
it('RUBY HOOKS: before_action/rescue_from symbols resolve class-scoped incl. inherited; validates is excluded', async () => {
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(