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:
co-authored by
Claude Opus 4.8
Colby McHenry
parent
fd03f31b2c
commit
6e2a24d96a
@@ -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');
|
||||
|
||||
@@ -529,6 +529,47 @@ function resolveCppIncludePath(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this reference a PHP include/require PATH (vs a namespace `use` symbol)?
|
||||
*
|
||||
* include/require emit a file path ("lib.php", "inc/db.php", "../x.php"),
|
||||
* whereas namespace use is an FQN (App\Foo\Bar) or a bare class symbol
|
||||
* (Closure). PHP identifiers contain neither '/' nor '.', so a slash or dot
|
||||
* marks a path-shaped include. Such references resolve to files only — never
|
||||
* to a same-named symbol — so callers must not fall back to the name-matcher.
|
||||
*/
|
||||
export function isPhpIncludePathRef(ref: UnresolvedRef): boolean {
|
||||
return (
|
||||
ref.language === 'php' &&
|
||||
ref.referenceKind === 'imports' &&
|
||||
(ref.referenceName.includes('/') || ref.referenceName.includes('.'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a PHP include/require path to a project-relative file path.
|
||||
*
|
||||
* PHP resolves includes relative to the including file's directory (the
|
||||
* common case for procedural codebases); php.ini `include_path` is not
|
||||
* modeled. Callers pass an already-extracted static literal path.
|
||||
*/
|
||||
function resolvePhpIncludePath(
|
||||
includePath: string,
|
||||
fromFile: string,
|
||||
context: ResolutionContext
|
||||
): string | null {
|
||||
const projectRoot = context.getProjectRoot();
|
||||
const fromDir = path.dirname(path.join(projectRoot, fromFile));
|
||||
const basePath = path.resolve(fromDir, includePath);
|
||||
const relativePath = path.relative(projectRoot, basePath).replace(/\\/g, '/');
|
||||
if (context.fileExists(relativePath)) return relativePath;
|
||||
// The literal may omit the .php extension (e.g. include "config").
|
||||
for (const ext of EXTENSION_RESOLUTION.php ?? []) {
|
||||
if (context.fileExists(relativePath + ext)) return relativePath + ext;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract import mappings from a file
|
||||
*/
|
||||
@@ -1122,6 +1163,36 @@ export function resolveViaImport(
|
||||
return null;
|
||||
}
|
||||
|
||||
// PHP include/require — resolve the static string path to a file→file
|
||||
// edge, mirroring the C/C++ branch above. Distinguish include PATHS from
|
||||
// namespace `use` symbols by shape: an include path contains a slash or a
|
||||
// file extension ("lib.php", "inc/db.php", "../x.php"), whereas a namespace
|
||||
// use is an FQN (App\Foo\Bar) or a bare class symbol (Closure) — PHP
|
||||
// identifiers contain neither '/' nor '.'. Only path-shaped references are
|
||||
// includes; symbol references fall through to the namespace resolution.
|
||||
if (isPhpIncludePathRef(ref)) {
|
||||
const resolvedPath = resolvePhpIncludePath(ref.referenceName, ref.filePath, context);
|
||||
if (resolvedPath) {
|
||||
const basename = resolvedPath.split('/').pop()!;
|
||||
const fileNode = context
|
||||
.getNodesByName(basename)
|
||||
.find((n) => n.kind === 'file' && n.filePath === resolvedPath);
|
||||
if (fileNode) {
|
||||
return {
|
||||
original: ref,
|
||||
targetNodeId: fileNode.id,
|
||||
confidence: 0.9,
|
||||
resolvedBy: 'import',
|
||||
};
|
||||
}
|
||||
}
|
||||
// A path-shaped include that doesn't resolve to a known project file is a
|
||||
// dead end. Return unresolved rather than falling through to the symbol
|
||||
// name-matcher, which would mis-connect e.g. "inc/db.php" to an unrelated
|
||||
// db.php elsewhere in the tree — a wrong edge is worse than a missing one.
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use cached import mappings (avoids re-reading and re-parsing per ref)
|
||||
const imports = context.getImportMappings(ref.filePath, ref.language);
|
||||
if (imports.length === 0 && !context.readFile(ref.filePath)) {
|
||||
|
||||
+13
-1
@@ -17,7 +17,7 @@ import {
|
||||
ImportMapping,
|
||||
} from './types';
|
||||
import { matchReference, sameLanguageFamily, crossesKnownFamily } from './name-matcher';
|
||||
import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs } from './import-resolver';
|
||||
import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef } from './import-resolver';
|
||||
import { detectFrameworks } from './frameworks';
|
||||
import { synthesizeCallbackEdges } from './callback-synthesizer';
|
||||
import { loadProjectAliases, type AliasMap } from './path-aliases';
|
||||
@@ -666,6 +666,18 @@ export class ReferenceResolver {
|
||||
candidates.push(importResult);
|
||||
}
|
||||
|
||||
// PHP include/require paths resolve to files via import resolution only.
|
||||
// If that didn't find the file, do NOT fall back to the symbol
|
||||
// name-matcher — it would mis-connect e.g. "inc/db.php" to an unrelated
|
||||
// db.php elsewhere in the tree (a wrong edge is worse than none, #660).
|
||||
if (isPhpIncludePathRef(ref)) {
|
||||
return candidates.length > 0
|
||||
? candidates.reduce((best, curr) =>
|
||||
curr.confidence > best.confidence ? curr : best
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
// Strategy 3: Try name matching
|
||||
const nameResult = this.gateLanguage(matchReference(ref, this.context), ref);
|
||||
if (nameResult) {
|
||||
|
||||
Reference in New Issue
Block a user