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
+71
View File
@@ -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
View File
@@ -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) {