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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user