fix(extraction): map PHP include/require to file→file dependency edges (#660) (#663)

PHP's importTypes only captured namespace_use_declaration, so
include/require(_once) — the dependency mechanism in procedural and
script-style PHP — never produced edges. callers, impact, and trace
missed the entire file-include graph; only namespace `use` became a
dependency edge.

Capture the four include/require expression types and emit file→file
imports edges, reusing the path-based resolution that C/C++ #include
already goes through. Only static string-literal paths are resolved
(relative to the including file); dynamic forms (include $var,
require __DIR__ . '/x', interpolated strings) are skipped.

Include PATHS are distinguished from namespace `use` symbols by shape: a
path contains '/' or '.', which PHP identifiers and FQNs never do. A
path-shaped include that doesn't resolve to a known project file is left
unresolved and does NOT fall back to the symbol name-matcher, which would
otherwise mis-connect "inc/db.php" to an unrelated db.php elsewhere — a
wrong edge is worse than a missing one.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Colby McHenry <me@colbymchenry.com>
This commit is contained in:
Max Hsu
2026-06-08 20:31:15 -04:00
committed by GitHub
co-authored by Claude Opus 4.8 Colby McHenry
parent fd03f31b2c
commit 6e2a24d96a
6 changed files with 295 additions and 3 deletions
+40 -1
View File
@@ -2,6 +2,37 @@ import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getNodeText } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
// include / require (+ _once) expression node types. These carry the
// file→file dependency in procedural PHP, where `include`/`require` — not
// namespace `use` — is how a file pulls in another (issue #660).
const PHP_INCLUDE_TYPES = new Set([
'include_expression',
'include_once_expression',
'require_expression',
'require_once_expression',
]);
/**
* Extract a static string-literal path from a PHP include/require expression.
*
* Returns null for dynamic forms (`include $var`, `require __DIR__ . '/x'`,
* interpolated strings) — they have no resolvable compile-time path, which
* matches the issue's "static string literals (the common case)" scope.
*/
function phpStaticIncludePath(node: SyntaxNode, source: string): string | null {
// The path argument is the expression's first named child; the call-style
// form `require("x")` wraps it in a parenthesized_expression.
let arg: SyntaxNode | null = node.namedChild(0);
if (arg?.type === 'parenthesized_expression') arg = arg.namedChild(0);
if (!arg || (arg.type !== 'string' && arg.type !== 'encapsed_string')) return null;
// Pure literal only: any non-`string_content` child (interpolated variable,
// escape sequence, …) means the value isn't a static path.
const parts = arg.namedChildren;
if (parts.some((c: SyntaxNode) => c.type !== 'string_content')) return null;
const content = parts.find((c: SyntaxNode) => c.type === 'string_content');
return content ? getNodeText(content, source) : null;
}
export const phpExtractor: LanguageExtractor = {
functionTypes: ['function_definition'],
classTypes: ['class_declaration', 'trait_declaration'],
@@ -11,7 +42,7 @@ export const phpExtractor: LanguageExtractor = {
enumTypes: ['enum_declaration'],
enumMemberTypes: ['enum_case'],
typeAliasTypes: [],
importTypes: ['namespace_use_declaration'],
importTypes: ['namespace_use_declaration', ...PHP_INCLUDE_TYPES],
callTypes: ['function_call_expression', 'member_call_expression', 'scoped_call_expression'],
variableTypes: ['const_declaration'],
fieldTypes: ['property_declaration'],
@@ -93,6 +124,14 @@ export const phpExtractor: LanguageExtractor = {
extractImport: (node, source) => {
const importText = source.substring(node.startIndex, node.endIndex).trim();
// include / require (+ _once): emit a file→file dependency. The path is a
// static string literal in the common case; dynamic forms resolve to null
// and are skipped (no import node, no edge).
if (PHP_INCLUDE_TYPES.has(node.type)) {
const includePath = phpStaticIncludePath(node, source);
return includePath ? { moduleName: includePath, signature: importText } : null;
}
// Check for grouped imports: use X\{A, B} - return null for core fallback
const namespacePrefix = node.namedChildren.find((c: SyntaxNode) => c.type === 'namespace_name');
const useGroup = node.namedChildren.find((c: SyntaxNode) => c.type === 'namespace_use_group');